diff --git a/.github/instructions/janus-host.instructions.md b/.github/instructions/janus-host.instructions.md new file mode 100644 index 0000000..7e6e6f2 --- /dev/null +++ b/.github/instructions/janus-host.instructions.md @@ -0,0 +1,96 @@ +--- +description: "Use when modifying the Janus host, Janus step-ca deployment, Step SSH CA, mTLS issuance, CA bootstrap artifacts, or Janus SOPS secrets. Covers idiomatic Nix, secret placement, and troubleshooting." +name: "Janus Host Instructions" +applyTo: "modules/hosts/janus/**, modules/services/step-ca/step-ca.nix, modules/features/step-client.nix" +--- + +# Janus Host Instructions + +Use this instruction when changing Janus host wiring, Janus CA material, or the shared Step CA module that Janus consumes. + +## Host Intent And Boundaries + +- Treat Janus as the homelab certificate authority host. It runs `step-ca` for X.509 issuance, SSH user and host certificates, and mTLS certificates. +- Assume Janus CA endpoints are private/LAN-only unless the task explicitly changes the exposure model. +- Keep Janus as a concrete host under `modules/hosts/janus`; keep reusable behavior in shared modules such as `modules/services/step-ca/step-ca.nix`, `modules/services/step-ca/ssh-host.nix`, `modules/features/step-client.nix`, and `modules/features/mtls.nix`. +- Preserve host identity unless the task explicitly renames the deployed CA: `hostname = "janus"`, `flake.nixosConfigurations."${hostname}"`, and `networking.hostName = hostname`. +- Keep Janus-specific paths, DNS names, IP SANs, and CA artifacts in the Janus host area or in Janus-specific option values. Do not move them into generic shared modules unless they become a deliberate module interface. + +## Required Module Composition + +When editing `modules/hosts/janus/default.nix`, preserve this composition unless the task explicitly changes Janus architecture: + +- `nixos.lxc` for the container host shape. +- `nixos.mysops` with `sops.defaultSopsFile = ./secrets.yaml` for host-local encrypted CA runtime material. +- `nixos.step-ca` to run the CA daemon and render `/etc/smallstep/ca.json` from Nix plus SOPS paths. +- `nixos.step-client` to install Step trust bootstrap defaults and root CA material. +- `nixos.step-ssh-host` so Janus itself participates in SSH host certificate flow. +- `nixos.mtls` so Janus can issue and renew its own mTLS certificate bundle. + +## Step CA Deployment Pattern + +- Prefer structured Nix values rendered with `builtins.toJSON` over hand-written JSON strings for `ca.json`. +- Prefer the NixOS `services.step-ca.settings` interface for normal `ca.json` settings. Use a `sops.templates`-rendered config only when the rendered JSON needs sops-nix placeholders or runtime secret paths. +- Keep secret-bearing `ca.json` content rendered through `sops.templates` when it includes values from `config.sops.placeholder`; do not put decrypted private keys, passwords, or provisioner encrypted private-key values directly in the Nix store. +- Continue using `services.step-ca` for the daemon contract: `enable`, `settings`, `address`, `port`, `openFirewall`, `package`, and `intermediatePasswordFile`. +- Remember that the NixOS module overrides `services.step-ca.settings.address` with `services.step-ca.address` plus `services.step-ca.port`. Change the high-level address/port options when changing the listener. +- Keep the intermediate password as a runtime string path from `config.sops.secrets..path`; do not use a Nix path literal or the password file can be copied to the globally readable Nix store. Do not pass passwords through command-line arguments or environment variables. +- Account for upstream NixOS service behavior when debugging: the module writes `/etc/smallstep/ca.json`, starts `step-ca.service` as `Type=notify`, sets `DynamicUser = true`, uses `StateDirectory = "step-ca"`, and passes `intermediatePasswordFile` through systemd `LoadCredential`. +- If `step-ca` cannot read key files referenced by `ca.json`, check sops-nix owner/group/mode and systemd credential handling before loosening permissions. The password file path and CA signing key paths are separate contracts. +- Keep Step CA runtime state in `/var/lib/step-ca/db` unless the task explicitly migrates storage. If moving away from embedded Badger, document database backup and HA implications. +- Keep `root`, `crt`, `key`, `ssh.hostKey`, and `ssh.userKey` in `ca.json` pointing at public cert paths or sops-nix materialized secret paths as appropriate. +- Use an ACME provisioner for automated X.509 issuance, an `SSHPOP` provisioner for SSH certificate renewal/rekey behavior, and the `admin` JWK provisioner for explicit administrative or scripted issuance. Ensure JWK claims include `enableSSHCA = true` when it is expected to sign SSH certificates. +- Treat Nix and SOPS as the source of truth. If `step ca provisioner add`, `remove`, or `update` is used for investigation, copy the intended result back into Nix/SOPS and reload or restart `step-ca`; do not leave live `ca.json` drift on the host. +- Use `authority.claims` or provisioner-level claims for certificate lifetimes, renewal behavior, and SSH CA enablement. Avoid `allowRenewalAfterExpiry` unless the task explicitly accepts the added risk for intermittently connected clients. +- Add `authority.policy` when constraining which X.509 SANs or SSH principals Janus may issue. This is mandatory for any move away from private/LAN-only operation. + +## CA Material And Secret Placement + +Store each kind of material in the narrowest place that matches who needs it: + +- `modules/hosts/janus/secrets.yaml`: Janus runtime CA secrets used by `step-ca`, including `janus/ca_password`, `janus/intermediate_ca_key`, `janus/ssh_host_ca_key`, `janus/ssh_user_ca_key`, and `janus/admin_provisioner_encrypted_key`. +- `keys/secrets.yaml`: shared provisioner credential `janus/admin_jwk`, because client and host certificate modules outside Janus consume it to request certs. +- `modules/hosts/janus/root_ca.crt`, `intermediate_ca.crt`, `fingerprint`, `ssh_user_ca_key.pub`, and `ssh_host_ca_key.pub`: public trust artifacts that may be committed and reviewed. +- Offline secret storage only: `root_ca_key` and the root key password. The running CA should not need the root private key for normal operation. + +When editing secrets: + +- Use SOPS commands (`sops`, `edit-secrets`, or `sops set`) rather than editing `ENC[...]` payloads. +- Use YAML literal blocks for PEM or OpenSSH private keys so newlines are preserved. +- After changing Janus recipient rules, run `sops --config .sops.yaml updatekeys -y modules/hosts/janus/secrets.yaml` so the host-local file is actually rewrapped. +- Keep `.sops.yaml` scoped: Janus host runtime secrets should match `modules/hosts/janus/secrets.yaml`; shared credentials should match `keys/secrets.yaml`. + +## Public Trust Artifacts + +- If `root_ca.crt` changes, update `fingerprint` from `step certificate fingerprint ` and update `modules/features/step-client.nix` defaults in the same change. +- If `intermediate_ca.crt` changes, confirm it still chains to the committed root and that the running CA uses the matching `intermediate_ca_key` from SOPS. +- If SSH CA keys rotate, update the matching public key files and every SSH trust consumer, including `modules/services/ssh.nix` known-host CA values and `TrustedUserCAKeys` behavior. +- Treat `README.md` bootstrap commands as part of the operational contract. Keep them aligned with secret names, public artifact paths, CA URL, DNS names, and IP SANs. + +## mTLS And Client Trust + +- Janus mTLS values should stay aligned with Step CA identity: update `step-ca.dnsNames`, `mtls.subject`, `mtls.san`, and Step client defaults together when CA names or addresses change. +- `modules/features/step-client.nix` is the shared bootstrap surface. Its default CA URL, root certificate, and fingerprint should describe Janus unless the repo intentionally migrates to another CA. +- mTLS certificate files are runtime outputs under the configured cert directory, not tracked source files. Do not store mTLS private keys in Git or the Nix store. + +## Step/Nix Troubleshooting Checklist + +Start with the smallest check that matches the change: + +1. Host evaluation: `nix eval .#nixosConfigurations.janus.config.system.build.toplevel.drvPath` +2. Build planning: `nix build .#nixosConfigurations.janus.config.system.build.toplevel --no-link --dry-run` +3. Secret contract: confirm every `step-ca.secrets.*` value has a matching `sops.secrets` declaration and a matching key in `modules/hosts/janus/secrets.yaml`. +4. Rendered config: on the host, inspect `/etc/smallstep/ca.json` or `/etc/step-ca` paths without copying secret content into logs. +5. Service health: check `systemctl status step-ca.service`, `journalctl -u step-ca.service`, and `step ca health --ca-url https://janus.john-stream.com` after trust is bootstrapped. +6. Provisioners: use `step ca provisioner list` to confirm ACME, SSHPOP, and admin JWK availability. +7. SSH cert flow: use `ssh-host-cert-check`, `systemctl status step-ssh-host-renew.service`, and `systemctl status step-ssh-host-renew.timer`. +8. mTLS flow: use `mtls-check`, `mtls-generate`, and `systemctl status mtls-renew.timer` for host certificate renewal issues. + +## Change Safety Rules + +- Do not deploy a root private key to Janus unless the task explicitly changes the trust model. +- Do not rename `janus/admin_jwk` without updating all consumers in the same change. +- Do not expose ACME or `/provisioners` endpoints publicly without an explicit migration away from the private/LAN-only model plus policy and endpoint exposure review. +- Do not use certificate templates casually; if templates are added, keep them narrow and use `toJson` for user-controlled values. +- If changing certificate durations, prefer short-lived leaf certs with automated renewal over long-lived leaf certs. +- If changing service ports or proxying, account for Step renewal behavior: mTLS renewal is sensitive to layer-7 proxies unless configured for token-based renewal. \ No newline at end of file diff --git a/.sops.yaml b/.sops.yaml index 3c548ce..df466ef 100644 --- a/.sops.yaml +++ b/.sops.yaml @@ -3,10 +3,12 @@ keys: - &john-pc age1ykcs39e62pz3xu6cedg8ea685kv5d5qsrhgkndygzm8rx30xd5ys5t3qxt - &test-nix age1gvplss0ddmyf6vpjy363wu3n057vhm0j6n7tc94cxd8kadapypws5mtaj0 - &soteria age1h0prahyukq4l564yqwgcpg3g6gdrjflk0suklussjjrjstxd9uesws8633 + - &janus age1n94nkvmzezdj08t4dpwukd7s5k4cx2rldwvx9x5qhx3xhqm48yhq6ejldt creation_rules: - path_regex: modules/hosts/janus/secrets\.yaml$ key_groups: - age: + - *janus - *john-pc - *test-nix - path_regex: keys/secrets\.yaml$ @@ -16,6 +18,7 @@ creation_rules: - *john-pc - *test-nix - *soteria + - *janus - path_regex: soteria/secrets\.yaml$ key_groups: - age: diff --git a/keys/secrets.yaml b/keys/secrets.yaml index a7232ce..e2fc229 100644 --- a/keys/secrets.yaml +++ b/keys/secrets.yaml @@ -11,40 +11,49 @@ sops: age: - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBhbmduOWR1ZloxWjRjTjFp - ZFpvUFA2cStzYllUa1BhSmJBYWVUSmNGblZ3Cmw3TnVBSGtwaDV4ZlRRT2h6OWw0 - bGd6dUQ2eE1QVWNTTitSSG80NVhraU0KLS0tIHRVMXFFRGh5cjlwNXpIb0F4TnF3 - Ykplcm1DWm04RExHYnRjQjdCOVhLaE0K/VGQ/58QWOAWPToQt22W4iBX7rrh/lxL - Via5/T25c64Eh6FXzar+z9zdHjS4s7PLsf6iJIY84xpKCpSAkcaquw== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBuQytmUDE3NVg1RWlYcjVq + RVp6dHlqOHlNdXAyWkNlOVlGQlIwZERpdFRrCk1CTW84Q1B0MEFKTmpFM2FNS3lE + NDY4b0k3dTdST1VyQlFZVUhGSlpudDgKLS0tIHlVZDBrd0VFSWRsUGtpVjVIYnUx + VGc0WjlNSzE1U2h2cFNZekxoNUhSdVUKYpl5ZlZpH5zvfNUVrPdd+dqwZ16zJn7D + cbc5xZLtDpoeG3ZQQeV9T4WGTKycZN8Wo13Iw7BtQL/zUnUU8L/y8g== -----END AGE ENCRYPTED FILE----- recipient: age1f6drjusg866yscj8029tk4yfpgecklrvezldm02ankm6h8nnwu5s2u6ahy - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBkdEVYYzR2M1hDSmR6aVZO - WCtBUTBVUU96SitsTFlBT2IzQjk2Q21LV2tvCkRxVEVESVFsWlczdU5CT2V1a0cw - MUxXNFJFa1BvRFdGbVpkdDRTYVhROUkKLS0tIHBMaGFTRmgrYnkraGt3YkRCMXhw - Rk9GMUhXcWhYL3NXdmRZN0FtQjR2bU0Kql1oAOuJDJ2jcR+WcibBiFiVcMEBbXVS - cxydfdl3/la1tqiMd6rnDh/iB5hwjeeULwrgE/5Qjc7w23hMN9AF1g== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB6czRVNUhGaFV1U0dUKzNp + YVl0TGZBTnBIaS96dWo2S3BZUFBSclhITkJNCkZZU0dCdnA4d1VVOFRjU2JueG1C + MndFazdFZG5CbEdPU2xSS24xNnBHQ2sKLS0tIFh3TWxydi96alBKRkpqa3VKekUx + ck5hbU1ValNJU2d1cTVTZU10NSsxVkEK5/dMd08yTRKNmunA3u7RYtJsaYVRD/cG + cXL2g5ewWl/vhFH71pwCpXIyEhKCF1U6HR9VlesnwU8ZOON59n9FKg== -----END AGE ENCRYPTED FILE----- recipient: age1ykcs39e62pz3xu6cedg8ea685kv5d5qsrhgkndygzm8rx30xd5ys5t3qxt - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSArTnc1ZEorSDlZSW1qY3Zu - NExrYXgyQjRIQUNRYlFXZCt4bXcxRndzWW40CjNCM3p0VDcwbENoTGxFNGZRR3lM - R0pnSlluTUpiNk91d0hlci9sOUtBYjQKLS0tIGp2eWpReExSNTdCS0RBQ1F5dDNt - eWhuak00dTFZV0k1WEREQkRPWUFmc00KQ13ormwGmzwPxvId8WXe1PY4FG3g1Juw - KnBf20sSRkJ/oj3iEhfd8ZmXeoK1Xc44aeCcVe7NEzVtMhjIdzcNqg== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWVWlDaTdpK2t1Z1pTbmZO + V2o5VW9oa3c3eE9Yc2lzaWtJdk5KcWZFTUQ4CkI2UnN3NWxQOUVwaDFXWmY0ODBU + NXFHeEYwYnUvVkRmNzFQZVNUZHd3bjgKLS0tIDA2Q2J2bnVGUHJ6UEIzNHpmbTRL + U284VStkZ3MyS0x2RTA4akJPRmJtbU0KSKpSQDPiC+m6ta9kYb5J2uYstW2fz2Xq + zVYs/F1727hasnwTOnR+rdN2k72dpxof1T6CxRKWZ3xjkvFqis2Nhg== -----END AGE ENCRYPTED FILE----- recipient: age1gvplss0ddmyf6vpjy363wu3n057vhm0j6n7tc94cxd8kadapypws5mtaj0 - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWTHN6TDBHdFU1L1lEQzg4 - TW1vT08vYnp3bzBzSFlxbGpDSFNycFYycFJRCnYvcjVQOC9rZTFXb0lHQWFUVVhZ - ZFVtM09zWW9GZzNMRnBHdHNFNnZHdTgKLS0tIFh1b1pnKzJQUWg4YkRIWVFnRDNz - bXRzY1piak43N0dMZFQ1WWdSZjBBT2cKujwWnuf7LFZe1TIu0R+Vc+HGCYqaeRkD - jBuPRyN7cqCZ5cK9492BVLg48toU/djCSs1w33aLeTF5Ug/Swduqkg== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBPbFUxTmhablI5SEczeWlV + Q20vYzFGR21GUzNQZVdSb3hCOXNNMUN5K3dNCi9FeEpPcHJCV0t1eU5VMmNzclla + eWdWL2RVMXZMbHhPN2Z1NHdBRnBpQnMKLS0tIG5XR1QrOXJQNHRNQzZvU0hVaFpv + eU82a3Y4enZpUTJIU0dObkkxa1J5K0kKoiSmt5LcM8U82pwwsvMBsgEbQ6u3+156 + GFXQFp2+gk8zsxtzCkW1nl52Bw3GfXS7cJTW3jOVBw/YquDTc8nhXQ== -----END AGE ENCRYPTED FILE----- recipient: age1h0prahyukq4l564yqwgcpg3g6gdrjflk0suklussjjrjstxd9uesws8633 + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBETlZVQ2RyT21KeWtNRFd6 + ZytTWStEdFZkaG9jZzJkU253SzE1VUgvQkNNCk1QTDF6dGo4aXF2TmhtRlcrM3dk + Z25kVXZxQTlJY1pIMXZQOU9FdWN6T1UKLS0tIGhBU0FVNHd6cWhiZzh3Skp6Nzdz + SHhEMndWN1Z1TlRoajRnSEZ1UmFvbmsKR79t34F3rjQg6IzQEIc7W/VUjpHLgFus + wlMkfHfZqUDo/ORvOJmT337l2XuK/spbsNlYqfupCWUbvtMZNhnkAg== + -----END AGE ENCRYPTED FILE----- + recipient: age1n94nkvmzezdj08t4dpwukd7s5k4cx2rldwvx9x5qhx3xhqm48yhq6ejldt lastmodified: "2026-03-15T15:06:29Z" mac: ENC[AES256_GCM,data:cF/TJ8VkzrHRUrO5iGdRdlFtqV/5EQ15JwQKIywJvsh0NERK67T21czSP7923MiL0u5QTVPn/rO8R5E/8gBu3r8+fLq+CFl9PDQHEX2JhnYOD5WZR412WMZq3MVR94IMTOrQANMVpS4uhMyvnrqOe4AenxLDyzrYhkwf1KQh4w0=,iv:Qwy8z4uXGMlf+kTMNiE42M9l8LtSJ+O7diknRrsSeYI=,tag:qlCY9r8HnEDmq/jw59C/sg==,type:str] unencrypted_suffix: _unencrypted diff --git a/modules/features/mtls.nix b/modules/features/mtls.nix index 8a03a85..d1b2faf 100644 --- a/modules/features/mtls.nix +++ b/modules/features/mtls.nix @@ -144,6 +144,33 @@ in type = lib.types.str; default = "/etc/step-ca/certs"; }; + bootstrap = { + enable = lib.mkOption { + description = "Enable initial mTLS issuance when cert material is missing or invalid."; + type = lib.types.bool; + default = false; + }; + wantedBy = lib.mkOption { + description = "systemd targets that should pull in mtls-bootstrap.service."; + type = with lib.types; listOf str; + default = [ "multi-user.target" ]; + }; + after = lib.mkOption { + description = "systemd units/targets that mtls-bootstrap.service should run after."; + type = with lib.types; listOf str; + default = [ "network-online.target" ]; + }; + wants = lib.mkOption { + description = "systemd units/targets that mtls-bootstrap.service should pull in."; + type = with lib.types; listOf str; + default = [ "network-online.target" ]; + }; + provisionerPasswordFile = lib.mkOption { + description = "Optional path passed to mtls-generate as --provisioner-password-file for noninteractive issuance."; + type = lib.types.nullOr lib.types.str; + default = null; + }; + }; certReaders = lib.mkOption { description = ""; type = lib.types.listOf lib.types.str; @@ -179,6 +206,38 @@ in mtlsRenewWrapper.outputs.systemd-system ]; + systemd.services.mtls-bootstrap = lib.mkIf cfg.bootstrap.enable { + description = "Issue initial mTLS certificate if missing or invalid"; + wantedBy = cfg.bootstrap.wantedBy; + after = cfg.bootstrap.after; + wants = cfg.bootstrap.wants; + path = with pkgs; [ coreutils step-cli ]; + serviceConfig = { + Type = "oneshot"; + User = cfg.renew.user; + Group = cfg.renew.group; + }; + script = '' + set -euo pipefail + + if [ -s "${cfg.certFile}" ] \ + && [ -s "${cfg.keyFile}" ] \ + && [ -s "${cfg.bundleFile}" ] \ + && step certificate inspect "${cfg.certFile}" >/dev/null 2>&1; then + echo "mTLS certificate already exists" + exit 0 + fi + + echo "Issuing initial mTLS certificate" + cmd=(/run/current-system/sw/bin/mtls-generate) + ${lib.optionalString (cfg.bootstrap.provisionerPasswordFile != null) '' + cmd+=(--provisioner-password-file "${cfg.bootstrap.provisionerPasswordFile}") + ''} + + "''${cmd[@]}" + ''; + }; + systemd.timers.mtls-renew = lib.mkIf cfg.renew.enable { wantedBy = [ "timers.target" ]; timerConfig = { diff --git a/modules/features/step-client.nix b/modules/features/step-client.nix index cb4626f..d5381d6 100644 --- a/modules/features/step-client.nix +++ b/modules/features/step-client.nix @@ -68,6 +68,7 @@ in systemd.tmpfiles.rules = [ "d ${cfg.certDir} 0750 root root -" + "L+ ${cfg.certDir}/root_ca.crt - - - - ${cfg.root}" "d /root/.step 0700 root root -" "d /root/.step/config 0700 root root -" "d /root/.step/certs 0700 root root -" diff --git a/modules/hosts/janus/README.md b/modules/hosts/janus/README.md index 70e3b04..e1f174b 100644 --- a/modules/hosts/janus/README.md +++ b/modules/hosts/janus/README.md @@ -5,6 +5,13 @@ Generate passwords: mkdir -p /tmp/janus-step-ca-bootstrap && chmod 700 /tmp/janus-step-ca-bootstrap && cd /tmp/janus-step-ca-bootstrap && umask 077 && openssl rand -base64 48 > ca_password.txt && openssl rand -base64 48 > admin_jwk_password.txt ``` +Generate the Janus OpenSSH host key that Nix will materialize from SOPS at +`/etc/ssh/ssh_host_ed25519_key`: + +```shell +ssh-keygen -t ed25519 -N '' -C janus@john-stream.com -f /tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key +``` + Bootstrap CA materials with SSH enabled: ```shell STEPPATH=/tmp/janus-step-ca-bootstrap/step step ca init --name Janus --dns janus.john-stream.com --dns 192.168.1.244 --address :443 --provisioner admin --password-file /tmp/janus-step-ca-bootstrap/ca_password.txt --provisioner-password-file /tmp/janus-step-ca-bootstrap/admin_jwk_password.txt --ssh --deployment-type standalone --with-ca-url https://janus.john-stream.com @@ -17,9 +24,24 @@ Insert generated runtime CA material into `modules/hosts/janus/secrets.yaml` und - `/tmp/janus-step-ca-bootstrap/step/secrets/ssh_host_ca_key` -> `ssh_host_ca_key` - `/tmp/janus-step-ca-bootstrap/step/secrets/ssh_user_ca_key` -> `ssh_user_ca_key` - `/tmp/janus-step-ca-bootstrap/step/config/ca.json` -> `admin_provisioner_encrypted_key` (copy `authority.provisioners[].encryptedKey` for the `admin` JWK provisioner) +- `/tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key` -> `ssh_host_ed25519_key` If you are only validating wiring first, `admin_provisioner_encrypted_key` can be an encrypted placeholder and replaced later. +The checked-in `ssh_host_ed25519_key` value is also only an encrypted placeholder. +Replace it before deploying Janus, otherwise OpenSSH and SSH host certificate +issuance will fail because the materialized host key is not a valid private key. + +For multiline secret values, avoid putting private key contents in shell history. +One safe non-interactive pattern is to JSON-encode the file content and pass that +temporary JSON file to `sops set --value-file`: + +```shell +jq -Rs . /tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key > /tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key.json +sops --config .sops.yaml set --value-file modules/hosts/janus/secrets.yaml '["janus"]["ssh_host_ed25519_key"]' /tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key.json +rm -f /tmp/janus-step-ca-bootstrap/ssh_host_ed25519_key.json +``` + If rotating provisioner password, also set: - `/tmp/janus-step-ca-bootstrap/admin_jwk_password.txt` -> `janus.admin_jwk` in `keys/secrets.yaml` @@ -27,6 +49,7 @@ If rotating provisioner password, also set: Secret source-of-truth after this split: - `modules/hosts/janus/secrets.yaml`: Janus runtime CA secrets (`ca_password`, `intermediate_ca_key`, `ssh_host_ca_key`, `ssh_user_ca_key`, `admin_provisioner_encrypted_key`) +- `modules/hosts/janus/secrets.yaml`: Janus OpenSSH host private key (`ssh_host_ed25519_key`) materialized to `/etc/ssh/ssh_host_ed25519_key` - `keys/secrets.yaml`: shared Janus provisioner secret (`janus.admin_jwk`) consumed by `step-ssh-host` across hosts Then update public artifacts in repo from generated output: @@ -38,6 +61,26 @@ Then update public artifacts in repo from generated output: step certificate fingerprint /tmp/janus-step-ca-bootstrap/step/certs/root_ca.crt ``` - `modules/hosts/janus/ssh_user_ca_key.pub` from `/tmp/janus-step-ca-bootstrap/step/certs/ssh_user_ca_key.pub` +- `modules/hosts/janus/ssh_host_ca_key.pub` from `/tmp/janus-step-ca-bootstrap/step/certs/ssh_host_ca_key.pub` + +## First boot checks + +After switching Janus, verify the declarative bootstrap units instead of running +ad hoc issuance commands first: + +```shell +systemctl status step-ca.service +systemctl status step-ssh-host-renew.service +systemctl status step-ssh-host-renew.timer +systemctl status mtls-bootstrap.service +ssh-host-cert-check +mtls-check +``` + +`step-ssh-host-renew.service` issues the SSH host certificate when it is missing +or expiring. `mtls-bootstrap.service` issues the first Janus mTLS bundle +only when the configured certificate files are absent or invalid; recurring mTLS +renewal remains handled by `mtls-renew.timer`. ## Back up the root CA key offline diff --git a/modules/hosts/janus/default.nix b/modules/hosts/janus/default.nix index f1e4150..4111357 100644 --- a/modules/hosts/janus/default.nix +++ b/modules/hosts/janus/default.nix @@ -16,9 +16,20 @@ in nixos.docker nixos.login-text nixos.mtls - ({ lib, pkgs, ... }: { + ({ config, lib, pkgs, ... }: { networking.hostName = hostname; + loginText.extraServiceStatus = { + "Step-CA" = "step-ca"; + }; sops.defaultSopsFile = ./secrets.yaml; + sops.secrets."janus/ssh_host_ed25519_key" = { + sopsFile = ./secrets.yaml; + owner = "root"; + group = "root"; + mode = "0600"; + path = "/etc/ssh/ssh_host_ed25519_key"; + restartUnits = [ "sshd.service" "step-ssh-host-renew.service" ]; + }; step-ssh-host = { hostname = hostname; extraPrincipals = [ @@ -49,6 +60,12 @@ in "${hostname}.john-stream.com" "192.168.1.244" ]; + bootstrap = { + enable = true; + after = [ "network-online.target" "sops-nix.service" "step-ca.service" ]; + wants = [ "network-online.target" "step-ca.service" ]; + provisionerPasswordFile = config.sops.secrets."janus/admin_jwk".path; + }; }; users.users."${username}" = { diff --git a/modules/hosts/janus/secrets.yaml b/modules/hosts/janus/secrets.yaml index 7ef0ea0..d020f56 100644 --- a/modules/hosts/janus/secrets.yaml +++ b/modules/hosts/janus/secrets.yaml @@ -1,31 +1,40 @@ janus: - ca_password: ENC[AES256_GCM,data:GmuqoePDJd5Cn7+sWbvXPlGoUf3SVgYnHOoq9mnPWNWj,iv:VY/8olA+yu66wW+RbhD58GtA0YUWuAtm1HUXtNIIuLk=,tag:fCz3x6B4UPw+XpJE0F3t/g==,type:str] - intermediate_ca_crt: ENC[AES256_GCM,data:9W6QXGlcPdfas1ea6S3w1OYI/SeF6YDIdF5J7Hv4ype3,iv:H8oVfbMLBRvU0ywovVSB84g0q1wPhHEdzfpIVAY0Bkg=,tag:7/pU0N1SlfC1i8H2IgPGVA==,type:str] - intermediate_ca_key: ENC[AES256_GCM,data:wrzE8pgSZrtMA2jyLuVhMFxr47ICDBUkqOFyFJex9h3g,iv:I3Sz07e+cFDOUunCu7ZGcAjckRJDy3NmTXoB0vtA0HI=,tag:inANSVzyAzWlX4J5pheY8A==,type:str] - ssh_host_ca_key: ENC[AES256_GCM,data:IT3PcnbrexRDvxoVEyyxYBx7+Brm3SzVP3Yr2UGkFjeH,iv:edfMiRR+EVx9veTH+mEAFtEdC/rlW8uU5jMD8UX2Ylo=,tag:KKS/2GQE8V6obbjfMLspug==,type:str] - ssh_user_ca_key: ENC[AES256_GCM,data:w3gc97CYUODNxk0gIyghd9PCksDVvrdvl8j/NQe/tCtw,iv:WbWlVCPV7niVHEJ9sVcO9SAcLSfquE/bvcR5KClxszk=,tag:08uj+lv9MflYOtQh28K6pw==,type:str] - admin_provisioner_encrypted_key: ENC[AES256_GCM,data:/EdlRiPn/LHpQk5sA9VVYTz/U4i6Ag==,iv:r6Rm38L1R4xsnyR97e3J2FdH4KWYewZdADosv9WLAXw=,tag:MnWq9lI7GAXN0FmGTa9zmQ==,type:str] + ca_password: ENC[AES256_GCM,data:z6i0ELrn6XwVbJ7gP1GkpWe0Xw==,iv:3pZs/kzPp6bL8iv8jITZnAHuCycbSjhWswgzuJuLvjE=,tag:3gUftdyVXPVWe5dGxh9dvw==,type:str] + intermediate_ca_key: ENC[AES256_GCM,data:0QVk/coqb4xvf2vVhZS9wpo9qrGIcdwPRuHqORyJlUIC0VAwH8AoZNKM54uarwag7Wpb+ltv6vhi4frcp+RzGOCDQET28pSrgXBKw5B31vA4uNI6z8pbZbsUrIbVuQDCw/QAu2v+xuCyMyYK58r4hGaCR6RZu6LCZ9Gx7JgmBZWQcZ3Li5jxG+Cm0HM9AympBhCcIGyWfRXreTZkGlzOhknO/MsBhjTmEwpNi4XZdTlsh1dF2tJ3l9nd7B260yAAwcI8AFyCFSIxAZOxYiYWwqUOQu/tFnDA3Zl7Q2LCwCjrr4UyYd+f33R43BWJPD/8TkPOeoyC4RHGIcB11UyyPXGm6TISJITYd0Ii0t8jPkPPFT3bwx5BEadI+/ICYte8bb5d5uV0gbAZB1EpWmeOcEVOOIt076dr6Q==,iv:1ygRHwQfyAAhG7dmm6aB2b5VVDv6JY0/SJGyVVoVSiM=,tag:GTeWsVEE912GXXNjkK2dHw==,type:str] + ssh_host_ca_key: ENC[AES256_GCM,data:SA46pzoWXdCLRWPZEHS+n93rspAZ7OQEZTfGF8zNXtuP//522eEocq0CGmmi8AJdST0oWnwbx1EFTyDlz2T8/1eP6ucfNigY8BicLymfdMb57uqJs7lAqN3xakdiM+SqfQcEuGy+SjJjqTjfHw7eqUcFXAuqFcTu6roF+kbVQwqmJyFDhh844aB+YhwIjphBYStqYu6CXGrE4UZBdHtyTHT514I0C1FRXBB6T8QzHr006hh+F7TCCnjVEd+JFNVD9dbf+zfWSdmi5edVRO8/fOrsP0PlDz7LQLocc3YJO+SiLC0m47ihszJJFhE/rYdVfPiagw9L6OmXi2RfDOF1/w2+r2d7Ki9WcyHItnzNsb1FEOFvVICT304s/KbGkesQLpp2JDzyPV3xOIxo/mmSsrJ3kKCahJpxdQ==,iv:rl/Vms9w/8NzaNkSU8kgk2SRV/Ic0DYhdtgT1YFoeEw=,tag:H1GrRqh0CmPN+EfS7+zb3w==,type:str] + ssh_user_ca_key: ENC[AES256_GCM,data:jyMrso0G2KVfvn1/Qdl+A0dcQU9/CIrbj1F6hbPB3KWROiQghe/ytTdIsAgKYb+0xUfH27tVjufLY6zEqviPxczzfN9y0ldk+caxH96VEy7da4UdR9rJa7Rl1bvcobRcVIXUCK7rA1deHtJr2wq0KspK4id2QON3w6ggXvYRCe0IAo16p0eGQUMotHqMO+M0qWRkV8IqMKObmxZePK6SGNUEleUNXfSbL4mbN50MIZt9lVKOxOVNZK1BjETtei6IjeeFhPcitJFRjbQTwFB6+5NYqLhEDEZyO3vMZe3poEFtPw9TFlhtYIwtWlBhl+nbh3mAbDWDsHcTix5AL2VVPuDoNpnwCCE4OFzcXjBlBZr28x40o+ukrCxityCXf8M99K3NJVyqgVMdNzjcmViM6pc6wV20t8t/mQ==,iv:RLv3ltoa8Hl1x5Q5xD8xKvlluGd6VTTwxaiV8KhKJBk=,tag:owD14446JnZWznpY9cG2BA==,type:str] + admin_provisioner_encrypted_key: ENC[AES256_GCM,data:lv85vgW77Qiy4UOcHt5SaVvuGmBsDIdjTxbUUaxGeP2H+XghTVuHZ/oLNXaPYqBFKKFqctG7FeL3/Gnm5wD7DXn9z6Cwc9eSZjNHUEiYcQLThBHDUOArk/CHhWvuAhWbuT5yBwVJkUmBpO5ZR3slRAh8RS/EBhb3iOXGW63i2Bj1kFAyf4XucmWY8lkRRpIj11gkWA1gVZqJkQI5AJDf4ikvYI/5p33wtQFpClsKJsJRzaoCee+m3w2Obq8hYMdS1mGnXm1L3tbkAj0130dl4/eOvOZq7le2S5Fo0WAFFyk1nhNz771fRI3WtiA85IHC9Nd3v6jGek+PVclk0D4rfvlQTF1yOpemtcsX2BbAbbB2j7sJ5a5bM9O5lotBoFlGQP/co5EU7Ki0+sTYdQxsHVSQhvsCTv5IhSMSjrzkUVN0RV7EwSkKdLK65Q8bR1pmOdXVDAPsyjB4tUJlwWKisrkAc5oyM+mI0Lalz7nvbTYvgtiS0vcBnGmRwcxDC5q//BTm4RhAB2e/My0MadVxF3aEn6fMaoK+3ef1zAToBRG8bV1vC+trhz3ucPqvMNPxjrSQpOdxzJyqlPgfKFVRI/DEQhO0XWpnU3h7wA1mV/dyjeosFVydfKN5iOLl+xF3VgRpisV2oE0Fgas6JkOGwy/d+MpkgNz/Amr+9phZA/bXl2lpDYIKj6aBduqizI4jV1jpM2FHtv99Jkg24DKIcPSZVvzVYr6j8FMmgaxOsA3Z72GlleLdwBKtuBC0GvU=,iv:kcJEhE1d4vYLHk+W2ay2rid+KCuarAIxUZSl/0wi5W4=,tag:7VARgV4a1nT+beIYWelqyw==,type:str] + ssh_host_ed25519_key: ENC[AES256_GCM,data:HUL5aKHKXLet6Z1cZKsEgesjT/MoiF2y1MIIdD5YzvUyBBCUuoeZuEPs5TnQwthVITHFsiQP0CJQARLGX2QM0+GpMsw/C7pZYbkuBgVbo0U8qA9fixga3wqqk/2b8bgage8cwjCxg/WCLnfa97b7eeQabSU451VYk7nClLoR1Vh0BSGde0oaXMv0eYmgGPMJaD0JMCZX9wAgX2+VcW2MNP80vsyOJNp/GvkV+9RWa1h3DUh/bpywk3rFm4ftTCcpHjwXNyhXOXWXkheJtKWHAWkPSVzvygODVy45ZZqXke7mOsPZfANy4O2IILic6MLaU/TDQ7FbrNAKq9RPHLAMR8C7XFq7QV2MoLkpZ3fzJOn6tLzfgaGmvHBIekywsHJMgCv3MH1l1Uvw2LYY31dFN6z41I7Mj/bFZGZ4TWDQchuJSAF56aosXSw9DCiTY7gly13HkXNYbTrdKM3BjA5TNyYtJ+KMipket4hBUDAVVQ+fBbk087RFmcuzBSAuCnpkoRvPv767/ht8Q5YQpTY=,iv:m1ZKNB5Ab7pKywHQvxqElkCHokNZUZYbxfXXRF8bDD0=,tag:NPA7ErsJMMmff9XYHrUqbQ==,type:str] sops: age: - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCTEFTSy96QTluaWo0bURH - YlpFSWx3VnYvSHpDYU1PNmZrb0FuVHVTcG00CmJnT09OZEJad01OZk8zUzlPT1Ft - bVpHeW9UMXZYN1JJQVgrTk9OamhZeXcKLS0tIFhXMVNDQjhVcUp2aHhjenlGVW9z - cTNPalJOQVJNQWxzMnVMT1lYQS9SNW8KWapdX8dwxEvcqhKI8RJmCWRrV7sRmS72 - sUt6HaUGpQfUQ97MtS01DYaSZjbAvj8t//mXhLubZddDTtTuhbpjAg== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBCdFNwM2JQSVlFZXpJZ0VQ + eDFYek1TMnNTM3lLbjh4SG56VDl6MGsvb1drCnpLbGZ1N2FwSmRacitFVDJxaFlD + Vk5XeCtxRGM0OGV2UFNwQmlFV1pLS1EKLS0tIHB5bm1iRG9WWmpjTlBKcWkwWktz + SWVQOVlJU3RBejdpaGpyTTAzVmh0MFkK6nLey1QwSw5J8WeDdUIjJo62pylqOKmP + +GANs3OQkRuwW2+nwG+LJR2pfUS6O6PDOz7ZWGKih90/Bk7EMtjOIQ== + -----END AGE ENCRYPTED FILE----- + recipient: age1n94nkvmzezdj08t4dpwukd7s5k4cx2rldwvx9x5qhx3xhqm48yhq6ejldt + - enc: | + -----BEGIN AGE ENCRYPTED FILE----- + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSAyeE04L0RvYmlnaWpxUWhm + M01vQVc2bFdNZ2k4TjJBOWZDTlV1V2VkVGd3CnhseTlOZWtqbmpldXUrWnRxSWdR + dFFPTFZtSlZjZVdKMDdYQkVJeG5wZUUKLS0tIGxRbEE3ZjNrUTlLSDMrVVIzbnJ5 + cEFnNmErTlhQMEtZM282STVRUGl0dVkKDyyTGVFDm6u+cdGu/PzFUFgj6ewCf0fi + vFhmYA8bTGNg6loThbYxm4uN1u+mJgwOKJ3FRR+u9yYEF6xvqrIbdw== -----END AGE ENCRYPTED FILE----- recipient: age1ykcs39e62pz3xu6cedg8ea685kv5d5qsrhgkndygzm8rx30xd5ys5t3qxt - enc: | -----BEGIN AGE ENCRYPTED FILE----- - YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBWcnVWS2lpMDM5VWxLWDIx - R0xWQ3ZOaEVFNTR5bXArNTc4MzFOM2xCQWhjCnBZeUtJQ3RvWDdZeVRpRzBPbTl2 - M3FOTlRnTDhIaGg2VzM3MFVEc1FVUmcKLS0tIDAzeDlaZGt5Q2dJbzNWRVB4Qmwr - SWpxcytNdEFBQ2o0Q3lWSHZSNEdCUUEKgHE0j678WIhcQQsZQ5RcQNkQcP++ibvr - ZT3ScL4PAYhH+lxciJK4tit53taevbp5o0jFibaup4ByDvgj7HPclw== + YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSBjeHQ4aDlSU2M2bzE1ODFY + Ym1NNEFFaVhrVlR0WUs1ZUVwTFVGN3QwMFZJCmdJWWFaeHByUlB2U3FLbEpwRkJI + ckRTNWx2dVl3Mi9ZNjhwUFNTdjZIaTAKLS0tIDZ0dFpCa2F3aFRCYmh2N3Nwb2dF + ZUJxakM3Wnc3U3JwTklPcllydXZBUXcKlf9C2/Gb9H9PdEKHdAd48dsalm43vYIz + ODYoLItstmJvT/rZ+XijZFALNsRrIj5435CW6V34OsfCkFkjeHz17w== -----END AGE ENCRYPTED FILE----- recipient: age1gvplss0ddmyf6vpjy363wu3n057vhm0j6n7tc94cxd8kadapypws5mtaj0 - lastmodified: "2026-07-04T04:20:22Z" - mac: ENC[AES256_GCM,data:N0+lAFghaas9fg9PMcsaMPKJbYmnSrgPIFPsEuC749nzMX1IfQVaTLYm6ziZiq8TpscGR1tBqzw2eXiSZG4XfI44naEzO8CvY87aaZAL19/c/j4XE8eBqv1RLsLMbBIYYs8h6aZXCwhWUa3bwFukjueD0iu2X3md6V4T+4RnYSE=,iv:Xd/DCSDymp+KURHjM3Cht8oqbyN/k5+eybdi3pK5eww=,tag:tmDJoo5RKXQTnVjqkXsZ1w==,type:str] + lastmodified: "2026-07-04T16:08:14Z" + mac: ENC[AES256_GCM,data:9cWYDRVot45SO79sR6ETDI2zwMW5EVH2k3pOObQ/FWPhkdZYBkox3uf2UXgzCo9An98ta3bmhcZnfv77jAnZOz088s4MWTvl89ViI0SFtPnm94Ml2NopxmKdRndja5Aj5AOD5MUrqofaSXIhOTxekp/goCGknd986zuUa7yVhhw=,iv:38ycphhpGYKHFhWTQeM5QlfzS8cnaC3Y5yAkO/kxfEk=,tag:ILOV+YyP3kA3e9FJmBC5Gw==,type:str] unencrypted_suffix: _unencrypted version: 3.13.1 diff --git a/modules/services/ssh.nix b/modules/services/ssh.nix index 79cafb6..f814fec 100644 --- a/modules/services/ssh.nix +++ b/modules/services/ssh.nix @@ -1,13 +1,14 @@ { inputs, ... }: let userName = "john"; - sshHostCAPubKey = "ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBNug18oLH0vZxnibXJzMJvTWFPZTnSlhCDDVi+rHhgnIum6ZXQ4SF+VHOOAM5BbzZmMKitNJ5lcrGP15Eur7DzQ="; + sshHostCAPubKeyPath = ../hosts/janus/ssh_host_ca_key.pub; in { flake.modules.nixos.ssh = { config, pkgs, lib, ... }: let cfg = config.ssh; configDir = "/etc/ssh"; + sshHostCAPubKey = lib.removeSuffix "\n" (builtins.readFile sshHostCAPubKeyPath); in { options.ssh = { @@ -72,6 +73,7 @@ in identityFile = cfg.identityFile; publicKeyFile = "${identityFile}.pub"; certificateFile = "${identityFile}-cert.pub"; + sshHostCAPubKey = lib.removeSuffix "\n" (builtins.readFile sshHostCAPubKeyPath); in { options.ssh = with lib; { diff --git a/modules/services/step-ca/ssh-host.nix b/modules/services/step-ca/ssh-host.nix index 7829ce4..e5c9e10 100644 --- a/modules/services/step-ca/ssh-host.nix +++ b/modules/services/step-ca/ssh-host.nix @@ -11,6 +11,24 @@ cfg.hostname "${cfg.hostname}.john-stream.com" ] ++ cfg.extraPrincipals); + sshHostCertRenew = pkgs.writeShellScriptBin "ssh-host-cert-renew" '' + set -euo pipefail + + if [ ! -s "${sshKeyPath}.pub" ]; then + ${lib.getExe' pkgs.openssh "ssh-keygen"} -y -f "${sshKeyPath}" > "${sshKeyPath}.pub" + chmod 0644 "${sshKeyPath}.pub" + fi + + ${lib.getExe pkgs.step-cli} ssh certificate \ + --host --sign \ + --provisioner "${cfg.provisioner}" \ + --provisioner-password-file "${provisionerPasswordPath}" \ + ${principalArgs} \ + "${cfg.hostname}" "${sshKeyPath}.pub" + ''; + sshHostCertCheck = pkgs.writeShellScriptBin "ssh-host-cert-check" '' + ${lib.getExe' pkgs.openssh "ssh-keygen"} -Lf ${sshCertPath} + ''; in { # NixOS Options @@ -45,17 +63,9 @@ }; networking.nameservers = [ "192.168.1.150" ]; networking.dhcpcd.extraConfig = "nohook resolv.conf"; - environment.systemPackages = with pkgs; [ - # step-cli - (writeShellScriptBin "ssh-host-cert-renew" '' - ${lib.getExe pkgs.step-cli} ssh certificate \ - --host --sign \ - --provisioner "${cfg.provisioner}" \ - --provisioner-password-file "${provisionerPasswordPath}" \ - ${principalArgs} \ - "${cfg.hostname}" "${sshKeyPath}.pub" - '') - (writeShellScriptBin "ssh-host-cert-check" "${lib.getExe' pkgs.openssh "ssh-keygen"} -Lf ${sshCertPath}") + environment.systemPackages = [ + sshHostCertRenew + sshHostCertCheck ]; systemd.services.step-ssh-host-renew = { @@ -63,7 +73,7 @@ wantedBy = [ ]; after = [ "network-online.target" ]; wants = [ "network-online.target" ]; - path = with pkgs; [ coreutils systemd step-cli openssh ]; + path = with pkgs; [ coreutils systemd step-cli openssh ]; serviceConfig = { Type = "oneshot"; User = "root"; @@ -71,8 +81,14 @@ }; script = '' set -euo pipefail - if ${lib.getExe pkgs.step-cli} ssh needs-renewal "${sshCertPath}" --expires-in "4h"; then - echo "Renewing SSH host certificate" + + renew=0 + if [ ! -s "${sshCertPath}" ]; then + echo "SSH host cert missing: ${sshCertPath}" + renew=1 + elif ${lib.getExe pkgs.step-cli} ssh needs-renewal "${sshCertPath}" --expires-in "4h"; then + echo "SSH host cert needs renewal" + renew=1 else rc=$? if [ "$rc" -eq 1 ]; then @@ -81,12 +97,17 @@ fi if [ "$rc" -eq 2 ]; then - echo "SSH host cert missing: ${sshCertPath}" >&2 - exit 1 + echo "SSH host cert missing or unreadable: ${sshCertPath}" + renew=1 + else + echo "step ssh needs-renewal failed with rc=$rc" >&2 + exit "$rc" fi + fi - echo "step ssh needs-renewal failed with rc=$rc" >&2 - exit "$rc" + if [ "$renew" -eq 1 ]; then + ${lib.getExe sshHostCertRenew} + ${lib.getExe sshHostCertCheck} fi ''; };