Refactor ssh&users
This commit is contained in:
+69
-7
@@ -63,13 +63,75 @@ User apps / dotfiles → wisdom. Daemons, firewall, kernel → NixOS. Sometimes
|
|||||||
- `desktopWallpapers` copies `inputs.wallpapers` into the store; override `chiasson.desktop.wallpapers.source` if needed.
|
- `desktopWallpapers` copies `inputs.wallpapers` into the store; override `chiasson.desktop.wallpapers.source` if needed.
|
||||||
|
|
||||||
|
|
||||||
## Users
|
## Users & SSH
|
||||||
|
|
||||||
User definitions live in `usersCatalogDefaults` (`modules/system/users/catalog-default.nix`) — olivier, server, builder, etc. Pick who exists on a host with `chiasson.users.enabled`.
|
User definitions live in the catalog defaults block inside `modules/system/users.nix` (was previously split across four files in `modules/system/users/`; **folded into one for cohesion**). olivier, server, builder, etc. Pick who exists on a host with `chiasson.users.enabled`.
|
||||||
|
|
||||||
|
### SSH model — read this once
|
||||||
|
|
||||||
|
SSH authentication has two independent sides:
|
||||||
|
|
||||||
|
| Side | Holds | Decides |
|
||||||
|
|------|-------|---------|
|
||||||
|
| **Client** (connecting from) | identity private keys | "Here's proof I own 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.
|
||||||
|
|
||||||
|
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:
|
||||||
|
|
||||||
|
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.
|
||||||
|
3. rbw holds the private key; HM-managed shells + the desktop session expose `SSH_AUTH_SOCK=$XDG_RUNTIME_DIR/rbw/…` declaratively (no activation scripts).
|
||||||
|
|
||||||
|
### Bitwarden onboarding
|
||||||
|
|
||||||
|
For each new user (run on the user's primary machine):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Generate a keypair with rbw (creates a Bitwarden item + prints the pubkey).
|
||||||
|
rbw gen ssh-key olivier
|
||||||
|
|
||||||
|
# 2. Paste the printed `ssh-ed25519 AAAA… olivier` line into
|
||||||
|
# modules/lib/ssh-inventory.nix under users.olivier.publicKey.
|
||||||
|
|
||||||
|
# 3. Push the user to a host:
|
||||||
|
ssh root@nix-server nixos-rebuild switch --flake .#nix-server
|
||||||
|
|
||||||
|
# 4. Have the user ssh into the fleet from any client — their rbw key
|
||||||
|
# is the only thing offered, and every server already has it in
|
||||||
|
# authorized_keys.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Adding a host
|
||||||
|
|
||||||
|
Edit `modules/lib/ssh-inventory.nix` and add:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
hosts."newhost" = {
|
||||||
|
hostName = "192.168.2.X"; # IP — required for navi to deploy here
|
||||||
|
aliases = [ "newhost" ]; # what you type at the ssh prompt
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add `self.nixosModules.<host>Configuration` to `modules/deploy/navi.nix`.
|
||||||
|
|
||||||
|
### Adding a catalog user
|
||||||
|
|
||||||
|
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`.
|
||||||
|
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.:
|
||||||
|
|
||||||
|
```nix
|
||||||
|
chiasson.users.enabled = [ "olivier" "<name>" ];
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passwords
|
||||||
|
|
||||||
Passwords aren't in the repo. They're in `secrets/secrets.yaml` (encrypted with sops). Each host that has `olivier` must declare that secret and set `neededForUsers = true`; sops-nix decrypts it at boot, and the catalog points `hashedPasswordFile` at that file. That's why the catalog is a proper module instead of a static list — it needs `config.sops.secrets.…` at eval time.
|
Passwords aren't in the repo. They're in `secrets/secrets.yaml` (encrypted with sops). Each host that has `olivier` must declare that secret and set `neededForUsers = true`; sops-nix decrypts it at boot, and the catalog points `hashedPasswordFile` at that file. That's why the catalog is a proper module instead of a static list — it needs `config.sops.secrets.…` at eval time.
|
||||||
|
|
||||||
On a host:
|
### Host overrides
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
# configuration.nix — machine policy
|
# configuration.nix — machine policy
|
||||||
@@ -77,6 +139,8 @@ chiasson.users.enabled = [ "olivier" ];
|
|||||||
chiasson.users.hostOverrides.<name> = { /* optional */ };
|
chiasson.users.hostOverrides.<name> = { /* optional */ };
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Home-manager per-host slices (desktop/laptop hosts)
|
||||||
|
|
||||||
Desktop hosts also have `home.nix` exporting `flake.nixosModules.<host>Home`, wired from `default.nix` alongside `*Configuration`:
|
Desktop hosts also have `home.nix` exporting `flake.nixosModules.<host>Home`, wired from `default.nix` alongside `*Configuration`:
|
||||||
|
|
||||||
```nix
|
```nix
|
||||||
@@ -86,7 +150,7 @@ modules = [
|
|||||||
self.nixosModules."14900kHome"
|
self.nixosModules."14900kHome"
|
||||||
];
|
];
|
||||||
|
|
||||||
# home.nix — flake fragment, per-host `chiasson.home.*` toggles
|
# home.nix — flake fragment, per-host chiasson.home.* toggles
|
||||||
{ self, inputs, ... }: {
|
{ self, inputs, ... }: {
|
||||||
flake.nixosModules."14900kHome" = { self, pkgs, ... }: {
|
flake.nixosModules."14900kHome" = { self, pkgs, ... }: {
|
||||||
imports = [ self.nixosModules.desktopHomeBase ];
|
imports = [ self.nixosModules.desktopHomeBase ];
|
||||||
@@ -102,9 +166,7 @@ modules = [
|
|||||||
|
|
||||||
`flake.nixosModules.desktopHomeBase` expands `lib.wisdomCatalogExtraModules` plus shared desktop toggles. Host `*Home` modules append per-host `chiasson.home` overrides (and rare inline `home.packages` blocks). `chiasson.users.extraModules` concatenates lists from multiple modules (base + host), so both can set the same user key.
|
`flake.nixosModules.desktopHomeBase` expands `lib.wisdomCatalogExtraModules` plus shared desktop toggles. Host `*Home` modules append per-host `chiasson.home` overrides (and rare inline `home.packages` blocks). `chiasson.users.extraModules` concatenates lists from multiple modules (base + host), so both can set the same user key.
|
||||||
|
|
||||||
`usersHomeIntegration` turns that into `users.users` + HM. Don't hand-roll catalog users unless you're changing the users module itself.
|
The integration block at the bottom of `modules/system/users.nix` turns the catalog + overrides into `users.users` + HM + inbound SSH. Don't hand-roll catalog users unless you're changing this module.
|
||||||
|
|
||||||
SSH: `sshInbound` on NixOS, outbound/rbw under `modules/ssh/home-manager/`.
|
|
||||||
|
|
||||||
## Adding things
|
## Adding things
|
||||||
|
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ let
|
|||||||
// lib.optionalAttrs (name == "nix-server") {
|
// lib.optionalAttrs (name == "nix-server") {
|
||||||
targetPort = 22;
|
targetPort = 22;
|
||||||
}
|
}
|
||||||
) ssh.activeHosts;
|
) ssh.activeHosts /* nix-deploy target list — IPs only */;
|
||||||
|
|
||||||
metaNixpkgs = import inputs.nixpkgs {
|
metaNixpkgs = import inputs.nixpkgs {
|
||||||
system = "x86_64-linux";
|
system = "x86_64-linux";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{ ... }: {
|
{ ... }: {
|
||||||
flake.lib.rbwSshSocket =
|
flake.lib.rbwSshSocket =
|
||||||
let
|
let
|
||||||
|
# Bash/Fish init snippets — used by wisdom/shells/{bash,fish}.nix.
|
||||||
bashSnippet = ''
|
bashSnippet = ''
|
||||||
if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
|
if [ -z "''${SSH_AUTH_SOCK:-}" ]; then
|
||||||
if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
|
if [ -n "''${XDG_RUNTIME_DIR:-}" ]; then
|
||||||
@@ -21,10 +22,10 @@
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
'';
|
'';
|
||||||
in
|
in {
|
||||||
{
|
|
||||||
inherit bashSnippet fishSnippet;
|
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";
|
sessionVariable = "$XDG_RUNTIME_DIR/rbw/ssh-agent-socket";
|
||||||
activationPath = "/run/user/$(id -u)/rbw/ssh-agent-socket";
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+103
-78
@@ -1,131 +1,156 @@
|
|||||||
{ lib, ... }: {
|
{ lib, ... }: {
|
||||||
flake.lib.sshInventory =
|
flake.lib.sshInventory =
|
||||||
let
|
let
|
||||||
|
# Hosts: pure network endpoints. No public keys live here anymore —
|
||||||
|
# keys are per USER (see `users` below), one key per person.
|
||||||
hosts = {
|
hosts = {
|
||||||
"14900k" = {
|
"14900k" = {
|
||||||
hostName = "192.168.2.25";
|
hostName = "192.168.2.25";
|
||||||
aliases = [ "14900k" "nixdesk" ];
|
aliases = [ "14900k" "nixdesk" ];
|
||||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILwUevBGnf+Y/sL1ZsB4bt0c50a89iqwPRoYUGP4UHsL 14900k";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
ideapad = {
|
ideapad = {
|
||||||
hostName = "192.168.2.229";
|
hostName = "192.168.2.229";
|
||||||
aliases = [ "ideapad" ];
|
aliases = [ "ideapad" ];
|
||||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIQwaaI90xIMjZ46EcMyO8kBwGCxf7qVL75IYhw8Ssze ideapad";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
t2mbp = {
|
t2mbp = {
|
||||||
hostName = "192.168.2.15";
|
hostName = "192.168.2.15";
|
||||||
aliases = [ "t2mbp" ];
|
aliases = [ "t2mbp" ];
|
||||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMhVWB9YXl/FuQvufle4VWUas/QM8qCKoRd5a83Tt3S6 t2mbp";
|
|
||||||
};
|
};
|
||||||
|
"uConsole" = {
|
||||||
uConsole = {
|
|
||||||
hostName = "192.168.2.99";
|
hostName = "192.168.2.99";
|
||||||
aliases = [ "uConsole" "uconsole" ];
|
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 = {
|
test = {
|
||||||
hostName = "test";
|
hostName = "test";
|
||||||
aliases = [ "test" ];
|
aliases = [ "test" ];
|
||||||
publicKey = null;
|
|
||||||
};
|
};
|
||||||
|
"nix-server" = {
|
||||||
nix-server = {
|
|
||||||
hostName = "192.168.2.238";
|
hostName = "192.168.2.238";
|
||||||
aliases = [ "nix-server" ];
|
aliases = [ "nix-server" ];
|
||||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIL3KDicMjtOFR6LfZrFzfAD1gdYUdwv6ZM4PSgtmIuzd nix-server";
|
|
||||||
};
|
};
|
||||||
|
|
||||||
r5500 = {
|
r5500 = {
|
||||||
hostName = "192.168.2.100";
|
hostName = "192.168.2.100";
|
||||||
aliases = [ "r5500" ];
|
aliases = [ "r5500" ];
|
||||||
publicKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIK7iWCEtkYDLZFRF3w1gzyAok5VCAGUOwu4iWZdMjf3D r5500";
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
mkIdentityFileName = hostName: ".ssh/id_ed25519_${lib.strings.toLower hostName}.pub";
|
# Per-user public keys. One keypair per person, generated once with
|
||||||
activeHosts = builtins.removeAttrs hosts (
|
# `ssh-keygen -t ed25519 -C <comment>` (or `rbw gen ssh-key <user>`),
|
||||||
builtins.filter (name: hosts.${name}.publicKey == null) (builtins.attrNames hosts)
|
# 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:
|
# Hosts SSH is actually safe to deploy/scan to: anything whose hostName
|
||||||
builtins.listToAttrs (
|
# parses as an IPv4 dotted-quad. Filters out scratchpad/non-routable
|
||||||
builtins.map
|
# entries (e.g. `hostName = "test"`).
|
||||||
(hostName: {
|
activeHosts = lib.filterAttrs
|
||||||
name = mkIdentityFileName hostName;
|
(_: h: lib.match "[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+" h.hostName != null)
|
||||||
value.text = "${selectedHosts.${hostName}.publicKey}\n";
|
hosts;
|
||||||
})
|
|
||||||
(builtins.attrNames selectedHosts)
|
|
||||||
);
|
|
||||||
|
|
||||||
# Gitea git-over-SSH listens on port 222. System SSH (nix deploy, server@…) uses port 22
|
# .pub filename convention. OpenSSH 7.3+ accepts a `.pub` file as
|
||||||
# via the catalog `nix-server` Host block — never list nix-server or 192.168.2.238 here.
|
# IdentityFile to filter `IdentityAgent` (rbw) keys without storing the
|
||||||
giteaSshBlock = identityAgent: ''
|
# private key on disk — keep this trick.
|
||||||
Host git.chiasson.cloud gitea
|
mkIdentityFileName = userName: ".ssh/id_ed25519_${lib.strings.toLower userName}.pub";
|
||||||
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
|
|
||||||
'';
|
|
||||||
|
|
||||||
mkSshConfigTemplate =
|
mkSshConfigTemplate =
|
||||||
{
|
{
|
||||||
selectedHosts ? activeHosts,
|
selectedHosts ? activeHosts,
|
||||||
user ? null,
|
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
|
let
|
||||||
hostBlocks = builtins.map
|
# Match blocks for cross-account SSH. Filter out cfg.user since
|
||||||
(hostName:
|
# the matching pub is already loaded via the Host block's
|
||||||
let
|
# IdentityFile line — re-emitting it would just duplicate.
|
||||||
entry = selectedHosts.${hostName};
|
matchBlocks = lib.concatStringsSep "\n" (map
|
||||||
hostPatterns = builtins.concatStringsSep " " (entry.aliases ++ [ entry.hostName ]);
|
(n: ''
|
||||||
userLine = if user == null then "" else " User ${user}\n";
|
Match user ${n}
|
||||||
portLine =
|
IdentityFile ~/${mkIdentityFileName n}
|
||||||
if hostName == "nix-server" then
|
|
||||||
" Port 22\n"
|
|
||||||
else
|
|
||||||
"";
|
|
||||||
in
|
|
||||||
''
|
|
||||||
Host ${hostPatterns}
|
|
||||||
HostName ${entry.hostName}
|
|
||||||
${userLine}${portLine} IdentityFile ~/${mkIdentityFileName hostName}
|
|
||||||
IdentityAgent ${identityAgent}
|
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
'')
|
'')
|
||||||
(builtins.attrNames selectedHosts);
|
(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
|
in
|
||||||
builtins.concatStringsSep "\n" (
|
# Filter empty sections so an absent Match block (no extraIdentities)
|
||||||
[
|
# doesn't add leading whitespace to the rendered config.
|
||||||
(giteaSshBlock identityAgent)
|
lib.concatStringsSep "\n" (lib.filter (s: s != "") [
|
||||||
]
|
matchBlocks
|
||||||
++ hostBlocks
|
giteaBlock
|
||||||
++ [
|
hostBlocks
|
||||||
''
|
''
|
||||||
Host *
|
Host *
|
||||||
IdentitiesOnly yes
|
IdentitiesOnly yes
|
||||||
IdentityAgent none
|
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
|
in
|
||||||
{
|
{
|
||||||
inherit hosts activeHosts mkIdentityFiles mkSshConfigTemplate;
|
inherit hosts users activeHosts;
|
||||||
authorizedKeys = lib.unique (
|
mkSshConfigTemplate = mkSshConfigTemplate;
|
||||||
builtins.map (entry: entry.publicKey) (builtins.attrValues activeHosts)
|
mkAuthorizedKeyLine = mkAuthorizedKeyLine;
|
||||||
);
|
mkIdentityFileName = mkIdentityFileName;
|
||||||
identityFiles = mkIdentityFiles activeHosts;
|
|
||||||
sshConfigTemplate = mkSshConfigTemplate { };
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,43 @@
|
|||||||
{ self, ... }: {
|
{ self, ... }: {
|
||||||
flake.homeManagerModules.sshOutboundRbw = {
|
flake.homeManagerModules.sshOutboundRbw =
|
||||||
config,
|
{ config, lib, pkgs, ... }:
|
||||||
lib,
|
|
||||||
pkgs,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
let
|
||||||
cfg = config.chiasson.ssh.outbound.rbw;
|
cfg = config.chiasson.ssh.outbound.rbw;
|
||||||
inventory = self.lib.sshInventory;
|
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 =
|
selectedHostNames =
|
||||||
if cfg.hosts == [ "all" ] then
|
if cfg.hosts == [ "all" ] then
|
||||||
builtins.attrNames inventory.activeHosts
|
builtins.attrNames inventory.activeHosts
|
||||||
else
|
else
|
||||||
cfg.hosts;
|
cfg.hosts;
|
||||||
missing = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames;
|
missingHosts = builtins.filter (name: !(builtins.hasAttr name inventory.hosts)) selectedHostNames;
|
||||||
selectedHosts = builtins.listToAttrs (
|
# All identities this HM user may authenticate AS: cfg.user ∪
|
||||||
builtins.map (name: {
|
# extraIdentities. `cfg.user` can be null (e.g. user not in inventory
|
||||||
inherit name;
|
# yet) — in that case the Host-block IdentityFile line is suppressed
|
||||||
value = inventory.hosts.${name};
|
# and cross-account SSH is wired from extraIdentities alone.
|
||||||
}) selectedHostNames
|
effectiveIdentities = lib.filter (n: n != null) ([ cfg.user ] ++ cfg.extraIdentities);
|
||||||
);
|
defaultUserHasKey =
|
||||||
sshConfigTemplate = inventory.mkSshConfigTemplate {
|
cfg.user == null
|
||||||
selectedHosts = selectedHosts;
|
|| (inventory.users.${cfg.user}.publicKey or "") != "";
|
||||||
user = cfg.user;
|
# 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
|
in
|
||||||
{
|
{
|
||||||
options.chiasson.ssh.outbound.rbw = {
|
options.chiasson.ssh.outbound.rbw = {
|
||||||
enable = lib.mkEnableOption "Generated `~/.ssh/config` + rbw agent socket.";
|
enable = lib.mkEnableOption "Generated `~/.ssh/config` + rbw agent socket.";
|
||||||
user = lib.mkOption {
|
user = lib.mkOption {
|
||||||
type = lib.types.str;
|
type = lib.types.nullOr lib.types.str;
|
||||||
default = config.home.username;
|
default = config.home.username;
|
||||||
description = "`User` in generated `Host` blocks.";
|
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 {
|
hosts = lib.mkOption {
|
||||||
type = lib.types.listOf lib.types.str;
|
type = lib.types.listOf lib.types.str;
|
||||||
@@ -41,7 +47,25 @@
|
|||||||
manageSshConfig = lib.mkOption {
|
manageSshConfig = lib.mkOption {
|
||||||
type = lib.types.bool;
|
type = lib.types.bool;
|
||||||
default = true;
|
default = true;
|
||||||
description = "Write `~/.ssh/config` from the template.";
|
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.
|
||||||
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,28 +73,51 @@
|
|||||||
{
|
{
|
||||||
assertions = [
|
assertions = [
|
||||||
{
|
{
|
||||||
assertion = missing == [ ];
|
assertion = missingHosts == [ ];
|
||||||
message = "ssh.outbound.rbw: unknown host keys: ${builtins.concatStringsSep ", " missing}";
|
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.packages = with pkgs; [ rbw pinentry-gtk2 ];
|
||||||
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
|
home.sessionVariables.SSH_AUTH_SOCK = self.lib.rbwSshSocket.sessionVariable;
|
||||||
home.file = inventory.mkIdentityFiles selectedHosts;
|
# 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;
|
programs.ssh.enable = lib.mkIf cfg.manageSshConfig false;
|
||||||
home.activation.rbwSshConfig = lib.mkIf cfg.manageSshConfig (lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
}
|
||||||
mkdir -p "$HOME/.ssh"
|
(lib.mkIf cfg.manageSshConfig {
|
||||||
chmod 700 "$HOME/.ssh"
|
home.file.".ssh/config".text = inventory.mkSshConfigTemplate {
|
||||||
RBW_SSH_SOCK="${self.lib.rbwSshSocket.activationPath}"
|
selectedHosts = builtins.listToAttrs (map (n: { name = n; value = inventory.hosts.${n}; }) selectedHostNames);
|
||||||
cat > "$HOME/.ssh/config" <<'EOF'
|
user = cfg.user;
|
||||||
${sshConfigTemplate}
|
inherit (cfg) extraIdentities;
|
||||||
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 = {
|
systemd.user.services.rbw-agent-bootstrap = {
|
||||||
Unit = {
|
Unit = {
|
||||||
Description = "Bootstrap rbw SSH agent";
|
Description = "Bootstrap rbw SSH agent";
|
||||||
@@ -82,15 +129,13 @@ EOF
|
|||||||
ExecStart = "${pkgs.bash}/bin/bash -lc '${pkgs.rbw}/bin/rbw unlocked >/dev/null 2>&1 || true'";
|
ExecStart = "${pkgs.bash}/bin/bash -lc '${pkgs.rbw}/bin/rbw unlocked >/dev/null 2>&1 || true'";
|
||||||
RemainAfterExit = true;
|
RemainAfterExit = true;
|
||||||
};
|
};
|
||||||
Install = {
|
Install.WantedBy = [ "graphical-session.target" ];
|
||||||
WantedBy = [ "graphical-session.target" ];
|
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
home.activation.rbwPinentryConfig = lib.hm.dag.entryAfter [ "writeBoundary" ] ''
|
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
|
${pkgs.rbw}/bin/rbw config set pinentry "${pkgs.pinentry-gtk2}/bin/pinentry-gtk-2" >/dev/null 2>&1 || true
|
||||||
'';
|
'';
|
||||||
}
|
}
|
||||||
|
])
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,35 +1,51 @@
|
|||||||
{ self, ... }: {
|
{ self, ... }: {
|
||||||
flake.nixosModules.sshInbound = {
|
flake.nixosModules.sshInbound =
|
||||||
config,
|
{ config, lib, ... }:
|
||||||
lib,
|
|
||||||
...
|
|
||||||
}:
|
|
||||||
let
|
let
|
||||||
cfg = config.chiasson.ssh.inbound;
|
cfg = config.chiasson.ssh.inbound;
|
||||||
inventory = self.lib.sshInventory;
|
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
|
in
|
||||||
{
|
{
|
||||||
options.chiasson.ssh.inbound = {
|
options.chiasson.ssh.inbound = {
|
||||||
enable = lib.mkEnableOption "Apply SSH inventory public keys to `authorized_keys`.";
|
enable = lib.mkEnableOption "Write user-specific SSH inventory public keys into `authorized_keys`.";
|
||||||
userAuthorizedHosts = lib.mkOption {
|
userAuthorizedHosts = lib.mkOption {
|
||||||
type = lib.types.attrsOf (lib.types.either (lib.types.enum [ "all" ]) (lib.types.listOf lib.types.str));
|
type = lib.types.attrsOf (lib.types.either (lib.types.enum [ "all" ]) (lib.types.listOf lib.types.str));
|
||||||
default = { };
|
default = { };
|
||||||
example = {
|
example = {
|
||||||
olivier = "all";
|
olivier = "all";
|
||||||
admin = [ "14900k" "t2mbp" ];
|
server = [ "192.168.2.25" "192.168.2.15" ];
|
||||||
};
|
};
|
||||||
description = ''
|
description = ''
|
||||||
Catalog users that receive the SSH inventory public keys in `authorized_keys`.
|
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.
|
||||||
'';
|
'';
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
config = lib.mkIf cfg.enable {
|
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
|
users.users = lib.mapAttrs
|
||||||
(_user: _selection: {
|
(name: selection: {
|
||||||
openssh.authorizedKeys.keys = inventory.authorizedKeys;
|
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