feat(copyparty): add NixOS guest

This commit is contained in:
2026-08-16 15:15:47 -04:00
parent acdc497e4c
commit 98f0ac304d
5 changed files with 392 additions and 3 deletions

View File

@@ -18,6 +18,11 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
## Gotchas
- Guest modules are host-agnostic wiring, while host files own deployment-specific service configuration such as served volumes and host paths.
For example, `guests/copyparty.nix` should expose options and translate them into guest mounts/service settings, while `hosts/pikachu/default.nix` chooses `/inbox`, `/media`, and their backing paths.
- `sops.secrets.<name>.owner` must name a user known on the decrypting host.
For guest-only service users, do not set the owner to the guest uid unless the host also declares that user.
Use a host-visible owner plus a shared group mode when an identity-mapped guest service can read through its group.
- Actual Budget's NixOS service runs `actual-server` with `WorkingDirectory = services.actual.settings.dataDir`, and the `actual-server` CLI wrapper sets `ACTUAL_CONFIG_PATH=./config.json` when that current directory contains `config.json`, overriding the NixOS module's generated config path before the server loads configuration.
When migrating old community-script state to the normal upstream `/data` shape, move or rewrite a copied `/data/config.json` so it cannot shadow the declarative module settings.
- Subagent completion delivery is non-blocking through immediate spawn, milestone notifications, retained terminal entries, and `subagent_list` or `subagent_result` retrieval.
@@ -78,10 +83,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.

126
guests/copyparty.nix Normal file
View File

@@ -0,0 +1,126 @@
{
config,
lib,
my,
...
}:
let
cfg = config.guests.copyparty;
serviceUid = my.serviceUid "copyparty";
volumeType = lib.types.submodule {
options = {
hostPath = lib.mkOption {
type = lib.types.str;
description = "Host path bind-mounted into the Copyparty guest for this volume.";
};
path = lib.mkOption {
type = lib.types.str;
description = "Guest path served by this Copyparty volume.";
};
access = lib.mkOption {
type = lib.types.attrsOf (lib.types.listOf lib.types.str);
default = { };
description = "Copyparty access map from permission string to account names.";
};
flags = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Copyparty volume flags.";
};
};
};
in
{
imports = [
(my.guest {
name = "copyparty";
interior = {
modules.services.copyparty = {
enable = true;
inherit (cfg) accountName stateDir;
passwordFile = "/run/secrets/${cfg.passwordSecret}";
volumes = lib.mapAttrs (_urlPath: volume: {
inherit (volume) path access flags;
}) cfg.volumes;
};
};
})
];
options.guests.copyparty = {
accountName = lib.mkOption {
type = lib.types.str;
default = "alexion";
description = "Copyparty account name configured for password authentication.";
};
passwordSecret = lib.mkOption {
type = lib.types.str;
default = "services/copyparty/users/alexion/password";
description = "SOPS secret name containing the Copyparty account password.";
};
stateDir = lib.mkOption {
type = lib.types.str;
default = "/var/lib/copyparty";
description = "Guest path where Copyparty stores service state.";
};
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.";
};
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.statePath != null;
message = "guests.copyparty.statePath must point at the host directory that stores Copyparty state.";
}
{
assertion = cfg.volumes != { };
message = "guests.copyparty.volumes must declare the host's served Copyparty volumes.";
}
];
guests.copyparty = {
secrets = [ cfg.passwordSecret ];
mounts =
{
${cfg.stateDir} = {
hostPath = cfg.statePath;
create = {
owner = serviceUid;
group = "storage";
mode = "0770";
};
};
}
// lib.mapAttrs' (_urlPath: volume: lib.nameValuePair volume.path { inherit (volume) hostPath; }) cfg.volumes;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
sops.secrets.${cfg.passwordSecret} = {
owner = "root";
group = "storage";
mode = "0440";
};
};
}

View File

@@ -81,6 +81,35 @@
dataPath = "/pikachu/data/actualbudget";
};
guests.copyparty = {
enable = true;
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
volumes = {
"/inbox" = {
hostPath = "/pikachu/__inbox";
path = "/srv/inbox";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
"/media" = {
hostPath = "/pikachu/media";
path = "/srv/media";
access.A = [ "alexion" ];
flags = [
"d2d"
"d2t"
"dthumb"
];
};
};
};
environment.systemPackages = with pkgs; [
pciutils
smartmontools

View File

@@ -10,6 +10,7 @@ let
genAttrs
flatten
hasSuffix
escapeShellArg
mapAttrsToList
;
@@ -133,6 +134,19 @@ let
TasksMax = cfg.limits.tasksMax;
};
createdMounts = lib.filterAttrs (_guestPath: m: m.create != null) cfg.mounts;
mountCreateScript = lib.concatStringsSep "\n" (
mapAttrsToList (
_guestPath: m:
let
create = m.create;
in
''
${config.system.path}/bin/install -d -o ${escapeShellArg (toString create.owner)} -g ${escapeShellArg create.group} -m ${escapeShellArg create.mode} ${escapeShellArg m.hostPath}
''
) createdMounts
);
# A networked guest owns its bridged interface through its own networkd, the only stable MAC pin for a nested container.
# The interface is eth0, the name a nested container gives its bridged veth.
# It takes the placement MAC, and the static address or DHCP when that is unset.
@@ -219,6 +233,39 @@ let
service must write to the pool data it owns.
'';
};
create = lib.mkOption {
type = lib.types.nullOr (lib.types.submodule {
options = {
type = lib.mkOption {
type = lib.types.enum [ "directory" ];
default = "directory";
description = "Host path type to create before the guest starts.";
};
owner = lib.mkOption {
type = lib.types.oneOf [
lib.types.str
lib.types.ints.positive
];
description = "Owner assigned to the created host path.";
};
group = lib.mkOption {
type = lib.types.str;
description = "Group assigned to the created host path.";
};
mode = lib.mkOption {
type = lib.types.str;
example = "0770";
description = "Mode assigned to the created host path.";
};
};
});
default = null;
description = ''
Opt-in host path creation before the guest starts. Existing
paths are left enabled unless this is set, so served storage
roots can still be required to exist explicitly.
'';
};
};
}
);
@@ -341,7 +388,10 @@ let
# is up but not after that specific bridge existing.
systemd.services."container@${machineName}" = lib.mkIf (cfg.backend == "container") (
lib.mkMerge [
{ serviceConfig = limitConfig; }
{
serviceConfig = limitConfig;
preStart = lib.mkIf (createdMounts != { }) mountCreateScript;
}
(lib.mkIf networked (
let
bridgeDevice = "sys-subsystem-net-devices-${lib.replaceStrings [ "-" ] [ "\\x2d" ] (bridgeName cfg.vlan)}.device";

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 ];
};
}