diff --git a/nixos/doc/manual/release-notes/rl-2611.section.md b/nixos/doc/manual/release-notes/rl-2611.section.md index d9de29b0c06e..15365b49c57e 100644 --- a/nixos/doc/manual/release-notes/rl-2611.section.md +++ b/nixos/doc/manual/release-notes/rl-2611.section.md @@ -99,6 +99,8 @@ - `services.plantuml-server.packages.jetty` now supports `jetty_12`, it no longer supports `jetty_11`. +- `services.komodo-periphery` has been updated to support version 2.0.0. Some options have been renamed to match the new configuration structure; compatibility aliases are provided for the renamed options. The `passkeys` and `outbound.onboardingKey` options have been removed; use `passkeyFiles`, `auth.privateKey`/`auth.corePublicKeys`, or `outbound.onboardingKeyFile` instead. New outbound mode configuration is available under `outbound.*`. + ## Other Notable Changes {#sec-release-26.11-notable-changes} diff --git a/nixos/modules/services/admin/komodo-periphery.nix b/nixos/modules/services/admin/komodo-periphery.nix index 5b7643baeea6..d4b0426903da 100644 --- a/nixos/modules/services/admin/komodo-periphery.nix +++ b/nixos/modules/services/admin/komodo-periphery.nix @@ -8,41 +8,65 @@ let cfg = config.services.komodo-periphery; settingsFormat = pkgs.formats.toml { }; + actualRepoDir = if cfg.repoDir != null then cfg.repoDir else "${cfg.rootDirectory}/repos"; + actualStackDir = if cfg.stackDir != null then cfg.stackDir else "${cfg.rootDirectory}/stacks"; + actualBuildDir = if cfg.buildDir != null then cfg.buildDir else "${cfg.rootDirectory}/builds"; + genFinalSettings = let + actualServerEnabled = + if cfg.inbound.serverEnabled != null then + cfg.inbound.serverEnabled + else + (cfg.outbound.coreAddress == ""); + + hasAnyPrivateKey = cfg.auth.privateKey != ""; + baseSettings = { - port = cfg.port; - bind_ip = cfg.bindIp; - root_directory = cfg.rootDirectory; - repo_dir = "${cfg.rootDirectory}/repos"; - stack_dir = "${cfg.rootDirectory}/stacks"; - ssl_enabled = cfg.ssl.enable; + default_terminal_command = cfg.defaultTerminalCommand; + disable_terminals = cfg.disableTerminals; + disable_container_terminals = cfg.disableContainerTerminals; + + stats_polling_rate = cfg.statsPollingRate; + container_stats_polling_rate = cfg.containerStatsPollingRate; + legacy_compose_cli = cfg.legacyComposeCli; + + include_disk_mounts = cfg.includeDiskMounts; + exclude_disk_mounts = cfg.excludeDiskMounts; + + # When a private key is explicitly configured, it's passed via env var. + # Otherwise, use the default file path so Periphery auto-generates a key. + private_key = if !hasAnyPrivateKey then "file:${cfg.rootDirectory}/keys/periphery.key" else ""; + core_public_keys = [ ]; + passkeys = [ ]; + + server_enabled = actualServerEnabled; + port = cfg.inbound.port; + bind_ip = cfg.inbound.bindIp; + allowed_ips = cfg.inbound.allowedIps; + ssl_enabled = cfg.inbound.ssl.enable; } - // lib.optionalAttrs cfg.ssl.enable { - ssl_key_file = cfg.ssl.keyFile; - ssl_cert_file = cfg.ssl.certFile; + // { + core_address = cfg.outbound.coreAddress; + connect_as = cfg.outbound.connectAs; + onboarding_key = ""; } // { logging = { level = cfg.logging.level; stdio = cfg.logging.stdio; + opentelemetry_service_name = cfg.logging.opentelemetryServiceName; + opentelemetry_scope_name = cfg.logging.opentelemetryScopeName; + pretty = cfg.logging.pretty; } // lib.optionalAttrs (cfg.logging.otlpEndpoint != "") { otlp_endpoint = cfg.logging.otlpEndpoint; }; - allowed_ips = cfg.allowedIps; - passkeys = cfg.passkeys; - disable_terminals = cfg.disableTerminals; - disable_container_exec = cfg.disableContainerExec; - stats_polling_rate = cfg.statsPollingRate; - container_stats_polling_rate = cfg.containerStatsPollingRate; - legacy_compose_cli = cfg.legacyComposeCli; - include_disk_mounts = cfg.includeDiskMounts; - exclude_disk_mounts = cfg.excludeDiskMounts; + pretty_startup_config = cfg.prettyStartupConfig; } // cfg.extraSettings; in - lib.filterAttrsRecursive (_: v: v != null && v != { } && v != [ ]) baseSettings; + lib.filterAttrsRecursive (_: v: v != null && v != { } && v != [ ] && v != "") baseSettings; configFile = if cfg.configFile == null then @@ -51,36 +75,98 @@ let cfg.configFile; in { + imports = with lib; [ + (mkRenamedOptionModule + [ "services" "komodo-periphery" "port" ] + [ "services" "komodo-periphery" "inbound" "port" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "bindIp" ] + [ "services" "komodo-periphery" "inbound" "bindIp" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "allowedIps" ] + [ "services" "komodo-periphery" "inbound" "allowedIps" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "ssl" "enable" ] + [ "services" "komodo-periphery" "inbound" "ssl" "enable" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "ssl" "keyFile" ] + [ "services" "komodo-periphery" "inbound" "ssl" "keyFile" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "ssl" "certFile" ] + [ "services" "komodo-periphery" "inbound" "ssl" "certFile" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "serverEnabled" ] + [ "services" "komodo-periphery" "inbound" "serverEnabled" ] + ) + (mkRenamedOptionModule + [ "services" "komodo-periphery" "disableContainerExec" ] + [ "services" "komodo-periphery" "disableContainerTerminals" ] + ) + (mkRemovedOptionModule [ "services" "komodo-periphery" "passkeys" ] + "services.komodo-periphery.passkeys has been removed. Use passkeyFiles for v1.X compatibility, or migrate to auth.privateKey and auth.corePublicKeys (v2.0+)." + ) + (mkRemovedOptionModule [ "services" "komodo-periphery" "outbound" "onboardingKey" ] + "services.komodo-periphery.outbound.onboardingKey has been removed. Use outbound.onboardingKeyFile instead for better security." + ) + ]; + options.services.komodo-periphery = { enable = lib.mkEnableOption "Periphery, a multi-server Docker and Git deployment agent by Komodo"; package = lib.mkPackageOption pkgs "komodo" { }; - configFile = lib.mkOption { - type = lib.types.nullOr lib.types.path; + dockerHost = lib.mkOption { + type = lib.types.nullOr lib.types.str; default = null; - description = "Path to the periphery configuration file. If null, a configuration file will be generated from the module options."; + description = '' + Docker host socket URI to use for container operations. + If null, uses the default Docker socket and automatically enables Docker. + Set this to use alternative container runtimes like Podman or remote Docker hosts. + ''; example = lib.literalExpression '' - pkgs.writeText "periphery.toml" ''' - port = 8120 - bind_ip = "[::]" - ssl_enabled = true - [logging] - level = "info" - ''' + # Podman rootless + "unix:///run/user/1000/podman/podman.sock" + + # Podman rootful + "unix:///run/podman/podman.sock" + + # Remote Docker + "tcp://192.168.1.100:2375" ''; }; - port = lib.mkOption { - type = lib.types.port; - default = 8120; - description = "Port for the Periphery agent to listen on."; - }; + configFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + Path to the Periphery configuration file. - bindIp = lib.mkOption { - type = lib.types.str; - default = "[::]"; - description = "IP address to bind to."; + - If `null` (default), a configuration file will be automatically generated + from the module options (recommended for most users). + - If set to a path, that file will be used directly as the configuration file. + - You can also use `pkgs.writeText` to write configuration inline in your NixOS configuration. + + When using a custom config file, all other module options (except `package`, `user`, `group`, + `environment`, and `environmentFile`) will be ignored. + ''; + example = lib.literalExpression '' + # Option 1: Use an existing config file + "/etc/komodo/periphery.toml" + + # Option 2: Write config inline using pkgs.writeText + pkgs.writeText "periphery.toml" ''' + root_directory = "/var/lib/komodo-periphery" + + logging.level = "info" + logging.stdio = "standard" + ''' + ''; }; rootDirectory = lib.mkOption { @@ -89,24 +175,25 @@ in description = "Root directory for Komodo Periphery data."; }; - ssl = { - enable = lib.mkEnableOption "SSL/TLS support" // { - default = true; - }; + repoDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/repos"''; + description = "Directory for Komodo Periphery to manage repos. If null, defaults to `\${rootDirectory}/repos`."; + }; - keyFile = lib.mkOption { - type = lib.types.path; - default = "${cfg.rootDirectory}/ssl/key.pem"; - defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/ssl/key.pem"''; - description = "Path to SSL key file."; - }; + stackDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/stacks"''; + description = "Directory for Komodo Periphery to manage stacks. If null, defaults to `\${rootDirectory}/stacks`."; + }; - certFile = lib.mkOption { - type = lib.types.path; - default = "${cfg.rootDirectory}/ssl/cert.pem"; - defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/ssl/cert.pem"''; - description = "Path to SSL certificate file."; - }; + buildDir = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/builds"''; + description = "Directory for Komodo Periphery to manage builds. If null, defaults to `\${rootDirectory}/builds`."; }; logging = { @@ -139,26 +226,41 @@ in description = "OpenTelemetry OTLP endpoint for traces."; example = "http://localhost:4317"; }; + + opentelemetryServiceName = lib.mkOption { + type = lib.types.str; + default = "Periphery"; + description = "(v2.0+) OpenTelemetry service name attached to telemetry."; + }; + + opentelemetryScopeName = lib.mkOption { + type = lib.types.str; + default = "Komodo"; + description = "(v2.0+) OpenTelemetry scope name attached to telemetry."; + }; + + pretty = lib.mkOption { + type = lib.types.bool; + default = false; + description = "(v2.0+) Enable human-readable logging (multi-line)."; + }; }; - allowedIps = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; - description = "IP addresses or subnets allowed to call the periphery API. Empty list allows all."; - example = [ - "::ffff:12.34.56.78" - "10.0.10.0/24" - ]; + prettyStartupConfig = lib.mkOption { + type = lib.types.bool; + default = false; + description = "(v2.0+) Enable human-readable startup config log (multi-line)."; }; - passkeys = lib.mkOption { - type = lib.types.listOf lib.types.str; - default = [ ]; + passkeyFiles = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; description = '' - Passkeys required to access the periphery API. - WARNING: These will be stored in the Nix store in plain text! + Path to file containing passkeys (v1.X compatibility). + This will be passed via `PERIPHERY_PASSKEYS_FILE` environment variable. + Consider migrating to the new v2.0+ authentication mechanism. ''; - example = [ "your-secure-passkey" ]; + example = "/run/secrets/komodo-passkey"; }; extraSettings = lib.mkOption { @@ -170,16 +272,23 @@ in }; }; + defaultTerminalCommand = lib.mkOption { + type = lib.types.str; + default = "bash"; + description = "(v2.0+) Default terminal command used to initialize the shell."; + example = "zsh"; + }; + disableTerminals = lib.mkOption { type = lib.types.bool; default = false; description = "Disable remote shell access through Periphery."; }; - disableContainerExec = lib.mkOption { + disableContainerTerminals = lib.mkOption { type = lib.types.bool; default = false; - description = "Disable remote container shell access through Periphery."; + description = "(v2.0+) Disable remote container shell access through Periphery."; }; statsPollingRate = lib.mkOption { @@ -222,6 +331,147 @@ in ]; }; + auth = { + privateKey = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + (v2.0+) Private key used with the Noise handshake. + + Use `file:/path/to/file` prefix to load from a file. If the file doesn't exist, + Periphery will generate and write a new key to the path. + + If empty, defaults to `file:''${rootDirectory}/keys/periphery.key`. + ''; + example = lib.literalExpression '' + # Reference a secret file + "file:''${config.age.secrets.komodo-private-key.path}" + + # Or direct path + "file:/run/secrets/komodo-private-key" + ''; + }; + + corePublicKeys = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + (v2.0+) Accepted public keys to allow Core(s) to connect. + Accepts Spki base64 DER directly or can reference files using `file:/path/to/core.pub` prefix. + You can mix direct keys and file references. + + For better security, use the `file:` prefix to reference secret files. + ''; + example = lib.literalExpression '' + [ + # Direct key value + "MCowBQYDK2VuAyEATZgrjGHeF0KJUe0+n77+qAWOg3YzEzXOmQWaRgO3OGQ=" + # Reference secret files + "file:''${config.age.secrets.komodo-core1-pub.path}" + "file:''${config.age.secrets.komodo-core2-pub.path}" + ] + ''; + }; + }; + + inbound = { + serverEnabled = lib.mkOption { + type = lib.types.nullOr lib.types.bool; + default = null; + description = '' + Enable the inbound connection server for Core -> Periphery connections. + If null, defaults to false when `outbound.coreAddress` is defined, otherwise true. + ''; + }; + + port = lib.mkOption { + type = lib.types.port; + default = 8120; + description = "Port for the inbound Periphery server to listen on."; + }; + + bindIp = lib.mkOption { + type = lib.types.str; + default = "[::]"; + description = '' + IP address the periphery server will bind to. + The default allows external IPv4 and IPv6 connections. + ''; + }; + + allowedIps = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + description = '' + Limit the IP addresses which can connect to Periphery. + Supports IPv4/IPv6 addresses and subnets. + Empty list allows all connections. + ''; + example = [ + "::ffff:12.34.56.78" + "10.0.10.0/24" + ]; + }; + + ssl = { + enable = lib.mkEnableOption "(v2.0+) SSL/TLS support for inbound connections" // { + default = true; + }; + + keyFile = lib.mkOption { + type = lib.types.path; + default = "${cfg.rootDirectory}/ssl/key.pem"; + defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/ssl/key.pem"''; + description = '' + Path to SSL key file. + If the file doesn't exist and SSL is enabled, self-signed keys will be generated using openssl. + ''; + }; + + certFile = lib.mkOption { + type = lib.types.path; + default = "${cfg.rootDirectory}/ssl/cert.pem"; + defaultText = lib.literalExpression ''"''${config.services.komodo-periphery.rootDirectory}/ssl/cert.pem"''; + description = '' + Path to SSL certificate file. + If the file doesn't exist and SSL is enabled, self-signed certificate will be generated using openssl. + ''; + }; + }; + }; + + outbound = { + coreAddress = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + (v2.0+) The address of Komodo Core. Must be reachable from this host. + If provided, Periphery will operate in outbound mode. + ''; + example = "demo.komo.do"; + }; + + connectAs = lib.mkOption { + type = lib.types.str; + default = ""; + description = '' + (v2.0+) The Server this Periphery agent should connect as. + Must match an existing Server name or id. + ''; + example = "server-name"; + }; + + onboardingKeyFile = lib.mkOption { + type = lib.types.nullOr lib.types.path; + default = null; + description = '' + (v2.0+) Path to file containing the onboarding key. + This will be passed via `PERIPHERY_ONBOARDING_KEY_FILE` environment variable. + ''; + example = lib.literalExpression ''"''${config.age.secrets.komodo-onboarding.path}"''; + }; + }; + user = lib.mkOption { type = lib.types.str; default = "komodo-periphery"; @@ -253,14 +503,14 @@ in }; config = lib.mkIf cfg.enable { - virtualisation.docker.enable = true; + virtualisation.docker.enable = lib.mkDefault (cfg.dockerHost == null); users.users.${cfg.user} = lib.mkIf (cfg.user == "komodo-periphery") { isSystemUser = true; group = cfg.group; description = "Komodo Periphery service user"; home = cfg.rootDirectory; - extraGroups = [ "docker" ]; + extraGroups = lib.optional (cfg.dockerHost == null) "docker"; }; users.groups.${cfg.group} = lib.mkIf (cfg.group == "komodo-periphery") { }; @@ -271,16 +521,26 @@ in user = cfg.user; group = cfg.group; }; - "${cfg.rootDirectory}/repos".d = { + "${actualRepoDir}".d = { mode = "0755"; user = cfg.user; group = cfg.group; }; - "${cfg.rootDirectory}/stacks".d = { + "${actualStackDir}".d = { mode = "0755"; user = cfg.user; group = cfg.group; }; + "${actualBuildDir}".d = { + mode = "0755"; + user = cfg.user; + group = cfg.group; + }; + "${cfg.rootDirectory}/keys".d = { + mode = "0700"; + user = cfg.user; + group = cfg.group; + }; "${cfg.rootDirectory}/ssl".d = { mode = "0700"; user = cfg.user; @@ -292,35 +552,63 @@ in description = "Komodo Periphery - Multi-server Docker and Git deployment agent"; after = [ "network-online.target" - "docker.service" - ]; + ] + ++ lib.optional (cfg.dockerHost == null) "docker.service"; wants = [ "network-online.target" - "docker.service" - ]; + ] + ++ lib.optional (cfg.dockerHost == null) "docker.service"; wantedBy = [ "multi-user.target" ]; serviceConfig = { Type = "simple"; User = cfg.user; Group = cfg.group; - SupplementaryGroups = [ "docker" ]; + SupplementaryGroups = lib.mkIf (cfg.dockerHost == null) [ "docker" ]; Restart = "on-failure"; RestartSec = "10s"; WorkingDirectory = cfg.rootDirectory; ExecStart = lib.escapeShellArgs [ - "${lib.getExe' cfg.package "periphery"}" + (lib.getExe' cfg.package "periphery") "--config-path" - (if cfg.configFile != null then cfg.configFile else configFile) + configFile ]; Environment = lib.mapAttrsToList (name: value: "${name}=${value}") ( - cfg.environment - // lib.optionalAttrs (!cfg.disableTerminals) { - PATH = "/run/current-system/sw/bin:/run/wrappers/bin"; + lib.optionalAttrs (cfg.configFile == null) { + PERIPHERY_ROOT_DIRECTORY = cfg.rootDirectory; + PERIPHERY_REPO_DIR = actualRepoDir; + PERIPHERY_STACK_DIR = actualStackDir; + PERIPHERY_BUILD_DIR = actualBuildDir; } + // lib.optionalAttrs (cfg.configFile == null && cfg.inbound.ssl.enable) { + PERIPHERY_SSL_KEY_FILE = cfg.inbound.ssl.keyFile; + PERIPHERY_SSL_CERT_FILE = cfg.inbound.ssl.certFile; + } + // lib.optionalAttrs (cfg.dockerHost != null) { + DOCKER_HOST = cfg.dockerHost; + } + // lib.optionalAttrs (cfg.passkeyFiles != null) { + PERIPHERY_PASSKEYS_FILE = cfg.passkeyFiles; + } + // lib.optionalAttrs (cfg.auth.privateKey != "") { + PERIPHERY_PRIVATE_KEY = cfg.auth.privateKey; + } + // lib.optionalAttrs (cfg.auth.corePublicKeys != [ ]) { + PERIPHERY_CORE_PUBLIC_KEYS = lib.concatStringsSep "," cfg.auth.corePublicKeys; + } + // lib.optionalAttrs (cfg.outbound.onboardingKeyFile != null) { + PERIPHERY_ONBOARDING_KEY_FILE = cfg.outbound.onboardingKeyFile; + } + // cfg.environment ); + + ExecSearchPath = lib.mkIf (!cfg.disableTerminals) [ + "/run/current-system/sw/bin" + "/run/wrappers/bin" + ]; + EnvironmentFile = lib.mkIf (cfg.environmentFile != null) cfg.environmentFile; StateDirectory = "komodo-periphery"; @@ -329,7 +617,7 @@ in NoNewPrivileges = true; PrivateTmp = true; ProtectSystem = "full"; - ProtectHome = true; + ProtectHome = "read-only"; }; }; }; diff --git a/nixos/tests/komodo-periphery.nix b/nixos/tests/komodo-periphery.nix index 64bcbfa7b6e3..e376ddee6506 100644 --- a/nixos/tests/komodo-periphery.nix +++ b/nixos/tests/komodo-periphery.nix @@ -5,22 +5,63 @@ maintainers = with lib.maintainers; [ channinghe ]; }; - nodes.machine = - { config, pkgs, ... }: - { - virtualisation.docker.enable = true; - services.komodo-periphery = { - enable = true; - bindIp = "127.0.0.1"; - port = 8120; - ssl.enable = false; - passkeys = [ "test-passkey" ]; + nodes = { + periphery = + { ... }: + { + virtualisation.docker.enable = true; + services.komodo-periphery = { + enable = true; + inbound.bindIp = "127.0.0.1"; + inbound.port = 8120; + inbound.ssl.enable = false; + inbound.serverEnabled = true; + disableTerminals = true; + disableContainerTerminals = true; + statsPollingRate = "10-sec"; + containerStatsPollingRate = "1-min"; + logging.level = "debug"; + extraSettings = { + secrets.TEST_SECRET = "test-value"; + }; + auth.privateKey = "file:/var/lib/komodo-periphery/keys/test.key"; + auth.corePublicKeys = [ "MCowBQYDK2VuAyEATZgrjGHeF0KJUe0+n77+qAWOg3YzEzXOmQWaRgO3OGQ=" ]; + }; }; - }; + + peripheryOutbound = + { ... }: + { + virtualisation.docker.enable = true; + services.komodo-periphery = { + enable = true; + inbound.ssl.enable = false; + logging.level = "debug"; + outbound.coreAddress = "core.invalid"; + outbound.connectAs = "test-server"; + }; + }; + }; testScript = '' - machine.wait_for_unit("komodo-periphery.service") - machine.wait_for_open_port(8120) - machine.succeed("systemctl status komodo-periphery.service") + start_all() + + with subtest("Inbound periphery starts and serves /version"): + periphery.wait_for_unit("komodo-periphery.service") + periphery.wait_for_open_port(8120) + periphery.succeed("curl -fsS --max-time 10 http://127.0.0.1:8120/version") + + with subtest("Periphery creates managed directories"): + periphery.succeed("test -d /var/lib/komodo-periphery") + periphery.succeed("test -d /var/lib/komodo-periphery/repos") + periphery.succeed("test -d /var/lib/komodo-periphery/stacks") + periphery.succeed("test -d /var/lib/komodo-periphery/builds") + periphery.succeed("test -d /var/lib/komodo-periphery/keys") + periphery.succeed("test -d /var/lib/komodo-periphery/ssl") + + with subtest("Outbound periphery stays active despite unreachable core"): + peripheryOutbound.wait_for_unit("komodo-periphery.service") + peripheryOutbound.sleep(15) + peripheryOutbound.succeed("systemctl is-active komodo-periphery") ''; }