Add an interactive first-run setup wizard to the name authority

A complement to the existing FLOONET_* env config, never a replacement. When
FLOONET_DOMAIN is already set (docker compose and the systemd EnvironmentFile
both set it) the binary runs headless exactly as before, including validate()
fail-fast. Only when nothing is configured AND stdin/stdout is an interactive
TTY does it prompt for the essentials (domain, bind address, data dir, pay
mode, name transfers), derive base_url and relays from the domain so validate()
always passes, write a conventional env file, load it, and continue starting
up. A non-TTY with no config stays headless.

The env file is written where the service convention consumes it: FLOONET_ENV_FILE
if set, else /etc/floonet-authority.env when writable (the systemd default), else
./.env (the docker-compose default). On startup the binary also loads the first
existing candidate non-overriding, so a wizard-written file is picked up on this
run and subsequent runs while the real systemd/docker environment always wins.

Tests in tests/setup.rs cover the headless guarantee (config present never runs
the wizard), the prompt flow, env-file rendering/parsing, and non-overriding load.
This commit is contained in:
2ro
2026-07-06 01:47:58 -04:00
parent 6143fce8ac
commit 45c6941b80
4 changed files with 564 additions and 1 deletions
+1
View File
@@ -20,6 +20,7 @@ pub mod node;
pub mod paid;
pub mod proof;
pub mod ratelimit;
pub mod setup;
pub mod util;
pub use config::Config;
+26 -1
View File
@@ -18,7 +18,7 @@
// POST /api/v1/transfer/claim. Strictly non-custodial, no GoblinPay
// involvement. See the README and docs-notes/name-transfer-spec.md.
use floonet_name_authority::{handlers, App, Config};
use floonet_name_authority::{handlers, setup, App, Config};
use std::sync::Arc;
#[tokio::main]
@@ -29,6 +29,31 @@ async fn main() {
)
.init();
// Pick up a conventional env file if one exists (non-overriding), so a
// direct run reuses prior configuration. Under systemd/docker the real
// environment is already set, so this changes nothing there.
if let Some(path) = setup::load_first_existing() {
tracing::info!("loaded configuration from {}", path.display());
}
// First-run wizard: only when nothing is configured AND we are on a TTY.
// Existing deploys always set FLOONET_DOMAIN (compose/systemd) and are not
// interactive, so they never reach this branch.
if setup::decide_wizard(setup::config_present(), setup::is_interactive()) {
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());
std::process::exit(1);
}
}
Err(e) => {
eprintln!("setup wizard failed: {e}");
std::process::exit(1);
}
}
}
let cfg = match Config::from_env() {
Ok(c) => c,
Err(e) => {
+354
View File
@@ -0,0 +1,354 @@
// First-run interactive setup wizard, a complement to the env-var config.
//
// The authority is normally configured entirely through FLOONET_* environment
// variables (see config.rs and .env.example): docker compose injects them and
// the systemd unit loads them from an EnvironmentFile, so those deploys are
// fully headless and this module never runs. But an operator who just runs the
// binary by hand, with nothing configured, would otherwise start on the
// `floonet.example` placeholders. For that case only, when stdin/stdout is an
// interactive terminal, we prompt for the handful of essentials, write a
// conventional env file, load it, and continue starting up.
//
// The rules, in order:
// * FLOONET_DOMAIN already set (real deploy) -> no wizard, headless as today.
// * nothing set AND an interactive TTY -> guided wizard.
// * nothing set AND no TTY -> no wizard, headless as today.
//
// The env file we write is the same file the service convention consumes: the
// path in FLOONET_ENV_FILE if set, else /etc/floonet-authority.env when we can
// write it (the systemd EnvironmentFile default), else ./.env in the working
// directory (the docker-compose default). On the next run load_first_existing()
// finds it, sets FLOONET_DOMAIN, and the wizard stays out of the way.
use std::io::{self, BufRead, IsTerminal, Write};
use std::path::{Path, PathBuf};
/// The bare-metal / systemd convention: deploy/systemd/floonet-authority.service
/// loads this exact file via `EnvironmentFile=`.
pub const DEFAULT_ENV_FILE: &str = "/etc/floonet-authority.env";
/// Env files we will load on startup, in priority order. The first one that
/// exists wins. This spans both deployment conventions (the systemd
/// `/etc` file and the docker-compose `./.env`) plus an explicit override.
pub fn env_file_candidates() -> Vec<PathBuf> {
let mut out = Vec::new();
if let Ok(p) = std::env::var("FLOONET_ENV_FILE") {
if !p.is_empty() {
out.push(PathBuf::from(p));
}
}
out.push(PathBuf::from(DEFAULT_ENV_FILE));
out.push(PathBuf::from(".env"));
out
}
/// Load the first existing candidate env file into the process environment
/// (non-overriding). Returns the path loaded, if any. Under systemd/docker the
/// real environment is already populated, so this is a harmless no-op there.
pub fn load_first_existing() -> Option<PathBuf> {
for path in env_file_candidates() {
if path.is_file() {
// A parse/read hiccup on an optional file must not stop startup;
// config validation still runs afterwards.
let _ = load_env_file(&path);
return Some(path);
}
}
None
}
/// Is the authority already configured? True when the one variable every real
/// 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)
}
/// Are we attached to an interactive terminal on both stdin and stdout?
pub fn is_interactive() -> bool {
io::stdin().is_terminal() && io::stdout().is_terminal()
}
/// The single decision: run the wizard only when nothing is configured AND we
/// are interactive. Pulled out as a pure function so the headless guarantee
/// (config present => never a wizard, regardless of TTY) is directly testable.
pub fn decide_wizard(config_present: bool, interactive: bool) -> bool {
!config_present && interactive
}
/// Where the wizard writes its env file. Honors FLOONET_ENV_FILE, else the
/// systemd `/etc` convention when that directory is writable (a root install),
/// else `./.env` in the working directory (the docker-compose convention).
pub fn wizard_target() -> PathBuf {
if let Ok(p) = std::env::var("FLOONET_ENV_FILE") {
if !p.is_empty() {
return PathBuf::from(p);
}
}
let etc = PathBuf::from(DEFAULT_ENV_FILE);
match etc.parent() {
Some(dir) if dir_writable(dir) => etc,
_ => PathBuf::from(".env"),
}
}
/// Probe whether we can create files in `dir` by writing and removing a temp
/// file. Cheap and reliable across the root/unprivileged split, no extra deps.
fn dir_writable(dir: &Path) -> bool {
let probe = dir.join(format!(".floonet-setup-probe-{}", std::process::id()));
match std::fs::File::create(&probe) {
Ok(_) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
/// Parse env-file text into ordered key/value pairs. Blank lines and `#`
/// comments are skipped, a leading `export ` is tolerated, and a single layer
/// of matching single or double quotes around the value is stripped. Pure (no
/// environment side effects) so it can be unit tested directly.
pub fn parse_env_file(text: &str) -> Vec<(String, String)> {
let mut out = Vec::new();
for raw in text.lines() {
let line = raw.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").unwrap_or(line);
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() {
continue;
}
let value = unquote(value.trim());
out.push((key.to_string(), value));
}
out
}
fn unquote(v: &str) -> String {
let bytes = v.as_bytes();
if v.len() >= 2
&& ((bytes[0] == b'"' && bytes[v.len() - 1] == b'"')
|| (bytes[0] == b'\'' && bytes[v.len() - 1] == b'\''))
{
v[1..v.len() - 1].to_string()
} else {
v.to_string()
}
}
/// Load an env file into the process environment WITHOUT overriding variables
/// already set. Returns how many variables it set. Non-overriding is what keeps
/// this safe alongside systemd/docker: the real environment always wins.
pub fn load_env_file(path: &Path) -> io::Result<usize> {
let text = std::fs::read_to_string(path)?;
let mut set = 0;
for (key, value) in parse_env_file(&text) {
if std::env::var(&key).is_err() {
std::env::set_var(&key, &value);
set += 1;
}
}
Ok(set)
}
/// The answers the wizard collects. Kept separate from rendering so the render
/// step is a pure, testable function.
#[derive(Debug, Clone)]
pub struct Answers {
pub domain: String,
pub bind_addr: String,
pub data_dir: String,
pub pay_mode: String,
pub price_grin: String,
pub goblinpay_url: String,
pub goblinpay_token: String,
pub transfers: bool,
pub grin_node_url: String,
}
/// Render the answers into env-file text. Only emits the keys the answers touch,
/// mirroring .env.example wording. base_url and relays are DERIVED from the
/// domain so they always satisfy Config::validate() (base_url host must equal
/// the domain), which is why the wizard does not prompt for them separately.
pub fn render_env(a: &Answers) -> String {
let mut s = String::new();
s.push_str("# Generated by the floonet-name-authority first-run setup wizard.\n");
s.push_str("# Edit freely; every key maps to a FLOONET_* variable (see .env.example).\n\n");
s.push_str(&format!("FLOONET_DOMAIN={}\n", a.domain));
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_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)),
"write" => s.push_str(&format!("FLOONET_WRITE_PRICE_GRIN={}\n", a.price_grin)),
_ => {}
}
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));
}
if a.transfers {
s.push_str("FLOONET_TRANSFERS=true\n");
s.push_str(&format!("FLOONET_GRIN_NODE_URL={}\n", a.grin_node_url));
}
s
}
/// 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()
}
/// Drive the interactive prompts over the given reader/writer, returning the
/// collected answers. Generic over the streams so tests can feed canned input.
pub fn collect_answers<R: BufRead, W: Write>(
r: &mut R,
w: &mut W,
) -> 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, "Press Enter to accept each [default].\n")?;
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")?;
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());
if pay_mode != "off" {
let price_q = if pay_mode == "name" {
"Price to claim a name, in GRIN"
} else {
"Price for write access, in GRIN"
};
// 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", "")?;
}
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")?;
}
Ok(Answers {
domain,
bind_addr,
data_dir,
pay_mode,
price_grin,
goblinpay_url,
goblinpay_token,
transfers,
grin_node_url,
})
}
/// Collect answers over the streams, render, and write to `target`. Returns the
/// path written. Split from the process-env plumbing so it is fully testable.
pub fn run_wizard<R: BufRead, W: Write>(
r: &mut R,
w: &mut W,
target: &Path,
) -> io::Result<PathBuf> {
let answers = collect_answers(r, w)?;
let body = render_env(&answers);
if let Some(dir) = target.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)?;
}
}
std::fs::write(target, body)?;
writeln!(w, "\nWrote {}. Starting up...\n", target.display())?;
Ok(target.to_path_buf())
}
/// 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();
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)
}
fn prompt<R: BufRead, W: Write>(
r: &mut R,
w: &mut W,
question: &str,
default: &str,
) -> io::Result<String> {
if default.is_empty() {
write!(w, "{question}: ")?;
} else {
write!(w, "{question} [{default}]: ")?;
}
w.flush()?;
let mut line = String::new();
// EOF (0 bytes) falls back to the default rather than looping forever.
if r.read_line(&mut line)? == 0 {
return Ok(default.to_string());
}
let trimmed = line.trim();
Ok(if trimmed.is_empty() {
default.to_string()
} else {
trimmed.to_string()
})
}
fn prompt_choice<R: BufRead, W: Write>(
r: &mut R,
w: &mut W,
question: &str,
default: &str,
choices: &[&str],
) -> io::Result<String> {
// Re-ask a bounded number of times on an unrecognized answer, then accept
// the default so a piped/misused stream can never wedge startup.
for _ in 0..5 {
let answer = prompt(r, w, question, default)?;
let lower = answer.to_lowercase();
if choices.contains(&lower.as_str()) {
return Ok(lower);
}
writeln!(w, " Please answer one of: {}", choices.join(", "))?;
}
Ok(default.to_string())
}
fn prompt_yes_no<R: BufRead, W: Write>(
r: &mut R,
w: &mut W,
question: &str,
default: bool,
) -> io::Result<bool> {
let shown = if default { "Y/n" } else { "y/N" };
let answer = prompt(r, w, question, shown)?;
Ok(match answer.trim().to_lowercase().as_str() {
"y" | "yes" => true,
"n" | "no" => false,
// Anything else (including the echoed default token) keeps the default.
_ => default,
})
}
+183
View File
@@ -0,0 +1,183 @@
// Tests for the first-run setup wizard (src/setup.rs). The wizard is a
// COMPLEMENT to the env-var config: it must never fire for a configured
// (headless) deploy, and when it does fire it must write an env file whose
// values satisfy Config::validate(). These tests avoid mutating the shared
// process environment except through uniquely named keys, so they are safe to
// run in parallel with the rest of the suite.
use std::io::Cursor;
use std::path::PathBuf;
use floonet_name_authority::setup::{
self, collect_answers, db_path_in, load_env_file, parse_env_file, render_env, run_wizard,
Answers,
};
/// The headless guarantee: whenever configuration is present, the wizard is
/// never chosen, regardless of whether we are on a TTY. This is the core
/// "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");
}
/// 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");
}
#[test]
fn parse_env_file_handles_comments_quotes_and_export() {
let text = "\
# a comment
FLOONET_DOMAIN=names.example
export FLOONET_BASE_URL=https://names.example
QUOTED=\"a value\"
SINGLE='b value'
# trailing comment
BLANKVAL=
=nokey
";
let pairs = parse_env_file(text);
assert_eq!(
pairs,
vec![
("FLOONET_DOMAIN".to_string(), "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()),
]
);
}
#[test]
fn render_env_derives_base_url_and_relays_from_domain() {
let a = Answers {
domain: "names.example".into(),
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(),
};
let body = render_env(&a);
// base_url and relays are derived so the validate() host==domain invariant
// always holds; off-mode emits no price or goblinpay keys.
assert!(body.contains("FLOONET_DOMAIN=names.example\n"));
assert!(body.contains("FLOONET_BASE_URL=https://names.example\n"));
assert!(body.contains("FLOONET_RELAYS=wss://names.example\n"));
assert!(body.contains("FLOONET_NAMES_DB=/var/lib/floonet-authority/names.db\n"));
assert!(body.contains("FLOONET_PAY_MODE=off\n"));
assert!(!body.contains("GOBLINPAY_URL"));
assert!(!body.contains("FLOONET_TRANSFERS"));
}
#[test]
fn render_env_paid_name_and_transfers() {
let a = Answers {
domain: "names.example".into(),
bind_addr: "127.0.0.1:8191".into(),
data_dir: "/data".into(),
pay_mode: "name".into(),
price_grin: "1".into(),
goblinpay_url: "http://127.0.0.1:8192".into(),
goblinpay_token: "tok".into(),
transfers: true,
grin_node_url: "https://api.grin.money/v2/foreign".into(),
};
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"));
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.
assert!(!body.contains("FLOONET_WRITE_PRICE_GRIN"));
}
#[test]
fn db_path_joins_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
/// confirm the collected answers reflect the mix of typed values and defaults.
#[test]
fn collect_answers_uses_input_then_defaults() {
// domain typed; bind + data-dir Enter (defaults); pay mode off; transfers no.
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");
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");
assert_eq!(a.pay_mode, "off");
assert!(!a.transfers);
}
/// End to end over the file: run the wizard with canned input into a temp path,
/// then parse it back and confirm it is a config-present file (FLOONET_DOMAIN
/// set) with the derived, validate()-satisfying base_url/relays.
#[test]
fn run_wizard_writes_loadable_env_file() {
let input = b"names.example\n0.0.0.0:9000\n/tmp/floonet-data\noff\nn\n";
let mut reader = Cursor::new(&input[..]);
let mut out: Vec<u8> = Vec::new();
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");
assert_eq!(written, target);
let text = std::fs::read_to_string(&target).expect("read back");
let pairs = parse_env_file(&text);
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_NAMES_BIND").as_deref(), Some("0.0.0.0:9000"));
assert_eq!(get("FLOONET_NAMES_DB").as_deref(), Some("/tmp/floonet-data/names.db"));
let _ = std::fs::remove_file(&target);
}
/// 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
/// FLOONET_* and racing other tests.
#[test]
fn load_env_file_is_non_overriding() {
let pid = std::process::id();
let unset_key = format!("FLOONET_SETUP_TEST_UNSET_{pid}");
let preset_key = format!("FLOONET_SETUP_TEST_PRESET_{pid}");
std::env::set_var(&preset_key, "original");
let target = std::env::temp_dir().join(format!("floonet-load-test-{pid}.env"));
std::fs::write(
&target,
format!("{unset_key}=from_file\n{preset_key}=from_file\n"),
)
.expect("write");
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");
let _ = std::fs::remove_file(&target);
std::env::remove_var(&unset_key);
std::env::remove_var(&preset_key);
}