Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 54 additions & 7 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,21 +72,69 @@ 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
# minutes to report the same class of problem as a store path.
- 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
Expand Down Expand Up @@ -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.<system>.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:
Expand Down
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
56 changes: 54 additions & 2 deletions nix/module.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -110,20 +122,58 @@ 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" ];
wantedBy = [ "multi-user.target" ];
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;

Expand All @@ -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.
Expand Down
43 changes: 43 additions & 0 deletions nix/test.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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'"
)
'';
}
Loading