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.
This commit is contained in:
2ro
2026-07-06 19:55:29 -04:00
parent efa3439413
commit 828ffefd73
13 changed files with 735 additions and 73 deletions
+12 -2
View File
@@ -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
+38 -6
View File
@@ -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=<GP_API_TOKEN from your GoblinPay>
```
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 |
+20 -4
View File
@@ -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)
Executable
+204
View File
@@ -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@<relay-domain> 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
+1
View File
@@ -484,6 +484,7 @@ dependencies = [
"hex",
"hmac",
"http-body-util",
"libc",
"nostr",
"parking_lot",
"rand 0.8.6",
+4
View File
@@ -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
+18 -6
View File
@@ -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 {
+4 -1
View File
@@ -29,7 +29,10 @@ pub fn routes(app: Arc<App>) -> 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));
+53 -1
View File
@@ -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<String> = 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);
}
}
}
+40 -9
View File
@@ -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 { .. }
));
}
}
+220 -20
View File
@@ -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<PathBuf> {
/// 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<usize> {
/// 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: BufRead, W: Write>(
r: &mut R,
w: &mut W,
hidden: bool,
) -> io::Result<Answers> {
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<R: BufRead, W: Write>(
// 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<R: BufRead, W: Write>(
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: BufRead, W: Write>(
r: &mut R,
w: &mut W,
target: &Path,
hidden: bool,
) -> io::Result<PathBuf> {
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<R: BufRead, W: Write>(
}
}
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<PathBuf> {
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<R: BufRead, W: Write>(
@@ -352,3 +487,68 @@ fn prompt_yes_no<R: BufRead, W: Write>(
_ => 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: BufRead, W: Write>(
r: &mut R,
w: &mut W,
question: &str,
hidden: bool,
) -> io::Result<String> {
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<libc::termios>,
}
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);
}
}
}
}
+20 -6
View File
@@ -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")
+101 -18
View File
@@ -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<u8> = 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<u8> = 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);