Compare commits

..

2 Commits

Author SHA1 Message Date
Olivier c90d368432 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.
2026-07-15 23:27:03 -03:00
Olivier a787840ce2 style(desktop): reformat nix code and simplify module logic
Refactor `modules/desktop/options.nix` to improve code readability through
consistent indentation and whitespace management. Simplified the
conditional logic for `home-manager.extraSpecialArgs` to remove
unnecessary complexity.

Clean up host configurations by removing commented-out code, removing
unnecessary whitespace, and flattening nested attribute sets into
single-line declarations where appropriate.
2026-07-15 22:20:07 -03:00
17 changed files with 346 additions and 337 deletions
+5 -5
View File
@@ -47,7 +47,7 @@ Leave upstream `services.*`, `networking.*`, `home.*`, etc. alone.
Aggregates: `nixosModules.system` and `nixosModules.desktop` import their leaves. Host configs import those stacks and set options — they shouldn't reimplement whole subsystems. Aggregates: `nixosModules.system` and `nixosModules.desktop` import their leaves. Host configs import those stacks and set options — they shouldn't reimplement whole subsystems.
**Home** — files under `modules/wisdom/`. Baseline is `homeManagerModules.wisdom` (`chiasson.home.enable`, wired via `chiasson.desktop.homeManager.bundleWisdom`). Other `wisdom*` slices auto-wire once per user via `lib.wisdomCatalogExtraModules self` (`modules/lib/wisdom-catalog.nix`); hosts only set matching `chiasson.home.*.enable` toggles — no re-import in `home.nix`. **Home** — files under `modules/wisdom/`. Baseline is `homeManagerModules.wisdom` (`chiasson.home.enable`, wired via `chiasson.desktop.homeManager.bundleWisdom`). Other `wisdom*` slices auto-wire once per user via `lib.wisdomCatalogExtraModules self` (`modules/desktop/options.nix`); hosts only set matching `chiasson.home.*.enable` toggles — no re-import in `home.nix`.
User apps / dotfiles → wisdom. Daemons, firewall, kernel → NixOS. Sometimes both (LocalSend: HM installs, `systemLocalsend` opens the firewall). User apps / dotfiles → wisdom. Daemons, firewall, kernel → NixOS. Sometimes both (LocalSend: HM installs, `systemLocalsend` opens the firewall).
@@ -76,9 +76,9 @@ SSH authentication has two independent sides:
| **Client** (connecting from) | identity private keys | "Here's proof I own key K" | | **Client** (connecting from) | identity private keys | "Here's proof I own key K" |
| **Server** (connecting to) | `authorized_keys` | "Do I trust key K?" | | **Server** (connecting to) | `authorized_keys` | "Do I trust key K?" |
A key is just a cryptographic identity — it's not bound to a host or a machine account name. The model is **one key per user account**: each catalog account (`olivier`, `server`, `builder`) has its own keypair in `modules/lib/ssh-inventory.nix`, regardless of which machine that account runs on. Concretely: when `olivier@14900k` sshes to `server@r5500`, OpenSSH routes via a `Match user server` block (generated from `chiasson.ssh.outbound.rbw.extraIdentities`) → `IdentityFile ~/.ssh/id_ed25519_server.pub` (written from `users.server.publicKey`) to ask the rbw agent for the `server` private key; r5500's `server` `authorized_keys` holds the matching server pubkey. olivier's catalog default sets `extraIdentities = [ "server" "builder" ]` so `ssh <account>@<host>` Just Works for every catalog role. We also support `from="ip,cidr"` constraints per account to scope which source IPs may authenticate as it. A key is just a cryptographic identity — it's not bound to a host or a machine account name. The model is **one key per user account**: each catalog account (`olivier`, `server`, `builder`) has its own keypair in `modules/ssh/inventory.nix`, regardless of which machine that account runs on. Concretely: when `olivier@14900k` sshes to `server@r5500`, OpenSSH routes via a `Match user server` block (generated from `chiasson.ssh.outbound.rbw.extraIdentities`) → `IdentityFile ~/.ssh/id_ed25519_server.pub` (written from `users.server.publicKey`) to ask the rbw agent for the `server` private key; r5500's `server` `authorized_keys` holds the matching server pubkey. olivier's catalog default sets `extraIdentities = [ "server" "builder" ]` so `ssh <account>@<host>` Just Works for every catalog role. We also support `from="ip,cidr"` constraints per account to scope which source IPs may authenticate as it.
This repo enforces "one key per user": every user has one ed25519 keypair, the private key in Bitwarden, the public key inline in `modules/lib/ssh-inventory.nix`. On SSH connect: This repo enforces "one key per user": every user has one ed25519 keypair, the private key in Bitwarden, the public key inline in `modules/ssh/inventory.nix`. On SSH connect:
1. `~/.ssh/config` (managed by home-manager) sets `IdentityAgent SSH_AUTH_SOCK` and `IdentityFile ~/.ssh/id_ed25519_<user>.pub` for every host block. 1. `~/.ssh/config` (managed by home-manager) sets `IdentityAgent SSH_AUTH_SOCK` and `IdentityFile ~/.ssh/id_ed25519_<user>.pub` for every host block.
2. OpenSSH reads the `.pub` file as a **filter** against the agent (OpenSSH 7.3+ trick — `.pub` as IdentityFile tells the agent "give me the private key matching this pub"), so even with multiple keys loaded, only the matching one is tried. This is why the old "too many auth failures" symptom is gone by construction. 2. OpenSSH reads the `.pub` file as a **filter** against the agent (OpenSSH 7.3+ trick — `.pub` as IdentityFile tells the agent "give me the private key matching this pub"), so even with multiple keys loaded, only the matching one is tried. This is why the old "too many auth failures" symptom is gone by construction.
@@ -105,7 +105,7 @@ ssh root@nix-server nixos-rebuild switch --flake .#nix-server
### Adding a host ### Adding a host
Edit `modules/lib/ssh-inventory.nix` and add: Edit `modules/ssh/inventory.nix` and add:
```nix ```nix
hosts."newhost" = { hosts."newhost" = {
@@ -119,7 +119,7 @@ Then add `self.nixosModules.<host>Configuration` to `modules/deploy/navi.nix`.
### Adding a catalog user ### Adding a catalog user
1. Generate a keypair: `rbw gen ssh-key <name>`. 1. Generate a keypair: `rbw gen ssh-key <name>`.
2. Add a `users.<name> = { publicKey = "ssh-ed25519 …"; };` line under `users` in `modules/lib/ssh-inventory.nix`. 2. Add a `users.<name> = { publicKey = "ssh-ed25519 …"; };` line under `users` in `modules/ssh/inventory.nix`.
3. Add a `<name> = { isNormalUser = true; … ssh.inbound.enable = true; ssh.inbound.authorizedHosts = "all"; }` entry under `chiasson.users.catalog` in `modules/system/users.nix`. 3. Add a `<name> = { isNormalUser = true; … ssh.inbound.enable = true; ssh.inbound.authorizedHosts = "all"; }` entry under `chiasson.users.catalog` in `modules/system/users.nix`.
4. Enable on every host that needs that user, e.g.: 4. Enable on every host that needs that user, e.g.:
+32 -1
View File
@@ -98,9 +98,40 @@ let
metaNixpkgs = import inputs.nixpkgs { metaNixpkgs = import inputs.nixpkgs {
system = "x86_64-linux"; system = "x86_64-linux";
}; };
# Build a raw Navi hive attrset from host specs + deployment targets.
# `inputs.navi.lib.makeHive` turns the result into `flake.naviHive`.
mkNaviHiveConfig =
{
metaNixpkgs,
hostSpecs,
deployments,
}:
let
deployNodes = lib.filterAttrs (name: _: deployments ? ${name}) hostSpecs;
in
{
meta = {
nixpkgs = metaNixpkgs;
nodeNixpkgs = lib.mapAttrs (
name: spec:
import inputs.nixpkgs {
system = spec.system;
}
) deployNodes;
nodeSpecialArgs = lib.mapAttrs (_: spec: spec.specialArgs) deployNodes;
allowApplyAll = false;
};
}
// lib.mapAttrs (
name: spec:
{
imports = spec.modules or [ spec.configuration ];
deployment = deployments.${name};
}
) deployNodes;
in in
{ {
flake.navi = self.lib.mkNaviHiveConfig { flake.navi = mkNaviHiveConfig {
inherit metaNixpkgs hostSpecs deployments; inherit metaNixpkgs hostSpecs deployments;
}; };
+46 -24
View File
@@ -1,4 +1,19 @@
{ ... }: { { lib, ... }: {
# Resolve `wisdom*` HM slices for NixOS `extraModules` (`self` is not an HM specialArg).
# Slices are gated by `chiasson.home.*.enable`; hosts only flip toggles in `home.nix`.
# Lives here (not `modules/lib/`) because `hosts/desktop-home-base.nix` consumes it and
# fragment eval order requires the definition to sort before `hosts/`.
flake.lib.wisdomCatalogExtraModules =
self:
let
names = lib.sort builtins.lessThan (
lib.filter (n: lib.hasPrefix "wisdom" n && n != "wisdom" && n != "wisdomShellBash") (
builtins.attrNames self.homeManagerModules
)
);
in
map (name: self.homeManagerModules.${name}) names;
flake.nixosModules.desktopOptions = flake.nixosModules.desktopOptions =
{ config, options, lib, pkgs, self, inputs, ... }: { config, options, lib, pkgs, self, inputs, ... }:
let let
@@ -34,11 +49,13 @@
{ {
options.chiasson.desktop = { options.chiasson.desktop = {
defaultSession = lib.mkOption { defaultSession = lib.mkOption {
type = lib.types.nullOr (lib.types.enum [ type = lib.types.nullOr (
"hyprland" lib.types.enum [
"niri" "hyprland"
"plasma" "niri"
]); "plasma"
]
);
default = null; default = null;
example = "niri"; example = "niri";
description = '' description = ''
@@ -56,10 +73,12 @@
displayManager = { displayManager = {
variant = lib.mkOption { variant = lib.mkOption {
type = lib.types.nullOr (lib.types.enum [ type = lib.types.nullOr (
"sddm" lib.types.enum [
"dankgreeter" "sddm"
]); "dankgreeter"
]
);
default = null; default = null;
description = '' description = ''
SDDM vs DankGreeter (greetd + DMS [docs](https://danklinux.com/docs/dankgreeter/nixos-flake)). SDDM vs DankGreeter (greetd + DMS [docs](https://danklinux.com/docs/dankgreeter/nixos-flake)).
@@ -80,11 +99,13 @@
}; };
displayManager.sddm = { displayManager.sddm = {
wayland.enable = lib.mkEnableOption '' wayland.enable =
SDDM greeter on Wayland (nicer on HiDPI; turn off if the greeter glitches on your GPU). lib.mkEnableOption ''
'' // { SDDM greeter on Wayland (nicer on HiDPI; turn off if the greeter glitches on your GPU).
default = true; ''
}; // {
default = true;
};
enableHidpi = lib.mkOption { enableHidpi = lib.mkOption {
type = lib.types.bool; type = lib.types.bool;
@@ -159,12 +180,14 @@
}; };
keyring = { keyring = {
enable = lib.mkEnableOption '' enable =
gnome-keyring + pam (login + sddm or greetd) + HM user service + `gcr` + `services.xserver.updateDbusEnvironment`. lib.mkEnableOption ''
niri/hyprland: `dbus-update-activation-environment` at compositor start so libsecret/Electron see `WAYLAND_DISPLAY`. gnome-keyring + pam (login + sddm or greetd) + HM user service + `gcr` + `services.xserver.updateDbusEnvironment`.
'' // { niri/hyprland: `dbus-update-activation-environment` at compositor start so libsecret/Electron see `WAYLAND_DISPLAY`.
default = true; ''
}; // {
default = true;
};
}; };
}; };
@@ -184,8 +207,7 @@
message = "chiasson.desktop.defaultSession = \"plasma\" requires chiasson.desktop.plasma.enable = true."; message = "chiasson.desktop.defaultSession = \"plasma\" requires chiasson.desktop.plasma.enable = true.";
} }
{ {
assertion = assertion = cfg.displayManager.variant != "dankgreeter" || cfg.hyprland.enable || cfg.niri.enable;
cfg.displayManager.variant != "dankgreeter" || cfg.hyprland.enable || cfg.niri.enable;
message = "chiasson.desktop.displayManager.variant = \"dankgreeter\" requires chiasson.desktop.hyprland or chiasson.desktop.niri."; message = "chiasson.desktop.displayManager.variant = \"dankgreeter\" requires chiasson.desktop.hyprland or chiasson.desktop.niri.";
} }
{ {
@@ -210,7 +232,7 @@
(lib.mkIf (dmsEnabled && hmAvailable) { (lib.mkIf (dmsEnabled && hmAvailable) {
"home-manager".sharedModules = [ self.homeManagerModules.desktopShellDms ]; "home-manager".sharedModules = [ self.homeManagerModules.desktopShellDms ];
}) })
(lib.mkIf (hmAvailable && (dmsEnabled || cfg.niri.enable)) { (lib.mkIf hmAvailable {
"home-manager".extraSpecialArgs = { inherit inputs; }; "home-manager".extraSpecialArgs = { inherit inputs; };
}) })
]; ];
-1
View File
@@ -22,7 +22,6 @@
spotify.openDiscoveryFirewall = true; spotify.openDiscoveryFirewall = true;
pokeclicker.enable = true; pokeclicker.enable = true;
}; };
}; };
} }
]; ];
-13
View File
@@ -4,18 +4,5 @@
{ self, ... }: { self, ... }:
{ {
imports = [ self.nixosModules.desktopHomeBase ]; imports = [ self.nixosModules.desktopHomeBase ];
# Host-only HM overrides (tablet apps, toggles off from desktop-home-base, …).
chiasson.users.extraModules.olivier = [
# {
# chiasson.home = {
# # editors.kate.enable = true;
# };
# }
# (
# { pkgs, ... }:
# { home.packages = with pkgs; [ ]; }
# )
];
}; };
} }
+1 -4
View File
@@ -8,9 +8,7 @@
chiasson.users.extraModules.olivier = [ chiasson.users.extraModules.olivier = [
{ {
chiasson.home = { chiasson.home = {
browsers = { browsers.edge.enable = true;
edge.enable = true;
};
apps = { apps = {
discord.enable = true; discord.enable = true;
@@ -19,6 +17,5 @@
}; };
} }
]; ];
}; };
} }
+1 -3
View File
@@ -7,9 +7,7 @@
chiasson.users.extraModules.olivier = [ chiasson.users.extraModules.olivier = [
{ {
chiasson.home = { chiasson.home.hardware.uconsoleGamepad.enable = true;
hardware.uconsoleGamepad.enable = true;
};
} }
]; ];
}; };
-33
View File
@@ -1,33 +0,0 @@
# Build a raw Navi hive attrset from host specs + deployment targets.
# Call `inputs.navi.lib.makeHive` on the result to produce `flake.naviHive`.
{ lib, inputs, ... }: {
flake.lib.mkNaviHiveConfig =
{
metaNixpkgs,
hostSpecs,
deployments,
}:
let
deployNodes = lib.filterAttrs (name: _: deployments ? ${name}) hostSpecs;
in
{
meta = {
nixpkgs = metaNixpkgs;
nodeNixpkgs = lib.mapAttrs (
name: spec:
import inputs.nixpkgs {
system = spec.system;
}
) deployNodes;
nodeSpecialArgs = lib.mapAttrs (_: spec: spec.specialArgs) deployNodes;
allowApplyAll = false;
};
}
// lib.mapAttrs (
name: spec:
{
imports = spec.modules or [ spec.configuration ];
deployment = deployments.${name};
}
) deployNodes;
}
-31
View File
@@ -1,31 +0,0 @@
{ ... }: {
flake.lib.rbwSshSocket =
let
# Bash/Fish init snippets — used by wisdom/shells/{bash,fish}.nix.
bashSnippet = ''
if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
export SSH_AUTH_SOCK="$XDG_RUNTIME_DIR/rbw/ssh-agent-socket"
else
export SSH_AUTH_SOCK="/run/user/$(id -u)/rbw/ssh-agent-socket"
fi
fi
'';
fishSnippet =
pkgs: ''
if test -z "$SSH_AUTH_SOCK"
if test -n "$XDG_RUNTIME_DIR"
set -gx SSH_AUTH_SOCK "$XDG_RUNTIME_DIR/rbw/ssh-agent-socket"
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 = "$XDG_RUNTIME_DIR/rbw/ssh-agent-socket";
};
}
-100
View File
@@ -1,100 +0,0 @@
# Pure helpers: catalog → NixOS/HM/SSH shapes (`self.lib.usersMerge lib`).
{ ... }: {
flake.lib.usersMerge =
lib:
let
userHm = user: user.homeManager or { };
userSsh = user: user.ssh or { };
in
rec {
resolveHomeManagerModule =
moduleSpec:
let
t = builtins.typeOf moduleSpec;
in
if t == "path" || t == "string" then import moduleSpec else moduleSpec;
selectedUsersAttr =
{ catalog, enabled, hostOverrides }:
lib.listToAttrs (
map (name: {
inherit name;
value = lib.recursiveUpdate catalog.${name} (hostOverrides.${name} or { });
}) enabled
);
missingEnabledNames = catalog: enabled: builtins.filter (name: !(builtins.hasAttr name catalog)) enabled;
strayHomeUserKeys = homeUsers: enabled:
builtins.filter (k: !(builtins.elem k enabled)) (builtins.attrNames homeUsers);
mkNixosUser =
name: user:
{
isNormalUser = user.isNormalUser or true;
description = user.description or name;
extraGroups = user.extraGroups or [ ];
}
// lib.optionalAttrs (user ? hashedPasswordFile && user.hashedPasswordFile != null) {
hashedPasswordFile = user.hashedPasswordFile;
};
hmWiredNames =
selectedUsers:
lib.attrNames (
lib.filterAttrs (_: user:
let
hm = userHm user;
in
(hm.enable or false) && (hm.module or null) != null
) selectedUsers
);
rbwOutboundSnippet =
name: user:
let
outboundCfg = ((userSsh user).outbound or { }).rbw or { };
in
lib.mkIf (outboundCfg.enable or false) {
chiasson.ssh.outbound.rbw.enable = true;
chiasson.ssh.outbound.rbw.user = name;
chiasson.ssh.outbound.rbw.hosts =
if (outboundCfg.hosts or "all") == "all" then [ "all" ] else outboundCfg.hosts;
chiasson.ssh.outbound.rbw.extraIdentities = outboundCfg.extraIdentities or [ ];
};
mkHmUserModule =
{ name, user, hostExtraModules }:
let
hm = userHm user;
hmModule = resolveHomeManagerModule hm.module;
in
lib.mkMerge (
[
hmModule
(rbwOutboundSnippet name user)
]
++ (hm.extraModules or [ ])
++ hostExtraModules
);
inboundAuthorizedOrNull =
user:
let
inboundCfg = (userSsh user).inbound or { };
in
if !(inboundCfg.enable or false) then
null
else if (inboundCfg.authorizedHosts or "all") == "all" then
"all"
else
inboundCfg.authorizedHosts;
inboundHostsAttr =
selectedUsers:
lib.pipe selectedUsers [
(lib.mapAttrs (_: inboundAuthorizedOrNull))
(lib.filterAttrs (_: v: v != null))
];
};
}
-17
View File
@@ -1,17 +0,0 @@
# Resolve `wisdom*` HM slices for NixOS `extraModules` (`self` is not an HM specialArg).
# Slices are gated by `chiasson.home.*.enable`; hosts only flip toggles in `home.nix`.
{ lib, ... }: {
flake.lib.wisdomCatalogExtraModules =
self:
let
names = lib.sort builtins.lessThan (
lib.filter (
n:
lib.hasPrefix "wisdom" n
&& n != "wisdom"
&& n != "wisdomShellBash"
) (builtins.attrNames self.homeManagerModules)
);
in
map (name: self.homeManagerModules.${name}) names;
}
+83 -77
View File
@@ -7,10 +7,7 @@
# Resolve which hosts to emit blocks for, and which user identity to # Resolve which hosts to emit blocks for, and which user identity to
# use as the IdentityFile filter against the rbw agent. # use as the IdentityFile filter against the rbw agent.
selectedHostNames = selectedHostNames =
if cfg.hosts == [ "all" ] then if cfg.hosts == [ "all" ] then builtins.attrNames inventory.activeHosts else cfg.hosts;
builtins.attrNames inventory.activeHosts
else
cfg.hosts;
missingHosts = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames; missingHosts = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames;
# Resolve to the actual host attrs for the template. The "all" case # Resolve to the actual host attrs for the template. The "all" case
# reuses `activeHosts` directly instead of rebuilding the same attrset. # reuses `activeHosts` directly instead of rebuilding the same attrset.
@@ -18,20 +15,21 @@
if cfg.hosts == [ "all" ] then if cfg.hosts == [ "all" ] then
inventory.activeHosts inventory.activeHosts
else 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 # All identities this HM user may authenticate AS: cfg.user
# extraIdentities. `cfg.user` can be null (e.g. user not in inventory # extraIdentities. `cfg.user` can be null (e.g. user not in inventory
# yet) — in that case the Host-block IdentityFile line is suppressed # yet) — in that case the Host-block IdentityFile line is suppressed
# and cross-account SSH is wired from extraIdentities alone. # and cross-account SSH is wired from extraIdentities alone.
effectiveIdentities = lib.filter (n: n != null) ([ cfg.user ] ++ cfg.extraIdentities); effectiveIdentities = lib.filter (n: n != null) ([ cfg.user ] ++ cfg.extraIdentities);
defaultUserHasKey = defaultUserHasKey = cfg.user == null || (inventory.users.${cfg.user}.publicKey or "") != "";
cfg.user == null
|| (inventory.users.${cfg.user}.publicKey or "") != "";
# Each extra identity must have a pubkey in the inventory. Dropping # Each extra identity must have a pubkey in the inventory. Dropping
# it silently would produce a dead Match block — fail the build. # it silently would produce a dead Match block — fail the build.
missingExtraKeys = lib.filter missingExtraKeys = lib.filter (n: (inventory.users.${n}.publicKey or "") == "") cfg.extraIdentities;
(n: (inventory.users.${n}.publicKey or "") == "")
cfg.extraIdentities;
in in
{ {
options.chiasson.ssh.outbound.rbw = { options.chiasson.ssh.outbound.rbw = {
@@ -64,11 +62,14 @@
extraIdentities = lib.mkOption { extraIdentities = lib.mkOption {
type = lib.types.listOf lib.types.str; type = lib.types.listOf lib.types.str;
default = [ ]; default = [ ];
example = [ "server" "builder" ]; example = [
"server"
"builder"
];
description = '' description = ''
Catalog accounts this HM user may authenticate AS, in addition Catalog accounts this HM user may authenticate AS, in addition
to `cfg.user`. Each entry must have a publicKey pasted into 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 primary laptop: `[ "server" "builder" ]` so fleet/ops commands
like `ssh server@r5500` or `ssh builder@nix-server` work like `ssh server@r5500` or `ssh builder@nix-server` work
without touching `/etc/passwd` on each host. without touching `/etc/passwd` on each host.
@@ -76,73 +77,78 @@
}; };
}; };
config = lib.mkIf cfg.enable (lib.mkMerge [ 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 [
{ {
home.packages = with pkgs; [ rbw pinentry-gtk2 ]; assertions = [
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable; {
# Write a `.pub` file per identity this HM user may act as assertion = missingHosts == [ ];
# (cfg.user extraIdentities). OpenSSH reads each one via message = "ssh.outbound.rbw: unknown host keys: ${builtins.concatStringsSep ", " missingHosts}";
# 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. assertion = defaultUserHasKey;
home.file = lib.listToAttrs (map message = "ssh.outbound.rbw: no publicKey for inventory user `${cfg.user}` in `modules/ssh/inventory.nix`.";
(n: { }
name = inventory.mkIdentityFileName n; {
value.text = "${inventory.users.${n}.publicKey}\n"; assertion = missingExtraKeys == [ ];
}) message = "ssh.outbound.rbw: extraIdentities has entries with no publicKey in `modules/ssh/inventory.nix`: ${builtins.concatStringsSep ", " missingExtraKeys}";
effectiveIdentities); }
programs.ssh.enable = lib.mkIf cfg.manageSshConfig false; # `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 { (lib.mkMerge [
home.file.".ssh/config".text = inventory.mkSshConfigTemplate { {
inherit selectedHosts; home.packages = with pkgs; [
user = cfg.user; rbw
inherit (cfg) extraIdentities; pinentry-gtk2
}; ];
}) home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
{ # Write a `.pub` file per identity this HM user may act as
systemd.user.services.rbw-agent-bootstrap = { # (cfg.user extraIdentities). OpenSSH reads each one via
Unit = { # IdentityFile/Match to filter agent keys — they never hold
Description = "Bootstrap rbw SSH agent"; # private key material, so 0644 (HM's default) is fine.
PartOf = [ "graphical-session.target" ]; # OpenSSH's StrictModes only rejects group/other-writable files.
After = [ "graphical-session.target" ]; 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'"; systemd.user.services.rbw-agent-bootstrap = {
RemainAfterExit = true; 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 }
''; ])
} ]
]) );
]);
}; };
} }
+1 -1
View File
@@ -36,7 +36,7 @@
{ {
assertions = [{ assertions = [{
assertion = cfg.enable -> missingKeys == [ ]; 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 { (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;
};
}
+138 -27
View File
@@ -8,18 +8,115 @@
{ config, options, lib, ... }: { config, options, lib, ... }:
let let
cfg = config.chiasson.users; cfg = config.chiasson.users;
usersLib = self.lib.usersMerge lib; usersLib =
let
userHm = user: user.homeManager or { };
userSsh = user: user.ssh or { };
in
rec {
resolveHomeManagerModule =
moduleSpec:
let
t = builtins.typeOf moduleSpec;
in
if t == "path" || t == "string" then import moduleSpec else moduleSpec;
selectedUsersAttr =
{ catalog, enabled, hostOverrides }:
lib.listToAttrs (
map (name: {
inherit name;
value = lib.recursiveUpdate catalog.${name} (hostOverrides.${name} or { });
}) enabled
);
missingEnabledNames =
catalog: enabled: builtins.filter (name: !(builtins.hasAttr name catalog)) enabled;
strayHomeUserKeys =
homeUsers: enabled: builtins.filter (k: !(builtins.elem k enabled)) (builtins.attrNames homeUsers);
mkNixosUser =
name: user:
{
isNormalUser = user.isNormalUser or true;
description = user.description or name;
extraGroups = user.extraGroups or [ ];
}
// lib.optionalAttrs (user ? hashedPasswordFile && user.hashedPasswordFile != null) {
hashedPasswordFile = user.hashedPasswordFile;
};
hmWiredNames =
selectedUsers:
lib.attrNames (
lib.filterAttrs (
_: user:
let
hm = userHm user;
in
(hm.enable or false) && (hm.module or null) != null
) selectedUsers
);
rbwOutboundSnippet =
name: user:
let
outboundCfg = ((userSsh user).outbound or { }).rbw or { };
in
lib.mkIf (outboundCfg.enable or false) {
chiasson.ssh.outbound.rbw.enable = true;
chiasson.ssh.outbound.rbw.user = name;
chiasson.ssh.outbound.rbw.hosts =
if (outboundCfg.hosts or "all") == "all" then [ "all" ] else outboundCfg.hosts;
chiasson.ssh.outbound.rbw.extraIdentities = outboundCfg.extraIdentities or [ ];
};
mkHmUserModule =
{ name, user, hostExtraModules }:
let
hm = userHm user;
hmModule = resolveHomeManagerModule hm.module;
in
lib.mkMerge (
[
hmModule
(rbwOutboundSnippet name user)
]
++ (hm.extraModules or [ ])
++ hostExtraModules
);
inboundAuthorizedOrNull =
user:
let
inboundCfg = (userSsh user).inbound or { };
in
if !(inboundCfg.enable or false) then
null
else if (inboundCfg.authorizedHosts or "all") == "all" then
"all"
else
inboundCfg.authorizedHosts;
inboundHostsAttr =
selectedUsers:
lib.pipe selectedUsers [
(lib.mapAttrs (_: inboundAuthorizedOrNull))
(lib.filterAttrs (_: v: v != null))
];
};
inventory = self.lib.sshInventory; inventory = self.lib.sshInventory;
olivierEnabled = lib.elem "olivier" cfg.enabled; olivierEnabled = lib.elem "olivier" cfg.enabled;
# Merge catalog + per-host overrides into the final user attrs. # Merge catalog + per-host overrides into the final user attrs.
selectedUsers = lib.listToAttrs (map selectedUsers = lib.listToAttrs (
(name: { map (name: {
inherit name; inherit name;
value = lib.recursiveUpdate cfg.catalog.${name} (cfg.hostOverrides.${name} or { }); value = lib.recursiveUpdate cfg.catalog.${name} (cfg.hostOverrides.${name} or { });
}) }) cfg.enabled
cfg.enabled); );
names = usersLib.hmWiredNames selectedUsers; names = usersLib.hmWiredNames selectedUsers;
missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled; missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled;
@@ -27,26 +124,27 @@
hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options; hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options;
inboundUsersAttr = usersLib.inboundHostsAttr selectedUsers; inboundUsersAttr = usersLib.inboundHostsAttr selectedUsers;
hmUsersAttr = lib.listToAttrs (map hmUsersAttr = lib.listToAttrs (
(name: { map (name: {
inherit name; inherit name;
value = usersLib.mkHmUserModule { value = usersLib.mkHmUserModule {
inherit name; inherit name;
user = selectedUsers.${name}; user = selectedUsers.${name};
hostExtraModules = cfg.extraModules.${name} or [ ]; hostExtraModules = cfg.extraModules.${name} or [ ];
}; };
}) }) names
names); );
# Fish shell wiring: HM's fish module declares its package, but # Fish shell wiring: HM's fish module declares its package, but
# /etc/passwd + /etc/shells need updating so login shells work. # /etc/passwd + /etc/shells need updating so login shells work.
# Only fires for HM users that actually enable fish. # Only fires for HM users that actually enable fish.
hmFishUsers = hmFishUsers =
if !hmAvailable then { } if !hmAvailable then
{ }
else else
lib.filterAttrs lib.filterAttrs (
(name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names) name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names
config.home-manager.users; ) config.home-manager.users;
in in
{ {
imports = [ imports = [
@@ -108,12 +206,18 @@
isNormalUser = true; isNormalUser = true;
description = "Olivier"; description = "Olivier";
extraGroups = [ extraGroups = [
"networkmanager" "wheel" "docker" "fuse" "uinput" "kvm" "networkmanager"
"wheel"
"docker"
"fuse"
"uinput"
"kvm"
# `video` lets brightnessctl/light udev rules own /sys/backlight without sudo. # `video` lets brightnessctl/light udev rules own /sys/backlight without sudo.
# Harmless on headless hosts (no devices). # Harmless on headless hosts (no devices).
"video" "video"
# DRI render + input for gamescope / Steam on Wayland, no sudo. # DRI render + input for gamescope / Steam on Wayland, no sudo.
"render" "input" "render"
"input"
]; ];
hashedPasswordFile = lib.mkIf olivierEnabled ( hashedPasswordFile = lib.mkIf olivierEnabled (
config.sops.secrets."users/olivier/hashedPassword".path config.sops.secrets."users/olivier/hashedPassword".path
@@ -147,14 +251,20 @@
# `.pub` filter for each of these and emits a `Match user` # `.pub` filter for each of these and emits a `Match user`
# block, so `ssh server@r5500` / `ssh builder@nix-server` # block, so `ssh server@r5500` / `ssh builder@nix-server`
# route the right private key from the rbw agent. # route the right private key from the rbw agent.
ssh.outbound.rbw.extraIdentities = [ "server" "builder" ]; ssh.outbound.rbw.extraIdentities = [
"server"
"builder"
];
}; };
server = { server = {
isNormalUser = true; isNormalUser = true;
description = "Server user"; description = "Server user";
extraGroups = [ "wheel" ]; extraGroups = [ "wheel" ];
homeManager = { enable = false; module = null; }; homeManager = {
enable = false;
module = null;
};
ssh.inbound.enable = true; ssh.inbound.enable = true;
ssh.inbound.authorizedHosts = "all"; ssh.inbound.authorizedHosts = "all";
ssh.outbound.rbw.enable = false; ssh.outbound.rbw.enable = false;
@@ -166,7 +276,10 @@
description = "Navi fleet deploy (push + activate only)"; description = "Navi fleet deploy (push + activate only)";
extraGroups = [ ]; extraGroups = [ ];
createHome = false; createHome = false;
homeManager = { enable = false; module = null; }; homeManager = {
enable = false;
module = null;
};
ssh.inbound.enable = true; ssh.inbound.enable = true;
ssh.inbound.authorizedHosts = "all"; ssh.inbound.authorizedHosts = "all";
}; };
@@ -195,9 +308,7 @@
]; ];
# NixOS user accounts (no HM, raw). # NixOS user accounts (no HM, raw).
users.users = lib.mapAttrs users.users = lib.mapAttrs (name: user: usersLib.mkNixosUser name user) selectedUsers;
(name: user: usersLib.mkNixosUser name user)
selectedUsers;
} }
(lib.optionalAttrs hmAvailable { (lib.optionalAttrs hmAvailable {
"home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true; "home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true;
@@ -216,12 +327,12 @@
# /etc/shells with `mkForce`. `mkNixosUser` doesn't emit `shell`, so # /etc/shells with `mkForce`. `mkNixosUser` doesn't emit `shell`, so
# the force is uncontested. # the force is uncontested.
(lib.mkIf (hmFishUsers != { }) { (lib.mkIf (hmFishUsers != { }) {
environment.shells = lib.mkAfter (lib.mapAttrsToList environment.shells = lib.mkAfter (
(_: hmUser: lib.getExe hmUser.programs.fish.package) lib.mapAttrsToList (_: hmUser: lib.getExe hmUser.programs.fish.package) hmFishUsers
hmFishUsers); );
users.users = lib.mapAttrs users.users = lib.mapAttrs (name: hmUser: {
(name: hmUser: { shell = lib.mkForce (lib.getExe hmUser.programs.fish.package); }) shell = lib.mkForce (lib.getExe hmUser.programs.fish.package);
hmFishUsers; }) hmFishUsers;
}) })
]; ];
}; };