diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fe31f5..8526c6b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,13 +72,15 @@ jobs: # exit, and a flake.lock bump landing on a nixpkgs rev where zig_0_17 is not # substitutable, which would start a source build of zig and LLVM. No job # here sets a timeout otherwise, so the default is 6 hours. - timeout-minutes: 20 + timeout-minutes: 30 if: github.event.pull_request.draft != true steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + # HEAD^1 is the base the PR merges into, for the module test filter below. + fetch-depth: 2 # Cheap and offline, so it runs before nix is even installed: it names the # offending file and hash in about a second, where nix build takes ~2 @@ -86,7 +88,53 @@ jobs: - name: Check nix/deps.nix and the package version against the manifests run: ./scripts/verify-nix-deps.sh + # The NixOS module VM test boots guests on top of the package build, so a PR + # only runs it when it touches the module, the flake, this workflow or the + # config loading the module relies on (src/config.zig, src/main.zig). Pushes + # to main always run it. If the diff cannot be computed, it runs. + - name: Decide whether to run the NixOS module test + id: module-test + env: + EVENT: ${{ github.event_name }} + run: | + if [ "$EVENT" != pull_request ]; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif ! changed="$(git diff --name-only HEAD^1 HEAD)"; then + echo "run=true" >> "$GITHUB_OUTPUT" + elif printf '%s\n' "$changed" | grep -qE '^(nix/|flake\.(nix|lock)$|\.github/workflows/ci\.yml$|src/(config|main)\.zig$)'; then + echo "run=true" >> "$GITHUB_OUTPUT" + fi + + # install-nix-action would curl the installer and run it unchecked. Fetch the + # same pinned version to disk and verify it first, as the Zig setup does. The + # installer pins the sha256 of the binary tarball it downloads and aborts on + # a mismatch, so this one digest covers the whole bootstrap. + - name: Download and verify the Nix installer + env: + NIX_VERSION: 2.35.2 + # sha256 of releases.nixos.org/nix/nix-2.35.2/install, equal to its published install.sha256 + NIX_INSTALL_SHA256: 9adda97297d9e8ab360df95c729eabff4f4f93d6db091953c3a68f29e3fb130c + run: | + set -euo pipefail + installer="$RUNNER_TEMP/nix-install" + curl -fsSL --retry 3 -o "$installer" \ + "https://releases.nixos.org/nix/nix-$NIX_VERSION/install" + echo "$NIX_INSTALL_SHA256 $installer" | sha256sum -c - + + # The runner user is not a trusted nix user, so the daemon refuses + # client-supplied restricted settings (substituters, trusted keys, + # require-sigs), including any a PR adds to flake.nix nixConfig. Flake + # config is also not accepted at all, and the only substituter and signing + # key are cache.nixos.org's. - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + with: + install_url: file://${{ runner.temp }}/nix-install + set_as_trusted_user: false + extra_nix_config: | + accept-flake-config = false + require-sigs = true + substituters = https://cache.nixos.org/ + trusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= # nix/deps.nix is the offline package cache for the Nix build, mirroring # the Zig manifests by hand. Nothing built it in CI, so it silently went @@ -132,12 +180,11 @@ jobs: *) echo "NIP-11 response missing software field: $body"; cat relay-nix.log; exit 1 ;; esac - # The flake also exposes checks..wisp-module, a NixOS VM test that - # boots the module and drives a real WebSocket client. It is not run here - # only because of runtime: it builds a guest kernel and userland on top of - # this job's work. KVM *is* available on these runners (the installer - # enables it), so adding it is a cost decision, not a capability one. - # Run it with `nix flake check` when touching nix/module.nix. + # Boots services.wisp in NixOS VMs (KVM is enabled by the installer) and + # drives a real WebSocket client, with and without settingsFile. + - name: NixOS module VM test + if: steps.module-test.outputs.run == 'true' + run: nix build .#checks.x86_64-linux.wisp-module --no-link --print-build-logs integration: permissions: diff --git a/CHANGELOG.md b/CHANGELOG.md index 6390aa8..005a0a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `[spider] mention_events_per_minute` (`WISP_SPIDER_MENTION_EVENTS_PER_MINUTE`, default 600): per upstream relay, a cap on events stored only because they p-tag a follow. Their authors are not followed, so anyone can create them in bulk; events by follows and the admin are never limited. Mentions dropped over the cap are not fetched again later. `0` removes the cap. - NIP-17 / NIP-59: gift wraps (kinds 1059 and the ephemeral 21059) are only served to a connection authenticated (NIP-42) as one of their p-tagged recipients, on REQ, live broadcast, COUNT and negentropy, and a REQ, COUNT or NEG-OPEN naming kind 1059 without authenticating is refused with `auth-required`. Anyone may still publish them, since they are signed by one-time keys. A gift wrap with no valid recipient `p` tag is rejected, since no one could ever read it. A recipient can delete a gift wrap addressed to them with a NIP-09 deletion; when every target is a wrap they received (or one an earlier deletion already removed), the deletion is applied but not stored or broadcast, so it does not publicly tie their key to the wraps. A deletion that also covers the signer's own events, or carries `a` tags, is kept as usual and so does reveal its targets. `17` and `59` are advertised in `supported_nips`. Hidden gift wraps still count toward a query's scan cap (as hidden NIP-78 events do), so a non-recipient can coarsely estimate how many wraps a key receives; NIP-59's randomized timestamps blunt the timing. +- NixOS module: `services.wisp.settingsFile` takes the absolute path of an extra config file, outside the Nix store, for values that must stay secret. It is read through systemd `LoadCredential=`, so it can be root-only, and its keys override `settings`. + +### Changed + +- NixOS module: the generated config reaches wisp as a systemd credential, so the relay's command line names `/run/credentials/wisp.service/wisp.toml` rather than a Nix store path. Existing `settings` configurations need no change. ### Fixed diff --git a/nix/module.nix b/nix/module.nix index 62750eb..6194343 100644 --- a/nix/module.nix +++ b/nix/module.nix @@ -23,6 +23,18 @@ let } cfg.settings; configFile = tomlFormat.generate "wisp.toml" finalSettings; + # wisp reads one config file and its line parser lets a later key override an earlier one, so the + # secret file is appended after the generated one. The `[]` line resets the section, so keys at the + # top of the secret file are read as top-level keys (as they would be standalone), not as keys of + # the generated file's last section. + mergeConfig = pkgs.writeShellScript "wisp-merge-config" '' + { ${pkgs.coreutils}/bin/cat "$CREDENTIALS_DIRECTORY/wisp.toml" + printf '\n[]\n' + ${pkgs.coreutils}/bin/cat "$CREDENTIALS_DIRECTORY/secrets.toml" + } > "$RUNTIME_DIRECTORY/wisp.toml" + ''; + runtimeConfig = if cfg.settingsFile == null then "%d/wisp.toml" else "%t/wisp/wisp.toml"; + # The spider (disabled by default) makes outbound relay connections; when it is on the sandbox must # allow the address families glibc's resolver needs -- AF_NETLINK for interface enumeration, AF_UNIX # for the nss-resolve/nscd socket -- otherwise DNS lookups fail. @@ -110,12 +122,43 @@ in are comma-separated strings, not TOML arrays: use `"wss://a,wss://b"`, not `[ "wss://a" ]`. Do not put secrets here: the rendered config.toml is stored world-readable in the Nix store. - Route any secret through systemd `LoadCredential=`/`EnvironmentFile=` instead. + Put them in `settingsFile` instead. + ''; + }; + + settingsFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + example = "/run/secrets/wisp.toml"; + description = '' + Absolute path to an extra wisp config file, in the same format as config.toml, for values that + must stay out of the Nix store. It is read at service start through systemd `LoadCredential=`, + so it can be owned by root with mode 0600, and it is appended after `settings`, so any key it + sets overrides the same key from `settings`. It must be an absolute path given as a string; a + Nix path literal is rejected because it would be copied into the store. + + The module reads only `settings` (not this file) to decide the firewall port, the sandbox's + address families and the network-online ordering, so keep `server.host`, `server.port` and + `spider.enabled` in `settings`. ''; }; }; config = lib.mkIf cfg.enable { + assertions = [ + { + assertion = + cfg.settingsFile == null + || ( + lib.hasPrefix "/" cfg.settingsFile + && !lib.hasPrefix "${builtins.storeDir}/" cfg.settingsFile + && !lib.hasInfix "\n" cfg.settingsFile + && !lib.hasInfix "%" cfg.settingsFile + ); + message = "services.wisp.settingsFile must be an absolute path outside the Nix store, without newlines or '%'."; + } + ]; + systemd.services.wisp = { description = "wisp nostr relay"; documentation = [ "https://github.com/privkeyio/wisp" ]; @@ -123,7 +166,14 @@ in after = [ "network.target" ] ++ lib.optional needsNetworkOnline "network-online.target"; wants = lib.optional needsNetworkOnline "network-online.target"; serviceConfig = { - ExecStart = "${lib.getExe cfg.package} relay ${configFile}"; + # Credentials keep the config off the command line and let a root-only settingsFile be read + # by the service's dynamic user. + LoadCredential = [ + "wisp.toml:${configFile}" + ] + ++ lib.optional (cfg.settingsFile != null) "secrets.toml:${cfg.settingsFile}"; + ExecStartPre = lib.optional (cfg.settingsFile != null) mergeConfig; + ExecStart = "${lib.getExe cfg.package} relay ${runtimeConfig}"; Restart = "on-failure"; RestartSec = 5; @@ -134,6 +184,8 @@ in # A dedicated, unprivileged, ephemeral user; LMDB store persists under StateDirectory. DynamicUser = true; StateDirectory = "wisp"; + RuntimeDirectory = "wisp"; + RuntimeDirectoryMode = "0700"; WorkingDirectory = cfg.dataDir; # Sandboxing: a network relay needs no more than its data dir and inet sockets. diff --git a/nix/test.nix b/nix/test.nix index 4b6fef4..7632a21 100644 --- a/nix/test.nix +++ b/nix/test.nix @@ -33,7 +33,31 @@ in }; }; + # settingsFile: a root-only file read through LoadCredential and appended after `settings`. + nodes.secret = + { ... }: + { + imports = [ self.nixosModules.wisp ]; + environment.etc."wisp/secrets.toml" = { + mode = "0600"; + # The leading top-level key must not land in the generated file's last section ([storage]); + # if it did, storage.path would point at a missing directory and the relay would not start. + text = '' + path = "/nonexistent/wisp.mdb" + [relay] + name = "secret relay" + description = "from settingsFile" + ''; + }; + services.wisp = { + enable = true; + settings.relay.name = "test relay"; + settingsFile = "/etc/wisp/secrets.toml"; + }; + }; + testScript = '' + start_all() relay.wait_for_unit("wisp.service") relay.wait_for_open_port(7777) @@ -51,5 +75,24 @@ in relay.succeed("test -d /var/lib/wisp") relay.succeed("systemctl show wisp.service | grep -qx 'ProtectSystem=strict'") relay.succeed("systemctl show wisp.service | grep -qx 'DynamicUser=yes'") + + # The config reaches wisp as a credential, so the command line names no Nix store path. + relay.succeed( + "tr '\\0' ' ' < /proc/$(systemctl show -p MainPID --value wisp.service)/cmdline " + + "| grep -q ' relay /run/credentials/wisp.service/wisp.toml'" + ) + + secret.wait_for_unit("wisp.service") + secret.wait_for_open_port(7777) + nip11 = secret.succeed( + "${pkgs.curl}/bin/curl -sf -H 'Accept: application/nostr+json' http://127.0.0.1:7777/" + ) + assert '"name":"secret relay"' in nip11, nip11 + assert '"description":"from settingsFile"' in nip11, nip11 + secret.succeed("test \"$(stat -c %a /run/wisp/wisp.toml)\" = 600") + secret.succeed( + "tr '\\0' ' ' < /proc/$(systemctl show -p MainPID --value wisp.service)/cmdline " + + "| grep -q ' relay /run/wisp/wisp.toml'" + ) ''; }