systemd: make systemd-ssh-generator work (#372979)

+2
doc/manpage-urls.json
···
"systemd-socket-activate(1)": "https://www.freedesktop.org/software/systemd/man/systemd-socket-activate.html",
"systemd-socket-proxyd(8)": "https://www.freedesktop.org/software/systemd/man/systemd-socket-proxyd.html",
"systemd-soft-reboot.service(8)": "https://www.freedesktop.org/software/systemd/man/systemd-soft-reboot.service.html",
+
"systemd-ssh-generator(8)": "https://www.freedesktop.org/software/systemd/man/systemd-ssh-generator.html",
+
"systemd-ssh-proxy(1)": "https://www.freedesktop.org/software/systemd/man/systemd-ssh-proxy.html",
"systemd-stdio-bridge(1)": "https://www.freedesktop.org/software/systemd/man/systemd-stdio-bridge.html",
"systemd-stub(7)": "https://www.freedesktop.org/software/systemd/man/systemd-stub.html",
"systemd-suspend-then-hibernate.service(8)": "https://www.freedesktop.org/software/systemd/man/systemd-suspend-then-hibernate.service.html",
+13
nixos/doc/manual/release-notes/rl-2505.section.md
···
- GOverlay has been updated to 1.2, please check the [upstream changelog](https://github.com/benjamimgois/goverlay/releases) for more details.
+
- systemd's {manpage}`systemd-ssh-generator(8)` now works out of the box on NixOS.
+
- You can ssh into VMs without any networking configuration if your hypervisor configures the vm to support AF_VSOCK.
+
It still requires the usual ssh authentication methods.
+
- An SSH key for the root user can be provisioned using the `ssh.authorized_keys.root` systemd credential.
+
This can be useful for booting an installation image and providing the SSH key with an smbios string.
+
- SSH can be used for suid-less privilege escalation on the local system without having to rely on networking:
+
```shell
+
ssh root@.host
+
```
+
- systemd's {manpage}`systemd-ssh-proxy(1)` is enabled by default. It can be disabled using [`programs.ssh.systemd-ssh-proxy.enable`](#opt-programs.ssh.systemd-ssh-proxy.enable).
+
+
- SSH host key generation has been separated into the dedicated systemd service sshd-keygen.service.
+
- [`services.mongodb`](#opt-services.mongodb.enable) is now compatible with the `mongodb-ce` binary package. To make use of it, set [`services.mongodb.package`](#opt-services.mongodb.package) to `pkgs.mongodb-ce`.
- [`services.jupyter`](#opt-services.jupyter.enable) is now compatible with `Jupyter Notebook 7`. See [the migration guide](https://jupyter-notebook.readthedocs.io/en/latest/migrate_to_notebook7.html) for details.
+14
nixos/modules/programs/ssh.nix
···
description = "Whether to configure SSH_ASKPASS in the environment.";
};
+
systemd-ssh-proxy.enable = lib.mkOption {
+
type = lib.types.bool;
+
default = true;
+
description = ''
+
Whether to enable systemd's ssh proxy plugin.
+
See {manpage}`systemd-ssh-proxy(1)`.
+
'';
+
};
+
askPassword = lib.mkOption {
type = lib.types.str;
default = "${pkgs.x11_ssh_askpass}/libexec/x11-ssh-askpass";
···
environment.etc."ssh/ssh_config".text = ''
# Custom options from `extraConfig`, to override generated options
${cfg.extraConfig}
+
+
${lib.optionalString cfg.systemd-ssh-proxy.enable ''
+
# See systemd-ssh-proxy(1)
+
Include ${config.systemd.package}/lib/systemd/ssh_config.d/20-systemd-ssh-proxy.conf
+
''}
# Generated options from other settings
Host *
+79 -63
nixos/modules/services/networking/ssh/sshd.nix
···
"ssh/sshd_config".source = sshconf;
};
-
systemd =
-
let
-
service =
-
{ description = "SSH Daemon";
-
wantedBy = lib.optional (!cfg.startWhenNeeded) "multi-user.target";
-
after = [ "network.target" ];
-
stopIfChanged = false;
-
path = [ cfg.package pkgs.gawk ];
-
environment.LD_LIBRARY_PATH = nssModulesPath;
-
-
restartTriggers = lib.optionals (!cfg.startWhenNeeded) [
-
config.environment.etc."ssh/sshd_config".source
-
];
-
-
preStart =
-
''
-
# Make sure we don't write to stdout, since in case of
-
# socket activation, it goes to the remote side (#19589).
-
exec >&2
+
systemd.tmpfiles.settings."ssh-root-provision" = {
+
"/root"."d-" = { user = "root"; group = ":root"; mode = ":700"; };
+
"/root/.ssh"."d-" = { user = "root"; group = ":root"; mode = ":700"; };
+
"/root/.ssh/authorized_keys"."f^" = { user = "root"; group = ":root"; mode = ":600"; argument = "ssh.authorized_keys.root"; };
+
};
-
${lib.flip lib.concatMapStrings cfg.hostKeys (k: ''
-
if ! [ -s "${k.path}" ]; then
-
if ! [ -h "${k.path}" ]; then
-
rm -f "${k.path}"
-
fi
-
mkdir -p "$(dirname '${k.path}')"
-
chmod 0755 "$(dirname '${k.path}')"
-
ssh-keygen \
-
-t "${k.type}" \
-
${lib.optionalString (k ? bits) "-b ${toString k.bits}"} \
-
${lib.optionalString (k ? rounds) "-a ${toString k.rounds}"} \
-
${lib.optionalString (k ? comment) "-C '${k.comment}'"} \
-
${lib.optionalString (k ? openSSHFormat && k.openSSHFormat) "-o"} \
-
-f "${k.path}" \
-
-N ""
-
fi
-
'')}
-
'';
-
-
serviceConfig =
-
{ ExecStart =
-
(lib.optionalString cfg.startWhenNeeded "-") +
-
"${cfg.package}/bin/sshd " + (lib.optionalString cfg.startWhenNeeded "-i ") +
-
"-D " + # don't detach into a daemon process
-
"-f /etc/ssh/sshd_config";
-
KillMode = "process";
-
} // (if cfg.startWhenNeeded then {
-
StandardInput = "socket";
-
StandardError = "journal";
-
} else {
-
Restart = "always";
-
Type = "simple";
-
});
-
-
};
-
in
-
-
if cfg.startWhenNeeded then {
-
-
sockets.sshd =
-
{ description = "SSH Socket";
+
systemd =
+
{
+
sockets.sshd = lib.mkIf cfg.startWhenNeeded {
+
description = "SSH Socket";
wantedBy = [ "sockets.target" ];
socketConfig.ListenStream = if cfg.listenAddresses != [] then
lib.concatMap
···
socketConfig.Accept = true;
# Prevent brute-force attacks from shutting down socket
socketConfig.TriggerLimitIntervalSec = 0;
+
};
+
+
services."sshd@" = {
+
description = "SSH per-connection Daemon";
+
after = [ "network.target" "sshd-keygen.service" ];
+
wants = [ "sshd-keygen.service" ];
+
stopIfChanged = false;
+
path = [ cfg.package ];
+
environment.LD_LIBRARY_PATH = nssModulesPath;
+
+
serviceConfig = {
+
Type = "notify";
+
ExecStart = lib.concatStringsSep " " [
+
"-${lib.getExe' cfg.package "sshd"}"
+
"-i"
+
"-D"
+
"-f /etc/ssh/sshd_config"
+
];
+
KillMode = "process";
+
StandardInput = "socket";
+
StandardError = "journal";
};
+
};
-
services."sshd@" = service;
+
services.sshd = lib.mkIf (! cfg.startWhenNeeded) {
+
description = "SSH Daemon";
+
wantedBy = [ "multi-user.target" ];
+
after = [ "network.target" "sshd-keygen.service" ];
+
wants = [ "sshd-keygen.service" ];
+
stopIfChanged = false;
+
path = [ cfg.package ];
+
environment.LD_LIBRARY_PATH = nssModulesPath;
-
} else {
+
restartTriggers = [ config.environment.etc."ssh/sshd_config".source ];
-
services.sshd = service;
+
serviceConfig = {
+
Type = "notify";
+
Restart = "always";
+
ExecStart = lib.concatStringsSep " " [
+
(lib.getExe' cfg.package "sshd")
+
"-D"
+
"-f" "/etc/ssh/sshd_config"
+
];
+
KillMode = "process";
+
};
+
};
+
services.sshd-keygen = {
+
description = "SSH Host Keys Generation";
+
unitConfig = {
+
ConditionFileNotEmpty = map (k: "|!${k.path}") cfg.hostKeys;
+
};
+
serviceConfig = {
+
Type = "oneshot";
+
};
+
path = [ cfg.package ];
+
script =
+
lib.flip lib.concatMapStrings cfg.hostKeys (k: ''
+
if ! [ -s "${k.path}" ]; then
+
if ! [ -h "${k.path}" ]; then
+
rm -f "${k.path}"
+
fi
+
mkdir -p "$(dirname '${k.path}')"
+
chmod 0755 "$(dirname '${k.path}')"
+
ssh-keygen \
+
-t "${k.type}" \
+
${lib.optionalString (k ? bits) "-b ${toString k.bits}"} \
+
${lib.optionalString (k ? rounds) "-a ${toString k.rounds}"} \
+
${lib.optionalString (k ? comment) "-C '${k.comment}'"} \
+
${lib.optionalString (k ? openSSHFormat && k.openSSHFormat) "-o"} \
+
-f "${k.path}" \
+
-N ""
+
fi
+
'');
+
};
};
networking.firewall.allowedTCPPorts = lib.optionals cfg.openFirewall cfg.ports;
+6 -1
nixos/modules/system/boot/systemd.nix
···
systemd.managerEnvironment = {
# Doesn't contain systemd itself - everything works so it seems to use the compiled-in value for its tools
# util-linux is needed for the main fsck utility wrapping the fs-specific ones
-
PATH = lib.makeBinPath (config.system.fsPackages ++ [cfg.package.util-linux]);
+
PATH = lib.makeBinPath (
+
config.system.fsPackages
+
++ [cfg.package.util-linux]
+
# systemd-ssh-generator needs sshd in PATH
+
++ lib.optional config.services.openssh.enable config.services.openssh.package
+
);
LOCALE_ARCHIVE = "/run/current-system/sw/lib/locale/locale-archive";
TZDIR = "/etc/zoneinfo";
# If SYSTEMD_UNIT_PATH ends with an empty component (":"), the usual unit load path will be appended to the contents of the variable
+1
nixos/tests/all-tests.nix
···
systemd-portabled = handleTest ./systemd-portabled.nix {};
systemd-repart = handleTest ./systemd-repart.nix {};
systemd-resolved = handleTest ./systemd-resolved.nix {};
+
systemd-ssh-proxy = runTest ./systemd-ssh-proxy.nix;
systemd-shutdown = handleTest ./systemd-shutdown.nix {};
systemd-sysupdate = runTest ./systemd-sysupdate.nix;
systemd-sysusers-mutable = runTest ./systemd-sysusers-mutable.nix;
-3
nixos/tests/openssh.nix
···
server_lazy_socket.wait_for_unit("sshd.socket", timeout=30)
with subtest("manual-authkey"):
-
client.succeed("mkdir -m 700 /root/.ssh")
client.succeed(
'${pkgs.openssh}/bin/ssh-keygen -t ed25519 -f /root/.ssh/id_ed25519 -N ""'
)
···
public_key = public_key.strip()
client.succeed("chmod 600 /root/.ssh/id_ed25519")
-
server.succeed("mkdir -m 700 /root/.ssh")
server.succeed("echo '{}' > /root/.ssh/authorized_keys".format(public_key))
-
server_lazy.succeed("mkdir -m 700 /root/.ssh")
server_lazy.succeed("echo '{}' > /root/.ssh/authorized_keys".format(public_key))
client.wait_for_unit("network.target")
+76
nixos/tests/systemd-ssh-proxy.nix
···
+
{
+
pkgs,
+
lib,
+
config,
+
...
+
}:
+
# This tests that systemd-ssh-proxy and systemd-ssh-generator work correctly with:
+
# - a local unix socket on the same system
+
# - a vsock socket inside a vm
+
let
+
inherit (import ./ssh-keys.nix pkgs)
+
snakeOilEd25519PrivateKey
+
snakeOilEd25519PublicKey
+
;
+
qemu = config.nodes.virthost.virtualisation.qemu.package;
+
iso =
+
(import ../lib/eval-config.nix {
+
inherit (pkgs.stdenv.hostPlatform) system;
+
modules = [
+
../modules/installer/cd-dvd/iso-image.nix
+
{
+
services.openssh = {
+
enable = true;
+
settings.PermitRootLogin = "prohibit-password";
+
};
+
isoImage.isoBaseName = lib.mkForce "nixos";
+
isoImage.makeBiosBootable = true;
+
system.stateVersion = lib.trivial.release;
+
}
+
];
+
}).config.system.build.isoImage;
+
in
+
{
+
name = "systemd-ssh-proxy";
+
meta.maintainers = with pkgs.lib.maintainers; [ marie ];
+
+
nodes = {
+
virthost = {
+
services.openssh = {
+
enable = true;
+
settings.PermitRootLogin = "prohibit-password";
+
};
+
users.users = {
+
root.openssh.authorizedKeys.keys = [ snakeOilEd25519PublicKey ];
+
nixos = {
+
isNormalUser = true;
+
};
+
};
+
systemd.services.test-vm = {
+
script = "${lib.getExe qemu} --nographic -smp 1 -m 512 -cdrom ${iso}/iso/nixos.iso -device vhost-vsock-pci,guest-cid=3 -smbios type=11,value=\"io.systemd.credential:ssh.authorized_keys.root=${snakeOilEd25519PublicKey}\"";
+
};
+
};
+
};
+
+
testScript = ''
+
virthost.systemctl("start test-vm.service")
+
+
virthost.succeed("mkdir -p ~/.ssh")
+
virthost.succeed("cp '${snakeOilEd25519PrivateKey}' ~/.ssh/id_ed25519")
+
virthost.succeed("chmod 600 ~/.ssh/id_ed25519")
+
+
with subtest("ssh into a vm with vsock"):
+
virthost.wait_until_succeeds("systemctl is-active test-vm.service")
+
virthost.wait_until_succeeds("ssh -i ~/.ssh/id_ed25519 vsock/3 echo meow | grep meow")
+
virthost.wait_until_succeeds("ssh -i ~/.ssh/id_ed25519 vsock/3 shutdown now")
+
virthost.wait_until_succeeds("! systemctl is-active test-vm.service")
+
+
with subtest("elevate permissions using local ssh socket"):
+
virthost.wait_for_unit("sshd-unix-local.socket")
+
virthost.succeed("sudo --user=nixos mkdir -p /home/nixos/.ssh")
+
virthost.succeed("cp ~/.ssh/id_ed25519 /home/nixos/.ssh/id_ed25519")
+
virthost.succeed("chmod 600 /home/nixos/.ssh/id_ed25519")
+
virthost.succeed("chown nixos /home/nixos/.ssh/id_ed25519")
+
virthost.succeed("sudo --user=nixos ssh -o StrictHostKeyChecking=no -o IdentitiesOnly=yes -i /home/nixos/.ssh/id_ed25519 root@.host whoami | grep root")
+
'';
+
}
pkgs/os-specific/linux/systemd/0019-timesyncd-disable-NSCD-when-DNSSEC-validation-is-dis.patch pkgs/os-specific/linux/systemd/0021-timesyncd-disable-NSCD-when-DNSSEC-validation-is-dis.patch
+25
pkgs/os-specific/linux/systemd/0020-install-unit_file_exists_full-follow-symlinks.patch
···
+
From 7be486fb25dc4ea212cb17f6a3f4a434a557b0d9 Mon Sep 17 00:00:00 2001
+
From: Marie Ramlow <me@nycode.dev>
+
Date: Fri, 10 Jan 2025 15:51:33 +0100
+
Subject: [PATCH] install: unit_file_exists_full: follow symlinks
+
+
---
+
src/shared/install.c | 2 +-
+
1 file changed, 1 insertion(+), 1 deletion(-)
+
+
diff --git a/src/shared/install.c b/src/shared/install.c
+
index 53566b7eef..0975cd47c7 100644
+
--- a/src/shared/install.c
+
+++ b/src/shared/install.c
+
@@ -3217,7 +3217,7 @@ int unit_file_exists_full(RuntimeScope scope, const LookupPaths *lp, const char
+
&c,
+
lp,
+
name,
+
- /* flags= */ 0,
+
+ /* flags= */ SEARCH_FOLLOW_CONFIG_SYMLINKS,
+
ret_path ? &info : NULL,
+
/* changes= */ NULL,
+
/* n_changes= */ NULL);
+
--
+
2.47.0
+
+7 -3
pkgs/os-specific/linux/systemd/default.nix
···
./0016-systemctl-edit-suggest-systemdctl-edit-runtime-on-sy.patch
./0017-meson.build-do-not-create-systemdstatedir.patch
./0018-Revert-bootctl-update-list-remove-all-instances-of-s.patch # https://github.com/systemd/systemd/issues/33392
+
# systemd tries to link the systemd-ssh-proxy ssh config snippet with tmpfiles
+
# if the install prefix is not /usr, but that does not work for us
+
# because we include the config snippet manually
+
./0019-meson-Don-t-link-ssh-dropins.patch
+
./0020-install-unit_file_exists_full-follow-symlinks.patch
]
++ lib.optionals (stdenv.hostPlatform.isLinux && stdenv.hostPlatform.isGnu) [
-
./0019-timesyncd-disable-NSCD-when-DNSSEC-validation-is-dis.patch
+
./0021-timesyncd-disable-NSCD-when-DNSSEC-validation-is-dis.patch
]
++ lib.optionals stdenv.hostPlatform.isMusl (
let
···
(lib.mesonOption "umount-path" "${lib.getOutput "mount" util-linux}/bin/umount")
# SSH
-
# Disabled for now until someone makes this work.
-
(lib.mesonOption "sshconfdir" "no")
+
(lib.mesonOption "sshconfdir" "")
(lib.mesonOption "sshdconfdir" "no")
# Features