refactor(ssh): restructure inventory and modularize helper logic

Relocate SSH inventory and rbw socket wiring from `modules/lib/` to
dedicated `modules/ssh/` modules to improve logical grouping.

- Move `ssh-inventory.nix` to `modules/ssh/inventory.nix`.
- Move `rbw-ssh-socket.nix` to `modules/ssh/rbw.nix`.
- Relocate `users-merge.nix` logic into `modules/system/users.nix` to
  reduce dependency on generic library modules.
- Update `docs/conventions.md` to reflect new module paths.
- Refactor `modules/deploy/navi.nix` to use a local helper for
  generating Navi hive configurations.
- Add Pi5 specific Niri KDL configuration snippets.
This commit is contained in:
2026-07-15 23:27:03 -03:00
parent a787840ce2
commit c90d368432
13 changed files with 314 additions and 293 deletions
+83 -77
View File
@@ -7,10 +7,7 @@
# Resolve which hosts to emit blocks for, and which user identity to
# use as the IdentityFile filter against the rbw agent.
selectedHostNames =
if cfg.hosts == [ "all" ] then
builtins.attrNames inventory.activeHosts
else
cfg.hosts;
if cfg.hosts == [ "all" ] then builtins.attrNames inventory.activeHosts else cfg.hosts;
missingHosts = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames;
# Resolve to the actual host attrs for the template. The "all" case
# reuses `activeHosts` directly instead of rebuilding the same attrset.
@@ -18,20 +15,21 @@
if cfg.hosts == [ "all" ] then
inventory.activeHosts
else
builtins.listToAttrs (map (n: { name = n; value = inventory.hosts.${n}; }) selectedHostNames);
builtins.listToAttrs (
map (n: {
name = n;
value = inventory.hosts.${n};
}) selectedHostNames
);
# All identities this HM user may authenticate AS: cfg.user
# extraIdentities. `cfg.user` can be null (e.g. user not in inventory
# yet) — in that case the Host-block IdentityFile line is suppressed
# and cross-account SSH is wired from extraIdentities alone.
effectiveIdentities = lib.filter (n: n != null) ([ cfg.user ] ++ cfg.extraIdentities);
defaultUserHasKey =
cfg.user == null
|| (inventory.users.${cfg.user}.publicKey or "") != "";
defaultUserHasKey = cfg.user == null || (inventory.users.${cfg.user}.publicKey or "") != "";
# Each extra identity must have a pubkey in the inventory. Dropping
# it silently would produce a dead Match block — fail the build.
missingExtraKeys = lib.filter
(n: (inventory.users.${n}.publicKey or "") == "")
cfg.extraIdentities;
missingExtraKeys = lib.filter (n: (inventory.users.${n}.publicKey or "") == "") cfg.extraIdentities;
in
{
options.chiasson.ssh.outbound.rbw = {
@@ -64,11 +62,14 @@
extraIdentities = lib.mkOption {
type = lib.types.listOf lib.types.str;
default = [ ];
example = [ "server" "builder" ];
example = [
"server"
"builder"
];
description = ''
Catalog accounts this HM user may authenticate AS, in addition
to `cfg.user`. Each entry must have a publicKey pasted into
`modules/lib/ssh-inventory.nix`. Typical for the operator's
`modules/ssh/inventory.nix`. Typical for the operator's
primary laptop: `[ "server" "builder" ]` so fleet/ops commands
like `ssh server@r5500` or `ssh builder@nix-server` work
without touching `/etc/passwd` on each host.
@@ -76,73 +77,78 @@
};
};
config = lib.mkIf cfg.enable (lib.mkMerge [
{
assertions = [
{
assertion = missingHosts == [ ];
message = "ssh.outbound.rbw: unknown host keys: ${builtins.concatStringsSep ", " missingHosts}";
}
{
assertion = defaultUserHasKey;
message = "ssh.outbound.rbw: no publicKey for inventory user `${cfg.user}` in `modules/lib/ssh-inventory.nix`.";
}
{
assertion = missingExtraKeys == [ ];
message = "ssh.outbound.rbw: extraIdentities has entries with no publicKey in `modules/lib/ssh-inventory.nix`: ${builtins.concatStringsSep ", " missingExtraKeys}";
}
# `enable=true` with neither a default user nor any extras gives the
# agent no IdentityFile at all; with IdentitiesOnly yes the user
# would silently be unable to authenticate as any catalog user.
{
assertion = effectiveIdentities != [ ];
message = "ssh.outbound.rbw: enabled but no identities wired. Set `chiasson.ssh.outbound.rbw.user` (defaults to `home.username`) or list catalog accounts under `extraIdentities`.";
}
];
}
(lib.mkMerge [
config = lib.mkIf cfg.enable (
lib.mkMerge [
{
home.packages = with pkgs; [ rbw pinentry-gtk2 ];
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
# Write a `.pub` file per identity this HM user may act as
# (cfg.user extraIdentities). OpenSSH reads each one via
# IdentityFile/Match to filter agent keys — they never hold
# private key material, so 0644 (HM's default) is fine.
# OpenSSH's StrictModes only rejects group/other-writable files.
home.file = lib.listToAttrs (map
(n: {
name = inventory.mkIdentityFileName n;
value.text = "${inventory.users.${n}.publicKey}\n";
})
effectiveIdentities);
programs.ssh.enable = lib.mkIf cfg.manageSshConfig false;
assertions = [
{
assertion = missingHosts == [ ];
message = "ssh.outbound.rbw: unknown host keys: ${builtins.concatStringsSep ", " missingHosts}";
}
{
assertion = defaultUserHasKey;
message = "ssh.outbound.rbw: no publicKey for inventory user `${cfg.user}` in `modules/ssh/inventory.nix`.";
}
{
assertion = missingExtraKeys == [ ];
message = "ssh.outbound.rbw: extraIdentities has entries with no publicKey in `modules/ssh/inventory.nix`: ${builtins.concatStringsSep ", " missingExtraKeys}";
}
# `enable=true` with neither a default user nor any extras gives the
# agent no IdentityFile at all; with IdentitiesOnly yes the user
# would silently be unable to authenticate as any catalog user.
{
assertion = effectiveIdentities != [ ];
message = "ssh.outbound.rbw: enabled but no identities wired. Set `chiasson.ssh.outbound.rbw.user` (defaults to `home.username`) or list catalog accounts under `extraIdentities`.";
}
];
}
(lib.mkIf cfg.manageSshConfig {
home.file.".ssh/config".text = inventory.mkSshConfigTemplate {
inherit selectedHosts;
user = cfg.user;
inherit (cfg) extraIdentities;
};
})
{
systemd.user.services.rbw-agent-bootstrap = {
Unit = {
Description = "Bootstrap rbw SSH agent";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
(lib.mkMerge [
{
home.packages = with pkgs; [
rbw
pinentry-gtk2
];
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
# Write a `.pub` file per identity this HM user may act as
# (cfg.user extraIdentities). OpenSSH reads each one via
# IdentityFile/Match to filter agent keys — they never hold
# private key material, so 0644 (HM's default) is fine.
# OpenSSH's StrictModes only rejects group/other-writable files.
home.file = lib.listToAttrs (
map (n: {
name = inventory.mkIdentityFileName n;
value.text = "${inventory.users.${n}.publicKey}\n";
}) effectiveIdentities
);
programs.ssh.enable = lib.mkIf cfg.manageSshConfig false;
}
(lib.mkIf cfg.manageSshConfig {
home.file.".ssh/config".text = inventory.mkSshConfigTemplate {
inherit selectedHosts;
user = cfg.user;
inherit (cfg) extraIdentities;
};
Service = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -lc '${pkgs.rbw}/bin/rbw unlocked >/dev/null 2>&1 || true'";
RemainAfterExit = true;
})
{
systemd.user.services.rbw-agent-bootstrap = {
Unit = {
Description = "Bootstrap rbw SSH agent";
PartOf = [ "graphical-session.target" ];
After = [ "graphical-session.target" ];
};
Service = {
Type = "oneshot";
ExecStart = "${pkgs.bash}/bin/bash -lc '${pkgs.rbw}/bin/rbw unlocked >/dev/null 2>&1 || true'";
RemainAfterExit = true;
};
Install.WantedBy = [ "graphical-session.target" ];
};
Install.WantedBy = [ "graphical-session.target" ];
};
home.activation.rbwPinentryConfig = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.rbw}/bin/rbw config set pinentry "${pkgs.pinentry-gtk2}/bin/pinentry-gtk-2" >/dev/null 2>&1 || true
'';
}
])
]);
home.activation.rbwPinentryConfig = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
${pkgs.rbw}/bin/rbw config set pinentry "${pkgs.pinentry-gtk2}/bin/pinentry-gtk-2" >/dev/null 2>&1 || true
'';
}
])
]
);
};
}
+159
View File
@@ -0,0 +1,159 @@
{ lib, ... }: {
flake.lib.sshInventory =
let
# Hosts: pure network endpoints. No public keys live here anymore —
# keys are per USER (see `users` below), one key per person.
hosts = {
"14900k" = {
hostName = "192.168.2.25";
aliases = [ "14900k" "nixdesk" ];
};
ideapad = {
hostName = "192.168.2.229";
aliases = [ "ideapad" ];
};
t2mbp = {
hostName = "192.168.2.15";
aliases = [ "t2mbp" ];
};
"uConsole" = {
hostName = "192.168.2.99";
aliases = [ "uConsole" "uconsole" ];
};
# Scratchpad / recovery target — hostName isn't an IP, so `activeHosts`
# excludes it from navi deployments. Leave it in `hosts` for inventory
# visibility while recovery is in progress.
test = {
hostName = "test";
aliases = [ "test" ];
};
"nix-server" = {
hostName = "192.168.2.238";
aliases = [ "nix-server" ];
};
r5500 = {
hostName = "192.168.2.100";
aliases = [ "r5500" ];
};
};
# Per-user public keys. One keypair per person, generated once with
# `ssh-keygen -t ed25519 -C <comment>` (or `rbw gen ssh-key <user>`),
# private key stored in Bitwarden under the same name. Paste the
# `ssh-ed25519 AAAA… <comment>` line here.
users = {
olivier = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICpORzDw8kEOobjywl7bQaEp6Tez1c/ZP+7VBDsTcM8I olivier";
};
server = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAICsksvNGOt/ebJtpRi9ocLBjwyl16VSaf+BsL2Hz5PRy server";
};
builder = {
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIILBtfW1cq9mG6uAjAfpZuVa1EWDSTPSCThapDXXlLgV builder";
};
};
# Hosts SSH is actually safe to deploy/scan to: anything whose hostName
# parses as an IPv4 dotted-quad. Filters out scratchpad/non-routable
# entries (e.g. `hostName = "test"`).
activeHosts = lib.filterAttrs
(_: h: lib.match "^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$" h.hostName != null)
hosts;
# .pub filename convention. OpenSSH 7.3+ accepts a `.pub` file as
# IdentityFile to filter `IdentityAgent` (rbw) keys without storing the
# private key on disk — keep this trick.
mkIdentityFileName = userName: ".ssh/id_ed25519_${lib.strings.toLower userName}.pub";
mkSshConfigTemplate =
{
selectedHosts ? activeHosts,
user ? null,
# Catalog accounts the operator may authenticate AS via
# `ssh <account>@<host>`. Each entry gets a `Match user <account>`
# block that overrides IdentityFile so OpenSSH asks the rbw agent
# for that account's private key. `cfg.user` is already covered
# by the Host blocks, so don't list it here.
extraIdentities ? [ ],
identityAgent ? "SSH_AUTH_SOCK",
}:
let
# Shared `IdentityFile` filter line (when `user` is set). Used by
# both the per-host and gitea blocks so the two can't drift apart.
idLine = lib.optionalString (user != null) " IdentityFile ~/${mkIdentityFileName user}\n";
# Match blocks for cross-account SSH. Filter out cfg.user since
# the matching pub is already loaded via the Host block's
# IdentityFile line — re-emitting it would just duplicate.
matchBlocks = lib.concatStringsSep "\n" (map
(n: ''
Match user ${n}
IdentityFile ~/${mkIdentityFileName n}
IdentitiesOnly yes
'')
(builtins.filter (n: n != user) extraIdentities));
# Per-host block. `IdentityFile` (when `user` is set) is a .pub file
# pointing at the agent's matching private key. With IdentitiesOnly
# yes, only agent keys matching that pub are tried — fixes the old
# "too many auth failures" symptom by design.
hostBlock = hostName:
let
e = selectedHosts.${hostName};
hostPatterns = lib.concatStringsSep " " (e.aliases ++ [ e.hostName ]);
in ''
Host ${hostPatterns}
HostName ${e.hostName}
${idLine} IdentityAgent ${identityAgent}
IdentitiesOnly yes
'';
# Gitea block: always emitted. `User git`, port 222. olivier's
# public key (passed via `IdentityFile`) is what gitea accepts
# because it gets pushed into gitea's authorized_keys through
# gitea's own UI for that account.
giteaBlock = ''
Host git.chiasson.cloud gitea
HostName 192.168.2.238
Port 222
User git
${idLine} IdentityAgent ${identityAgent}
IdentitiesOnly yes
'';
hostBlocks = lib.concatStringsSep "\n" (map hostBlock (lib.attrNames selectedHosts));
in
# Filter empty sections so an absent Match block (no extraIdentities)
# doesn't add leading whitespace to the rendered config.
lib.concatStringsSep "\n" (lib.filter (s: s != "") [
matchBlocks
giteaBlock
hostBlocks
''
Host *
IdentitiesOnly yes
IdentityAgent none
''
]);
# Per-user line for `users.users.<u>.openssh.authorizedKeys.keys`.
# `selection` is either "all" or a list of source identifiers for
# sshd's `from=` constraint (IP, CIDR, or hostname if reverse-DNS works).
# Returns null if inventory is missing the user's pubkey — caller is
# expected to assert and fail the build rather than silently lock out.
mkAuthorizedKeyLine = userName: selection:
let
entry = users.${userName} or null;
in
if entry == null then null
else if (entry.publicKey or "") == "" then null
else if selection == "all" then entry.publicKey
else ''from="${lib.concatStringsSep "," selection}" ${entry.publicKey}'';
in
{
inherit hosts users activeHosts;
mkSshConfigTemplate = mkSshConfigTemplate;
mkAuthorizedKeyLine = mkAuthorizedKeyLine;
mkIdentityFileName = mkIdentityFileName;
};
}
+1 -1
View File
@@ -36,7 +36,7 @@
{
assertions = [{
assertion = cfg.enable -> missingKeys == [ ];
message = "chiasson.ssh.inbound: users enabled but missing public key in `modules/lib/ssh-inventory.nix`: ${lib.concatStringsSep ", " missingKeys}";
message = "chiasson.ssh.inbound: users enabled but missing public key in `modules/ssh/inventory.nix`: ${lib.concatStringsSep ", " missingKeys}";
}];
}
(lib.mkIf cfg.enable {
+39
View File
@@ -0,0 +1,39 @@
# rbw (Bitwarden) SSH-agent socket wiring, shared across shells and the
# outbound-rbw Home Manager module. Exposed as `flake.lib.rbwSshSocket`.
{ ... }: {
flake.lib.rbwSshSocket =
let
socketPath = "$XDG_RUNTIME_DIR/rbw/ssh-agent-socket";
# Bash init snippet — used by wisdom/shells/bash.nix (profile + interactive).
bashSnippet = ''
if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
export SSH_AUTH_SOCK="${socketPath}"
else
export SSH_AUTH_SOCK="/run/user/$(id -u)/rbw/ssh-agent-socket"
fi
fi
'';
# Fish init snippet — used by wisdom/shells/fish.nix.
fishSnippet = pkgs: ''
if test -z "$SSH_AUTH_SOCK"
if test -n "$XDG_RUNTIME_DIR"
set -gx SSH_AUTH_SOCK "${socketPath}"
else
set -l _hm_uid (${pkgs.coreutils}/bin/id -u)
set -gx SSH_AUTH_SOCK "/run/user/$_hm_uid/rbw/ssh-agent-socket"
end
end
'';
in
{
inherit bashSnippet fishSnippet;
# Used by home-manager modules to expose SSH_AUTH_SOCK to GUI apps
# and HM-managed shells declaratively (no activation script needed).
sessionVariable = socketPath;
};
}