Compare commits

...

20 Commits

Author SHA1 Message Date
e812f4632d chore(agents): disable global skills 2026-08-26 09:27:38 -04:00
a0604c708e refactor(flake): remove project-level checks 2026-08-26 09:15:09 -04:00
8eb873f57b chore(agents): update skills flake 2026-08-26 09:00:13 -04:00
48d9d3280f feat(registry): validate unique addresses and routes 2026-08-25 22:29:56 -04:00
f45aa44ead refactor(registry): add stable uid registry 2026-08-25 21:45:56 -04:00
622d09818f refactor(registry): remove legacy endpoint helpers 2026-08-25 00:26:32 -04:00
cb59b18703 feat(fish): add shell shortcut registry 2026-08-24 23:54:54 -04:00
45c6d01b1f refactor(reverse-proxy): use route registry 2026-08-24 21:58:54 -04:00
c267a2ec5c refactor(network): use address registry 2026-08-23 23:28:04 -04:00
6f963920ec feat(registry): add generic fleet namespace 2026-08-23 22:37:53 -04:00
21fa48d93d feat(fish): add herdr session abbreviation 2026-08-23 17:10:18 -04:00
cd602b74b6 feat(hyprlock): show idle inhibit feedback 2026-08-23 13:30:16 -04:00
34bf1c7c22 feat(hyprlock): add idle lock inhibit toggle 2026-08-23 12:31:51 -04:00
932dad911f feat(fish): add remote deploy helper 2026-08-22 20:03:15 -04:00
2c6d586cf5 feat(copyparty): trust reverse proxy headers 2026-08-22 14:05:48 -04:00
0781c7cb30 refactor(reverse-proxy): use endpoints for deployed routes 2026-08-22 12:51:34 -04:00
8838472001 feat(reverse-proxy): derive backends from endpoints 2026-08-20 20:04:34 -04:00
bd2c1583da feat(network): add fleet endpoint registry 2026-08-20 10:46:24 -04:00
4677dbe591 fix(reverse-proxy): reject unmatched HTTPS hosts 2026-08-17 23:04:13 -04:00
c50e996851 feat(reverse-proxy): collect fleet routes 2026-08-17 23:04:13 -04:00
30 changed files with 753 additions and 476 deletions

View File

@@ -101,3 +101,10 @@ 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.
- `self.lib.registry.collect { hosts = self.nixosConfigurations; }` reads top-level host `config.registry` declarations only.
Registry declarations inside guest interiors live under `config.containers.<guest>.config.registry` and do not enter the collected fleet registry unless host-side code also publishes them or the collector is explicitly extended to recurse into guests.
- 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.

8
flake.lock generated
View File

@@ -562,11 +562,11 @@
]
},
"locked": {
"lastModified": 1785695024,
"narHash": "sha256-DLLk6X5zu3cRT50p18uHVdwjGVtiS0t/661M34q02zU=",
"lastModified": 1787455371,
"narHash": "sha256-MJ0bX4/cksSbGl7v9o0l+Wy1xAEa3BgzG7Hs8EEGde0=",
"ref": "refs/heads/main",
"rev": "9b2a6bcd583d7d6bf7e5377c3632f601692df209",
"revCount": 54,
"rev": "9a1d21da4d3e70165ff772967d639df244dcc2ea",
"revCount": 55,
"type": "git",
"url": "https://git.alexion.dev/alexion/skills"
},

114
flake.nix
View File

@@ -98,111 +98,13 @@
];
};
# `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
'';
};
# `nix flake check` builds every discovered host 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;
};
}

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

@@ -6,7 +6,6 @@
}:
let
cfg = config.guests.copyparty;
serviceUid = my.serviceUid "copyparty";
volumeType = lib.types.submodule {
options = {
@@ -43,6 +42,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 +80,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 = { };
@@ -103,7 +112,7 @@ in
${cfg.stateDir} = {
hostPath = cfg.statePath;
create = {
owner = serviceUid;
owner = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = "storage";
mode = "0770";
};

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

@@ -31,6 +31,8 @@
# So wifi can be joined from the console.
networking.networkmanager.enable = true;
registry.address.neogaia.self = "10.23.50.146";
# So setup can be driven over the network.
# The matching host public keys sit beside this file in plaintext, since
# publishing them is their purpose.

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.
@@ -67,6 +72,8 @@
pools.pikachu = { };
};
registry.address.pikachu.self = "10.23.10.102";
modules.ssh.enable = true;
modules.ssh.hostKeys.sopsFile = ../../secrets/pikachu.yaml;
modules.ssh.userKey.sopsFile = ../../secrets/pikachu.yaml;
@@ -79,10 +86,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 +100,7 @@
enable = true;
vlan = 20;
mac = "02:98:64:5a:07:4f";
address = "10.23.20.109";
};
guests.copyparty = {
@@ -97,10 +108,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

@@ -13,8 +13,7 @@ let
mapAttrsToList
;
serviceIdentityRegistry = import ./service-identities.nix { inherit lib; };
reverseProxyRoutes = import ./reverse-proxy-routes.nix { inherit lib; };
registry = import ./registry.nix { inherit lib self; };
collectNixFiles =
dir:
@@ -34,8 +33,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 +46,6 @@ let
self
specialArgs
bridgeName
reverseProxyRoutes
;
};
@@ -65,6 +64,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,11 +84,9 @@ in
mkHost
mkHosts
bridgeName
reverseProxyRoutes
registry
;
inherit (guests) deriveMac guest;
inherit (serviceIdentityRegistry) serviceUid;
serviceIdentities = serviceIdentityRegistry.identities;
}

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 = [
{

216
lib/registry.nix Normal file
View File

@@ -0,0 +1,216 @@
{ lib, self }:
let
inherit (lib)
all
concatLists
concatMapStringsSep
filter
filterAttrs
foldl'
hasAttr
hasSuffix
mapAttrs
mapAttrs'
mapAttrsToList
mkOption
nameValuePair
removeSuffix
types
unique
;
checkType = types.submodule {
options = {
assertion = mkOption {
type = types.functionTo types.bool;
description = ''
Predicate run against this registry's flattened collected entries.
'';
};
message = mkOption {
type = types.str;
description = ''
Error message shown when this registry validation check fails.
'';
};
};
};
predicates = {
uniqueValue = value: entries: builtins.length (unique (map value entries)) == builtins.length entries;
};
registryLib = lib // {
registry = (lib.registry or { }) // {
inherit predicates;
};
};
registryDefinitionModule = {
options = {
type = mkOption {
type = types.raw;
description = ''
NixOS option type for one registry entry.
'';
};
replicate = mkOption {
type = types.bool;
default = false;
description = ''
Whether collected entries are merged and published under every host key.
'';
};
checks = mkOption {
type = types.listOf checkType;
default = [ ];
description = ''
Validation checks for this registry's flattened collected entries.
'';
};
};
};
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.
'';
};
mkRegistry = path:
let
evaluated = lib.evalModules {
specialArgs.lib = registryLib;
modules = [
registryDefinitionModule
path
];
};
in
{
inherit (evaluated.config) type replicate checks;
};
definitions = mapAttrs' (
name: type:
let
path = self + "/registry/${name}";
in
nameValuePair (registryName name) (mkRegistry path)
) (filterAttrs (name: type: type == "regular" && hasSuffix ".nix" name) (builtins.readDir (self + "/registry")));
flattenRegistryEntries = registryName: data:
concatLists (
mapAttrsToList (
host: entries:
mapAttrsToList (entry: value: {
path = "registry.${registryName}.${host}.${entry}";
inherit host entry value;
}) entries
) data
);
rawDeclarations = registryName: hosts:
mapAttrs (_hostName: host: host.config.registry.${registryName} or { }) hosts;
replicatedEntries = registryName: raw:
let
entries = concatLists (
concatLists (
mapAttrsToList (
_evalHost: hostDeclarations:
mapAttrsToList (
hostName: declarations:
mapAttrsToList (entryName: value: {
path = "registry.${registryName}.${hostName}.${entryName}";
inherit entryName value;
}) declarations
) hostDeclarations
) raw
)
);
in
foldl' (
acc: entry:
if hasAttr entry.entryName acc then
if acc.${entry.entryName}.value == entry.value then
acc
else
throw "conflicting replicated registry entry ${entry.path} does not match ${acc.${entry.entryName}.path}"
else
acc // {
${entry.entryName} = entry;
}
) { } entries;
replicatedData = registryName: hosts: raw:
let
entries = mapAttrs (_entryName: entry: entry.value) (replicatedEntries registryName raw);
in
mapAttrs (_hostName: _host: entries) hosts;
validationAssertions = data:
concatLists (
mapAttrsToList (
registryName: registry:
let
entries = flattenRegistryEntries registryName data.${registryName};
in
map (check: {
assertion = check.assertion entries;
inherit (check) message;
}) registry.checks
) definitions
);
assertValid = data:
let
assertions = validationAssertions data;
failures = filter (assertion: !assertion.assertion) assertions;
in
assert lib.assertMsg (all (assertion: assertion.assertion) assertions) (
concatMapStringsSep "\n" (assertion: assertion.message) failures
);
data;
join = registry: host: entry:
filterAttrs (_name: value: value != null) (
mapAttrs (registryName: _registry: (registry.${registryName}.${host} or { }).${entry} or null) definitions
);
collectData =
{ hosts }:
let
raw = mapAttrs (registryName: _registry: rawDeclarations registryName hosts) definitions;
data = mapAttrs (
registryName: registry:
if registry.replicate or false then
replicatedData registryName hosts raw.${registryName}
else
mapAttrs (hostName: host: host.config.registry.${registryName}.${hostName} or { }) hosts
) definitions;
in
assertValid data;
withOperations = registry:
registry
// {
join = host: entry: join registry host entry;
};
in
{
inherit definitions predicates;
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

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

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

@@ -1,33 +1,12 @@
{
config,
inputs,
pkgs,
...
}:
# Global agent skills, placed under the skills directory so they are active in
# every project.
let
user = config.user.name;
# The skills installed globally, as derivations from the skills flake.
# grill interviews the operator relentlessly to resolve a plan before building.
# design-skill drafts and audits Agent Skills for structural predictability.
# wayfinder, research, prototype, slice, and subagents guide work from exploration through implementation tickets and delegation.
# implement, test-driven-development, and review guide execution and validation once tickets are ready.
skills = with inputs.skills.packages.${pkgs.stdenv.hostPlatform.system}; [
grill
design-skill
wayfinder
research
prototype
slice
subagents
implement
test-driven-development
review
];
in
{
home-manager.sharedModules = [ inputs.skills.homeModules.default ];
home-manager.users.${user}.programs.agents.skills = skills;
home-manager.users.${user}.programs.agents.skills = [ ];
}

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
];
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

@@ -1,7 +1,6 @@
{
config,
lib,
my,
...
}:
let
@@ -29,12 +28,6 @@ in
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";
@@ -43,21 +36,27 @@ in
};
config = lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
uid = cfg.uid;
group = cfg.group;
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.actualbudget = 20001;
}
services.actual = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
settings = {
inherit (cfg) port dataDir;
(lib.mkIf cfg.enable {
users.users.${cfg.user} = {
isSystemUser = true;
uid = config.registry."stable-uid".${config.networking.hostName}.actualbudget;
group = cfg.group;
};
};
};
services.actual = {
enable = true;
openFirewall = true;
user = cfg.user;
group = cfg.group;
settings = {
inherit (cfg) port dataDir;
};
};
})
];
}

View File

@@ -1,7 +1,6 @@
{
config,
lib,
my,
pkgs,
...
}:
@@ -48,6 +47,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 +75,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
@@ -94,12 +101,6 @@ in
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";
@@ -128,52 +129,72 @@ in
default = { };
description = "Copyparty volumes keyed by their URL path.";
};
};
config = lib.mkIf cfg.enable {
assertions = [
{
assertion = cfg.volumes != { };
message = "modules.services.copyparty.volumes must declare at least one served volume.";
}
];
reverseProxy = {
enable = lib.mkEnableOption "reverse-proxy trust settings for Copyparty";
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);
trustedSources = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
description = "IP ranges that may supply Copyparty's forwarded client IP header.";
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
};
config = lib.mkMerge [
{
registry."stable-uid".${config.networking.hostName}.copyparty = 20002;
}
(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 = config.registry."stable-uid".${config.networking.hostName}.copyparty;
group = cfg.group;
home = cfg.stateDir;
};
systemd.services.copyparty = {
description = "Copyparty file server";
after = [ "network.target" ];
wantedBy = [ "multi-user.target" ];
serviceConfig = {
ReloadSignal = "SIGUSR1";
Type = "simple";
User = cfg.user;
Group = cfg.group;
WorkingDirectory = cfg.stateDir;
Environment = [
"HOME=${cfg.stateDir}"
"XDG_CONFIG_HOME=${cfg.stateDir}"
];
StateDirectory = "copyparty";
RuntimeDirectory = "copyparty";
RuntimeDirectoryMode = "0700";
LoadCredential = [ "password:${cfg.passwordFile}" ];
ExecStartPre = configScript;
ExecStart = "${lib.getExe cfg.package} -c /run/copyparty/copyparty.conf";
Restart = "on-failure";
NoNewPrivileges = true;
PrivateTmp = true;
ProtectSystem = "strict";
ProtectHome = true;
ReadWritePaths = [ cfg.stateDir ] ++ map (volume: volume.path) (lib.attrValues cfg.volumes);
};
};
networking.firewall.allowedTCPPorts = [ cfg.port ];
})
];
}

View File

@@ -1,6 +1,7 @@
{
config,
lib,
registry,
...
}:
# SSH on this machine, in both directions.
@@ -189,7 +190,7 @@ in
};
neogaia = {
hostName = "10.23.50.146";
hostName = registry.address.neogaia.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJS+wp7K123+4BT6G4f954R6WyrbWveY7VlpoBUf6I5p neogaia"
@@ -198,7 +199,7 @@ in
};
pikachu = {
hostName = "10.23.10.102";
hostName = registry.address.pikachu.self;
clientKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAINNqJIC6VRXyvrNf3n9su9KdPCikC3CjK/QrCK2reHdB alexion@pikachu";
hostKeys = [
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIKljRf4pJO+pqEqjpPz08gOYq3g1PpxvE66xVw7uMEnA root@pikachu"

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;

11
registry/address.nix Normal file
View File

@@ -0,0 +1,11 @@
{ lib, ... }:
{
type = lib.types.str;
checks = [
{
assertion = lib.registry.predicates.uniqueValue (entry: entry.value);
message = "Address registry values must be unique.";
}
];
}

25
registry/route.nix Normal file
View File

@@ -0,0 +1,25 @@
{ lib, ... }:
{
checks = [
{
assertion = lib.registry.predicates.uniqueValue (entry: entry.value.domain);
message = "Route domains must be unique.";
}
];
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;
}

31
registry/stable-uid.nix Normal file
View File

@@ -0,0 +1,31 @@
{ lib, ... }:
let
inherit (lib)
filterAttrs
groupBy
mapAttrs
unique
;
serviceNamesByUid = entries:
mapAttrs (_uid: uidEntries: unique (map (entry: entry.entry) uidEntries)) (
groupBy (entry: toString entry.value) entries
);
duplicateUids = entries:
filterAttrs (_uid: serviceNames: builtins.length serviceNames > 1) (serviceNamesByUid entries);
in
{
# Next stable UID: 20003
replicate = true;
type = lib.types.ints.positive;
checks = [
{
assertion = entries: duplicateUids entries == { };
message = "Stable UID allocations must be unique.";
}
];
}