Compare commits
10 Commits
369f130d65
...
throwaway/
| Author | SHA1 | Date | |
|---|---|---|---|
| 4598993dfe | |||
| 680cdb75eb | |||
| 0141962fef | |||
| 114f0445cf | |||
| d6c60e4fa1 | |||
| 409a3cbc7f | |||
| acdc497e4c | |||
| 14da6e5c2e | |||
| 1226b4d185 | |||
| 2d43a9853d |
17
AGENTS.md
17
AGENTS.md
@@ -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
116
flake.nix
@@ -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
50
guests/actualbudget.nix
Normal 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
136
guests/copyparty.nix
Normal 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
61
guests/reverse-proxy.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -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
94
lib/default.nix
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
77
lib/reverse-proxy-routes.nix
Normal file
77
lib/reverse-proxy-routes.nix
Normal 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; };
|
||||
}
|
||||
);
|
||||
}
|
||||
59
lib/service-identities.nix
Normal file
59
lib/service-identities.nix
Normal 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
82
modules/reverse-proxy.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
}
|
||||
63
modules/services/actualbudget.nix
Normal file
63
modules/services/actualbudget.nix
Normal 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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
201
modules/services/copyparty.nix
Normal file
201
modules/services/copyparty.nix
Normal 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 ];
|
||||
};
|
||||
}
|
||||
105
prototypes/copyparty-rproxy-config/run.sh
Executable file
105
prototypes/copyparty-rproxy-config/run.sh
Executable 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"
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user