From 98f0ac304ddb63331c1d4ffc64226c2662e96105 Mon Sep 17 00:00:00 2001 From: alexion Date: Sun, 16 Aug 2026 15:15:47 -0400 Subject: [PATCH] feat(copyparty): add NixOS guest --- AGENTS.md | 9 +- guests/copyparty.nix | 126 +++++++++++++++++++++++ hosts/pikachu/default.nix | 29 ++++++ lib/default.nix | 52 +++++++++- modules/services/copyparty.nix | 179 +++++++++++++++++++++++++++++++++ 5 files changed, 392 insertions(+), 3 deletions(-) create mode 100644 guests/copyparty.nix create mode 100644 modules/services/copyparty.nix diff --git a/AGENTS.md b/AGENTS.md index adcd3a8..282b077 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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..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. diff --git a/guests/copyparty.nix b/guests/copyparty.nix new file mode 100644 index 0000000..5ac01d0 --- /dev/null +++ b/guests/copyparty.nix @@ -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"; + }; + }; +} diff --git a/hosts/pikachu/default.nix b/hosts/pikachu/default.nix index 43adb6f..cf9624b 100644 --- a/hosts/pikachu/default.nix +++ b/hosts/pikachu/default.nix @@ -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 diff --git a/lib/default.nix b/lib/default.nix index ba72278..f490aaa 100644 --- a/lib/default.nix +++ b/lib/default.nix @@ -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"; diff --git a/modules/services/copyparty.nix b/modules/services/copyparty.nix new file mode 100644 index 0000000..481f562 --- /dev/null +++ b/modules/services/copyparty.nix @@ -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 <