Compare commits

...

10 Commits

15 changed files with 1207 additions and 146 deletions

View File

@@ -18,6 +18,13 @@ 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.
`subagent_wait` intentionally blocks the parent tool call until its condition or timeout, so do not use it merely to keep background work alive during an interactive workflow.
- Nixvim's flake input following the root nixpkgs source does not make its Home Manager module reuse the host's `pkgs` instance.
@@ -38,8 +45,8 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
It is deployed on `neogaia` and verified: a commit in a repository outside this checkout is authored `alexion <contact@alexion.dev>` with no override.
Verify it that way rather than from this checkout, whose `.git/config` carries the same identity and would mask a broken module.
`~/.gitconfig` (a second global file that outranks the flake-managed `~/.config/git/config`) currently holds only a `tea` credential helper and no `user.*`, so it does not shadow the identity, but it is undeclared and will not survive a reimage.
- The primary build/verify seam for any Host is `nix flake check`, which builds `checks.x86_64-linux.<host>` (the system toplevel).
Cheap targeted checks use `nix eval .#nixosConfigurations.<host>.config...`.
- The primary build/verify seam is `nix flake check`, which builds project-level checks plus `checks.x86_64-linux.<host>` system toplevels.
Cheap targeted host checks use `nix eval .#nixosConfigurations.<host>.config...`.
- chaotic-nyx must **not** follow our `nixpkgs`, and its packages are built against chaotic's own pinned nixpkgs (its overlay defaults to `onTopOf = "flake-nixpkgs"`, the cache-friendly path).
That is what lets the `nyx-cache.chaotic.cx` binary cache hit instead of compiling the CachyOS kernel from source.
The tradeoff is that chaotic packages do not see our `unstable`/`stable` overlays.
@@ -76,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.
@@ -92,3 +99,5 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
- The current Steam desktop client is an XWayland application.
Its CEF windows do not support Ozone and Steam composites them into an SDL surface with X11 extensions, so SDL Wayland selectors do not make the visible client native Wayland.
Keep fractional scaling sharp with Hyprland's `xwayland.force_zero_scaling` and Steam's own `STEAM_FORCE_DESKTOPUI_SCALING` instead.
- AdGuard's LAN wildcard rewrite for `*.alexion.dev` points names at the reverse-proxy guest, including non-HTTP ports.
Keep `git.alexion.dev:2022` working by proxying that TCP stream through the reverse-proxy guest to Gitea.

116
flake.nix
View File

@@ -76,7 +76,7 @@
{ self, nixpkgs, ... }@inputs:
let
inherit (nixpkgs) lib;
my = import ./lib.nix { inherit lib inputs self; };
my = import ./lib { inherit lib inputs self; };
in
{
# Helper functions for discovering and building hosts.
@@ -98,13 +98,111 @@
];
};
# `nix flake check` builds each host's toplevel.
checks.x86_64-linux = lib.mapAttrs (
name: host:
if host.config.warnings == [] then
host.config.system.build.toplevel
else
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
) self.nixosConfigurations;
# `nix flake check` builds host toplevels and project-level checks.
checks.x86_64-linux =
let
pkgs = nixpkgs.legacyPackages.x86_64-linux;
serviceIdentities = import ./lib/service-identities.nix { inherit lib; };
serviceIdentityTestsPass = lib.all (result: result) (lib.attrValues serviceIdentities.tests);
in
(lib.mapAttrs (
name: host:
if host.config.warnings == [] then
host.config.system.build.toplevel
else
throw "Host ${name} has evaluation warnings:\n${lib.concatStringsSep "\n" host.config.warnings}"
) self.nixosConfigurations)
// {
service-identities = pkgs.runCommand "service-identities-check" { } ''
${lib.optionalString (!serviceIdentityTestsPass) "exit 1"}
touch $out
'';
reverse-proxy-basic-route =
let
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy = {
enable = true;
routes."budget.alexion.dev" = {
path = "/";
backend = "http://10.23.20.42:5006";
};
};
}
];
}).config;
vhost = testConfig.services.nginx.virtualHosts."budget.alexion.dev";
in
pkgs.runCommand "reverse-proxy-basic-route-check" { } ''
${
lib.optionalString (
!(vhost.forceSSL && vhost.enableACME && vhost.locations."/".proxyPass == "http://10.23.20.42:5006")
) "exit 1"
}
touch $out
'';
reverse-proxy-actualbudget-guest-route =
let
testConfig =
(self.nixosConfigurations.pikachu.extendModules {
modules = [
{
guests.actualbudget.reverseProxy = {
enable = true;
host = "budget.alexion.dev";
backend = "http://10.23.20.42:5006";
};
}
];
}).config;
route = testConfig.modules.reverse-proxy.routes.actualbudget;
in
pkgs.runCommand "reverse-proxy-actualbudget-guest-route-check" { } ''
${
lib.optionalString (
!(route.host == "budget.alexion.dev" && route.backend == "http://10.23.20.42:5006")
) "exit 1"
}
touch $out
'';
reverse-proxy-secondary-path-requires-root =
let
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy = {
enable = true;
routes.raichu-files = {
host = "files.alexion.dev";
path = "/raichu";
backend = "http://10.23.20.10:3923";
};
};
}
];
}).config;
hasFailedRootAssertion = lib.any (
assertion:
assertion.assertion == false
&& assertion.message == "modules.reverse-proxy.routes for files.alexion.dev must include a primary / route."
) testConfig.assertions;
in
pkgs.runCommand "reverse-proxy-secondary-path-requires-root-check" { } ''
${lib.optionalString (!hasFailedRootAssertion) "exit 1"}
touch $out
'';
};
};
}

50
guests/actualbudget.nix Normal file
View File

@@ -0,0 +1,50 @@
{
config,
lib,
my,
...
}:
let
cfg = config.guests.actualbudget;
dataDir = "/data";
in
{
imports = [
(my.guest {
name = "actualbudget";
interior = {
modules.services.actualbudget = {
enable = true;
inherit dataDir;
};
};
})
];
options.guests.actualbudget = {
dataPath = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "/srv/actualbudget";
description = "Host path bind-mounted as Actual Budget's data directory.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.dataPath != null;
message = "guests.actualbudget.dataPath must point at the host directory that stores Actual Budget data.";
}
];
guests.actualbudget = {
mounts.${dataDir}.hostPath = cfg.dataPath;
limits = {
memory = lib.mkDefault "2G";
cpu = lib.mkDefault "200%";
tasksMax = lib.mkDefault 512;
};
};
};
}

136
guests/copyparty.nix Normal file
View File

@@ -0,0 +1,136 @@
{
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}";
reverseProxy = {
enable = cfg.reverseProxy.enable;
inherit (cfg.reverseProxy) trustedSources;
};
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.";
};
reverseProxy.trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
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";
};
};
}

61
guests/reverse-proxy.nix Normal file
View File

@@ -0,0 +1,61 @@
{
config,
lib,
my,
...
}:
let
giteaSshBackend = "10.23.20.45:2022";
legacyBackend = "http://10.23.20.32";
legacyHosts = [
"games.alexion.dev"
"git.alexion.dev"
"music.alexion.dev"
"photos.alexion.dev"
"plex.alexion.dev"
"servers.alexion.dev"
"todo.alexion.dev"
"wings.alexion.dev"
];
legacyRoutes = lib.listToAttrs (
map (host: {
name = "legacy-${lib.replaceStrings [ "." ] [ "-" ] host}";
value = {
inherit host;
backend = legacyBackend;
};
}) legacyHosts
);
in
{
imports = [
(my.guest {
name = "reverse-proxy";
interior = {
modules.reverse-proxy = {
enable = true;
routes = config.modules.reverse-proxy.routes // legacyRoutes;
};
networking.firewall.allowedTCPPorts = [ 2022 ];
services.nginx.streamConfig = ''
server {
listen 2022;
proxy_pass ${giteaSshBackend};
}
'';
};
})
];
config = lib.mkIf config.guests.reverse-proxy.enable {
guests.reverse-proxy = {
limits = {
memory = lib.mkDefault "1G";
cpu = lib.mkDefault "100%";
tasksMax = lib.mkDefault 256;
};
};
};
}

View File

@@ -20,14 +20,45 @@
systemd.network = {
enable = true;
networks."10-uplink" = {
matchConfig.MACAddress = "78:55:36:07:af:49";
networkConfig.DHCP = "yes";
linkConfig.RequiredForOnline = "routable";
netdevs = {
"40-br-vlan20".netdevConfig = {
Name = "br-vlan20";
Kind = "bridge";
};
"40-vlan20" = {
netdevConfig = {
Name = "vlan20";
Kind = "vlan";
};
vlanConfig.Id = 20;
};
};
networks = {
"10-uplink" = {
matchConfig = {
Name = "enp2s0";
MACAddress = "78:55:36:07:af:49";
};
networkConfig.DHCP = "yes";
vlan = [ "vlan20" ];
linkConfig.RequiredForOnline = "routable";
};
"40-vlan20" = {
matchConfig.Name = "vlan20";
networkConfig.Bridge = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
"40-br-vlan20" = {
matchConfig.Name = "br-vlan20";
linkConfig.RequiredForOnline = "no";
};
};
};
networking.useDHCP = false;
modules.network.vlans = [ 20 ];
boot.zfs.forceImportRoot = false;
modules.zfs = {
@@ -43,6 +74,59 @@
modules.git.enable = true;
modules.toolkit.enable = true;
guests.actualbudget = {
enable = true;
vlan = 20;
mac = "BC:24:11:C1:CD:28";
dataPath = "/pikachu/data/actualbudget";
reverseProxy = {
enable = true;
host = "budget.alexion.dev";
backend = "http://10.23.20.42:5006";
};
};
guests.reverse-proxy = {
enable = true;
vlan = 20;
mac = "02:98:64:5a:07:4f";
};
guests.copyparty = {
enable = true;
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
reverseProxy = {
enable = true;
host = "files.alexion.dev";
backend = "http://10.23.20.126:3923";
trustedSources = [ "10.23.20.109/32" ];
};
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

94
lib/default.nix Normal file
View File

@@ -0,0 +1,94 @@
{
lib,
inputs,
self,
}:
let
inherit (lib)
attrNames
filterAttrs
genAttrs
flatten
hasSuffix
mapAttrsToList
;
serviceIdentityRegistry = import ./service-identities.nix { inherit lib; };
reverseProxyRoutes = import ./reverse-proxy-routes.nix { inherit lib; };
collectNixFiles =
dir:
flatten (
mapAttrsToList (
name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
collectNixFiles path
else if type == "regular" && hasSuffix ".nix" name then
[ path ]
else
[ ]
) (builtins.readDir dir)
);
specialArgs = {
inherit inputs;
my = self.lib;
};
bridgeName = id: "br-vlan${toString id}";
guests = import ./guests.nix {
inherit
lib
self
specialArgs
bridgeName
reverseProxyRoutes
;
};
mkHost =
{
hostName,
system ? "x86_64-linux",
}:
inputs.nixpkgs.lib.nixosSystem {
inherit system specialArgs;
modules =
(collectNixFiles (self + "/modules"))
++ (collectNixFiles (self + "/guests"))
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
(self + "/system.nix")
(self + "/hosts/${hostName}")
{ networking.hostName = hostName; }
];
};
mkHosts =
hostsDir:
let
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
in
genAttrs hostNames (hostName: mkHost { inherit hostName; });
in
{
inherit
collectNixFiles
mkHost
mkHosts
bridgeName
reverseProxyRoutes
;
inherit (guests) deriveMac guest;
inherit (serviceIdentityRegistry) serviceUid;
serviceIdentities = serviceIdentityRegistry.identities;
}

View File

@@ -1,52 +1,13 @@
{
lib,
inputs,
self,
specialArgs,
bridgeName,
reverseProxyRoutes,
}:
let
inherit (lib)
attrNames
filterAttrs
genAttrs
flatten
hasSuffix
mapAttrsToList
;
inherit (lib) escapeShellArg mapAttrsToList;
# Recursively collect every `.nix` file under `dir` as a flat list, for a
# module's `imports`.
collectNixFiles =
dir:
flatten (
mapAttrsToList (
name: type:
let
path = dir + "/${name}";
in
if type == "directory" then
collectNixFiles path
else if type == "regular" && hasSuffix ".nix" name then
[ path ]
else
[ ]
) (builtins.readDir dir)
);
# The special arguments every configuration is evaluated with, host and guest
# interior alike.
specialArgs = {
inherit inputs;
my = self.lib;
};
# The name of a tagged VLAN's bridge, kept here as the one definition of a
# convention shared across the flake.
bridgeName = id: "br-vlan${toString id}";
# A guest with no operator-set MAC derives a stable one from its namespace path.
# The first octet 02 marks the address locally-administered and unicast.
# The rest is a slice of the path's hash.
# The same guest therefore always lands on the same address, which the operator can reserve at the router.
deriveMac =
name:
let
@@ -55,34 +16,6 @@ let
in
lib.concatStringsSep ":" ([ "02" ] ++ map octet [ 0 1 2 3 4 ]);
# Build one host: every module and every guest is imported unconditionally
# (inert until its `enable` flag is set), alongside chaotic, the host base,
# and the host's own directory.
mkHost =
{
hostName,
system ? "x86_64-linux",
}:
inputs.nixpkgs.lib.nixosSystem {
inherit system specialArgs;
modules =
(collectNixFiles (self + "/modules"))
++ (collectNixFiles (self + "/guests"))
++ [
inputs.chaotic.nixosModules.default
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
(self + "/system.nix")
(self + "/hosts/${hostName}")
{ networking.hostName = hostName; }
];
};
# Build a guest: a module-shaped definition whose body realizes its interior
# as a nested container standing on the guest-base, keyed by its namespace path.
# `name` is the dotted namespace under `guests.` and `interior` is an extra
# module merged into the container alongside the guest-base.
guest =
{
name,
@@ -96,16 +29,11 @@ let
networked = cfg.vlan != null;
# Host paths the operator maps into the guest, keyed by their in-guest path.
userMounts = lib.mapAttrs (_guestPath: m: {
inherit (m) hostPath;
isReadOnly = m.readOnly;
}) cfg.mounts;
# Each named secret bind-mounted read-only at the same `/run/secrets/<name>`
# path it holds on the host.
# No ownership is set here, since the container's one-to-one identity map
# carries the host file's owner through unchanged.
secretMounts = lib.listToAttrs (
map (
name:
@@ -119,29 +47,32 @@ let
) cfg.secrets
);
# An in-guest path claimed by both a mount and a secret, which the merge
# below would otherwise resolve silently in the secret's favour.
mountCollisions = lib.attrNames (builtins.intersectAttrs userMounts secretMounts);
# The resource caps the operator places on the guest's unit, dropping any
# left unset so systemd keeps its uncapped default for those.
limitConfig = lib.filterAttrs (_: v: v != null) {
MemoryMax = cfg.limits.memory;
CPUQuota = cfg.limits.cpu;
TasksMax = cfg.limits.tasksMax;
};
# 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.
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
);
guestNet =
{ lib, ... }:
{
config = lib.mkIf networked {
networking.useNetworkd = true;
# networkd default-enables resolved, which owns the guest's resolv.conf.
# The nested-container default of inheriting the host's file conflicts with that, so the guest keeps its own.
networking.useHostResolvConf = false;
systemd.network.networks."20-eth0" = {
@@ -200,6 +131,10 @@ let
guest takes its address by DHCP, keeping IP management at the router.
'';
};
reverseProxy = reverseProxyRoutes.mkRouteOptions {
subject = "guest route";
enableDefault = false;
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
lib.types.submodule {
@@ -217,6 +152,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.
'';
};
};
}
);
@@ -308,10 +276,12 @@ let
};
config = lib.mkIf cfg.enable {
# Declared here so the host is the one that decrypts each named secret.
# The guest carries no age key and decrypts nothing of its own.
sops.secrets = lib.genAttrs cfg.secrets (_: { });
modules.reverse-proxy.routes.${machineName} = lib.mkIf cfg.reverseProxy.enable (
reverseProxyRoutes.routeFields cfg.reverseProxy
);
assertions = [
{
assertion = mountCollisions == [ ];
@@ -333,13 +303,12 @@ let
}
];
# The operator's resource caps land on the guest's own unit, which a
# networked guest also orders after the bridge its veth enslaves to at
# start, since the container backend orders the unit after the network
# 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";
@@ -354,27 +323,9 @@ let
containers.${machineName} = lib.mkIf (cfg.backend == "container") {
autoStart = cfg.autoStart;
# The guest gets its own network namespace, so its services — its own
# sshd included — never contend with the host's.
privateNetwork = lib.mkDefault true;
# A networked guest's veth is enslaved to the VLAN's bridge, making it
# a first-class L2 citizen on that segment.
hostBridge = lib.mkIf networked (bridgeName cfg.vlan);
# The container shares the host's uid and gid space one to one.
# A guest process writing as the shared storage group then lands on a bind-mounted pool as that same group, with no permission juggling.
# A private-user mapping would shift the ids and reintroduce those errors, so it stays off.
privateUsers = lib.mkDefault "no";
# A nesting guest runs Podman or other OCI containers in its interior.
# The network-administration capability lets that runtime build its
# bridges and firewall rules.
# The tun and fuse device nodes are what it reaches for to network
# those containers and back their overlay storage.
# The remaining prerequisite, a delegated cgroup subtree for the
# runtime to manage, the container backend already grants every guest.
additionalCapabilities = lib.optionals cfg.nesting [ "CAP_NET_ADMIN" ];
allowedDevices = lib.optionals cfg.nesting [
{
@@ -401,21 +352,7 @@ let
};
};
};
# Discover every host (a subdirectory of `hostsDir`) and build each one.
mkHosts =
hostsDir:
let
hostNames = attrNames (filterAttrs (_name: type: type == "directory") (builtins.readDir hostsDir));
in
genAttrs hostNames (hostName: mkHost { inherit hostName; });
in
{
inherit
collectNixFiles
mkHost
mkHosts
guest
bridgeName
;
inherit deriveMac guest;
}

View File

@@ -0,0 +1,77 @@
{ lib }:
let
mkRouteOptions =
{
name ? null,
subject ? "route",
enableDefault ? true,
}:
{
enable = lib.mkOption {
type = lib.types.bool;
default = enableDefault;
description = "Whether this reverse-proxy ${subject} is active.";
};
host = lib.mkOption (
{
type = lib.types.str;
description = "Primary hostname served by this reverse-proxy ${subject}.";
}
// lib.optionalAttrs (name != null) {
default = name;
defaultText = lib.literalMD "the route attribute name";
}
);
aliases = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "Additional hostnames served by this reverse-proxy ${subject}.";
};
path = lib.mkOption {
type = lib.types.str;
default = "/";
description = "URL path prefix proxied by this reverse-proxy ${subject}.";
};
backend = lib.mkOption {
type = lib.types.str;
description = "Full HTTP backend URL that receives proxied traffic.";
};
tls = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether to serve this reverse-proxy ${subject} with ACME-backed TLS.";
};
websockets = lib.mkOption {
type = lib.types.bool;
default = false;
description = "Whether to enable websocket proxy headers for this reverse-proxy ${subject}.";
};
};
routeFields = route: {
inherit (route)
host
aliases
path
backend
tls
websockets
;
};
in
{
inherit mkRouteOptions routeFields;
routeType = lib.types.submodule (
{ name, ... }:
{
options = mkRouteOptions { inherit name; };
}
);
}

View File

@@ -0,0 +1,59 @@
{ lib }:
let
defaultIdentities = {
actualbudget = 20001;
copyparty = 20002;
};
duplicateUidMessage = duplicates: ''
Duplicate stable service UID allocation(s): ${lib.concatStringsSep ", " (
map (entry: "${toString entry.uid} is assigned to ${lib.concatStringsSep ", " entry.names}") duplicates
)}
'';
mk = identities:
let
names = lib.attrNames identities;
namesByUid = lib.foldl' (
acc: name:
let
uid = toString identities.${name};
in
acc // {
${uid} = (acc.${uid} or [ ]) ++ [ name ];
}
) { } names;
duplicates = lib.mapAttrsToList (uid: namesForUid: {
uid = lib.toInt uid;
names = namesForUid;
}) (lib.filterAttrs (_uid: namesForUid: builtins.length namesForUid > 1) namesByUid);
checkedIdentities =
assert lib.assertMsg (duplicates == [ ]) (duplicateUidMessage duplicates);
identities;
in
rec {
inherit checkedIdentities;
serviceUid = name:
if builtins.hasAttr name checkedIdentities then
checkedIdentities.${name}
else
throw "Unknown stable service identity `${name}`. Known identities: ${lib.concatStringsSep ", " (lib.attrNames checkedIdentities)}";
};
default = mk defaultIdentities;
tests = {
actualbudgetUid = default.serviceUid "actualbudget" == 20001;
copypartyUid = default.serviceUid "copyparty" == 20002;
missingLookupFails = !(builtins.tryEval (default.serviceUid "missing-service")).success;
duplicateUidFails = !(builtins.tryEval (mk {
first = 20003;
second = 20003;
}).checkedIdentities).success;
};
in
default // {
identities = default.checkedIdentities;
inherit tests;
}

82
modules/reverse-proxy.nix Normal file
View File

@@ -0,0 +1,82 @@
{ config, lib, my, ... }:
let
cfg = config.modules.reverse-proxy;
inherit (my) reverseProxyRoutes;
enabledRoutes = lib.filterAttrs (_: route: route.enable) cfg.routes;
routesByHost = lib.groupBy (route: route.host) (lib.attrValues enabledRoutes);
rootRoutesByHost = lib.mapAttrs (
host: routes:
let
roots = builtins.filter (route: route.path == "/") routes;
in
if roots == [ ] then null else builtins.head roots
) routesByHost;
hasDuplicatePaths = routes:
let
paths = map (route: route.path) routes;
in
lib.length paths != lib.length (lib.unique paths);
mkLocation = route: {
proxyPass = route.backend;
proxyWebsockets = route.websockets;
};
mkVirtualHost = host: routes:
let
rootRoute = rootRoutesByHost.${host};
in
lib.mkIf (rootRoute != null) {
forceSSL = rootRoute.tls;
enableACME = rootRoute.tls;
serverAliases = rootRoute.aliases;
locations = lib.listToAttrs (
map (route: lib.nameValuePair route.path (mkLocation route)) routes
);
};
in
{
options.modules.reverse-proxy = {
enable = lib.mkEnableOption "the Nginx reverse proxy route realizer";
routes = lib.mkOption {
type = lib.types.attrsOf reverseProxyRoutes.routeType;
default = { };
description = "Reverse-proxy route declarations keyed by stable route name.";
};
};
config = lib.mkIf cfg.enable {
assertions =
lib.mapAttrsToList (host: rootRoute: {
assertion = rootRoute != null;
message = "modules.reverse-proxy.routes for ${host} must include a primary / route.";
}) rootRoutesByHost
++ lib.mapAttrsToList (host: routes: {
assertion = !hasDuplicatePaths routes;
message = "modules.reverse-proxy.routes for ${host} must not declare the same path more than once.";
}) routesByHost;
networking.firewall.allowedTCPPorts = [
80
443
];
security.acme = {
acceptTerms = true;
defaults.email = "contact@alexion.dev";
};
services.nginx = {
enable = true;
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts = lib.mapAttrs mkVirtualHost routesByHost;
};
};
}

View File

@@ -0,0 +1,63 @@
{
config,
lib,
my,
...
}:
let
cfg = config.modules.services.actualbudget;
in
{
options.modules.services.actualbudget = {
enable = lib.mkEnableOption "Actual Budget";
port = lib.mkOption {
type = lib.types.port;
default = 5006;
description = "Port Actual Budget listens on.";
};
dataDir = lib.mkOption {
type = lib.types.str;
default = "/data";
description = "Directory where Actual Budget stores server and user files.";
};
user = lib.mkOption {
type = lib.types.str;
default = "actual";
description = "User account that runs Actual Budget.";
};
uid = lib.mkOption {
type = lib.types.ints.positive;
default = my.serviceUid "actualbudget";
description = "Stable uid for the Actual Budget service user.";
};
group = lib.mkOption {
type = lib.types.str;
default = "storage";
description = "Group account that owns Actual Budget's writable state.";
};
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
uid = cfg.uid;
group = cfg.group;
};
services.actual = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
settings = {
inherit (cfg) port dataDir;
};
};
};
}

View File

@@ -0,0 +1,201 @@
{
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}
''}
'';
renderTrustedSource = source: " xff-src: ${source}";
renderedReverseProxy = lib.optionalString cfg.reverseProxy.enable ''
rproxy: 1
xff-hdr: x-forwarded-for
${lib.concatStringsSep "\n" (map renderTrustedSource cfg.reverseProxy.trustedSources)}
'';
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
${renderedReverseProxy}
[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.";
};
reverseProxy = {
enable = lib.mkEnableOption "reverse-proxy trust settings for Copyparty";
trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.volumes != { };
message = "modules.services.copyparty.volumes must declare at least one served volume.";
}
{
assertion = !cfg.reverseProxy.enable || cfg.reverseProxy.trustedSources != [ ];
message = "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled.";
}
];
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 ];
};
}

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env bash
set -euo pipefail
bold=$'\033[1m'
reset=$'\033[0m'
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
mkdir -p "$tmp/state" "$tmp/root"
printf '%sQuestion%s\n' "$bold" "$reset"
printf 'Does the proposed copyparty reverse-proxy config parse and make a proxied HTTPS-origin login-shaped request pass the CORS gate?\n\n'
printf '%sNix-generated config fragment%s\n' "$bold" "$reset"
config_script=$(nix eval --raw '.#nixosConfigurations.pikachu.config.containers.copyparty.config.systemd.services.copyparty.serviceConfig.ExecStartPre')
awk '/\[global\]/{show=1} /\[accounts\]/{show=0} show {print}' "$config_script" | grep -E 'rproxy|xff-hdr|xff-src' || true
pkg=$(nix eval --raw '.#nixosConfigurations.pikachu.config.containers.copyparty.config.modules.services.copyparty.package.outPath')
make_conf() {
local file=$1 port=$2 rproxy=$3
cat > "$file" <<EOF
[global]
i: 127.0.0.1
p: $port
usernames
http-only
no-crt
no-thumb
no-snap
no-rescan
ses-db: $tmp/state/sessions-$port.db
chpw-db: $tmp/state/chpw-$port.json
shr-db: $tmp/state/shares-$port.db
$rproxy
[accounts]
alexion: prototype-password
[/]
$tmp/root
accs:
A: alexion
EOF
}
start_server() {
local conf=$1 log=$2
"$pkg/bin/copyparty" -c "$conf" >"$log" 2>&1 &
local pid=$!
for _ in {1..50}; do
if grep -q 'listening' "$log"; then
printf '%s' "$pid"
return 0
fi
sleep 0.1
done
cat "$log"
kill "$pid" 2>/dev/null || true
return 1
}
request() {
local label=$1 port=$2 host=$3 proto=${4:-}
local headers="$tmp/$label.headers"
local body="$tmp/$label.body"
local args=(
-sS
-D "$headers"
-o "$body"
-X POST
-H "Host: $host"
-H 'Origin: https://files.alexion.dev'
-H 'X-Forwarded-For: 10.23.20.20'
)
if [[ -n "$proto" ]]; then
args+=(-H "X-Forwarded-Proto: $proto")
fi
curl "${args[@]}" "http://127.0.0.1:$port/?pw=alexion:prototype-password" >/dev/null || true
printf '%-24s %s %s\n' "$label" "$(head -1 "$headers" | tr -d '\r')" "$(head -1 "$body")"
}
rproxy_config=' rproxy: 1
xff-hdr: x-forwarded-for
xff-src: 127.0.0.1/32'
make_conf "$tmp/rproxy.conf" 39234 "$rproxy_config"
pid=$(start_server "$tmp/rproxy.conf" "$tmp/rproxy.log")
printf '\n%sCORS gate probes%s\n' "$bold" "$reset"
request with-forwarded-https 39234 files.alexion.dev https
request missing-forwarded-proto 39234 files.alexion.dev
request wrong-forwarded-host 39234 '10.23.20.126:3923' https
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
printf '\n%sRelevant server log%s\n' "$bold" "$reset"
grep -E 'cors-reject|POST|http405|got proxied request|untrusted source|listening' "$tmp/rproxy.log" || true
printf '\n%sNginx proxy headers%s\n' "$bold" "$reset"
toplevel=$(nix build --print-out-paths '.#nixosConfigurations.pikachu.config.containers.reverse-proxy.config.system.build.toplevel' --no-link)
nginx_conf=$(readlink -f "$toplevel/etc/nginx/nginx.conf")
include_path=$(awk '/server_name files\.alexion\.dev;/{in_server=1} in_server && /include .*recommended-proxy_set_header/{print $2; exit}' "$nginx_conf" | tr -d ';')
cat "$include_path"

View File

@@ -1,4 +1,9 @@
alexion-password: ENC[AES256_GCM,data:EGTsJvW4/zA+FfhUtZD4E6HIsI0JQ3LLL511XjI9hDl3NaHNe4g+wWy7P5DQnW29G5W4TaPZIetUcV7OO+1btuRxTuwbWVOulA==,iv:Ihf1kz5DFtvy1bUnfQGAU0/BQrwUvqKwqnK1JbKnWHU=,tag:K0N/6H/IWesNMxzXhQQO/w==,type:str]
services:
copyparty:
users:
alexion:
password: ENC[AES256_GCM,data:SSyo2l6tCjgjxIuCEREIxU+inw2WHtJJR0/IEKUqefFfuAdA5Nf+lH/jfPw=,iv:9225w1Yur3KL3dBr1ct1aPvDdVJ94O8q9qAUiRrj8Nc=,tag:qZgx4XieVq4BcxkTZc0KGQ==,type:str]
sops:
age:
- enc: |
@@ -28,7 +33,7 @@ sops:
QkVLBz5x+ShVun27nN3oz8And0qXfwgXojhM3yWnBSUa9CFBsbTOJQ==
-----END AGE ENCRYPTED FILE-----
recipient: age1wf5s0n0tgt6ld2ysgu9dc67mj8ylwecgl4utzg7hqwy3kut9zyms7aglmh
lastmodified: "2026-07-20T03:22:00Z"
mac: ENC[AES256_GCM,data:ei7PKVAIjJ6fGkxqJFc5wdYapq1gElel3fTJ+yKhvWHU+39aKcllG66T3d9FitRztgyt69phykHdKvxDHRUwYeyl1YBzyf1ZpPU5mXJb+hkLtVB1Am7StcP+m7jFqKSmqtYhIT9OxUrH0MJ8qeoU9216otwkhhpPz2hr1s7KYFk=,iv:Pp03KmlinjJiiTZezr0LzzkcHb1a5XWgDpu38jhl9Rk=,tag:HAqFitge+KTCtDE24t8/ig==,type:str]
lastmodified: "2026-08-17T00:30:50Z"
mac: ENC[AES256_GCM,data:bNOsakxhrlO1quwLGA8inruHZ6tFpUo6cKMlV10jkJ75GOFzl5jzflGIuPfyncJCtIaZRBA2tS17TpGEEJozmbB2xQkeu67NmzOpEdoJzZWs/bY0FslbpGf8aGAd15BjOYNyMMNFavt0anRgEnsYXTDCflDCHLg1wvAvwtgRGYs=,iv:kfpzzWn3BHkSTQ8qpcD9lYn4wnZ/3nj/41SR25vUahE=,tag:8D1LSEBJIQcwYK9ETomwIw==,type:str]
unencrypted_suffix: _unencrypted
version: 3.13.2
version: 3.13.3