From 828ffefd734e319e9fac20be5cb07f66891ba670 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:55:29 -0400 Subject: [PATCH] Guided install.sh + secret-hardened setup wizard Bring the relay package up to the Grin-style guided-setup standard, matching GoblinPay's wizard experience: - install.sh at the repo root: one interactive script that asks a single topology question (name service alongside the relay / on a separate domain / relay only / authority standalone), builds what was chosen, installs the hardened systemd units, and hands off to the authority's setup wizard. Every prompt has a default; re-runnable; never clobbers an existing config. - Secret hardening (new house rule): the GoblinPay token and optional webhook secret are collected over HIDDEN prompts (terminal echo off via libc termios) and written to a root-only 0600 file, never the (proxy/backup-exposable) env file. The env file only references GOBLINPAY_TOKEN_FILE / GOBLINPAY_WEBHOOK_SECRET_FILE. install.sh drops in a systemd credential (LoadCredential + Environment=%d) so the DynamicUser reads a copy without the file being broadly readable; the base unit stays valid for a free (no-secret) authority. Aligns with GoblinPay's 0400 wallet-password + *_FILE pattern (feat/secret-hardening branch does not exist yet; used root-only 0600 config). - `floonet-name-authority setup [--reconfigure]` subcommand so install.sh can drive the wizard explicitly; the auto-run-on-first-launch path stays. - config.rs reads GOBLINPAY_WEBHOOK_SECRET_FILE (mirrors the token _FILE path). - README, .env.example, and the systemd unit header document the guided path and the secret model. Authority tests (incl. a new secret-out-of-env test) and the 32 plugin tests stay green; fmt/clippy clean. --- .env.example | 14 +- README.md | 44 ++++- deploy/systemd/floonet-authority.service | 24 ++- install.sh | 204 +++++++++++++++++++ name-authority/Cargo.lock | 1 + name-authority/Cargo.toml | 4 + name-authority/src/config.rs | 24 ++- name-authority/src/handlers/mod.rs | 5 +- name-authority/src/main.rs | 54 ++++- name-authority/src/paid.rs | 49 ++++- name-authority/src/setup.rs | 240 +++++++++++++++++++++-- name-authority/tests/http.rs | 26 ++- name-authority/tests/setup.rs | 119 +++++++++-- 13 files changed, 735 insertions(+), 73 deletions(-) create mode 100755 install.sh diff --git a/.env.example b/.env.example index 80adb3b..cb08096 100644 --- a/.env.example +++ b/.env.example @@ -74,12 +74,22 @@ FLOONET_WRITE_PRICE_GRIN=0 # Your GoblinPay server (https://code.gri.mw/GRIN/GoblinPay). The authority # creates invoices there and payers land on its hosted pay page. GOBLINPAY_URL= -# The GoblinPay API token (GP_API_TOKEN on the GoblinPay side). +# The GoblinPay API token (GP_API_TOKEN on the GoblinPay side). This is a SECRET. +# Prefer NOT to put it here: the guided installer (./install.sh) and the +# `floonet-name-authority setup` wizard collect it over a hidden prompt and write +# it to a root-only 0600 file, then set GOBLINPAY_TOKEN_FILE to point at it (the +# systemd unit exposes it to the service as a credential). Set GOBLINPAY_TOKEN +# inline only for a quick local/Docker test; it is read only if _FILE is unset. GOBLINPAY_TOKEN= +# Path to a 0600 file holding the token (what the wizard writes). Takes priority +# over GOBLINPAY_TOKEN above, so the secret need never sit in this env file. +#GOBLINPAY_TOKEN_FILE=/etc/floonet-authority/secrets/goblinpay_token # Optional: GoblinPay webhook secret. When set, point a GoblinPay webhook at # https://FLOONET_DOMAIN/api/v1/goblinpay/webhook and payments confirm -# instantly instead of on the next status poll. +# instantly instead of on the next status poll. Also a secret: prefer the _FILE +# form (the wizard writes it 0600) over the inline value. GOBLINPAY_WEBHOOK_SECRET= +#GOBLINPAY_WEBHOOK_SECRET_FILE=/etc/floonet-authority/secrets/goblinpay_webhook_secret # Seconds the write policy plugin caches paid-status verdicts. FLOONET_PAID_CACHE_SECS=60 diff --git a/README.md b/README.md index 4f5f2bc..a91a11c 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,29 @@ through strfry's own extension points: ## Deploy -Pick your comfort level. All three paths produce the same relay. +Pick your comfort level. All paths produce the same relay. + +### 0. Guided installer (easiest, Grin-style) + +```sh +sudo ./install.sh +``` + +One interactive script. It asks a single topology question - whether to run the +bundled name service **alongside the relay** (co-located on one domain), on a +**separate domain**, **relay only**, or the **name authority standalone** - then +builds what you chose, installs the hardened systemd units, and hands off to the +name authority's own setup wizard. Every prompt has a sensible default. Any +secret (the GoblinPay API token) is collected over a **hidden prompt** and +written to a root-only `0600` file, never the env file; the installer wires it +to the service as a systemd credential. Re-runnable, and it never clobbers an +existing config. Prefer this unless you want Docker. + +The name authority also runs its wizard on its own: with nothing configured, a +bare `floonet-name-authority` on a terminal offers it, or run it explicitly with +`floonet-name-authority setup` (`--reconfigure` to redo an existing config). When +`FLOONET_DOMAIN` is set (compose/systemd), the wizard never fires - those deploys +stay fully headless. ### 1. Docker Compose (recommended) @@ -153,6 +175,11 @@ GOBLINPAY_URL=https://pay.your.domain GOBLINPAY_TOKEN= ``` +The token is a secret. The guided installer and the `floonet-name-authority +setup` wizard collect it over a hidden prompt and store it in a root-only `0600` +file referenced by `GOBLINPAY_TOKEN_FILE`, so it never lands in the env file; +set `GOBLINPAY_TOKEN` inline only for a throwaway local test. + Modes: - `off`: everything free (default). @@ -327,9 +354,13 @@ system tor with the snippet in `deploy/tor/torrc` (a `HiddenServiceDir` plus a - **Rate limits** per IP on the authority's read and write endpoints, NIP-98 replay protection, name-change cooldown, and a poll throttle so outsiders cannot hammer GoblinPay through the public paid endpoint. -- **No secrets in the repo.** The GoblinPay token comes from the environment - or a `0400` file via `GOBLINPAY_TOKEN_FILE`; the authority never logs it. - The relay itself holds no secrets at all. +- **No secrets in the repo, and none in world-readable files.** The GoblinPay + token is collected by the setup wizard over a hidden prompt and written to a + root-only `0600` file that `GOBLINPAY_TOKEN_FILE` names (the systemd unit + exposes it to the service as a credential, so the dynamic user reads a copy + without the file being broadly readable). It is never written to the env file + and never logged. A free authority holds no secret at all, and the relay + itself holds none in any mode. - `events.maxEventSize` is sized so large gift-wrapped payloads fit. ## Configuration reference @@ -349,8 +380,9 @@ essentials: | `FLOONET_PAY_MODE` | `off` | `off` / `name` / `write` | | `FLOONET_NAME_PRICE_GRIN` | `0` | price of a name, in GRIN | | `FLOONET_WRITE_PRICE_GRIN` | `0` | price of write access, in GRIN | -| `GOBLINPAY_URL` / `GOBLINPAY_TOKEN` | unset | your GoblinPay server | -| `GOBLINPAY_WEBHOOK_SECRET` | unset | enables the webhook receiver | +| `GOBLINPAY_URL` | unset | your GoblinPay server base URL | +| `GOBLINPAY_TOKEN_FILE` / `GOBLINPAY_TOKEN` | unset | API token; prefer the `0600` `_FILE` form the wizard writes over the inline value | +| `GOBLINPAY_WEBHOOK_SECRET_FILE` / `GOBLINPAY_WEBHOOK_SECRET` | unset | enables the webhook receiver; `_FILE` keeps the secret out of the env file | | `FLOONET_TRANSFERS` | `false` | enable the name-transfer routes (off = they 404) | | `FLOONET_GRIN_NODE_URL` | unset | Grin node foreign API(s) for payment confirmation (**required** when transfers are on) | | `FLOONET_TRANSFER_MIN_CONF` | `10` | confirmations a payment kernel needs before a claim | diff --git a/deploy/systemd/floonet-authority.service b/deploy/systemd/floonet-authority.service index 826637e..cddd2af 100644 --- a/deploy/systemd/floonet-authority.service +++ b/deploy/systemd/floonet-authority.service @@ -1,16 +1,32 @@ # Hardened systemd unit for the Floonet name authority on bare metal. # -# Install: +# Recommended: the guided installer wires all of this up for you, including the +# secret credential drop-in below: +# sudo ./install.sh # builds, installs this unit, runs the wizard +# +# By hand: # cd name-authority && cargo build --release # sudo install -m0755 target/release/floonet-name-authority /usr/local/bin/ -# sudo install -m0640 ../.env /etc/floonet-authority.env # see .env.example +# sudo floonet-name-authority setup # writes /etc/floonet-authority.env # sudo install -m0644 ../deploy/systemd/floonet-authority.service /etc/systemd/system/ # sudo systemctl daemon-reload && sudo systemctl enable --now floonet-authority # # The service stores only public data plus payment grant state, but it is # still locked down: dynamic unprivileged user, read-only system, no new -# privileges. Keep the GoblinPay token out of world-readable files (the env -# file above is 0640, or use GOBLINPAY_TOKEN_FILE pointing at a 0400 file). +# privileges. A free authority (FLOONET_PAY_MODE=off) holds NO secret at all. +# +# When a paid mode is configured, the GoblinPay token is a secret and must never +# sit in the (root:root 0640, but proxy/backup-exposable) env file. The wizard +# writes it to /etc/floonet-authority/secrets/goblinpay_token at mode 0600 and +# the installer drops in floonet-authority.service.d/10-goblinpay-credential.conf +# with: +# [Service] +# LoadCredential=goblinpay_token:/etc/floonet-authority/secrets/goblinpay_token +# Environment=GOBLINPAY_TOKEN_FILE=%d/goblinpay_token +# so systemd (as root) loads the 0600 file and exposes a copy under +# $CREDENTIALS_DIRECTORY (%d) that the dynamic service user can read. That +# Environment= overrides the /etc path the env file names. The base unit stays +# valid for a free deploy because the LoadCredential lives only in the drop-in. [Unit] Description=Floonet name authority (name@domain -> nostr pubkey) diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1668bad --- /dev/null +++ b/install.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Guided, Grin-style installer for a Floonet relay + its bundled name authority. +# +# Run it with nothing configured and it asks a handful of questions, then does +# the work: builds what you chose, installs the hardened systemd units, and +# hands off to the name authority's own setup wizard (which collects any secret +# over a hidden prompt and writes it to a root-only 0600 file, never the env +# file). Everything it asks has a sensible default; press Enter to accept. +# +# The one topology question decides whether the bundled name service runs +# alongside the relay, on its own, or not at all: +# +# 1) Relay + name authority, co-located on ONE domain (recommended) +# 2) Relay + name authority, on SEPARATE domains +# 3) Relay only (name service off) +# 4) Name authority only (standalone; you run +# the relay elsewhere) +# +# Re-runnable: it upgrades binaries and units idempotently and never overwrites +# an existing /etc/floonet-authority.env (the wizard has its own --reconfigure +# guard). Requires root for the install steps, and a Rust toolchain (cargo) to +# build the authority. The relay is stock upstream strfry; building it needs a +# C++ toolchain and strfry's libs, so this script offers to build it via +# deploy/strfry/apply-spec.sh or points you at the Docker path instead. + +set -euo pipefail + +REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +AUTH_BIN=/usr/local/bin/floonet-name-authority +AUTH_ENV=/etc/floonet-authority.env +AUTH_UNIT=/etc/systemd/system/floonet-authority.service +AUTH_SECRETS_DIR=/etc/floonet-authority/secrets +AUTH_DROPIN_DIR=/etc/systemd/system/floonet-authority.service.d + +RELAY_BIN=/usr/local/bin/strfry +RELAY_PLUGIN=/usr/local/bin/floonet_writepolicy.py +RELAY_CONF_DIR=/etc/floonet-strfry +RELAY_UNIT=/etc/systemd/system/floonet-strfry.service + +say() { printf '\033[1;33m==>\033[0m %s\n' "$1"; } +warn() { printf '\033[1;31m!!\033[0m %s\n' "$1" >&2; } + +if [[ $EUID -ne 0 ]]; then + SUDO=sudo +else + SUDO="" +fi + +ask() { + # ask "Question" "default" -> echoes the answer (default on empty/no TTY) + local q="$1" def="$2" reply="" + if [[ -t 0 ]]; then + read -r -p "$(printf '\033[1;33m==>\033[0m %s [%s] ' "$q" "$def")" reply || reply="" + fi + printf '%s' "${reply:-$def}" +} + +yesno() { + # yesno "Question" "Y|N" -> returns 0 for yes, 1 for no + local q="$1" def="$2" reply="" + local hint="y/N" + [[ "$def" == "Y" ]] && hint="Y/n" + if [[ -t 0 ]]; then + read -r -p "$(printf '\033[1;33m==>\033[0m %s [%s] ' "$q" "$hint")" reply || reply="" + fi + reply="${reply:-$def}" + case "$reply" in + [Yy]*) return 0 ;; + *) return 1 ;; + esac +} + +# --- topology -------------------------------------------------------------- + +cat <<'EOF' + +Floonet installer +================= +A Floonet relay is stock strfry plus a small policy plugin. The package also +BUNDLES a name authority (name@domain identities). Choose how to run them: + + 1) Relay + name authority, co-located on ONE domain (recommended) + 2) Relay + name authority, on SEPARATE domains + 3) Relay only (name service off) + 4) Name authority only (standalone) + +EOF + +CHOICE="$(ask 'Topology (1/2/3/4)' '1')" +case "$CHOICE" in + 1) INSTALL_RELAY=yes; INSTALL_AUTH=yes; COLOCATED=yes ;; + 2) INSTALL_RELAY=yes; INSTALL_AUTH=yes; COLOCATED=no ;; + 3) INSTALL_RELAY=yes; INSTALL_AUTH=no; COLOCATED=no ;; + 4) INSTALL_RELAY=no; INSTALL_AUTH=yes; COLOCATED=no ;; + *) warn "Unrecognized choice '$CHOICE'; defaulting to 1 (relay + co-located authority)." + INSTALL_RELAY=yes; INSTALL_AUTH=yes; COLOCATED=yes ;; +esac + +# --- name authority -------------------------------------------------------- + +if [[ "$INSTALL_AUTH" == yes ]]; then + if ! command -v cargo >/dev/null 2>&1; then + warn "cargo (Rust toolchain) not found; needed to build the name authority." + warn "Install Rust from https://rustup.rs and re-run, or pick topology 3 (relay only)." + exit 1 + fi + + say "Building the name authority (cargo build --release --locked)" + ( cd "$REPO_DIR/name-authority" && cargo build --release --locked ) + + say "Installing the authority binary to $AUTH_BIN" + $SUDO install -m0755 "$REPO_DIR/name-authority/target/release/floonet-name-authority" "$AUTH_BIN" + + say "Creating the root-only secrets directory $AUTH_SECRETS_DIR (0700)" + $SUDO install -d -m0700 /etc/floonet-authority + $SUDO install -d -m0700 "$AUTH_SECRETS_DIR" + + say "Installing the systemd unit to $AUTH_UNIT" + $SUDO install -m0644 "$REPO_DIR/deploy/systemd/floonet-authority.service" "$AUTH_UNIT" + + if [[ -f "$AUTH_ENV" ]]; then + say "An existing $AUTH_ENV was found; leaving it untouched." + say "To reconfigure: $SUDO floonet-name-authority setup --reconfigure" + elif yesno 'Run the name authority setup wizard now?' 'Y'; then + # The wizard writes $AUTH_ENV plus, for a paid mode, the 0600 token file. + $SUDO env FLOONET_ENV_FILE="$AUTH_ENV" floonet-name-authority setup + else + say "Skipped the wizard. Run it later: $SUDO floonet-name-authority setup" + fi + + # When a paid mode wrote a token file, wire it as a systemd credential so the + # DynamicUser can read it without the 0600 file being world-readable. The + # drop-in keeps the base unit valid for a free (no-secret) authority. + if [[ -f "$AUTH_ENV" ]] && grep -q '^GOBLINPAY_TOKEN_FILE=' "$AUTH_ENV"; then + say "Paid mode detected; installing the GoblinPay token credential drop-in" + $SUDO install -d -m0755 "$AUTH_DROPIN_DIR" + tokfile="$(grep '^GOBLINPAY_TOKEN_FILE=' "$AUTH_ENV" | head -n1 | cut -d= -f2-)" + { + printf '[Service]\n' + printf 'LoadCredential=goblinpay_token:%s\n' "$tokfile" + printf 'Environment=GOBLINPAY_TOKEN_FILE=%%d/goblinpay_token\n' + if grep -q '^GOBLINPAY_WEBHOOK_SECRET_FILE=' "$AUTH_ENV"; then + hookfile="$(grep '^GOBLINPAY_WEBHOOK_SECRET_FILE=' "$AUTH_ENV" | head -n1 | cut -d= -f2-)" + printf 'LoadCredential=goblinpay_webhook_secret:%s\n' "$hookfile" + printf 'Environment=GOBLINPAY_WEBHOOK_SECRET_FILE=%%d/goblinpay_webhook_secret\n' + fi + } | $SUDO tee "$AUTH_DROPIN_DIR/10-goblinpay-credential.conf" >/dev/null + fi +fi + +# --- relay ----------------------------------------------------------------- + +if [[ "$INSTALL_RELAY" == yes ]]; then + if [[ -x "$REPO_DIR/deploy/strfry/strfry-build/strfry" ]]; then + say "Found a prebuilt strfry at deploy/strfry/strfry-build/strfry." + elif yesno 'Build stock strfry now via deploy/strfry/apply-spec.sh? (needs a C++ toolchain)' 'N'; then + say "Building stock upstream strfry (this takes a while)" + ( cd "$REPO_DIR" && ./deploy/strfry/apply-spec.sh ) + else + say "Skipping the strfry build. Two ways to get the relay running:" + say " Docker: cp .env.example .env && docker compose up -d" + say " No Docker: ./deploy/strfry/apply-spec.sh then re-run this installer" + fi + + if [[ -x "$REPO_DIR/deploy/strfry/strfry-build/strfry" ]]; then + say "Installing the relay binary to $RELAY_BIN" + $SUDO install -m0755 "$REPO_DIR/deploy/strfry/strfry-build/strfry" "$RELAY_BIN" + say "Installing the write-policy plugin to $RELAY_PLUGIN" + $SUDO install -m0755 "$REPO_DIR/plugin/floonet_writepolicy.py" "$RELAY_PLUGIN" + say "Installing the relay config to $RELAY_CONF_DIR/strfry.conf" + $SUDO install -m0644 -D "$REPO_DIR/deploy/strfry/strfry.conf" "$RELAY_CONF_DIR/strfry.conf" + say "Installing the systemd unit to $RELAY_UNIT" + $SUDO install -m0644 "$REPO_DIR/deploy/systemd/floonet-strfry.service" "$RELAY_UNIT" + fi +fi + +# --- finish ---------------------------------------------------------------- + +say "Reloading systemd" +$SUDO systemctl daemon-reload || true +[[ "$INSTALL_AUTH" == yes ]] && $SUDO systemctl enable floonet-authority >/dev/null 2>&1 || true +[[ "$INSTALL_RELAY" == yes && -x "$RELAY_BIN" ]] && $SUDO systemctl enable floonet-strfry >/dev/null 2>&1 || true + +echo +say "Install complete. Next steps:" +if [[ "$INSTALL_RELAY" == yes ]]; then + echo " Relay: $SUDO systemctl start floonet-strfry && journalctl -fu floonet-strfry" +fi +if [[ "$INSTALL_AUTH" == yes ]]; then + echo " Authority: $SUDO systemctl start floonet-authority && journalctl -fu floonet-authority" +fi +echo " Put a TLS proxy in front (see deploy/Caddyfile); it MUST set X-Real-IP." +if [[ "$INSTALL_AUTH" == yes && "$COLOCATED" == yes ]]; then + echo + echo " Co-located: serve the authority's NIP-05 lookup on the relay domain so" + echo " name@ resolves. Docker/Caddy do this automatically; for a" + echo " split nginx deploy include deploy/us-east/colocated-authority.conf in the" + echo " relay vhost. See the README section 'Co-locating names on the relay domain'." +elif [[ "$INSTALL_AUTH" == yes ]]; then + echo + echo " Standalone: give the authority its own hostname/vhost (deploy/us-east/)" + echo " and point FLOONET_DOMAIN / FLOONET_BASE_URL at it." +fi diff --git a/name-authority/Cargo.lock b/name-authority/Cargo.lock index 48d7ca1..63064c6 100644 --- a/name-authority/Cargo.lock +++ b/name-authority/Cargo.lock @@ -484,6 +484,7 @@ dependencies = [ "hex", "hmac", "http-body-util", + "libc", "nostr", "parking_lot", "rand 0.8.6", diff --git a/name-authority/Cargo.toml b/name-authority/Cargo.toml index a130690..81f3364 100644 --- a/name-authority/Cargo.toml +++ b/name-authority/Cargo.toml @@ -25,6 +25,10 @@ sha2 = "0.10" hmac = "0.12" hex = "0.4" parking_lot = "0.12" +# Terminal echo toggle for hidden secret entry (the GoblinPay token) in the +# first-run setup wizard: only tcgetattr/tcsetattr are called, to hide typed +# secrets so they never echo to the screen or a scrollback buffer. +libc = "0.2" tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Blocking HTTP client for the GoblinPay REST calls (wrapped in diff --git a/name-authority/src/config.rs b/name-authority/src/config.rs index 4db996a..a0fe51f 100644 --- a/name-authority/src/config.rs +++ b/name-authority/src/config.rs @@ -224,8 +224,22 @@ impl Config { .to_string(), _ => env_string("GOBLINPAY_TOKEN", ""), }; - let goblinpay_webhook_secret = - std::env::var("GOBLINPAY_WEBHOOK_SECRET").ok().filter(|s| !s.is_empty()); + // Webhook secret: prefer a 0600 file (GOBLINPAY_WEBHOOK_SECRET_FILE, + // what the setup wizard and the systemd credential wiring use) over an + // inline GOBLINPAY_WEBHOOK_SECRET, so the secret need never sit in a + // world-readable env file. + let goblinpay_webhook_secret = match std::env::var("GOBLINPAY_WEBHOOK_SECRET_FILE") { + Ok(path) if !path.is_empty() => Some( + std::fs::read_to_string(&path) + .map_err(|e| format!("GOBLINPAY_WEBHOOK_SECRET_FILE `{path}` unreadable: {e}"))? + .trim() + .to_string(), + ) + .filter(|s| !s.is_empty()), + _ => std::env::var("GOBLINPAY_WEBHOOK_SECRET") + .ok() + .filter(|s| !s.is_empty()), + }; let paid_poll_interval = Duration::from_secs(env_parse("FLOONET_PAID_POLL_INTERVAL_SECS", 5u64)); @@ -308,11 +322,9 @@ impl Config { return Err("FLOONET_PAY_MODE is on but GOBLINPAY_URL is not set".into()); } if self.goblinpay_token.is_empty() { - return Err( - "FLOONET_PAY_MODE is on but no GoblinPay token is set \ + return Err("FLOONET_PAY_MODE is on but no GoblinPay token is set \ (GOBLINPAY_TOKEN or GOBLINPAY_TOKEN_FILE)" - .into(), - ); + .into()); } } if self.pay_mode == PayMode::Name && self.name_price_nanogrin == 0 { diff --git a/name-authority/src/handlers/mod.rs b/name-authority/src/handlers/mod.rs index 39f660a..9b148af 100644 --- a/name-authority/src/handlers/mod.rs +++ b/name-authority/src/handlers/mod.rs @@ -29,7 +29,10 @@ pub fn routes(app: Arc) -> Router { .route("/api/v1/by-pubkey/{pubkey}", get(profile::by_pubkey)) .route("/api/v1/paid/{pubkey}", get(paidapi::paid_status)) .route("/api/v1/quote", post(paidapi::quote)) - .route("/api/v1/goblinpay/webhook", post(paidapi::goblinpay_webhook)) + .route( + "/api/v1/goblinpay/webhook", + post(paidapi::goblinpay_webhook), + ) .route("/api/v1/health", get(misc::health)) .route("/", get(misc::landing)); diff --git a/name-authority/src/main.rs b/name-authority/src/main.rs index b815595..83be13b 100644 --- a/name-authority/src/main.rs +++ b/name-authority/src/main.rs @@ -23,6 +23,16 @@ use std::sync::Arc; #[tokio::main] async fn main() { + // The `setup` subcommand runs the guided wizard explicitly (what install.sh + // invokes) and exits WITHOUT starting the server, so systemd can then start + // the service cleanly. `setup --reconfigure` re-runs even when a config + // already exists. Any other argument, or none, is the normal server launch. + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("setup") { + run_setup_subcommand(args.iter().any(|a| a == "--reconfigure")); + return; + } + tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()), @@ -43,7 +53,10 @@ async fn main() { match setup::run_first_run_wizard() { Ok(path) => { if let Err(e) = setup::load_env_file(&path) { - eprintln!("could not load the file just written ({}): {e}", path.display()); + eprintln!( + "could not load the file just written ({}): {e}", + path.display() + ); std::process::exit(1); } } @@ -77,3 +90,42 @@ async fn main() { .await .expect("server"); } + +/// Handle `floonet-name-authority setup [--reconfigure]`: run the interactive +/// wizard and exit. Refuses to clobber an existing config unless --reconfigure +/// is given (the wizard's env file overwrite is the only destructive step; no +/// data is touched). Exits nonzero on any failure so install.sh can react. +fn run_setup_subcommand(reconfigure: bool) { + // Load any existing config file so the guard below sees a prior setup. + let _ = setup::load_first_existing(); + + if setup::config_present() && !reconfigure { + eprintln!( + "A configuration already exists (FLOONET_DOMAIN is set). Re-run with\n\ + `setup --reconfigure` to overwrite it, or edit the env file by hand." + ); + std::process::exit(0); + } + + if !setup::is_interactive() { + eprintln!( + "setup is interactive but stdin/stdout is not a terminal.\n\ + Run it in a real terminal, e.g. sudo floonet-name-authority setup" + ); + std::process::exit(1); + } + + match setup::run_first_run_wizard() { + Ok(path) => { + println!( + "Setup complete. Configuration written to {}.", + path.display() + ); + println!("Start the service, e.g. sudo systemctl start floonet-authority"); + } + Err(e) => { + eprintln!("setup wizard failed: {e}"); + std::process::exit(1); + } + } +} diff --git a/name-authority/src/paid.rs b/name-authority/src/paid.rs index d592e20..e8b13e8 100644 --- a/name-authority/src/paid.rs +++ b/name-authority/src/paid.rs @@ -204,7 +204,14 @@ impl App { invoice_id = excluded.invoice_id, pay_url = excluded.pay_url, amount_nanogrin = excluded.amount_nanogrin, status = 'pending', created_at = excluded.created_at, paid_at = NULL", - rusqlite::params![pubkey, resource, inv.id, inv.pay_url, amount as i64, unix_now()], + rusqlite::params![ + pubkey, + resource, + inv.id, + inv.pay_url, + amount as i64, + unix_now() + ], ); } @@ -427,7 +434,10 @@ mod tests { #[test] fn free_mode_is_always_paid() { let app = Arc::new(App::open(Config::for_test())); - assert!(matches!(ensure_paid(&app, &"a".repeat(64), "name"), PaidOutcome::Paid)); + assert!(matches!( + ensure_paid(&app, &"a".repeat(64), "name"), + PaidOutcome::Paid + )); } #[test] @@ -437,7 +447,12 @@ mod tests { // First ask: an invoice is created and payment is due. let due = ensure_paid(&app, &pk, "name"); - let PaidOutcome::Due { invoice_id, pay_url, price_nanogrin } = due else { + let PaidOutcome::Due { + invoice_id, + pay_url, + price_nanogrin, + } = due + else { panic!("expected Due, got {due:?}"); }; assert_eq!(price_nanogrin, 1_500_000_000); @@ -445,13 +460,18 @@ mod tests { // Second ask: same invoice (idempotent), still due. let again = ensure_paid(&app, &pk, "name"); - let PaidOutcome::Due { invoice_id: id2, .. } = again else { + let PaidOutcome::Due { + invoice_id: id2, .. + } = again + else { panic!("expected Due"); }; assert_eq!(id2, invoice_id); // Settle at the backend; the next ask promotes the grant. - mock.statuses.lock().insert(invoice_id.clone(), "paid".into()); + mock.statuses + .lock() + .insert(invoice_id.clone(), "paid".into()); assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid)); // And it stays paid without further polling. assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid)); @@ -464,8 +484,13 @@ mod tests { let PaidOutcome::Due { invoice_id, .. } = ensure_paid(&app, &pk, "name") else { panic!("expected Due"); }; - mock.statuses.lock().insert(invoice_id.clone(), "expired".into()); - let PaidOutcome::Due { invoice_id: id2, .. } = ensure_paid(&app, &pk, "name") else { + mock.statuses + .lock() + .insert(invoice_id.clone(), "expired".into()); + let PaidOutcome::Due { + invoice_id: id2, .. + } = ensure_paid(&app, &pk, "name") + else { panic!("expected Due"); }; assert_ne!(id2, invoice_id, "a fresh invoice replaces the expired one"); @@ -489,7 +514,10 @@ mod tests { app.mark_grant_paid(&invoice_id); assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid)); app.consume_grant(&pk, "name"); - assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Due { .. })); + assert!(matches!( + ensure_paid(&app, &pk, "name"), + PaidOutcome::Due { .. } + )); } #[test] @@ -502,6 +530,9 @@ mod tests { app.mark_grant_paid(&invoice_id); assert!(matches!(ensure_paid(&app, &pk, "name"), PaidOutcome::Paid)); // Paying for a name does not grant write access. - assert!(matches!(ensure_paid(&app, &pk, "write"), PaidOutcome::Due { .. })); + assert!(matches!( + ensure_paid(&app, &pk, "write"), + PaidOutcome::Due { .. } + )); } } diff --git a/name-authority/src/setup.rs b/name-authority/src/setup.rs index c35f7f2..e024a89 100644 --- a/name-authority/src/setup.rs +++ b/name-authority/src/setup.rs @@ -21,6 +21,8 @@ // finds it, sets FLOONET_DOMAIN, and the wizard stays out of the way. use std::io::{self, BufRead, IsTerminal, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::io::AsRawFd; use std::path::{Path, PathBuf}; /// The bare-metal / systemd convention: deploy/systemd/floonet-authority.service @@ -61,7 +63,9 @@ pub fn load_first_existing() -> Option { /// deployment sets, FLOONET_DOMAIN, is present in the environment. Call this /// after load_first_existing() so a previously written env file counts. pub fn config_present() -> bool { - std::env::var("FLOONET_DOMAIN").map(|v| !v.is_empty()).unwrap_or(false) + std::env::var("FLOONET_DOMAIN") + .map(|v| !v.is_empty()) + .unwrap_or(false) } /// Are we attached to an interactive terminal on both stdin and stdout? @@ -159,7 +163,7 @@ pub fn load_env_file(path: &Path) -> io::Result { /// The answers the wizard collects. Kept separate from rendering so the render /// step is a pure, testable function. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Default)] pub struct Answers { pub domain: String, pub bind_addr: String, @@ -167,7 +171,20 @@ pub struct Answers { pub pay_mode: String, pub price_grin: String, pub goblinpay_url: String, + /// The GoblinPay API token, a SECRET, collected via a hidden prompt. It is + /// never written into the (potentially world-readable) env file: the wizard + /// writes it to a root-only `0600` file and the env file references that + /// file's path via `token_file` below. Kept here only in memory. pub goblinpay_token: String, + /// Optional GoblinPay webhook HMAC secret, also a secret, same handling as + /// the token. Empty when the operator does not configure a webhook. + pub goblinpay_webhook_secret: String, + /// Path of the `0600` file the token was written to, referenced from the env + /// file as `GOBLINPAY_TOKEN_FILE`. Filled in by `run_wizard`, not prompted. + pub token_file: String, + /// Path of the `0600` webhook-secret file, referenced as + /// `GOBLINPAY_WEBHOOK_SECRET_FILE`. Empty when no webhook secret was given. + pub webhook_secret_file: String, pub transfers: bool, pub grin_node_url: String, } @@ -184,10 +201,7 @@ pub fn render_env(a: &Answers) -> String { s.push_str(&format!("FLOONET_BASE_URL=https://{}\n", a.domain)); s.push_str(&format!("FLOONET_RELAYS=wss://{}\n", a.domain)); s.push_str(&format!("FLOONET_NAMES_BIND={}\n", a.bind_addr)); - s.push_str(&format!( - "FLOONET_NAMES_DB={}\n", - db_path_in(&a.data_dir) - )); + s.push_str(&format!("FLOONET_NAMES_DB={}\n", db_path_in(&a.data_dir))); s.push_str(&format!("FLOONET_PAY_MODE={}\n", a.pay_mode)); match a.pay_mode.as_str() { "name" => s.push_str(&format!("FLOONET_NAME_PRICE_GRIN={}\n", a.price_grin)), @@ -196,7 +210,15 @@ pub fn render_env(a: &Answers) -> String { } if a.pay_mode != "off" { s.push_str(&format!("GOBLINPAY_URL={}\n", a.goblinpay_url)); - s.push_str(&format!("GOBLINPAY_TOKEN={}\n", a.goblinpay_token)); + // The token is a secret: the env file only names the 0600 file it was + // written to, never the token itself. config.rs reads the file. + s.push_str(&format!("GOBLINPAY_TOKEN_FILE={}\n", a.token_file)); + if !a.webhook_secret_file.is_empty() { + s.push_str(&format!( + "GOBLINPAY_WEBHOOK_SECRET_FILE={}\n", + a.webhook_secret_file + )); + } } if a.transfers { s.push_str("FLOONET_TRANSFERS=true\n"); @@ -207,7 +229,10 @@ pub fn render_env(a: &Answers) -> String { /// Join a data directory with the fixed `names.db` filename. pub fn db_path_in(dir: &str) -> String { - Path::new(dir).join("names.db").to_string_lossy().into_owned() + Path::new(dir) + .join("names.db") + .to_string_lossy() + .into_owned() } /// Drive the interactive prompts over the given reader/writer, returning the @@ -215,20 +240,43 @@ pub fn db_path_in(dir: &str) -> String { pub fn collect_answers( r: &mut R, w: &mut W, + hidden: bool, ) -> io::Result { writeln!(w, "\nFloonet name authority: first-run setup")?; - writeln!(w, "No configuration found. A few questions and we are running.")?; + writeln!( + w, + "No configuration found. A few questions and we are running." + )?; writeln!(w, "Press Enter to accept each [default].\n")?; - let domain = prompt(r, w, "Domain for names (the @domain in name@domain)", "floonet.example")?; + let domain = prompt( + r, + w, + "Domain for names (the @domain in name@domain)", + "floonet.example", + )?; let bind_addr = prompt(r, w, "HTTP bind address", "127.0.0.1:8191")?; - let data_dir = prompt(r, w, "Data directory (SQLite database lives here)", "/var/lib/floonet-authority")?; + let data_dir = prompt( + r, + w, + "Data directory (SQLite database lives here)", + "/var/lib/floonet-authority", + )?; - writeln!(w, "\nCharging GRIN is optional; leave as `off` for a free authority.")?; - let pay_mode = prompt_choice(r, w, "Pay mode (off/name/write)", "off", &["off", "name", "write"])?; + writeln!( + w, + "\nCharging GRIN is optional; leave as `off` for a free authority." + )?; + let pay_mode = prompt_choice( + r, + w, + "Pay mode (off/name/write)", + "off", + &["off", "name", "write"], + )?; - let (mut price_grin, mut goblinpay_url, mut goblinpay_token) = - (String::new(), String::new(), String::new()); + let (mut price_grin, mut goblinpay_url, mut goblinpay_token, mut goblinpay_webhook_secret) = + (String::new(), String::new(), String::new(), String::new()); if pay_mode != "off" { let price_q = if pay_mode == "name" { "Price to claim a name, in GRIN" @@ -238,15 +286,37 @@ pub fn collect_answers( // A paid mode with a zero price fails validation, so default to 1. price_grin = prompt(r, w, price_q, "1")?; goblinpay_url = prompt(r, w, "GoblinPay server URL", "http://127.0.0.1:8192")?; - goblinpay_token = prompt(r, w, "GoblinPay API token", "")?; + // The token is a secret: read it with terminal echo off so it never + // shows on screen, and store it to a root-only 0600 file (never the + // env file). Enter leaves it blank; validation then refuses to start. + goblinpay_token = prompt_secret(r, w, "GoblinPay API token (input hidden)", hidden)?; + writeln!( + w, + "\nOptional: a GoblinPay webhook HMAC secret confirms payments \ + instantly instead of on the next poll." + )?; + goblinpay_webhook_secret = prompt_secret( + r, + w, + "GoblinPay webhook secret (input hidden, Enter to skip)", + hidden, + )?; } - writeln!(w, "\nName transfers are the optional non-custodial name marketplace.")?; + writeln!( + w, + "\nName transfers are the optional non-custodial name marketplace." + )?; let transfers = prompt_yes_no(r, w, "Enable name transfers?", false)?; let mut grin_node_url = String::new(); if transfers { // Required when transfers are on, or the authority refuses to start. - grin_node_url = prompt(r, w, "Grin node foreign-API URL", "https://api.grin.money/v2/foreign")?; + grin_node_url = prompt( + r, + w, + "Grin node foreign-API URL", + "https://api.grin.money/v2/foreign", + )?; } Ok(Answers { @@ -257,6 +327,11 @@ pub fn collect_answers( price_grin, goblinpay_url, goblinpay_token, + goblinpay_webhook_secret, + // Secret file paths are resolved and written by run_wizard, which knows + // where the env file lives; left empty here. + token_file: String::new(), + webhook_secret_file: String::new(), transfers, grin_node_url, }) @@ -268,8 +343,31 @@ pub fn run_wizard( r: &mut R, w: &mut W, target: &Path, + hidden: bool, ) -> io::Result { - let answers = collect_answers(r, w)?; + let mut answers = collect_answers(r, w, hidden)?; + + // Secrets (the GoblinPay token and any webhook secret) are written to a + // root-only 0600 file in a sibling secrets directory, NEVER into the env + // file, which a reverse proxy or backup may expose. The env file only names + // the file paths; config.rs and the systemd credential wiring read them. + if answers.pay_mode != "off" { + let secrets_dir = secrets_dir_for(target); + std::fs::create_dir_all(&secrets_dir)?; + // 0700 the directory so only its owner (root) can traverse it. + let _ = std::fs::set_permissions(&secrets_dir, std::fs::Permissions::from_mode(0o700)); + + let token_path = secrets_dir.join("goblinpay_token"); + write_secret_file(&token_path, &answers.goblinpay_token)?; + answers.token_file = token_path.to_string_lossy().into_owned(); + + if !answers.goblinpay_webhook_secret.is_empty() { + let hook_path = secrets_dir.join("goblinpay_webhook_secret"); + write_secret_file(&hook_path, &answers.goblinpay_webhook_secret)?; + answers.webhook_secret_file = hook_path.to_string_lossy().into_owned(); + } + } + let body = render_env(&answers); if let Some(dir) = target.parent() { if !dir.as_os_str().is_empty() { @@ -277,19 +375,56 @@ pub fn run_wizard( } } std::fs::write(target, body)?; + if !answers.token_file.is_empty() { + writeln!( + w, + "Wrote the GoblinPay token to {} (mode 0600, root only).", + answers.token_file + )?; + } writeln!(w, "\nWrote {}. Starting up...\n", target.display())?; Ok(target.to_path_buf()) } +/// The sibling directory that holds the wizard's 0600 secret files, derived from +/// where the env file lives: `/etc/floonet-authority.env` yields +/// `/etc/floonet-authority/secrets`, and the docker-compose `./.env` yields +/// `./floonet-authority/secrets`. Kept next to the env file so both deployment +/// conventions get a predictable, self-contained secrets location. +pub fn secrets_dir_for(env_file: &Path) -> PathBuf { + let parent = env_file + .parent() + .filter(|p| !p.as_os_str().is_empty()) + .map(Path::to_path_buf) + .unwrap_or_else(|| PathBuf::from(".")); + parent.join("floonet-authority").join("secrets") +} + +/// Write `contents` to `path` at mode 0600 (root read/write only). Any existing +/// file is removed first so a re-run (reconfigure) is not denied by a prior +/// read-only secret. A trailing newline is added for tidy `cat`/editor output; +/// config.rs trims it back off on read. +fn write_secret_file(path: &Path, contents: &str) -> io::Result<()> { + let _ = std::fs::remove_file(path); + let mut body = contents.trim().to_string(); + body.push('\n'); + std::fs::write(path, body)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?; + Ok(()) +} + /// Convenience entry point for main: pick the conventional target, run the /// wizard over the real stdin/stdout, and return the file written. pub fn run_first_run_wizard() -> io::Result { let target = wizard_target(); + // Hidden secret entry only makes sense on a real terminal; is_interactive() + // gates the whole wizard, so this is true whenever a secret is prompted. + let hidden = is_interactive(); let stdin = io::stdin(); let mut reader = stdin.lock(); let stdout = io::stdout(); let mut writer = stdout.lock(); - run_wizard(&mut reader, &mut writer, &target) + run_wizard(&mut reader, &mut writer, &target, hidden) } fn prompt( @@ -352,3 +487,68 @@ fn prompt_yes_no( _ => default, }) } + +/// Prompt for a secret. When `hidden` (a real TTY) terminal echo is turned off +/// for the read so the typed value never appears on screen or in scrollback; +/// the Enter keystroke is not echoed either, so we print the newline ourselves. +/// Reads through the SAME reader in both cases (no second stdin handle) so it is +/// exercisable in tests with `hidden = false` over a Cursor. +fn prompt_secret( + r: &mut R, + w: &mut W, + question: &str, + hidden: bool, +) -> io::Result { + write!(w, "{question}: ")?; + w.flush()?; + let mut line = String::new(); + let read = if hidden { + let _echo_off = EchoOff::new(); + let n = r.read_line(&mut line)?; + drop(_echo_off); + writeln!(w)?; + n + } else { + r.read_line(&mut line)? + }; + if read == 0 { + return Ok(String::new()); + } + Ok(line.trim().to_string()) +} + +/// RAII guard that disables terminal echo on stdin for its lifetime and restores +/// the prior state on drop. If stdin is not a real terminal (a pipe/test stream) +/// tcgetattr fails and this is an inert no-op, so callers pass `hidden = false` +/// in that case anyway. Only tcgetattr/tcsetattr are used. +struct EchoOff { + fd: i32, + orig: Option, +} + +impl EchoOff { + fn new() -> EchoOff { + let fd = io::stdin().as_raw_fd(); + let mut orig = None; + unsafe { + let mut t: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut t) == 0 { + orig = Some(t); + let mut modt = t; + modt.c_lflag &= !libc::ECHO; + let _ = libc::tcsetattr(fd, libc::TCSANOW, &modt); + } + } + EchoOff { fd, orig } + } +} + +impl Drop for EchoOff { + fn drop(&mut self) { + if let Some(orig) = self.orig { + unsafe { + let _ = libc::tcsetattr(self.fd, libc::TCSANOW, &orig); + } + } + } +} diff --git a/name-authority/tests/http.rs b/name-authority/tests/http.rs index 683febf..a5379f7 100644 --- a/name-authority/tests/http.rs +++ b/name-authority/tests/http.rs @@ -358,7 +358,11 @@ async fn paid_name_does_not_quote_for_invalid_or_taken_names() { let (s2, j2) = send(app.clone(), register_req(&bob, "alice")).await; assert_eq!(s2, StatusCode::CONFLICT); assert_eq!(j2["error"], "name taken"); - assert_eq!(mock.created.lock().len(), created_before, "no invoice minted"); + assert_eq!( + mock.created.lock().len(), + created_before, + "no invoice minted" + ); // A reserved name also never mints an invoice. let (s3, _) = send(app.clone(), register_req(&bob, "admin")).await; @@ -388,7 +392,9 @@ async fn paid_status_reflects_write_grants() { assert_eq!(mock.created.lock().len(), 0); // Quote write access (NIP-98) -> 402 with the pay URL. - let body = serde_json::json!({"resource": "write"}).to_string().into_bytes(); + let body = serde_json::json!({"resource": "write"}) + .to_string() + .into_bytes(); let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0); let quote = Request::builder() .method("POST") @@ -418,7 +424,9 @@ async fn paid_status_reflects_write_grants() { async fn quote_rejects_resources_not_for_sale() { let (app, _mock) = paid_test_app(PayMode::Name); let keys = Keys::generate(); - let body = serde_json::json!({"resource": "write"}).to_string().into_bytes(); + let body = serde_json::json!({"resource": "write"}) + .to_string() + .into_bytes(); let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0); let req = Request::builder() .method("POST") @@ -456,7 +464,9 @@ async fn goblinpay_webhook_nudges_grant_to_paid() { let pk = keys.public_key().to_hex(); // Create a pending write grant via quote. - let body = serde_json::json!({"resource": "write"}).to_string().into_bytes(); + let body = serde_json::json!({"resource": "write"}) + .to_string() + .into_bytes(); let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0); let quote = Request::builder() .method("POST") @@ -470,7 +480,9 @@ async fn goblinpay_webhook_nudges_grant_to_paid() { let invoice_id = j["invoice_id"].as_str().unwrap().to_string(); // Settle at the backend, then deliver the signed webhook nudge. - mock.statuses.lock().insert(invoice_id.clone(), "paid".into()); + mock.statuses + .lock() + .insert(invoice_id.clone(), "paid".into()); let payload = serde_json::json!({ "event_id": "evt-1", "event_type": "payment.confirmed", @@ -511,7 +523,9 @@ async fn webhook_alone_cannot_grant_unpaid_invoice() { let keys = Keys::generate(); let pk = keys.public_key().to_hex(); - let body = serde_json::json!({"resource": "write"}).to_string().into_bytes(); + let body = serde_json::json!({"resource": "write"}) + .to_string() + .into_bytes(); let auth = nip98_header(&keys, "POST", "/api/v1/quote", &body, 0); let quote = Request::builder() .method("POST") diff --git a/name-authority/tests/setup.rs b/name-authority/tests/setup.rs index 2bc0424..d125916 100644 --- a/name-authority/tests/setup.rs +++ b/name-authority/tests/setup.rs @@ -18,16 +18,28 @@ use floonet_name_authority::setup::{ /// "config-present means the wizard never triggers" test. #[test] fn config_present_never_runs_wizard() { - assert!(!setup::decide_wizard(true, true), "present + tty must skip wizard"); - assert!(!setup::decide_wizard(true, false), "present + no tty must skip wizard"); + assert!( + !setup::decide_wizard(true, true), + "present + tty must skip wizard" + ); + assert!( + !setup::decide_wizard(true, false), + "present + no tty must skip wizard" + ); } /// The only case that runs the wizard: nothing configured AND an interactive /// terminal. A non-TTY with no config stays headless (today's behavior). #[test] fn wizard_only_when_absent_and_interactive() { - assert!(setup::decide_wizard(false, true), "absent + tty runs wizard"); - assert!(!setup::decide_wizard(false, false), "absent + no tty stays headless"); + assert!( + setup::decide_wizard(false, true), + "absent + tty runs wizard" + ); + assert!( + !setup::decide_wizard(false, false), + "absent + no tty stays headless" + ); } #[test] @@ -48,7 +60,10 @@ BLANKVAL= pairs, vec![ ("FLOONET_DOMAIN".to_string(), "names.example".to_string()), - ("FLOONET_BASE_URL".to_string(), "https://names.example".to_string()), + ( + "FLOONET_BASE_URL".to_string(), + "https://names.example".to_string() + ), ("QUOTED".to_string(), "a value".to_string()), ("SINGLE".to_string(), "b value".to_string()), ("BLANKVAL".to_string(), "".to_string()), @@ -63,11 +78,7 @@ fn render_env_derives_base_url_and_relays_from_domain() { bind_addr: "127.0.0.1:8191".into(), data_dir: "/var/lib/floonet-authority".into(), pay_mode: "off".into(), - price_grin: String::new(), - goblinpay_url: String::new(), - goblinpay_token: String::new(), - transfers: false, - grin_node_url: String::new(), + ..Default::default() }; let body = render_env(&a); // base_url and relays are derived so the validate() host==domain invariant @@ -91,14 +102,19 @@ fn render_env_paid_name_and_transfers() { price_grin: "1".into(), goblinpay_url: "http://127.0.0.1:8192".into(), goblinpay_token: "tok".into(), + // The env file references the secret FILE, never the token itself. + token_file: "/etc/floonet-authority/secrets/goblinpay_token".into(), transfers: true, grin_node_url: "https://api.grin.money/v2/foreign".into(), + ..Default::default() }; let body = render_env(&a); assert!(body.contains("FLOONET_PAY_MODE=name\n")); assert!(body.contains("FLOONET_NAME_PRICE_GRIN=1\n")); assert!(body.contains("GOBLINPAY_URL=http://127.0.0.1:8192\n")); - assert!(body.contains("GOBLINPAY_TOKEN=tok\n")); + // The token is referenced by file path; the plaintext token never appears. + assert!(body.contains("GOBLINPAY_TOKEN_FILE=/etc/floonet-authority/secrets/goblinpay_token\n")); + assert!(!body.contains("GOBLINPAY_TOKEN=tok")); assert!(body.contains("FLOONET_TRANSFERS=true\n")); assert!(body.contains("FLOONET_GRIN_NODE_URL=https://api.grin.money/v2/foreign\n")); // write-only key must not leak into a name-mode file. @@ -107,7 +123,10 @@ fn render_env_paid_name_and_transfers() { #[test] fn db_path_joins_names_db() { - assert_eq!(db_path_in("/var/lib/floonet-authority"), "/var/lib/floonet-authority/names.db"); + assert_eq!( + db_path_in("/var/lib/floonet-authority"), + "/var/lib/floonet-authority/names.db" + ); } /// Drive the interactive prompts with canned input over in-memory streams and @@ -118,7 +137,8 @@ fn collect_answers_uses_input_then_defaults() { let input = b"names.example\n\n\noff\nn\n"; let mut reader = Cursor::new(&input[..]); let mut out: Vec = Vec::new(); - let a = collect_answers(&mut reader, &mut out).expect("wizard prompts"); + // hidden = false: secrets are read from the plain stream (no TTY in tests). + let a = collect_answers(&mut reader, &mut out, false).expect("wizard prompts"); assert_eq!(a.domain, "names.example"); assert_eq!(a.bind_addr, "127.0.0.1:8191"); assert_eq!(a.data_dir, "/var/lib/floonet-authority"); @@ -137,7 +157,7 @@ fn run_wizard_writes_loadable_env_file() { let target: PathBuf = std::env::temp_dir().join(format!("floonet-setup-test-{}.env", std::process::id())); - let written = run_wizard(&mut reader, &mut out, &target).expect("write env file"); + let written = run_wizard(&mut reader, &mut out, &target, false).expect("write env file"); assert_eq!(written, target); let text = std::fs::read_to_string(&target).expect("read back"); @@ -145,14 +165,73 @@ fn run_wizard_writes_loadable_env_file() { let get = |k: &str| pairs.iter().find(|(kk, _)| kk == k).map(|(_, v)| v.clone()); assert_eq!(get("FLOONET_DOMAIN").as_deref(), Some("names.example")); - assert_eq!(get("FLOONET_BASE_URL").as_deref(), Some("https://names.example")); - assert_eq!(get("FLOONET_RELAYS").as_deref(), Some("wss://names.example")); + assert_eq!( + get("FLOONET_BASE_URL").as_deref(), + Some("https://names.example") + ); + assert_eq!( + get("FLOONET_RELAYS").as_deref(), + Some("wss://names.example") + ); assert_eq!(get("FLOONET_NAMES_BIND").as_deref(), Some("0.0.0.0:9000")); - assert_eq!(get("FLOONET_NAMES_DB").as_deref(), Some("/tmp/floonet-data/names.db")); + assert_eq!( + get("FLOONET_NAMES_DB").as_deref(), + Some("/tmp/floonet-data/names.db") + ); let _ = std::fs::remove_file(&target); } +/// Paid mode: the GoblinPay token the operator types must NOT land in the env +/// file. run_wizard writes it to a sibling `0600` secrets file and the env file +/// only references that path via GOBLINPAY_TOKEN_FILE. This is the secret- +/// hardening guarantee (no secret in a potentially world-readable env file). +#[test] +fn run_wizard_paid_mode_keeps_token_out_of_env_file() { + use std::os::unix::fs::PermissionsExt; + + // domain; bind default; data-dir default; pay=name; price default; gp url + // default; token; webhook skipped; transfers no. + let input = b"names.example\n\n\nname\n1\n\nsecrettoken123\n\nn\n"; + let mut reader = Cursor::new(&input[..]); + let mut out: Vec = Vec::new(); + + let dir = std::env::temp_dir().join(format!("floonet-secret-test-{}", std::process::id())); + let target = dir.join("floonet-authority.env"); + let written = run_wizard(&mut reader, &mut out, &target, false).expect("write env file"); + assert_eq!(written, target); + + let env_text = std::fs::read_to_string(&target).expect("read env"); + // The plaintext token is nowhere in the env file. + assert!( + !env_text.contains("secrettoken123"), + "token must not be in the env file" + ); + assert!( + !env_text.contains("GOBLINPAY_TOKEN="), + "no inline GOBLINPAY_TOKEN key" + ); + assert!( + env_text.contains("GOBLINPAY_TOKEN_FILE="), + "env references the token file" + ); + + // The token lives in a 0600 file and holds exactly the typed value. + let token_path = dir + .join("floonet-authority") + .join("secrets") + .join("goblinpay_token"); + assert!(token_path.is_file(), "token file was written"); + let mode = std::fs::metadata(&token_path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "token file must be 0600"); + assert_eq!( + std::fs::read_to_string(&token_path).unwrap().trim(), + "secrettoken123" + ); + + let _ = std::fs::remove_dir_all(&dir); +} + /// load_env_file is non-overriding: it fills variables that are unset but never /// clobbers an already-set one (this is what keeps it safe next to the real /// systemd/docker environment). Uses uniquely named keys to avoid touching @@ -175,7 +254,11 @@ fn load_env_file_is_non_overriding() { let set = load_env_file(&target).expect("load"); assert_eq!(set, 1, "only the previously unset var should be set"); assert_eq!(std::env::var(&unset_key).unwrap(), "from_file"); - assert_eq!(std::env::var(&preset_key).unwrap(), "original", "preset must not be overridden"); + assert_eq!( + std::env::var(&preset_key).unwrap(), + "original", + "preset must not be overridden" + ); let _ = std::fs::remove_file(&target); std::env::remove_var(&unset_key);