feat(copyparty): add NixOS guest

This commit is contained in:
2026-08-16 15:15:47 -04:00
parent acdc497e4c
commit 52ce4635de
4 changed files with 303 additions and 2 deletions

View File

@@ -78,10 +78,10 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
- `home.sessionVariables` do **not** reach the Hyprland session, since UWSM does not source `hm-session-vars.sh`.
The cursor is therefore set through Hyprland's own `env = KEY,VALUE` in `modules/desktop/hyprland/hyprland.nix`, sourced from `config.stylix.cursor`.
Bibata ships XCursor format only (no `hyprcursor/` dir), rendered through Hyprland's XCursor fallback, so `XCURSOR_*` and `HYPRCURSOR_*` naming the same theme are both safe.
- `neogaia`, the repo's only host, is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
- `neogaia` is a wifi laptop with a btrfs root and no ZFS pools, so it cannot honestly carry `modules.network`, `modules.zfs`, or a networked/pool-mounted guest.
Enabling networkd takes over its DNS, its CachyOS `zfs-kernel` build is marked broken, and it has no bridge or pool to attach to.
Verify these against it ad hoc through `nixosConfigurations.neogaia.extendModules` (forcing a ZFS-capable `boot.kernelPackages` for the zfs case) plus `nix eval` of the derived values, never by committing the enablement.
A committed guest therefore leaves `vlan`, `mounts`, and `secrets` unset, and the standing enablement waits for the first wired server host with real storage.
Committed networked and pool-mounted guests belong on a wired storage host such as `pikachu`, not on `neogaia`.
- Herdr key names for shifted punctuation are not interchangeable with the physical base key plus `shift`.
The tab rename binding must use the produced literal, such as `prefix+<`, rather than `prefix+shift+comma`.
- Flake-managed Pi extension, prompt, and skill directories may still be written directly for throwaway development or local experiments.

113
guests/copyparty.nix Normal file
View File

@@ -0,0 +1,113 @@
{
config,
lib,
my,
...
}:
let
cfg = config.guests.copyparty;
accountName = "alexion";
passwordSecret = "services/copyparty/users/alexion/password";
serviceUid = my.serviceUid "copyparty";
stateDir = "/var/lib/copyparty";
inboxDir = "/srv/inbox";
mediaDir = "/srv/media";
in
{
imports = [
(my.guest {
name = "copyparty";
interior = {
modules.services.copyparty = {
enable = true;
inherit accountName stateDir;
passwordFile = "/run/secrets/${passwordSecret}";
volumes = {
"/inbox" = {
path = inboxDir;
access.A = [ accountName ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
"/media" = {
path = mediaDir;
access.A = [ accountName ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
};
};
};
})
];
options.guests.copyparty = {
statePath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/copyparty";
description = "Host path bind-mounted as Copyparty's state directory.";
};
inboxPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/inbox";
description = "Host path bind-mounted as Copyparty's inbox volume.";
};
mediaPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/media";
description = "Host path bind-mounted as Copyparty's media volume.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.statePath != null;
message = "guests.copyparty.statePath must point at the host directory that stores Copyparty state.";
}
{
assertion = cfg.inboxPath != null;
message = "guests.copyparty.inboxPath must point at the host directory served as /inbox.";
}
{
assertion = cfg.mediaPath != null;
message = "guests.copyparty.mediaPath must point at the host directory served as /media.";
}
];
guests.copyparty = {
secrets = [ passwordSecret ];
mounts = {
${stateDir}.hostPath = cfg.statePath;
${inboxDir}.hostPath = cfg.inboxPath;
${mediaDir}.hostPath = cfg.mediaPath;
};
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets.${passwordSecret} = {
owner = toString serviceUid;
group = "storage";
mode = "0440";
};
systemd.tmpfiles.rules = [
"d ${cfg.statePath} 0770 ${toString serviceUid} storage -"
];
};
}

View File

@@ -81,6 +81,15 @@
dataPath = "/pikachu/data/actualbudget";
};
guests.copyparty = {
enable = true;
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
inboxPath = "/pikachu/__inbox";
mediaPath = "/pikachu/media";
};
environment.systemPackages = with pkgs; [
pciutils
smartmontools

View File

@@ -0,0 +1,179 @@
{
config,
lib,
my,
pkgs,
...
}:
let
cfg = config.modules.services.copyparty;
volumeType = lib.types.submodule {
options = {
path = lib.mkOption {
type = lib.types.str;
description = "Filesystem path served by this volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
example = {
A = [ "alexion" ];
};
description = "Copyparty access map from permission string to account names.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "dthumb" ];
description = "Copyparty volume flags.";
};
};
};
renderAccess = permission: accounts: " ${permission}: ${lib.concatStringsSep ", " accounts}";
renderFlags = flags: lib.concatMapStringsSep "\n" (flag: " ${flag}") flags;
renderVolume = urlPath: volume: ''
[${urlPath}]
${volume.path}
accs:
${lib.concatStringsSep "\n" (lib.mapAttrsToList renderAccess volume.access)}
${lib.optionalString (volume.flags != [ ]) ''
flags:
${renderFlags volume.flags}
''}
'';
renderedVolumes = lib.concatStringsSep "\n" (lib.mapAttrsToList renderVolume cfg.volumes);
configScript = pkgs.writeShellScript "copyparty-config" ''
set -eu
umask 077
password="$(${pkgs.coreutils}/bin/tr -d '\n' < "$CREDENTIALS_DIRECTORY/password")"
cat > /run/copyparty/copyparty.conf <<EOF
[global]
p: ${toString cfg.port}
usernames
http-only
no-crt
no-thumb
no-snap
no-rescan
ses-db: ${cfg.stateDir}/sessions.db
chpw-db: ${cfg.stateDir}/chpw.json
shr-db: ${cfg.stateDir}/shares.db
[accounts]
${cfg.accountName}: $password
${renderedVolumes}
EOF
'';
in
{
options.modules.services.copyparty = {
enable = lib.mkEnableOption "Copyparty";
package = lib.mkPackageOption pkgs "copyparty-min" { };
port = lib.mkOption {
type = lib.types.port;
default = 3923;
description = "Port Copyparty listens on.";
};
user = lib.mkOption {
type = lib.types.str;
default = "copyparty";
description = "User account that runs Copyparty.";
};
uid = lib.mkOption {
type = lib.types.ints.positive;
default = my.serviceUid "copyparty";
description = "Stable uid for the Copyparty service user.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Copyparty's writable state and served files.";
};
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordFile = lib.mkOption {
type = lib.types.path;
description = "Runtime file containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Directory where Copyparty stores service state.";
};
volumes = lib.mkOption {
type = lib.types.attrsOf volumeType;
default = { };
description = "Copyparty volumes keyed by their URL path.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.volumes != { };
message = "modules.services.copyparty.volumes must declare at least one served volume.";
}
];
users.users.${cfg.user} = {
isSystemUser = true;
uid = cfg.uid;
group = cfg.group;
home = cfg.stateDir;
};
systemd.services.copyparty = {
description = "Copyparty file server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ReloadSignal = "SIGUSR1";
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
Environment = [
"HOME=${cfg.stateDir}"
"XDG_CONFIG_HOME=${cfg.stateDir}"
];
StateDirectory = "copyparty";
RuntimeDirectory = "copyparty";
RuntimeDirectoryMode = "0700";
LoadCredential = [ "password:${cfg.passwordFile}" ];
ExecStartPre = configScript;
ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf";
Restart = "on-failure";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes);
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
};
}