Compare commits

..

1 Commits

Author SHA1 Message Date
4598993dfe prototype copyparty reverse proxy config 2026-08-17 12:45:40 -04:00
23 changed files with 349 additions and 926 deletions

View File

@@ -101,8 +101,3 @@ The domain model (Host, Module, Skeleton, Auto-loader, Enable convention, overla
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.
- Remote deploy helpers that run target-side `sudo` over SSH need a TTY.
Use `ssh -t` unless the target command is guaranteed not to prompt for sudo credentials.
- A NixOS module function argument default such as `registry ? config.registry` is not a safe fallback for an unprovided module argument.
The module system still queries `_module.args.registry` and fails when it is absent.
When an optional special-argument fallback is intentionally needed, bind the module arguments through `@args` and test `args ? <name>` inside the module body.

515
flake.nix
View File

@@ -118,108 +118,22 @@
touch $out
'';
copyparty-reverse-proxy-trust-config =
let
pikachuConfig = self.nixosConfigurations.pikachu.config;
serviceConfig = pikachuConfig.containers.copyparty.config.systemd.services.copyparty.serviceConfig;
configScript = builtins.readFile serviceConfig.ExecStartPre;
plainConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
./modules/services/copyparty.nix
{
modules.services.copyparty = {
enable = true;
passwordFile = "/run/password";
volumes."/" = {
path = "/srv/files";
access.A = [ "alexion" ];
};
};
}
];
}).config;
plainScript = builtins.readFile plainConfig.systemd.services.copyparty.serviceConfig.ExecStartPre;
in
pkgs.runCommand "copyparty-reverse-proxy-trust-config-check" { } ''
${
lib.optionalString (
!(
lib.hasInfix " rproxy: 1" configScript
&& lib.hasInfix " xff-hdr: x-forwarded-for" configScript
&& lib.hasInfix " xff-src: 10.23.20.109/32" configScript
&& !(lib.hasInfix "rproxy" plainScript)
&& !(lib.hasInfix "xff-hdr" plainScript)
&& !(lib.hasInfix "xff-src" plainScript)
)
) "exit 1"
}
touch $out
'';
copyparty-reverse-proxy-trust-requires-source =
let
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
./modules/services/copyparty.nix
{
modules.services.copyparty = {
enable = true;
passwordFile = "/run/password";
reverseProxy.enable = true;
volumes."/" = {
path = "/srv/files";
access.A = [ "alexion" ];
};
};
}
];
}).config;
hasFailedTrustedSourcesAssertion = lib.any (
assertion:
assertion.assertion == false
&& assertion.message == "modules.services.copyparty.reverseProxy.trustedSources must name at least one trusted proxy source when reverse-proxy support is enabled."
) testConfig.assertions;
in
pkgs.runCommand "copyparty-reverse-proxy-trust-requires-source-check" { } ''
${lib.optionalString (!hasFailedTrustedSourcesAssertion) "exit 1"}
touch $out
'';
reverse-proxy-basic-route =
let
routeHost = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.test.budget = "10.23.20.42";
registry.route.test.budget = {
domain = "budget.alexion.dev";
port = 5006;
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect { hosts.test = routeHost; };
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
my = self.lib;
inherit registry;
};
specialArgs.my = self.lib;
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
modules.reverse-proxy = {
enable = true;
routes."budget.alexion.dev" = {
path = "/";
backend = "http://10.23.20.42:5006";
};
};
}
];
}).config;
@@ -236,414 +150,57 @@
reverse-proxy-actualbudget-guest-route =
let
testConfig = self.nixosConfigurations.pikachu.config;
route = testConfig.registry.route.pikachu.actualbudget;
vhost = testConfig.containers.reverse-proxy.config.services.nginx.virtualHosts."budget.alexion.dev";
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.domain == "budget.alexion.dev"
&& route.port == 5006
&& !(route ? backend)
&& !(route ? path)
&& !(route ? scheme)
&& !(route ? aliases)
&& !(route ? tls)
&& !(route ? websockets)
&& !(route ? enable)
&& vhost.locations."/".proxyPass == "http://10.23.20.42:5006"
)
!(route.host == "budget.alexion.dev" && route.backend == "http://10.23.20.42:5006")
) "exit 1"
}
touch $out
'';
generic-registry-fleet-view =
reverse-proxy-secondary-path-requires-root =
let
hosts = {
alpha = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.registry = registry;
modules = [
self.lib.registry.module
{
registry.address.alpha.service = "10.0.0.5";
system.stateVersion = "26.11";
}
];
};
beta = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.registry = registry;
modules = [
self.lib.registry.module
(
{ registry, ... }:
{
environment.etc."registry-address-check".text = registry.address.alpha.service;
system.stateVersion = "26.11";
}
)
];
};
};
registry = self.lib.registry.collect { inherit hosts; };
betaConfig = hosts.beta.config;
in
pkgs.runCommand "generic-registry-fleet-view-check" { } ''
${
lib.optionalString (
!(
registry.address.alpha.service == "10.0.0.5"
&& betaConfig.environment.etc."registry-address-check".text == "10.0.0.5"
&& !(registry.address ? alpha-service)
)
) "exit 1"
}
touch $out
'';
shell-shortcut-registry-fish-abbreviations =
let
testConfig = self.nixosConfigurations.neogaia.config;
user = testConfig.user.name;
shortcuts = testConfig.registry.shell-shortcut.${testConfig.networking.hostName};
fishConfig = testConfig.home-manager.users.${user}.programs.fish;
abbrs = fishConfig.shellAbbrs;
aliases = fishConfig.shellAliases;
expectedAliases = {
ls = "eza -alg --color=always --group-directories-first --icons=always";
la = "eza -a --color=always --group-directories-first --icons=always";
ll = "eza -lg --color=always --group-directories-first --icons=always";
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
"l." = "eza -a | grep -e '^\\.'";
};
expectedAbbrs = {
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
vi = "nvim";
vim = "nvim";
cp = "cp -v";
tmx = "tmux new-session -A -s";
hs = "herdr --session";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
update = "sudo nixos-rebuild switch --flake .#${testConfig.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
};
in
pkgs.runCommand "shell-shortcut-registry-fish-abbreviations-check" { } ''
${
lib.optionalString (
!(
lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts == (expectedAliases // expectedAbbrs)
&& lib.filterAttrs (name: _value: builtins.hasAttr name expectedAliases) aliases == expectedAliases
&& lib.filterAttrs (name: _value: builtins.hasAttr name expectedAbbrs) abbrs == expectedAbbrs
)
) "exit 1"
}
touch $out
'';
address-registry =
let
hostAddressConfig =
lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.neogaia.self = "10.23.50.146";
system.stateVersion = "26.11";
}
];
};
guestAddressConfig =
lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
inputs.sops-nix.nixosModules.sops
self.lib.registry.module
./modules/network.nix
(self.lib.guest { name = "copyparty"; })
{
networking.hostName = "pikachu";
modules.network.vlans = [ 20 ];
guests.copyparty = {
enable = true;
vlan = 20;
address = "10.23.20.126";
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect {
hosts = {
neogaia = hostAddressConfig;
pikachu = guestAddressConfig;
};
};
fleetEndpoints = self.lib.networkEndpoints.collectFleetEndpoints {
neogaia = hostAddressConfig;
pikachu = guestAddressConfig;
};
in
pkgs.runCommand "address-registry-check" { } ''
${
lib.optionalString (
!(
registry.address.neogaia.self == "10.23.50.146"
&& registry.address.pikachu.copyparty == "10.23.20.126"
&& fleetEndpoints.neogaia.address == "10.23.50.146"
&& fleetEndpoints."pikachu-copyparty".address == "10.23.20.126"
&& !(fleetEndpoints ? pikachu)
&& guestAddressConfig.config.containers.copyparty.config.systemd.network.networks."20-eth0".networkConfig.DHCP == "yes"
)
) "exit 1"
}
touch $out
'';
reverse-proxy-fleet-routes =
let
routeHost =
lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.routeHost.app = "10.23.20.126";
registry.route.routeHost.app = {
domain = "worker.alexion.dev";
port = 3923;
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect {
hosts = {
inherit routeHost;
pikachu = self.nixosConfigurations.pikachu;
};
};
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
my = self.lib;
inherit registry;
};
specialArgs.my = self.lib;
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
}
];
}).config;
vhosts = testConfig.services.nginx.virtualHosts;
in
pkgs.runCommand "reverse-proxy-fleet-routes-check" { } ''
${
lib.optionalString (
!(
registry.route ? routeHost
&& registry.route.routeHost ? app
&& registry.route.pikachu ? actualbudget
&& vhosts."worker.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923"
&& vhosts."budget.alexion.dev".locations."/".proxyPass == "http://10.23.20.42:5006"
&& vhosts."files.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923"
)
) "exit 1"
}
touch $out
'';
reverse-proxy-address-backends =
let
workerHost =
lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.worker.self = "10.23.50.146";
registry.route.worker.self = {
domain = "worker.alexion.dev";
port = 8080;
};
system.stateVersion = "26.11";
}
];
};
guestRouteHost =
lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
inputs.sops-nix.nixosModules.sops
self.lib.registry.module
./modules/network.nix
(self.lib.guest { name = "files"; })
{
networking.hostName = "pikachu";
modules.network.vlans = [ 20 ];
guests.files = {
modules.reverse-proxy = {
enable = true;
vlan = 20;
address = "10.23.20.126";
reverseProxy = {
enable = true;
route = {
domain = "files.alexion.dev";
port = 3923;
};
routes.raichu-files = {
host = "files.alexion.dev";
path = "/raichu";
backend = "http://10.23.20.10:3923";
};
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect {
hosts = {
worker = workerHost;
pikachu = guestRouteHost;
};
};
testConfig =
(lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
my = self.lib;
inherit registry;
};
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
}
];
}).config;
vhosts = testConfig.services.nginx.virtualHosts;
workerCompositeEntry = registry.join "worker" "self";
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-address-backends-check" { } ''
${
lib.optionalString (
!(
workerCompositeEntry.address == "10.23.50.146"
&& workerCompositeEntry.route.domain == "worker.alexion.dev"
&& vhosts."worker.alexion.dev".locations."/".proxyPass == "http://10.23.50.146:8080"
&& registry.route.pikachu.files.port == 3923
&& vhosts."files.alexion.dev".locations."/".proxyPass == "http://10.23.20.126:3923"
)
) "exit 1"
}
touch $out
'';
reverse-proxy-route-requires-address =
let
routeHost = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.route.raichu.files = {
domain = "files.alexion.dev";
port = 3923;
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect { hosts.raichu = routeHost; };
testConfig = lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
my = self.lib;
inherit registry;
};
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
system.stateVersion = "26.11";
}
];
};
result = builtins.tryEval (builtins.deepSeq testConfig.config.services.nginx.virtualHosts true);
in
pkgs.runCommand "reverse-proxy-route-requires-address-check" { } ''
${lib.optionalString result.success "exit 1"}
touch $out
'';
reverse-proxy-duplicate-domain-fails =
let
pikachu = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.pikachu.copyparty = "10.23.20.126";
registry.route.pikachu.copyparty = {
domain = "files.alexion.dev";
port = 3923;
};
system.stateVersion = "26.11";
}
];
};
raichu = lib.nixosSystem {
system = "x86_64-linux";
specialArgs.my = self.lib;
modules = [
self.lib.registry.module
{
registry.address.raichu.files = "10.23.20.10";
registry.route.raichu.files = {
domain = "files.alexion.dev";
port = 3923;
};
system.stateVersion = "26.11";
}
];
};
registry = self.lib.registry.collect { hosts = { inherit pikachu raichu; }; };
testConfig = lib.nixosSystem {
system = "x86_64-linux";
specialArgs = {
my = self.lib;
inherit registry;
};
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
system.stateVersion = "26.11";
}
];
};
result = builtins.tryEval (builtins.deepSeq testConfig.config.services.nginx.virtualHosts true);
in
pkgs.runCommand "reverse-proxy-duplicate-domain-fails-check" { } ''
${lib.optionalString result.success "exit 1"}
pkgs.runCommand "reverse-proxy-secondary-path-requires-root-check" { } ''
${lib.optionalString (!hasFailedRootAssertion) "exit 1"}
touch $out
'';
};

View File

@@ -13,7 +13,6 @@
# The modules tree reaches for these option namespaces, so they must be
# declared for the tree to evaluate even where a guest leaves them off.
my.registry.module
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
];

View File

@@ -6,55 +6,35 @@
}:
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;
services.nginx.virtualHosts = {
"games.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"git.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"music.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"photos.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"plex.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"servers.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"todo.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
"wings.alexion.dev" = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = "http://10.23.20.32";
};
modules.reverse-proxy = {
enable = true;
routes = config.modules.reverse-proxy.routes // legacyRoutes;
};
networking.firewall.allowedTCPPorts = [ 2022 ];

View File

@@ -1,9 +1,4 @@
{
config,
pkgs,
registry,
...
}:
{ pkgs, ... }:
# pikachu — AZW ME Pro server.
# Disk layout is in ./disk.nix.
# `fileSystems` for the root disk are derived from it.
@@ -84,13 +79,10 @@
vlan = 20;
mac = "BC:24:11:C1:CD:28";
dataPath = "/pikachu/data/actualbudget";
address = "10.23.20.42";
reverseProxy = {
enable = true;
route = {
domain = "budget.alexion.dev";
port = 5006;
};
host = "budget.alexion.dev";
backend = "http://10.23.20.42:5006";
};
};
@@ -98,7 +90,6 @@
enable = true;
vlan = 20;
mac = "02:98:64:5a:07:4f";
address = "10.23.20.109";
};
guests.copyparty = {
@@ -106,14 +97,11 @@
vlan = 20;
mac = "02:23:20:39:23:01";
statePath = "/pikachu/data/copyparty";
address = "10.23.20.126";
reverseProxy = {
enable = true;
route = {
domain = "files.alexion.dev";
port = 3923;
};
trustedSources = [ "${registry.address.${config.networking.hostName}."reverse-proxy"}/32" ];
host = "files.alexion.dev";
backend = "http://10.23.20.126:3923";
trustedSources = [ "10.23.20.109/32" ];
};
volumes = {
"/inbox" = {

View File

@@ -14,8 +14,7 @@ let
;
serviceIdentityRegistry = import ./service-identities.nix { inherit lib; };
registry = import ./registry.nix { inherit lib self; };
networkEndpoints = import ./network-endpoints.nix { inherit lib; };
reverseProxyRoutes = import ./reverse-proxy-routes.nix { inherit lib; };
collectNixFiles =
dir:
@@ -35,9 +34,8 @@ let
);
specialArgs = {
inherit inputs self;
inherit inputs;
my = self.lib;
registry = self.lib.registry.collect { hosts = self.nixosConfigurations; };
};
bridgeName = id: "br-vlan${toString id}";
@@ -48,6 +46,7 @@ let
self
specialArgs
bridgeName
reverseProxyRoutes
;
};
@@ -66,7 +65,6 @@ let
inputs.disko.nixosModules.disko
inputs.sops-nix.nixosModules.sops
inputs.stylix.nixosModules.stylix
registry.module
(self + "/system.nix")
(self + "/hosts/${hostName}")
{ networking.hostName = hostName; }
@@ -86,8 +84,7 @@ in
mkHost
mkHosts
bridgeName
registry
networkEndpoints
reverseProxyRoutes
;
inherit (guests) deriveMac guest;

View File

@@ -3,6 +3,7 @@
self,
specialArgs,
bridgeName,
reverseProxyRoutes,
}:
let
inherit (lib) escapeShellArg mapAttrsToList;
@@ -77,7 +78,8 @@ let
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig.DHCP = "yes";
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
address = lib.mkIf (cfg.address != null) [ cfg.address ];
};
};
};
@@ -123,19 +125,15 @@ let
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.23.20.126";
example = "10.0.10.5/24";
description = ''
Expected IP address assigned outside NixOS for this guest.
Left null, this guest contributes no address to the address registry.
The guest's static address, in CIDR form, on its VLAN. Left null, the
guest takes its address by DHCP, keeping IP management at the router.
'';
};
reverseProxy = {
enable = lib.mkEnableOption "a reverse-proxy route for this guest";
route = lib.mkOption {
type = self.lib.registry.definitions.route.type;
description = "Route registry entry published for this guest.";
};
reverseProxy = reverseProxyRoutes.mkRouteOptions {
subject = "guest route";
enableDefault = false;
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
@@ -280,9 +278,9 @@ let
config = lib.mkIf cfg.enable {
sops.secrets = lib.genAttrs cfg.secrets (_: { });
registry.address.${config.networking.hostName}.${machineName} = lib.mkIf (cfg.address != null) cfg.address;
registry.route.${config.networking.hostName}.${machineName} = lib.mkIf cfg.reverseProxy.enable cfg.reverseProxy.route;
modules.reverse-proxy.routes.${machineName} = lib.mkIf cfg.reverseProxy.enable (
reverseProxyRoutes.routeFields cfg.reverseProxy
);
assertions = [
{

View File

@@ -1,20 +0,0 @@
{ lib }:
# TODO: Remove this compatibility bridge when route backend resolution reads registry.address directly.
let
endpointFields = address: { inherit address; };
collectFleetEndpoints = hosts:
lib.concatMapAttrs (
hostName: host:
lib.mapAttrs' (
entryName: address:
let
endpointName = if entryName == "self" then hostName else "${hostName}-${entryName}";
in
lib.nameValuePair endpointName (endpointFields address)
) (host.config.registry.address.${hostName} or { })
) hosts;
in
{
inherit collectFleetEndpoints endpointFields;
}

View File

@@ -1,57 +0,0 @@
{ lib, self }:
let
inherit (lib)
filterAttrs
hasSuffix
mapAttrs
mapAttrs'
mkOption
nameValuePair
removeSuffix
types
;
registryName = fileName: removeSuffix ".nix" fileName;
registryOption = registry: mkOption {
type = types.attrsOf (types.attrsOf registry.type);
default = { };
description = ''
Lazily collected fleet-visible registry entries, nested by host and entry name.
'';
};
definitions = mapAttrs' (
name: type:
let
path = self + "/registry/${name}";
in
nameValuePair (registryName name) (import path { inherit lib; })
) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry")));
join = registry: host: entry:
filterAttrs (_name: value: value != null) (
mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
);
collectData =
{ hosts }:
mapAttrs (
registryName: _registry:
mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions;
withOperations = registry:
registry
// {
join = host: entry: join registry host entry;
};
in
{
inherit definitions;
module = {
options.registry = mapAttrs (_name: registry: registryOption registry) definitions;
};
collect = args: withOperations (collectData args);
}

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

@@ -13,8 +13,6 @@ in
options.modules.agents.herdr.enable = lib.mkEnableOption "Herdr, a terminal multiplexer for coding agents";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.hs = "herdr --session";
home-manager.users.${user} = {
home.packages = [ pkgs.herdr ];

View File

@@ -11,38 +11,6 @@ let
# The hyprlock this module installs, used by the lock keybind below.
hyprlock = "${config.home-manager.users.${user}.programs.hyprlock.package}/bin/hyprlock";
sleepIcon = builtins.fromJSON ''"\udb81\udcb2"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
idleLockToggle = pkgs.writeShellScript "lockscreen-idle-inhibit-toggle" ''
unit=lockscreen-idle-inhibit.service
refresh_waybar() {
${pkgs.procps}/bin/pkill -RTMIN+9 waybar >/dev/null 2>&1 || true
}
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet "$unit"; then
${pkgs.systemd}/bin/systemctl --user stop "$unit"
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepIcon}"
exit 0
fi
if ${pkgs.systemd}/bin/systemd-run \
--user \
--collect \
--unit=lockscreen-idle-inhibit \
--description="Disable idle-triggered screen locking" \
${pkgs.systemd}/bin/systemd-inhibit \
--what=idle \
--who=lockscreen-hotkey \
--why="lock screen disabled by hotkey" \
${pkgs.coreutils}/bin/sleep infinity >/dev/null; then
refresh_waybar
${pkgs.swayosd}/bin/swayosd-client --custom-message "${sleepOffIcon}"
fi
'';
in
{
options.modules.desktop.hyprland.hyprlock.enable = lib.mkEnableOption "the hyprlock lock screen";
@@ -87,7 +55,6 @@ in
# hyprlock never stacks over the first.
wayland.windowManager.hyprland.settings.bind = [
"$mod, X, exec, ${pkgs.procps}/bin/pidof hyprlock || ${hyprlock}"
"$mod CTRL, X, exec, ${idleLockToggle}"
];
};
};

View File

@@ -12,7 +12,6 @@ let
# A Nerd Font Private-Use-Area codepoint as a literal character, decoded
# through JSON so the glyph survives as bytes rather than an editor paste.
g = code: builtins.fromJSON ''"\u${code}"'';
sleepOffIcon = builtins.fromJSON ''"\udb81\udcb3"'';
# Renders and toggles mako's do-not-disturb mode.
# With no mako daemon answering, the status reads "notifications on", keeping
@@ -39,14 +38,6 @@ let
printf '{"text":"","class":"idle"}\n'
fi
'';
idleLockStatus = pkgs.writeShellScript "waybar-idle-lock-status" ''
if ${pkgs.systemd}/bin/systemctl --user is-active --quiet lockscreen-idle-inhibit.service; then
printf '{"text":"${sleepOffIcon}","tooltip":"Idle locking inhibited","class":"inhibited"}\n'
else
printf '{"text":"","class":"idle"}\n'
fi
'';
in
{
options.modules.desktop.waybar.enable = lib.mkEnableOption "the Waybar status bar";
@@ -72,7 +63,6 @@ in
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
"network"
@@ -165,13 +155,6 @@ in
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -8,8 +8,6 @@
let
cfg = config.modules.fish;
user = config.user.name;
shortcuts = config.registry.shell-shortcut.${config.networking.hostName};
commandsOf = shortcuts: lib.mapAttrs (_name: shortcut: shortcut.command) shortcuts;
in
{
options.modules.fish = {
@@ -29,8 +27,10 @@ in
home-manager.users.${user} = {
home.packages = with pkgs; [
bat
fastfetch
eza # backs the ls/la/ll aliases
bat # backs the manpager
fastfetch # the shell greeting
wget # backs the wget abbreviation
];
programs.fish = {
@@ -51,8 +51,37 @@ in
}
];
shellAliases = commandsOf (lib.filterAttrs (_name: shortcut: !shortcut.abbreviation) shortcuts);
shellAbbrs = commandsOf (lib.filterAttrs (_name: shortcut: shortcut.abbreviation) shortcuts);
shellAliases = {
ls = "eza -alg --color=always --group-directories-first --icons=always";
la = "eza -a --color=always --group-directories-first --icons=always";
ll = "eza -lg --color=always --group-directories-first --icons=always";
lt = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
"l." = "eza -a | grep -e '^\\.'";
};
shellAbbrs = {
# Walk up the tree.
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
vi = "nvim";
vim = "nvim";
cp = "cp -v";
tmx = "tmux new-session -A -s";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
# Rebuild the system, and reclaim disk from old generations.
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
};
functions = {
# Pick the logo that fits the terminal so the greeting never wraps.
@@ -91,11 +120,6 @@ in
description = "Copy a file, or recursively copy a source directory into a destination";
body = builtins.readFile ./functions/copy.fish;
};
deploy-to = {
description = "Deploy the current dotfiles branch to a host through target-local nixos-rebuild";
body = builtins.readFile ./functions/deploy-to.fish;
};
};
# Rendered by home-manager into ~/.config/fish/config.fish.

View File

@@ -1,53 +0,0 @@
if test (count $argv) -lt 1; or test (count $argv) -gt 2
echo "usage: deploy-to <host> [branch]" >&2
return 2
end
set host $argv[1]
set branch $argv[2]
if test -z "$branch"
set branch (git branch --show-current)
end
if test -z "$branch"
echo "deploy-to: could not determine the current branch" >&2
return 2
end
git rev-parse --show-toplevel >/dev/null 2>&1
or begin
echo "deploy-to: run this from a git checkout" >&2
return 2
end
set dirty_lines (git status --porcelain)
if test (count $dirty_lines) -gt 0
echo "deploy-to: refusing to deploy with a dirty local worktree" >&2
return 1
end
git fetch origin
or return $status
set remote_ref refs/remotes/origin/$branch
git rev-parse --verify --quiet $remote_ref >/dev/null
or begin
echo "deploy-to: origin/$branch does not exist" >&2
return 1
end
set local_head (git rev-parse HEAD)
set remote_head (git rev-parse $remote_ref)
if test "$local_head" != "$remote_head"
echo "deploy-to: local HEAD differs from origin/$branch" >&2
echo "deploy-to: push or reset the branch before deploying" >&2
return 1
end
set escaped_branch (string escape -- $branch)
set escaped_host (string escape -- $host)
set remote_command "cd ~/wrk/dotfiles && git fetch origin && git switch -- $escaped_branch && git reset --hard origin/$escaped_branch && sudo nixos-rebuild switch --flake .#$escaped_host --print-build-logs"
ssh -t $host $remote_command

View File

@@ -14,11 +14,6 @@ in
options.modules.nvim.enable = lib.mkEnableOption "Neovim, configured declaratively via nixvim";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName} = {
vi = "nvim";
vim = "nvim";
};
home-manager.sharedModules = [ inputs.nixvim.homeModules.nixvim ];
home-manager.users.${user} = hm: {

View File

@@ -1,71 +1,65 @@
{
config,
lib,
registry,
...
}:
{ config, lib, my, ... }:
let
cfg = config.modules.reverse-proxy;
inherit (my) reverseProxyRoutes;
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
enabledRoutes = lib.filterAttrs (_: route: route.enable) cfg.routes;
routesByHost = lib.groupBy (route: route.host) (lib.attrValues enabledRoutes);
mkBackend = address: route: "http://${address}:${toString route.port}";
mkVirtualHost = host: entry:
rootRoutesByHost = lib.mapAttrs (
host: routes:
let
compositeEntry = registry.join host entry;
route = compositeEntry.route;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
roots = builtins.filter (route: route.path == "/") routes;
in
{
inherit host entry;
name = route.domain;
value = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = mkBackend address route;
};
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
);
};
generatedVirtualHostList = lib.concatLists (
lib.mapAttrsToList (
host: routes:
lib.mapAttrsToList (entry: _route: mkVirtualHost host entry) routes
) registry.route
);
virtualHostsByDomain = lib.foldl' (
acc: virtualHost:
acc
// {
${virtualHost.name} = (acc.${virtualHost.name} or [ ]) ++ [ virtualHost ];
}
) { } generatedVirtualHostList;
duplicateDomainMessages = lib.concatLists (
lib.mapAttrsToList (
domain: virtualHosts:
if lib.length virtualHosts > 1 then
[
"duplicate route domain ${domain} in ${lib.concatMapStringsSep " and " (virtualHost: mkRegistryPath "route" virtualHost.host virtualHost.entry) virtualHosts}"
]
else
[ ]
) virtualHostsByDomain
);
generatedVirtualHosts =
if duplicateDomainMessages != [ ] then
throw (builtins.head duplicateDomainMessages)
else
lib.listToAttrs generatedVirtualHostList;
in
{
options.modules.reverse-proxy = {
enable = lib.mkEnableOption "the public edge 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
@@ -78,18 +72,11 @@ in
services.nginx = {
enable = true;
commonHttpConfig = ''
server {
listen 0.0.0.0:443 ssl default_server;
listen [::0]:443 ssl default_server;
ssl_reject_handshake on;
}
'';
recommendedGzipSettings = true;
recommendedOptimisation = true;
recommendedProxySettings = true;
recommendedTlsSettings = true;
virtualHosts = generatedVirtualHosts;
virtualHosts = lib.mapAttrs mkVirtualHost routesByHost;
};
};
}

View File

@@ -13,8 +13,6 @@ in
options.modules.tmux.enable = lib.mkEnableOption "tmux, configured via home-manager";
config = lib.mkIf cfg.enable {
registry.shell-shortcut.${config.networking.hostName}.tmx = "tmux new-session -A -s";
home-manager.users.${user}.programs.tmux = {
enable = true;

View File

@@ -1,13 +1,7 @@
{
config,
lib,
pkgs,
...
}:
{ config, lib, ... }:
# The baseline interactive toolset as one deliberate bundle.
let
cfg = config.modules.toolkit;
user = config.user.name;
in
{
options.modules.toolkit.enable =
@@ -15,52 +9,6 @@ in
# Each piece is turned on at default priority, so a host can still override any one of them.
config = lib.mkIf cfg.enable {
home-manager.users.${user}.home.packages = with pkgs; [
eza
wget
];
registry.shell-shortcut.${config.networking.hostName} = {
ls = {
command = "eza -alg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
la = {
command = "eza -a --color=always --group-directories-first --icons=always";
abbreviation = false;
};
ll = {
command = "eza -lg --color=always --group-directories-first --icons=always";
abbreviation = false;
};
lt = {
command = "eza -aT -I '.git' --color=always --group-directories-first --icons=always";
abbreviation = false;
};
"l." = {
command = "eza -a | grep -e '^\\.'";
abbreviation = false;
};
# Walk up the tree.
".." = "cd ..";
"..." = "cd ../..";
"...." = "cd ../../..";
cp = "cp -v";
tarnow = "tar -acf ";
untar = "tar -zxvf ";
wget = "wget -c ";
grep = "grep --color=auto";
fgrep = "fgrep --color=auto";
egrep = "egrep --color=auto";
jctl = "journalctl -p 3 -xb";
# Rebuild the system, and reclaim disk from old generations.
update = "sudo nixos-rebuild switch --flake .#${config.networking.hostName}";
cleanup = "sudo nix-collect-garbage -d";
};
modules.fish.enable = lib.mkDefault true;
# fish is also the login shell, so the bundled environment is what a shell drops into.
modules.fish.defaultShell = lib.mkDefault true;

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 +0,0 @@
{ lib }:
{
type = lib.types.str;
}

View File

@@ -1,18 +0,0 @@
{ lib }:
{
type = lib.types.submodule {
options = {
domain = lib.mkOption {
type = lib.types.str;
example = "files.alexion.dev";
description = "Public domain served by this reverse-proxy route entry.";
};
port = lib.mkOption {
type = lib.types.ints.between 1 65535;
example = 3923;
description = "Backend TCP port served by the matching address registry entry.";
};
};
};
}

View File

@@ -1,21 +0,0 @@
{ lib }:
let
entryType = lib.types.submodule {
options = {
command = lib.mkOption {
type = lib.types.str;
example = "nvim";
description = "Command expanded by this shell shortcut.";
};
abbreviation = lib.mkOption {
type = lib.types.bool;
default = true;
description = "Whether fish realizes this shell shortcut as an abbreviation instead of an alias.";
};
};
};
in
{
type = lib.types.coercedTo lib.types.str (command: { inherit command; }) entryType;
}