Compare commits

...

14 Commits

24 changed files with 961 additions and 246 deletions

View File

@@ -101,3 +101,8 @@ 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.

519
flake.nix
View File

@@ -118,25 +118,111 @@
touch $out
'';
reverse-proxy-basic-route =
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/reverse-proxy.nix
./modules/services/copyparty.nix
{
modules.reverse-proxy = {
modules.services.copyparty = {
enable = true;
routes."budget.alexion.dev" = {
path = "/";
backend = "http://10.23.20.42:5006";
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;
};
modules = [
./modules/reverse-proxy.nix
{
modules.reverse-proxy.enable = true;
}
];
}).config;
vhost = testConfig.services.nginx.virtualHosts."budget.alexion.dev";
in
pkgs.runCommand "reverse-proxy-basic-route-check" { } ''
@@ -150,57 +236,414 @@
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;
testConfig = self.nixosConfigurations.pikachu.config;
route = testConfig.registry.route.pikachu.actualbudget;
vhost = testConfig.containers.reverse-proxy.config.services.nginx.virtualHosts."budget.alexion.dev";
in
pkgs.runCommand "reverse-proxy-actualbudget-guest-route-check" { } ''
${
lib.optionalString (
!(route.host == "budget.alexion.dev" && route.backend == "http://10.23.20.42:5006")
!(
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"
)
) "exit 1"
}
touch $out
'';
reverse-proxy-secondary-path-requires-root =
generic-registry-fleet-view =
let
testConfig =
(lib.nixosSystem {
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;
};
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";
};
};
modules.reverse-proxy.enable = true;
}
];
}).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;
vhosts = testConfig.services.nginx.virtualHosts;
in
pkgs.runCommand "reverse-proxy-secondary-path-requires-root-check" { } ''
${lib.optionalString (!hasFailedRootAssertion) "exit 1"}
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 = {
enable = true;
vlan = 20;
address = "10.23.20.126";
reverseProxy = {
enable = true;
route = {
domain = "files.alexion.dev";
port = 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";
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"}
touch $out
'';
};

View File

@@ -13,6 +13,7 @@
# 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

@@ -43,6 +43,10 @@ in
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;
@@ -77,6 +81,12 @@ in
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 = { };

View File

@@ -6,35 +6,55 @@
}:
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;
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";
};
};
networking.firewall.allowedTCPPorts = [ 2022 ];

View File

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

View File

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

View File

@@ -3,7 +3,6 @@
self,
specialArgs,
bridgeName,
reverseProxyRoutes,
}:
let
inherit (lib) escapeShellArg mapAttrsToList;
@@ -78,8 +77,7 @@ let
systemd.network.networks."20-eth0" = {
matchConfig.Name = "eth0";
linkConfig.MACAddress = cfg.mac;
networkConfig = lib.mkIf (cfg.address == null) { DHCP = "yes"; };
address = lib.mkIf (cfg.address != null) [ cfg.address ];
networkConfig.DHCP = "yes";
};
};
};
@@ -125,15 +123,19 @@ let
address = lib.mkOption {
type = lib.types.nullOr lib.types.str;
default = null;
example = "10.0.10.5/24";
example = "10.23.20.126";
description = ''
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.
Expected IP address assigned outside NixOS for this guest.
Left null, this guest contributes no address to the address registry.
'';
};
reverseProxy = reverseProxyRoutes.mkRouteOptions {
subject = "guest route";
enableDefault = false;
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.";
};
};
mounts = lib.mkOption {
type = lib.types.attrsOf (
@@ -278,9 +280,9 @@ let
config = lib.mkIf cfg.enable {
sops.secrets = lib.genAttrs cfg.secrets (_: { });
modules.reverse-proxy.routes.${machineName} = lib.mkIf cfg.reverseProxy.enable (
reverseProxyRoutes.routeFields cfg.reverseProxy
);
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;
assertions = [
{

20
lib/network-endpoints.nix Normal file
View File

@@ -0,0 +1,20 @@
{ 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;
}

57
lib/registry.nix Normal file
View File

@@ -0,0 +1,57 @@
{ 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

@@ -1,77 +0,0 @@
{ 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,6 +13,8 @@ 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,6 +11,38 @@ 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";
@@ -55,6 +87,7 @@ 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,6 +12,7 @@ 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
@@ -38,6 +39,14 @@ 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";
@@ -63,6 +72,7 @@ in
modules-center = [ "clock" ];
modules-right = [
"custom/recording"
"custom/idle-lock"
"mpris"
"wireplumber"
"network"
@@ -155,6 +165,13 @@ in
exec = "${recStatus}";
interval = 1;
};
"custom/idle-lock" = {
return-type = "json";
exec = "${idleLockStatus}";
interval = "once";
signal = 9;
};
};
};
};

View File

@@ -8,6 +8,8 @@
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 = {
@@ -27,10 +29,8 @@ in
home-manager.users.${user} = {
home.packages = with pkgs; [
eza # backs the ls/la/ll aliases
bat # backs the manpager
fastfetch # the shell greeting
wget # backs the wget abbreviation
bat
fastfetch
];
programs.fish = {
@@ -51,37 +51,8 @@ in
}
];
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";
};
shellAliases = commandsOf (lib.filterAttrs (_name: shortcut: !shortcut.abbreviation) shortcuts);
shellAbbrs = commandsOf (lib.filterAttrs (_name: shortcut: shortcut.abbreviation) shortcuts);
functions = {
# Pick the logo that fits the terminal so the greeting never wraps.
@@ -120,6 +91,11 @@ 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

@@ -0,0 +1,53 @@
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,6 +14,11 @@ 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,65 +1,71 @@
{ config, lib, my, ... }:
{
config,
lib,
registry,
...
}:
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);
mkRegistryPath = registryName: host: entry: "registry.${registryName}.${host}.${entry}";
rootRoutesByHost = lib.mapAttrs (
host: routes:
mkBackend = address: route: "http://${address}:${toString route.port}";
mkVirtualHost = host: entry:
let
roots = builtins.filter (route: route.path == "/") routes;
compositeEntry = registry.join host entry;
route = compositeEntry.route;
address = compositeEntry.address or (throw "no matching entry in registry.address for ${mkRegistryPath "route" host entry}");
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
);
{
inherit host entry;
name = route.domain;
value = {
forceSSL = true;
enableACME = true;
locations."/".proxyPass = mkBackend address route;
};
};
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 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.";
};
enable = lib.mkEnableOption "the public edge reverse proxy";
};
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
@@ -72,11 +78,18 @@ 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 = lib.mapAttrs mkVirtualHost routesByHost;
virtualHosts = generatedVirtualHosts;
};
};
}

View File

@@ -48,6 +48,14 @@ let
''}
'';
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" ''
@@ -68,7 +76,7 @@ let
ses-db: ${cfg.stateDir}/sessions.db
chpw-db: ${cfg.stateDir}/chpw.json
shr-db: ${cfg.stateDir}/shares.db
${renderedReverseProxy}
[accounts]
${cfg.accountName}: $password
@@ -128,6 +136,16 @@ in
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 {
@@ -136,6 +154,10 @@ in
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} = {

View File

@@ -13,6 +13,8 @@ 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,7 +1,13 @@
{ config, lib, ... }:
{
config,
lib,
pkgs,
...
}:
# The baseline interactive toolset as one deliberate bundle.
let
cfg = config.modules.toolkit;
user = config.user.name;
in
{
options.modules.toolkit.enable =
@@ -9,6 +15,52 @@ 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;

4
registry/address.nix Normal file
View File

@@ -0,0 +1,4 @@
{ lib }:
{
type = lib.types.str;
}

18
registry/route.nix Normal file
View File

@@ -0,0 +1,18 @@
{ 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

@@ -0,0 +1,21 @@
{ 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;
}