Refactor ssh&users
This commit is contained in:
@@ -93,7 +93,7 @@ let
|
||||
// lib.optionalAttrs (name == "nix-server") {
|
||||
targetPort = 22;
|
||||
}
|
||||
) ssh.activeHosts;
|
||||
) ssh.activeHosts /* nix-deploy target list — IPs only */;
|
||||
|
||||
metaNixpkgs = import inputs.nixpkgs {
|
||||
system = "x86_64-linux";
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{ ... }: {
|
||||
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
|
||||
@@ -21,10 +22,10 @@
|
||||
end
|
||||
end
|
||||
'';
|
||||
in
|
||||
{
|
||||
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";
|
||||
activationPath = "/run/user/$(id -u)/rbw/ssh-agent-socket";
|
||||
};
|
||||
}
|
||||
|
||||
+110
-85
@@ -1,131 +1,156 @@
|
||||
{ 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" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILwUevBGnf+Y/sL1ZsB4bt0c50a89iqwPRoYUGP4UHsL 14900k";
|
||||
};
|
||||
|
||||
ideapad = {
|
||||
hostName = "192.168.2.229";
|
||||
aliases = [ "ideapad" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQwaaI90xIMjZ46EcMyO8kBwGCxf7qVL75IYhw8Ssze ideapad";
|
||||
};
|
||||
|
||||
t2mbp = {
|
||||
hostName = "192.168.2.15";
|
||||
aliases = [ "t2mbp" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMhVWB9YXl/FuQvufle4VWUas/QM8qCKoRd5a83Tt3S6 t2mbp";
|
||||
};
|
||||
|
||||
uConsole = {
|
||||
"uConsole" = {
|
||||
hostName = "192.168.2.99";
|
||||
aliases = [ "uConsole" "uconsole" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAVPR0lRAcywPR7iTchM3+eO7NCdXAR6NPzYXxalr+dP 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" ];
|
||||
publicKey = null;
|
||||
};
|
||||
|
||||
nix-server = {
|
||||
"nix-server" = {
|
||||
hostName = "192.168.2.238";
|
||||
aliases = [ "nix-server" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL3KDicMjtOFR6LfZrFzfAD1gdYUdwv6ZM4PSgtmIuzd nix-server";
|
||||
};
|
||||
|
||||
r5500 = {
|
||||
hostName = "192.168.2.100";
|
||||
aliases = [ "r5500" ];
|
||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK7iWCEtkYDLZFRF3w1gzyAok5VCAGUOwu4iWZdMjf3D r5500";
|
||||
};
|
||||
};
|
||||
|
||||
mkIdentityFileName = hostName: ".ssh/id_ed25519_${lib.strings.toLower hostName}.pub";
|
||||
activeHosts = builtins.removeAttrs hosts (
|
||||
builtins.filter (name: hosts.${name}.publicKey == null) (builtins.attrNames hosts)
|
||||
);
|
||||
# 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";
|
||||
};
|
||||
};
|
||||
|
||||
mkIdentityFiles = selectedHosts:
|
||||
builtins.listToAttrs (
|
||||
builtins.map
|
||||
(hostName: {
|
||||
name = mkIdentityFileName hostName;
|
||||
value.text = "${selectedHosts.${hostName}.publicKey}\n";
|
||||
})
|
||||
(builtins.attrNames selectedHosts)
|
||||
);
|
||||
# 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;
|
||||
|
||||
# Gitea git-over-SSH listens on port 222. System SSH (nix deploy, server@…) uses port 22
|
||||
# via the catalog `nix-server` Host block — never list nix-server or 192.168.2.238 here.
|
||||
giteaSshBlock = identityAgent: ''
|
||||
Host git.chiasson.cloud gitea
|
||||
HostName 192.168.2.238
|
||||
Port 222
|
||||
User git
|
||||
IdentityAgent ${identityAgent}
|
||||
IdentitiesOnly no
|
||||
|
||||
Match host nix-server,192.168.2.238 user git
|
||||
HostName 192.168.2.238
|
||||
Port 222
|
||||
User git
|
||||
IdentityAgent ${identityAgent}
|
||||
IdentitiesOnly no
|
||||
'';
|
||||
# .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,
|
||||
identityAgent ? "__RBW_SSH_SOCK__",
|
||||
# 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
|
||||
hostBlocks = builtins.map
|
||||
(hostName:
|
||||
let
|
||||
entry = selectedHosts.${hostName};
|
||||
hostPatterns = builtins.concatStringsSep " " (entry.aliases ++ [ entry.hostName ]);
|
||||
userLine = if user == null then "" else " User ${user}\n";
|
||||
portLine =
|
||||
if hostName == "nix-server" then
|
||||
" Port 22\n"
|
||||
else
|
||||
"";
|
||||
in
|
||||
''
|
||||
Host ${hostPatterns}
|
||||
HostName ${entry.hostName}
|
||||
${userLine}${portLine} IdentityFile ~/${mkIdentityFileName hostName}
|
||||
IdentityAgent ${identityAgent}
|
||||
IdentitiesOnly yes
|
||||
'')
|
||||
(builtins.attrNames selectedHosts);
|
||||
in
|
||||
builtins.concatStringsSep "\n" (
|
||||
[
|
||||
(giteaSshBlock identityAgent)
|
||||
]
|
||||
++ hostBlocks
|
||||
++ [
|
||||
''
|
||||
Host *
|
||||
# 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
|
||||
IdentityAgent none
|
||||
''
|
||||
]
|
||||
);
|
||||
'')
|
||||
(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 ]);
|
||||
idLine = if user == null then "" else " IdentityFile ~/${mkIdentityFileName user}\n";
|
||||
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
|
||||
${if user == null then "" else " IdentityFile ~/${mkIdentityFileName user}\n"} 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 activeHosts mkIdentityFiles mkSshConfigTemplate;
|
||||
authorizedKeys = lib.unique (
|
||||
builtins.map (entry: entry.publicKey) (builtins.attrValues activeHosts)
|
||||
);
|
||||
identityFiles = mkIdentityFiles activeHosts;
|
||||
sshConfigTemplate = mkSshConfigTemplate { };
|
||||
inherit hosts users activeHosts;
|
||||
mkSshConfigTemplate = mkSshConfigTemplate;
|
||||
mkAuthorizedKeyLine = mkAuthorizedKeyLine;
|
||||
mkIdentityFileName = mkIdentityFileName;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,96 +1,141 @@
|
||||
{ self, ... }: {
|
||||
flake.homeManagerModules.sshOutboundRbw = {
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.chiasson.ssh.outbound.rbw;
|
||||
inventory = self.lib.sshInventory;
|
||||
selectedHostNames =
|
||||
if cfg.hosts == [ "all" ] then
|
||||
builtins.attrNames inventory.activeHosts
|
||||
else
|
||||
cfg.hosts;
|
||||
missing = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames;
|
||||
selectedHosts = builtins.listToAttrs (
|
||||
builtins.map (name: {
|
||||
inherit name;
|
||||
value = inventory.hosts.${name};
|
||||
}) selectedHostNames
|
||||
);
|
||||
sshConfigTemplate = inventory.mkSshConfigTemplate {
|
||||
selectedHosts = selectedHosts;
|
||||
user = cfg.user;
|
||||
};
|
||||
in
|
||||
{
|
||||
options.chiasson.ssh.outbound.rbw = {
|
||||
enable = lib.mkEnableOption "Generated `~/.ssh/config` + rbw agent socket.";
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = config.home.username;
|
||||
description = "`User` in generated `Host` blocks.";
|
||||
};
|
||||
hosts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "all" ];
|
||||
description = "Inventory hosts to emit (or `[ \"all\" ]`).";
|
||||
};
|
||||
manageSshConfig = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Write `~/.ssh/config` from the template.";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable (lib.mkMerge [
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = missing == [ ];
|
||||
message = "ssh.outbound.rbw: unknown host keys: ${builtins.concatStringsSep ", " missing}";
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
home.packages = with pkgs; [ rbw pinentry-gtk2 ];
|
||||
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
|
||||
home.file = inventory.mkIdentityFiles selectedHosts;
|
||||
|
||||
programs.ssh.enable = lib.mkIf cfg.manageSshConfig false;
|
||||
home.activation.rbwSshConfig = lib.mkIf cfg.manageSshConfig (lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
||||
mkdir -p "$HOME/.ssh"
|
||||
chmod 700 "$HOME/.ssh"
|
||||
RBW_SSH_SOCK="${self.lib.rbwSshSocket.activationPath}"
|
||||
cat > "$HOME/.ssh/config" <<'EOF'
|
||||
${sshConfigTemplate}
|
||||
EOF
|
||||
sed -i "s|__RBW_SSH_SOCK__|$RBW_SSH_SOCK|g" "$HOME/.ssh/config"
|
||||
chmod 600 "$HOME/.ssh/config"
|
||||
'');
|
||||
|
||||
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" ];
|
||||
};
|
||||
flake.homeManagerModules.sshOutboundRbw =
|
||||
{ config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.chiasson.ssh.outbound.rbw;
|
||||
inventory = self.lib.sshInventory;
|
||||
# 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;
|
||||
missingHosts = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) 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 "") != "";
|
||||
# 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;
|
||||
in
|
||||
{
|
||||
options.chiasson.ssh.outbound.rbw = {
|
||||
enable = lib.mkEnableOption "Generated `~/.ssh/config` + rbw agent socket.";
|
||||
user = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = config.home.username;
|
||||
description = ''
|
||||
Inventory user whose public key is written to `~/.ssh/id_ed25519_<user>.pub`
|
||||
and used as `IdentityFile` to filter the rbw agent. Set `null`
|
||||
to disable the per-user .pub file / IdentityFile line (e.g.
|
||||
when the user isn't in the inventory yet).
|
||||
'';
|
||||
};
|
||||
hosts = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ "all" ];
|
||||
description = "Inventory hosts to emit (or `[ \"all\" ]`).";
|
||||
};
|
||||
manageSshConfig = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Write `~/.ssh/config` (disable for manual management).";
|
||||
};
|
||||
# Cross-account SSH on the operator's laptop/desktop. Each entry
|
||||
# adds a `~/.ssh/id_ed25519_<account>.pub` filter file and a
|
||||
# `Match user <account>` block in `~/.ssh/config` so
|
||||
# `ssh <account>@<host>` picks the right key from the rbw agent.
|
||||
# `cfg.user` is already wired by the Host blocks — don't list it here.
|
||||
extraIdentities = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
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
|
||||
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.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
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
|
||||
'';
|
||||
}
|
||||
]);
|
||||
};
|
||||
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 ];
|
||||
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 {
|
||||
selectedHosts = builtins.listToAttrs (map (n: { name = n; value = inventory.hosts.${n}; }) selectedHostNames);
|
||||
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" ];
|
||||
};
|
||||
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" ];
|
||||
};
|
||||
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,35 +1,51 @@
|
||||
{ self, ... }: {
|
||||
flake.nixosModules.sshInbound = {
|
||||
config,
|
||||
lib,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.chiasson.ssh.inbound;
|
||||
inventory = self.lib.sshInventory;
|
||||
in
|
||||
{
|
||||
options.chiasson.ssh.inbound = {
|
||||
enable = lib.mkEnableOption "Apply SSH inventory public keys to `authorized_keys`.";
|
||||
userAuthorizedHosts = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.either (lib.types.enum [ "all" ]) (lib.types.listOf lib.types.str));
|
||||
default = { };
|
||||
example = {
|
||||
olivier = "all";
|
||||
admin = [ "14900k" "t2mbp" ];
|
||||
flake.nixosModules.sshInbound =
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
cfg = config.chiasson.ssh.inbound;
|
||||
inventory = self.lib.sshInventory;
|
||||
# Every inbound-enabled user must have a publicKey pasted into the
|
||||
# inventory — silently dropping the key would lock the user out.
|
||||
missingKeys = lib.filter
|
||||
(name:
|
||||
let line = inventory.mkAuthorizedKeyLine name (cfg.userAuthorizedHosts.${name} or "all");
|
||||
in line == null)
|
||||
(lib.attrNames cfg.userAuthorizedHosts);
|
||||
in
|
||||
{
|
||||
options.chiasson.ssh.inbound = {
|
||||
enable = lib.mkEnableOption "Write user-specific SSH inventory public keys into `authorized_keys`.";
|
||||
userAuthorizedHosts = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.either (lib.types.enum [ "all" ]) (lib.types.listOf lib.types.str));
|
||||
default = { };
|
||||
example = {
|
||||
olivier = "all";
|
||||
server = [ "192.168.2.25" "192.168.2.15" ];
|
||||
};
|
||||
description = ''
|
||||
Catalog users whose inventory public key is installed in
|
||||
`authorized_keys`. The value is either `"all"` (no `from=`
|
||||
restriction) or a list of source identifiers appended to
|
||||
sshd's `from=` constraint. List entries should be IPs or CIDRs;
|
||||
hostnames require reverse-DNS to resolve cleanly on the target.
|
||||
'';
|
||||
};
|
||||
description = ''
|
||||
Catalog users that receive the SSH inventory public keys in `authorized_keys`.
|
||||
'';
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users = lib.mapAttrs
|
||||
(_user: _selection: {
|
||||
openssh.authorizedKeys.keys = inventory.authorizedKeys;
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
assertions = [{
|
||||
assertion = cfg.enable -> missingKeys == [ ];
|
||||
message = "chiasson.ssh.inbound: users enabled but missing public key in `modules/lib/ssh-inventory.nix`: ${lib.concatStringsSep ", " missingKeys}";
|
||||
}];
|
||||
}
|
||||
(lib.mkIf cfg.enable {
|
||||
users.users = lib.mapAttrs
|
||||
(name: selection: {
|
||||
openssh.authorizedKeys.keys = [ (inventory.mkAuthorizedKeyLine name selection) ];
|
||||
})
|
||||
cfg.userAuthorizedHosts;
|
||||
})
|
||||
cfg.userAuthorizedHosts;
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
# Users & SSH inbound: options + catalog defaults + integration. Single module
|
||||
# that used to be split across `modules/system/users/{default,catalog-options,
|
||||
# catalog-default,home-integration}.nix`. Per-host overrides / extra HM modules
|
||||
# work the same way they always have; only the file structure changed.
|
||||
{ self, ... }:
|
||||
{
|
||||
flake.nixosModules.users =
|
||||
{ config, options, lib, ... }:
|
||||
let
|
||||
cfg = config.chiasson.users;
|
||||
usersLib = self.lib.usersMerge lib;
|
||||
inventory = self.lib.sshInventory;
|
||||
|
||||
olivierEnabled = lib.elem "olivier" cfg.enabled;
|
||||
|
||||
# Merge catalog + per-host overrides into the final user attrs.
|
||||
selectedUsers = lib.listToAttrs (map
|
||||
(name: {
|
||||
inherit name;
|
||||
value = lib.recursiveUpdate cfg.catalog.${name} (cfg.hostOverrides.${name} or { });
|
||||
})
|
||||
cfg.enabled);
|
||||
|
||||
names = usersLib.hmWiredNames selectedUsers;
|
||||
missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled;
|
||||
stray = usersLib.strayHomeUserKeys cfg.extraModules cfg.enabled;
|
||||
hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options;
|
||||
inboundUsersAttr = usersLib.inboundHostsAttr selectedUsers;
|
||||
|
||||
hmUsersAttr = lib.listToAttrs (map
|
||||
(name: {
|
||||
inherit name;
|
||||
value = usersLib.mkHmUserModule {
|
||||
inherit name;
|
||||
user = selectedUsers.${name};
|
||||
hostExtraModules = cfg.extraModules.${name} or [ ];
|
||||
};
|
||||
})
|
||||
names);
|
||||
|
||||
# Fish shell wiring: HM's fish module declares its package, but
|
||||
# /etc/passwd + /etc/shells need updating so login shells work.
|
||||
# Only fires for HM users that actually enable fish.
|
||||
hmFishUsers =
|
||||
if !hmAvailable then { }
|
||||
else
|
||||
lib.filterAttrs
|
||||
(name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names)
|
||||
config.home-manager.users;
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
self.nixosModules.sshInbound
|
||||
{ _module.args = { inherit self usersLib; }; }
|
||||
];
|
||||
|
||||
####################
|
||||
# Options
|
||||
####################
|
||||
options.chiasson.users = {
|
||||
catalog = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
description = "User records; defaults below. Override with `hostOverrides` or `mkForce`.";
|
||||
};
|
||||
enabled = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Catalog names to materialize as `users.users` on this machine.";
|
||||
};
|
||||
hostOverrides = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
description = "`recursiveUpdate`'d onto catalog users on this host.";
|
||||
};
|
||||
# Standard `attrsOf (listOf …)` already concatenates lists across
|
||||
# module definitions; no custom merge function needed.
|
||||
extraModules = lib.mkOption {
|
||||
type = lib.types.attrsOf (lib.types.listOf lib.types.unspecified);
|
||||
default = { };
|
||||
description = ''
|
||||
Per-user Home Manager extraModules keyed by catalog name. Lists
|
||||
from multiple modules (desktopHomeBase + this host's home.nix)
|
||||
are concatenated.
|
||||
'';
|
||||
};
|
||||
homeManager.autoWire = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Auto-create Home Manager users from the catalog.";
|
||||
};
|
||||
};
|
||||
|
||||
####################
|
||||
# Catalog defaults + integration
|
||||
####################
|
||||
# Nix won't accept both `config.X = …` and `config = …` in the same
|
||||
# attrset (the path-attr partially defines `config`, then the explicit
|
||||
# `config = …` is a redefinition error). All `config.` writes must
|
||||
# therefore live INSIDE the single mkMerge block below.
|
||||
config = lib.mkMerge [
|
||||
####################
|
||||
# Catalog defaults
|
||||
####################
|
||||
{
|
||||
chiasson.users.catalog = {
|
||||
olivier = {
|
||||
isNormalUser = true;
|
||||
description = "Olivier";
|
||||
extraGroups = [
|
||||
"networkmanager" "wheel" "docker" "fuse" "uinput" "kvm"
|
||||
# `video` lets brightnessctl/light udev rules own /sys/backlight without sudo.
|
||||
# Harmless on headless hosts (no devices).
|
||||
"video"
|
||||
# DRI render + input for gamescope / Steam on Wayland, no sudo.
|
||||
"render" "input"
|
||||
];
|
||||
hashedPasswordFile = lib.mkIf olivierEnabled (
|
||||
config.sops.secrets."users/olivier/hashedPassword".path
|
||||
);
|
||||
homeManager = {
|
||||
enable = true;
|
||||
module = { ... }: {
|
||||
home.username = "olivier";
|
||||
home.homeDirectory = "/home/olivier";
|
||||
home.stateVersion = "25.11";
|
||||
programs.home-manager.enable = true;
|
||||
};
|
||||
};
|
||||
ssh.inbound.enable = true;
|
||||
ssh.inbound.authorizedHosts = "all";
|
||||
ssh.outbound.rbw.enable = true;
|
||||
ssh.outbound.rbw.hosts = [ "all" ];
|
||||
# olivier's laptop: the HM SSH module writes the matching
|
||||
# `.pub` filter for each of these and emits a `Match user`
|
||||
# block, so `ssh server@r5500` / `ssh builder@nix-server`
|
||||
# route the right private key from the rbw agent.
|
||||
ssh.outbound.rbw.extraIdentities = [ "server" "builder" ];
|
||||
};
|
||||
|
||||
server = {
|
||||
isNormalUser = true;
|
||||
description = "Server user";
|
||||
extraGroups = [ "wheel" ];
|
||||
homeManager = { enable = false; module = null; };
|
||||
ssh.inbound.enable = true;
|
||||
ssh.inbound.authorizedHosts = "all";
|
||||
ssh.outbound.rbw.enable = false;
|
||||
ssh.outbound.rbw.hosts = [ "all" ];
|
||||
};
|
||||
|
||||
builder = {
|
||||
isNormalUser = true;
|
||||
description = "Navi fleet deploy (push + activate only)";
|
||||
extraGroups = [ ];
|
||||
createHome = false;
|
||||
homeManager = { enable = false; module = null; };
|
||||
ssh.inbound.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
# Pull in olivier's hashed password from sops; `neededForUsers`
|
||||
# makes nixos decrypt it at boot so the user record can read it.
|
||||
sops.secrets."users/olivier/hashedPassword" = lib.mkIf olivierEnabled {
|
||||
neededForUsers = true;
|
||||
};
|
||||
}
|
||||
|
||||
####################
|
||||
# Integration
|
||||
####################
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = missing == [ ];
|
||||
message = "chiasson.users.enabled references unknown catalog names: ${lib.concatStringsSep ", " missing}";
|
||||
}
|
||||
{
|
||||
assertion = stray == [ ];
|
||||
message = "chiasson.users.extraModules has keys not in chiasson.users.enabled: ${lib.concatStringsSep ", " stray}";
|
||||
}
|
||||
];
|
||||
|
||||
# NixOS user accounts (no HM, raw).
|
||||
users.users = lib.mapAttrs
|
||||
(name: user: usersLib.mkNixosUser name user)
|
||||
selectedUsers;
|
||||
}
|
||||
(lib.optionalAttrs hmAvailable {
|
||||
"home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true;
|
||||
"home-manager".sharedModules = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) [
|
||||
self.homeManagerModules.sshOutboundRbw
|
||||
];
|
||||
"home-manager".users = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) hmUsersAttr;
|
||||
})
|
||||
# Wire user-specific ACLs into the inbound module — fail the build
|
||||
# if a user has no matching pubkey.
|
||||
(lib.mkIf (inboundUsersAttr != { }) {
|
||||
chiasson.ssh.inbound.enable = true;
|
||||
chiasson.ssh.inbound.userAuthorizedHosts = inboundUsersAttr;
|
||||
})
|
||||
# Fish-shell wiring: HM knows the package; we sync /etc/passwd +
|
||||
# /etc/shells with `mkForce`. `mkNixosUser` doesn't emit `shell`, so
|
||||
# the force is uncontested.
|
||||
(lib.mkIf (hmFishUsers != { }) {
|
||||
environment.shells = lib.mkAfter (lib.mapAttrsToList
|
||||
(_: hmUser: lib.getExe hmUser.programs.fish.package)
|
||||
hmFishUsers);
|
||||
users.users = lib.mapAttrs
|
||||
(name: hmUser: { shell = lib.mkForce (lib.getExe hmUser.programs.fish.package); })
|
||||
hmFishUsers;
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# Shared user definitions for all hosts that import `nixosModules.users`.
|
||||
# Module (not bare attrset) so catalog entries can use `config.*` for sops paths etc.
|
||||
{ ... }: {
|
||||
flake.nixosModules.usersCatalogDefaults =
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
olivierEnabled = lib.elem "olivier" config.chiasson.users.enabled;
|
||||
in
|
||||
{
|
||||
config.sops.secrets."users/olivier/hashedPassword" = lib.mkIf olivierEnabled {
|
||||
neededForUsers = true;
|
||||
};
|
||||
|
||||
config.chiasson.users.catalog = {
|
||||
olivier = {
|
||||
isNormalUser = true;
|
||||
description = "Olivier";
|
||||
extraGroups = [
|
||||
"networkmanager"
|
||||
"wheel"
|
||||
"docker"
|
||||
"fuse"
|
||||
"uinput"
|
||||
"kvm"
|
||||
# `video` is required for the brightnessctl/light udev rules to grant write access
|
||||
# to /sys/class/backlight/*/brightness without sudo. Harmless on hosts without a
|
||||
# backlight (servers, desktop towers): the group simply has no devices to own.
|
||||
"video"
|
||||
# DRI render nodes and input devices for gamescope / Steam on Wayland (no sudo).
|
||||
"render"
|
||||
"input"
|
||||
];
|
||||
|
||||
# Declared in this module when olivier is in `chiasson.users.enabled`.
|
||||
# With `neededForUsers`, `.path` is under /run/secrets-for-users/… (sops-nix README).
|
||||
hashedPasswordFile = lib.mkIf olivierEnabled (
|
||||
config.sops.secrets."users/olivier/hashedPassword".path
|
||||
);
|
||||
|
||||
homeManager = {
|
||||
enable = true;
|
||||
module =
|
||||
{ ... }:
|
||||
{
|
||||
home.username = "olivier";
|
||||
home.homeDirectory = "/home/olivier";
|
||||
home.stateVersion = "25.11";
|
||||
programs.home-manager.enable = true;
|
||||
};
|
||||
};
|
||||
|
||||
ssh = {
|
||||
inbound = {
|
||||
enable = true;
|
||||
authorizedHosts = "all";
|
||||
};
|
||||
outbound = {
|
||||
rbw = {
|
||||
enable = true;
|
||||
hosts = "all";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
server = {
|
||||
isNormalUser = true;
|
||||
description = "Server user";
|
||||
extraGroups = [ "wheel" ];
|
||||
|
||||
homeManager = {
|
||||
enable = false;
|
||||
module = null;
|
||||
};
|
||||
|
||||
ssh = {
|
||||
inbound = {
|
||||
enable = true;
|
||||
authorizedHosts = "all";
|
||||
};
|
||||
outbound = {
|
||||
rbw = {
|
||||
enable = false;
|
||||
hosts = "all";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
builder = {
|
||||
isNormalUser = true;
|
||||
description = "Navi fleet deploy (push + activate only)";
|
||||
extraGroups = [ ];
|
||||
createHome = false;
|
||||
|
||||
homeManager = {
|
||||
enable = false;
|
||||
module = null;
|
||||
};
|
||||
|
||||
ssh.inbound.enable = true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
{ ... }: {
|
||||
flake.nixosModules.usersCatalogOptions =
|
||||
{ lib, ... }:
|
||||
{
|
||||
options.chiasson.users = {
|
||||
catalog = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
description = ''
|
||||
User records merged from `usersCatalogDefaults`; override with `hostOverrides` or `mkForce`.
|
||||
'';
|
||||
};
|
||||
enabled = lib.mkOption {
|
||||
type = lib.types.listOf lib.types.str;
|
||||
default = [ ];
|
||||
description = "Catalog names to materialize as `users.users` on this machine.";
|
||||
};
|
||||
hostOverrides = lib.mkOption {
|
||||
type = lib.types.attrs;
|
||||
default = { };
|
||||
description = ''
|
||||
`recursiveUpdate`d onto catalog users.
|
||||
'';
|
||||
};
|
||||
extraModules = lib.mkOption {
|
||||
type =
|
||||
(lib.types.attrsOf (lib.types.listOf lib.types.unspecified))
|
||||
// {
|
||||
merge =
|
||||
loc: defs:
|
||||
let
|
||||
values = map (d: d.value) defs;
|
||||
names = lib.unique (lib.concatLists (map builtins.attrNames values));
|
||||
in
|
||||
lib.genAttrs names (
|
||||
name: lib.concatLists (map (v: v.${name} or [ ]) values)
|
||||
);
|
||||
};
|
||||
default = { };
|
||||
description = ''
|
||||
Per-user Home Manager `extraModules` keyed by catalog user name.
|
||||
Keys must match `chiasson.users.enabled`. Lists from multiple modules
|
||||
are concatenated (e.g. `desktop-home-base.nix` + host `home.nix`).
|
||||
'';
|
||||
};
|
||||
homeManager = {
|
||||
autoWire = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = "Create HM users from the catalog when true.";
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
# Catalog → NixOS `users.users` + Home Manager + SSH inbound.
|
||||
{ self, ... }: {
|
||||
flake.nixosModules.users =
|
||||
{ config, lib, ... }:
|
||||
let
|
||||
usersLib = self.lib.usersMerge lib;
|
||||
selectUsers =
|
||||
c:
|
||||
let
|
||||
uc = c.chiasson.users;
|
||||
in
|
||||
usersLib.selectedUsersAttr {
|
||||
catalog = uc.catalog;
|
||||
enabled = uc.enabled;
|
||||
hostOverrides = uc.hostOverrides;
|
||||
};
|
||||
in
|
||||
{
|
||||
imports = [
|
||||
self.nixosModules.sshInbound
|
||||
self.nixosModules.usersCatalogOptions
|
||||
self.nixosModules.usersCatalogDefaults
|
||||
{ _module.args = { inherit self usersLib selectUsers; }; }
|
||||
self.nixosModules.usersHomeIntegration
|
||||
];
|
||||
};
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
{ ... }: {
|
||||
flake.nixosModules.usersHomeIntegration =
|
||||
{ config, options, lib, self, usersLib, selectUsers, ... }:
|
||||
let
|
||||
cfg = config.chiasson.users;
|
||||
selected = selectUsers config;
|
||||
missing = usersLib.missingEnabledNames cfg.catalog cfg.enabled;
|
||||
stray = usersLib.strayHomeUserKeys cfg.extraModules cfg.enabled;
|
||||
names = usersLib.hmWiredNames selected;
|
||||
hmAvailable = lib.hasAttrByPath [ "home-manager" "users" ] options;
|
||||
hmUsersAttr = lib.listToAttrs (
|
||||
map (name: {
|
||||
inherit name;
|
||||
value = usersLib.mkHmUserModule {
|
||||
inherit name;
|
||||
user = selected.${name};
|
||||
hostExtraModules = cfg.extraModules.${name} or [ ];
|
||||
};
|
||||
}) names
|
||||
);
|
||||
inboundUsersAttr = usersLib.inboundHostsAttr selected;
|
||||
|
||||
# HM configures fish in ~/.config/fish but no longer sets /etc/passwd or /etc/shells.
|
||||
hmFishUsers =
|
||||
if !hmAvailable then { }
|
||||
else
|
||||
lib.filterAttrs (
|
||||
name: hmUser: (hmUser.programs.fish.enable or false) && builtins.elem name names
|
||||
) config.home-manager.users;
|
||||
in
|
||||
{
|
||||
config = lib.mkMerge [
|
||||
{
|
||||
assertions = [
|
||||
{
|
||||
assertion = missing == [ ];
|
||||
message = "chiasson.users.enabled references unknown catalog users: ${builtins.concatStringsSep ", " missing}";
|
||||
}
|
||||
{
|
||||
assertion = stray == [ ];
|
||||
message = "chiasson.users.extraModules has keys not in chiasson.users.enabled: ${builtins.concatStringsSep ", " stray}";
|
||||
}
|
||||
];
|
||||
}
|
||||
{
|
||||
users.users = lib.mapAttrs (name: user: usersLib.mkNixosUser name user) selected;
|
||||
}
|
||||
(lib.optionalAttrs hmAvailable {
|
||||
"home-manager".useGlobalPkgs = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) true;
|
||||
"home-manager".sharedModules = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) [ self.homeManagerModules.sshOutboundRbw ];
|
||||
"home-manager".users = lib.mkIf (cfg.homeManager.autoWire && names != [ ]) hmUsersAttr;
|
||||
})
|
||||
(lib.mkIf (inboundUsersAttr != { }) {
|
||||
chiasson.ssh.inbound.enable = true;
|
||||
chiasson.ssh.inbound.userAuthorizedHosts = inboundUsersAttr;
|
||||
})
|
||||
(lib.mkIf (hmFishUsers != { }) {
|
||||
environment.shells = lib.mkAfter (
|
||||
lib.mapAttrsToList (
|
||||
_: hmUser: lib.getExe hmUser.programs.fish.package
|
||||
) hmFishUsers
|
||||
);
|
||||
users.users = lib.mapAttrs (
|
||||
name: hmUser: {
|
||||
shell = lib.mkForce (lib.getExe hmUser.programs.fish.package);
|
||||
}
|
||||
) hmFishUsers;
|
||||
})
|
||||
];
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user