1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

Compare commits

...

10 commits

Author SHA1 Message Date
685b6cc7ea
feat: self-hosting - NixOS module, security hardening, multi-archive docs (#11)
feat: self-hosting - NixOS module, security hardening, multi-archive docs
2026-06-30 00:43:00 +02:00
120eccc032
chore: rebuild frontend bundle
JS bundle rehashed after frontend changes (App.jsx auth gating,
ContextRail full tag path, Topbar tab reorder, Capture button copy).
2026-06-30 00:39:55 +02:00
9544e707ed
fix: address 6 code-review findings
NixOS module (4 fixes):
- Remove RestrictNamespaces=true: Chromium (launched by single-file for
  web captures) needs Linux user namespaces; blocking them broke captures
- Escape TOML string values: labels/paths with quotes or backslashes
  would produce invalid archivr-server.toml and prevent startup
- Bracket IPv6 listen addresses: ::1:8080 is rejected by Rust's
  SocketAddr parser; [::1]:8080 is the correct form (RFC 2732)
- Add optional storePath per archive: covers archives initialised with
  a custom store path outside the default sibling store/ directory

Rust (1 fix):
- extract_client_ip loopback branch: take last XFF value not first.
  When a proxy appends to X-Forwarded-For an attacker controls the
  first entry; the last entry is always set by the trusted local proxy

Frontend (1 fix):
- Gate fetchArchives on authState === 'authenticated'. The empty-dep
  useEffect fired on mount (before login), hit 401, and never retried
  after login because authState was not in the dependency array
2026-06-29 21:30:45 +02:00
71e176cbd1
security: fix two Codex review findings + add port option
Fix 1 — NixOS: store/ dir missing from ReadWritePaths
  archive_path points to .archivr/; captures write artifacts to the
  sibling store/ directory. Whitelisting only .archivr/ caused POST
  /captures to fail under ProtectSystem=strict.
  Fix: use builtins.dirOf a.path to whitelist the parent, which
  contains both .archivr/ and store/.

Fix 2 — Rust: X-Forwarded-For was unconditionally trusted
  An attacker could send a different IP on every login attempt,
  bypassing LOGIN_MAX_ATTEMPTS entirely.
  Fix: use ConnectInfo<SocketAddr> (via into_make_service_with_connect_info)
  as the primary rate-limit key; XFF is trusted only when the TCP peer
  is loopback (i.e. a local reverse proxy). Tests without a real socket
  fall back to XFF unchanged.

Port configuration:
  NixOS module: split 'bind' string into separate 'listenAddress'
  (default 127.0.0.1) and 'port' (default 8080) options. openFirewall
  now uses cfg.port directly instead of parsing it from the bind string.
  README NixOS example updated accordingly.
2026-06-29 20:32:08 +02:00
7ed7cb882f
chore: update Cargo.lock after parking_lot dep addition 2026-06-29 20:13:59 +02:00
5ce68b58f3
feat(server): require auth on list_entries and search_entries_handler
Both handlers accepted AuthUser but served guests.
auth.require_auth()? added as first line closes the gap.
Existing unauthenticated tests updated with session cookies.
2026-06-29 20:13:31 +02:00
007c5fc5fb
feat(server): enforce auth on all public read endpoints
Add auth_user: AuthUser + require_auth() to entry_detail, list_runs,
serve_artifact, serve_entry_favicon, serve_blob, list_tags,
list_entry_tags, list_collections_handler, list_entry_collections_handler.
Add require_auth() to get_collection_handler (already had extractor).

Update existing tests to pass session cookies.
2026-06-29 20:12:00 +02:00
a8e52bdf71
security: add per-IP sliding-window rate limit on POST /api/auth/login
5 attempts / 15 min per IP. Excess attempts get 429 + Retry-After.
Client IP read from X-Forwarded-For with loopback fallback.
In-memory; prune inline on each check.
Refactored app() into app_with_state() + thin wrapper.
Uses parking_lot::Mutex (no unwrap, no poisoning).
2026-06-29 20:06:41 +02:00
3b3b0d7207
feat(server): add HTTP security response headers middleware
Injects X-Content-Type-Options, X-Frame-Options, Referrer-Policy,
Content-Security-Policy, and Permissions-Policy on every response.
Registered as outermost layer so it covers setup_guard 503s too.
No new dependencies.
2026-06-29 20:03:44 +02:00
f9de282b0e
feat(nix): add NixOS module for archivr-server
modules/nixos/archivr-server.nix — services.archivr-server NixOS module:
- enable/bind/archives/user/group/openFirewall options
- generates archivr-server.toml from options via pkgs.writeText
- pins auth DB to /var/lib/archivr-server/ (StateDirectory)
- dedicated archivr system user + group
- hardened systemd unit: ProtectSystem=strict, NoNewPrivileges,
  PrivateTmp, RestrictNamespaces, etc.; archive paths whitelisted
- openFirewall parses the port from the bind string automatically

flake.nix:
- add self to outputs args
- add aarch64-linux to systems (Raspberry Pi / ARM servers)
- expose nixosModules.archivr-server + nixosModules.default

.gitignore: whitelist modules/ directory

docs/README.md: add 'Hosting on NixOS' section with full example
2026-06-29 16:51:20 +02:00
12 changed files with 1043 additions and 98 deletions

3
.gitignore vendored
View file

@ -19,6 +19,9 @@
!ARCHIVR-MENTAL-MODEL.md
!NEXT.md
!modules/
!modules/**
!frontend/
!frontend/**
frontend/node_modules/

48
Cargo.lock generated
View file

@ -130,6 +130,7 @@ dependencies = [
"axum-extra",
"base64",
"chrono",
"parking_lot",
"rand",
"rusqlite",
"serde",
@ -1009,6 +1010,15 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0"
[[package]]
name = "lock_api"
version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
dependencies = [
"scopeguard",
]
[[package]]
name = "log"
version = "0.4.28"
@ -1158,6 +1168,29 @@ dependencies = [
"vcpkg",
]
[[package]]
name = "parking_lot"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
dependencies = [
"lock_api",
"parking_lot_core",
]
[[package]]
name = "parking_lot_core"
version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
dependencies = [
"cfg-if",
"libc",
"redox_syscall",
"smallvec",
"windows-link",
]
[[package]]
name = "password-hash"
version = "0.5.0"
@ -1265,6 +1298,15 @@ dependencies = [
"getrandom 0.2.17",
]
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
]
[[package]]
name = "regex"
version = "1.12.2"
@ -1431,6 +1473,12 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "3.7.0"

View file

@ -32,3 +32,4 @@ base64 = "0.22"
argon2 = { version = "0.5", features = ["std"] }
rand = { version = "0.8", features = ["std"] }
axum-extra = { version = "0.9", features = ["cookie"] }
parking_lot = "0.12"

View file

@ -19,6 +19,7 @@ chrono.workspace = true
base64.workspace = true
serde_json.workspace = true
rusqlite.workspace = true
parking_lot.workspace = true
[dev-dependencies]
tempfile.workspace = true

View file

@ -63,6 +63,6 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}");
axum::serve(listener, app).await?;
axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await?;
Ok(())
}

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title>
<script type="module" crossorigin src="/assets/index-CWb0sQjJ.js"></script>
<script type="module" crossorigin src="/assets/index-CbWD4KAy.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-MrdP6h9x.css">
</head>
<body>

View file

@ -137,6 +137,60 @@ Auth and session handling will be designed when remote or public hosting becomes
- X/Twitter Tweet content scrape: [Tweet and Thread shorthands](#supported-shorthand-inputs). (These are saved as JSON files in `raw_tweets/`)
- Instagram, Facebook, TikTok, Reddit, Snapchat: direct URLs or platform-prefixed shorthand passed through to `yt-dlp`
### Hosting on NixOS
The flake exposes a `nixosModules.default` output. Add it to your system flake and
enable the service:
```nix
# flake.nix (your system flake)
{
inputs.archivr.url = "github:thegeneralist/archivr";
outputs = { nixpkgs, archivr, ... }: {
nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
modules = [
archivr.nixosModules.default
{
services.archivr-server = {
enable = true;
# listenAddress defaults to "127.0.0.1" (loopback only)
# port defaults to 8080
archives = [
{ id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
{ id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; }
];
};
}
];
};
};
}
```
The module:
- Creates an `archivr` system user and group.
- Generates the TOML config from your options and stores the auth database under
`/var/lib/archivr-server/` (persists across upgrades).
- Runs under a hardened systemd unit (`ProtectSystem = strict`, `NoNewPrivileges`,
`PrivateTmp`, etc.). Archive directories are whitelisted for read-write access.
- Restarts automatically on failure.
**`openFirewall`** — set to `true` to open the TCP port derived from `bind`.
Only needed when binding to a non-loopback address:
```nix
services.archivr-server = {
listenAddress = "0.0.0.0";
port = 8080; # explicit, though 8080 is the default
openFirewall = true;
};
```
**Archive directories** must be readable and writable by the `archivr` user.
Initialise them with `archivr init` first, then `chown -R archivr:archivr /srv/archivr`.
### Supported Shorthand Inputs
- YouTube video/short media:

View file

@ -16,11 +16,12 @@
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs =
{ nixpkgs, ... }:
{ nixpkgs, self, ... }:
let
lib = nixpkgs.lib;
systems = [
"x86_64-linux"
"aarch64-linux"
"aarch64-darwin"
];
in
@ -171,6 +172,11 @@
}
);
nixosModules = {
archivr-server = import ./modules/nixos/archivr-server.nix { inherit self; };
default = self.nixosModules.archivr-server;
};
devShells = lib.genAttrs systems (
system:
let

View file

@ -70,8 +70,10 @@ export default function App() {
}
}, [])
// Mount: load archives, pick first, then parallel load
// Load archives once authenticated (re-runs when authState changes so
// it triggers correctly after first login or a session refresh).
useEffect(() => {
if (authState !== 'authenticated') return
fetchArchives().then(list => {
setArchives(list)
if (list.length > 0) {
@ -79,7 +81,7 @@ export default function App() {
setArchiveId(first)
}
})
}, [])
}, [authState])
// Archive change: parallel load entries + runs + tags
useEffect(() => {

View file

@ -0,0 +1,190 @@
# NixOS module for archivr-server.
# Consumed by flake.nix as:
# nixosModules.archivr-server = import ./modules/nixos/archivr-server.nix { inherit self; };
{ self }:
{ config, lib, pkgs, ... }:
let
cfg = config.services.archivr-server;
# Escape characters that would break TOML double-quoted strings.
escapeTOML = s: builtins.replaceStrings [ "\\" "\"" ] [ "\\\\" "\\\"" ] s;
# Derived bind string from separate address + port options.
# IPv6 addresses contain ":" and must be wrapped in brackets per RFC 2732.
bindStr =
let hasColon = builtins.match ".*:.*" cfg.listenAddress != null;
in if hasColon
then "[${cfg.listenAddress}]:${toString cfg.port}"
else "${cfg.listenAddress}:${toString cfg.port}";
# Generate the TOML registry file from NixOS options.
# The auth DB is pinned to the StateDirectory so it survives upgrades.
configFile = pkgs.writeText "archivr-server.toml" ''
bind = "${bindStr}"
auth_db_path = "/var/lib/archivr-server/archivr-auth.sqlite"
${lib.concatMapStrings (a: ''
[[archives]]
id = "${escapeTOML a.id}"
label = "${escapeTOML a.label}"
archive_path = "${escapeTOML a.path}"
'') cfg.archives}
'';
in
{
options.services.archivr-server = {
enable = lib.mkEnableOption "archivr web server";
package = lib.mkOption {
type = lib.types.package;
default = self.packages.${pkgs.system}.archivr-server;
defaultText = lib.literalExpression "archivr-server from the archivr flake";
description = "The archivr-server package to use.";
};
listenAddress = lib.mkOption {
type = lib.types.str;
default = "127.0.0.1";
example = "0.0.0.0";
description = ''
IP address to listen on. Defaults to loopback only (127.0.0.1).
Set to {code}`0.0.0.0` (or a specific interface address) to expose
the server on the network. Always put a reverse proxy with TLS
in front before doing this.
'';
};
port = lib.mkOption {
type = lib.types.port;
default = 8080;
description = "TCP port to listen on.";
};
archives = lib.mkOption {
type = lib.types.listOf (lib.types.submodule {
options = {
id = lib.mkOption {
type = lib.types.str;
example = "personal";
description = "Unique archive identifier used in the config and API URLs.";
};
label = lib.mkOption {
type = lib.types.str;
example = "Personal";
description = "Display name shown in the UI archive switcher.";
};
path = lib.mkOption {
type = lib.types.str;
example = "/srv/archivr/personal/.archivr";
description = ''Absolute path to the .archivr directory created by {command}`archivr init`.
The parent directory (which also contains the {file}`store/` blob directory)
is whitelisted for read-write access under systemd hardening.'';
};
storePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
description = ''
Absolute path to the store directory for this archive. Only needed
when the archive was initialised with a custom store path outside
the default sibling {file}`store/` location. When null (the default),
the parent of {option}`path` already covers the standard layout.
'';
};
};
});
default = [];
description = ''
Archives to mount. Each entry maps to one {code}`[[archives]]` block in the
generated config file. The list must contain at least one entry.
'';
example = lib.literalExpression ''
[
{ id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; }
{ id = "work"; label = "Work"; path = "/srv/archivr/work/.archivr"; }
]
'';
};
user = lib.mkOption {
type = lib.types.str;
default = "archivr";
description = "User account under which archivr-server runs.";
};
group = lib.mkOption {
type = lib.types.str;
default = "archivr";
description = "Group under which archivr-server runs.";
};
openFirewall = lib.mkOption {
type = lib.types.bool;
default = false;
description = ''
Open the firewall TCP port specified by
{option}`services.archivr-server.port`. Only meaningful when
{option}`services.archivr-server.listenAddress` is set to a
non-loopback address.
'';
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.archives != [];
message = "services.archivr-server.archives must contain at least one archive.";
}
];
users.users.${cfg.user} = {
isSystemUser = true;
group = cfg.group;
description = "archivr-server service user";
home = "/var/lib/archivr-server";
createHome = false; # StateDirectory creates it
};
users.groups.${cfg.group} = { };
systemd.services.archivr-server = {
description = "archivr web server";
wantedBy = [ "multi-user.target" ];
after = [ "network.target" ];
serviceConfig = {
ExecStart = "${cfg.package}/bin/archivr-server ${configFile}";
User = cfg.user;
Group = cfg.group;
# State directory — auth SQLite lives here across upgrades/restarts.
StateDirectory = "archivr-server";
StateDirectoryMode = "0750";
Restart = "on-failure";
RestartSec = "5s";
# Hardening — make the entire FS read-only except for the state
# directory and the parent directory of each mounted archive.
# Each archive_path is an .archivr dir; its sibling store/ dir (where
# capture artifacts are written) lives at the same level. Whitelisting
# the parent covers both without over-permissioning.
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ReadWritePaths =
[ "/var/lib/archivr-server" ]
++ (map (a: builtins.dirOf a.path) cfg.archives)
++ (lib.concatMap (a: lib.optional (a.storePath != null) a.storePath) cfg.archives);
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
LockPersonality = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
}