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

feat: self-hosting - NixOS module, security hardening, multi-archive docs (#11)

feat: self-hosting - NixOS module, security hardening, multi-archive docs
This commit is contained in:
TheGeneralist 2026-06-30 00:43:00 +02:00 committed by GitHub
commit 685b6cc7ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1043 additions and 98 deletions

3
.gitignore vendored
View file

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

48
Cargo.lock generated
View file

@ -130,6 +130,7 @@ dependencies = [
"axum-extra", "axum-extra",
"base64", "base64",
"chrono", "chrono",
"parking_lot",
"rand", "rand",
"rusqlite", "rusqlite",
"serde", "serde",
@ -1009,6 +1010,15 @@ version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" 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]] [[package]]
name = "log" name = "log"
version = "0.4.28" version = "0.4.28"
@ -1158,6 +1168,29 @@ dependencies = [
"vcpkg", "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]] [[package]]
name = "password-hash" name = "password-hash"
version = "0.5.0" version = "0.5.0"
@ -1265,6 +1298,15 @@ dependencies = [
"getrandom 0.2.17", "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]] [[package]]
name = "regex" name = "regex"
version = "1.12.2" version = "1.12.2"
@ -1431,6 +1473,12 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"

View file

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

View file

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

View file

@ -63,6 +63,6 @@ async fn main() -> Result<()> {
let listener = tokio::net::TcpListener::bind(addr).await?; let listener = tokio::net::TcpListener::bind(addr).await?;
println!("archivr-server listening on http://{addr}"); 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(()) Ok(())
} }

File diff suppressed because it is too large Load diff

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-MrdP6h9x.css">
</head> </head>
<body> <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/`) - 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` - 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 ### Supported Shorthand Inputs
- YouTube video/short media: - YouTube video/short media:

View file

@ -16,11 +16,12 @@
inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; inputs.nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
outputs = outputs =
{ nixpkgs, ... }: { nixpkgs, self, ... }:
let let
lib = nixpkgs.lib; lib = nixpkgs.lib;
systems = [ systems = [
"x86_64-linux" "x86_64-linux"
"aarch64-linux"
"aarch64-darwin" "aarch64-darwin"
]; ];
in 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 ( devShells = lib.genAttrs systems (
system: system:
let 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(() => { useEffect(() => {
if (authState !== 'authenticated') return
fetchArchives().then(list => { fetchArchives().then(list => {
setArchives(list) setArchives(list)
if (list.length > 0) { if (list.length > 0) {
@ -79,7 +81,7 @@ export default function App() {
setArchiveId(first) setArchiveId(first)
} }
}) })
}, []) }, [authState])
// Archive change: parallel load entries + runs + tags // Archive change: parallel load entries + runs + tags
useEffect(() => { 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 ];
};
};
}