1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00
archivr/modules/nixos/archivr-server.nix
TheGeneralist 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

170 lines
5.5 KiB
Nix

# 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;
# Derived bind string from separate address + port options.
bindStr = "${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 = "${a.id}"
label = "${a.label}"
archive_path = "${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.'';
};
};
});
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);
RestrictAddressFamilies = [ "AF_INET" "AF_INET6" "AF_UNIX" ];
LockPersonality = true;
RestrictNamespaces = true;
RestrictSUIDSGID = true;
RemoveIPC = true;
};
};
networking.firewall = lib.mkIf cfg.openFirewall {
allowedTCPPorts = [ cfg.port ];
};
};
}