diff --git a/name-authority/src/lib.rs b/name-authority/src/lib.rs index 6438783..4e3b3d3 100644 --- a/name-authority/src/lib.rs +++ b/name-authority/src/lib.rs @@ -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; diff --git a/name-authority/src/main.rs b/name-authority/src/main.rs index ec9216e..b815595 100644 --- a/name-authority/src/main.rs +++ b/name-authority/src/main.rs @@ -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) => { diff --git a/name-authority/src/setup.rs b/name-authority/src/setup.rs new file mode 100644 index 0000000..c35f7f2 --- /dev/null +++ b/name-authority/src/setup.rs @@ -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 { + 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 { + 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 { + 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: &mut R, + w: &mut W, +) -> io::Result { + 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: &mut R, + w: &mut W, + target: &Path, +) -> io::Result { + 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 { + 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: &mut R, + w: &mut W, + question: &str, + default: &str, +) -> io::Result { + 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: &mut R, + w: &mut W, + question: &str, + default: &str, + choices: &[&str], +) -> io::Result { + // 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: &mut R, + w: &mut W, + question: &str, + default: bool, +) -> io::Result { + 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, + }) +} diff --git a/name-authority/tests/setup.rs b/name-authority/tests/setup.rs new file mode 100644 index 0000000..2bc0424 --- /dev/null +++ b/name-authority/tests/setup.rs @@ -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 = 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 = 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); +}