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:
+12
-2
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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
@@ -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
|
||||
Generated
+1
@@ -484,6 +484,7 @@ dependencies = [
|
||||
"hex",
|
||||
"hmac",
|
||||
"http-body-util",
|
||||
"libc",
|
||||
"nostr",
|
||||
"parking_lot",
|
||||
"rand 0.8.6",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user