Add a third auto-loaded kind beside the host and the module: the guest, a reusable definition under guests/ that a host enables like a module and that realizes its interior as a systemd-nspawn nested container. Split the shared base config so a guest can stand on it. base.nix now holds the substrate both bases share — the primary user, home-manager, and the unstable/stable overlays. system.nix keeps the host-only machinery, and a new guest.nix is the slim guest-base: it imports the full modules tree, pins the interior release, and auto-enables the toolkit bundle and SSH so any guest is workable on sight. Give modules.ssh a guest flavor. A host restores its host keys from secrets as before, while a guest sets hostKeys.restore = false, names no sops files, and self-generates a host key, so it holds no age key of its own. The lib grows a guest helper that declares the guests.<path> namespace with an enable and a backend field. Only the container backend is built; microvm is a reserved value that trips a clear build-time assertion rather than silently building nothing. A sample guest exercises the whole path, and neogaia enables it, so the guest interior builds through the existing nix flake check seam.
171 lines
5.8 KiB
Nix
171 lines
5.8 KiB
Nix
{
|
|
config,
|
|
lib,
|
|
...
|
|
}:
|
|
# SSH on this machine, in both directions.
|
|
let
|
|
cfg = config.modules.ssh;
|
|
user = config.user.name;
|
|
|
|
hostKeySecret = type: "ssh-host-${type}-key";
|
|
userKeySecret = "ssh-user-ed25519-key";
|
|
|
|
in
|
|
{
|
|
options.modules.ssh = {
|
|
enable = lib.mkEnableOption "the OpenSSH daemon, with host keys restored from secrets";
|
|
|
|
workstationKeys = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [
|
|
"ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIGxQ4kWsBo2OGYIPOkFe0vNEcB3yoJwAu0y9wrdQzALE alexion@neogaia"
|
|
];
|
|
description = ''
|
|
Client public keys of the machines the operator works from.
|
|
|
|
Every machine admits these, so any of them reaches the whole fleet.
|
|
'';
|
|
};
|
|
|
|
serverKeys = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [ ];
|
|
description = ''
|
|
Client public keys of the machines that serve.
|
|
|
|
Only other servers admit these, so one that is compromised reaches no
|
|
machine the operator works from.
|
|
'';
|
|
};
|
|
|
|
authorizedKeys = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = cfg.workstationKeys;
|
|
defaultText = lib.literalExpression "config.modules.ssh.workstationKeys";
|
|
description = ''
|
|
Client public keys this machine admits for the primary user, drawn from
|
|
the lists above.
|
|
|
|
A machine the operator works from takes the workstation keys. One that
|
|
serves takes both, so servers reach each other. The default admits the
|
|
workstation keys, since a machine admitting none is unreachable.
|
|
'';
|
|
};
|
|
|
|
hostKeys.restore = lib.mkOption {
|
|
type = lib.types.bool;
|
|
default = true;
|
|
description = ''
|
|
Restore the host keys from secrets rather than letting the daemon
|
|
generate its own.
|
|
|
|
A machine with its own identity keeps its fingerprint across a reimage
|
|
by restoring committed keys. A guest carries no host identity, so it
|
|
turns this off and presents a self-generated key instead.
|
|
'';
|
|
};
|
|
|
|
hostKeys.sopsFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
description = ''
|
|
Encrypted file holding this host's SSH host private keys, one entry per
|
|
key type, named `ssh-host-<type>-key`. Required when `restore` is on.
|
|
|
|
These are the keys the daemon presents to identify itself to connecting
|
|
clients, not keys used to authenticate anyone to a remote server.
|
|
Restoring them from secrets rather than generating them keeps the host's
|
|
fingerprint across a reimage, so every client's `known_hosts` entry
|
|
stays valid.
|
|
'';
|
|
};
|
|
|
|
hostKeys.types = lib.mkOption {
|
|
type = lib.types.listOf lib.types.str;
|
|
default = [
|
|
"ed25519"
|
|
"rsa"
|
|
];
|
|
description = ''
|
|
Key types to restore, naming both the entries read from the encrypted
|
|
file and the algorithms the daemon offers. Dropping a type a client has
|
|
already pinned makes the host unrecognisable to it.
|
|
'';
|
|
};
|
|
|
|
userKey.sopsFile = lib.mkOption {
|
|
type = lib.types.nullOr lib.types.path;
|
|
default = null;
|
|
description = ''
|
|
Encrypted file holding this machine's SSH client private key, under the
|
|
entry `ssh-user-ed25519-key`. Left unset on a machine that authenticates
|
|
to no remote server, such as a guest.
|
|
|
|
This is the key the primary user offers to authenticate to a remote
|
|
server, not a key the daemon presents to identify this machine.
|
|
It belongs to this machine alone, so withdrawing its access does not
|
|
re-key any other.
|
|
'';
|
|
};
|
|
};
|
|
|
|
config = lib.mkIf cfg.enable (
|
|
lib.mkMerge [
|
|
{
|
|
services.openssh.enable = true;
|
|
|
|
# The primary user is the only account reachable over SSH.
|
|
users.users.${user}.openssh.authorizedKeys.keys = cfg.authorizedKeys;
|
|
}
|
|
|
|
# A machine with its own identity restores its host keys from secrets.
|
|
(lib.mkIf cfg.hostKeys.restore {
|
|
assertions = [
|
|
{
|
|
assertion = cfg.hostKeys.sopsFile != null;
|
|
message = "modules.ssh.hostKeys.restore requires modules.ssh.hostKeys.sopsFile to name the encrypted host keys.";
|
|
}
|
|
];
|
|
|
|
# The daemon reads its host keys once at startup, so a re-key has to
|
|
# restart it to take effect.
|
|
sops.secrets = lib.genAttrs (map hostKeySecret cfg.hostKeys.types) (_: {
|
|
inherit (cfg.hostKeys) sopsFile;
|
|
mode = "0400";
|
|
restartUnits = [ "sshd.service" ];
|
|
});
|
|
|
|
# An empty list is what stops the daemon generating keys of its own.
|
|
services.openssh.hostKeys = [ ];
|
|
services.openssh.extraConfig = lib.concatMapStrings (
|
|
type: "HostKey ${config.sops.secrets.${hostKeySecret type}.path}\n"
|
|
) cfg.hostKeys.types;
|
|
})
|
|
|
|
# The client key the primary user offers to remote servers, present only on
|
|
# a machine that has one.
|
|
(lib.mkIf (cfg.userKey.sopsFile != null) {
|
|
# The primary user is the only account that authenticates with this key,
|
|
# and the mode admits no other.
|
|
# The client rereads it per connection, so no unit restarts on a re-key.
|
|
sops.secrets.${userKeySecret} = {
|
|
inherit (cfg.userKey) sopsFile;
|
|
mode = "0400";
|
|
owner = user;
|
|
};
|
|
|
|
# The client reads the decrypted key where it is written, so no copy of it
|
|
# lives in the user's home to drift from the secret.
|
|
# Declaring no defaults of home-manager's own leaves every other directive
|
|
# at the one OpenSSH itself ships.
|
|
home-manager.users.${user}.programs.ssh = {
|
|
enable = true;
|
|
enableDefaultConfig = false;
|
|
settings."*".IdentityFile = config.sops.secrets.${userKeySecret}.path;
|
|
};
|
|
})
|
|
]
|
|
);
|
|
}
|