setup: full first-run wizard (auto-launch, bind + node prompts, reconfigure keeps restart mode)
ci / fmt / clippy / test (push) Has been cancelled
ci / fmt / clippy / test (push) Has been cancelled
Round out the interactive seed setup into a complete first-run wizard that complements env/config setup, with zero behavior change for existing deploys. - A bare `gp-server` (no subcommand) now offers the wizard on a genuinely fresh box (ingest on, no seed at rest, no mnemonic/password in the env, no /etc/goblinpay.env) when stdin AND stdout are a terminal, then boots in-process from the env file it wrote. A configured deploy returns unchanged (headless as before); a non-terminal fresh run falls through to today's fail-fast. Decision lives in the tested predicate needs_first_run_setup; the boot reload parses the written file via Config::from_lookup (config_from_written_env). - The wizard now walks the remaining essentials: the listen address (GP_BIND, default 127.0.0.1:8080, now rendered into the env) and the Grin node URL (press Enter to probe the curated list as before, or name your own). Renumbered the question flow accordingly. - Fix the reconfigure wart: the existing-wallet branch hardcoded Unattended, so a reconfigure silently rewrote the restart mode. It now detects the current mode (the manual drop-in marks manual), prompts with it as the default so pressing Enter preserves it, and can switch modes; persistence is unified in apply_restart_persistence, which moves the password on/off disk on a switch. write_secret_file removes any prior 0400 file first so an in-place rewrite is not denied. Tests: gp-core gains normalize_bind, parse_env_file, parse_restart_mode_default, and render_env round-trip coverage; gp-server gains the first-run predicate, the written-env reload, the node prompt, and the reconfigure mode-switch. cargo build and cargo test are green across the workspace.
This commit is contained in:
@@ -73,21 +73,30 @@ you), then:
|
||||
sudo gp-server setup
|
||||
```
|
||||
|
||||
On a fresh box you can skip even that: running `gp-server` with no configuration
|
||||
in an interactive terminal starts this same wizard automatically and then boots
|
||||
from what it wrote. An already-configured install (or a non-interactive run) is
|
||||
unaffected and starts headless exactly as before.
|
||||
|
||||
It asks a few questions, each with a default. It is grin-wallet-faithful about
|
||||
the two things that are yours to own — your wallet password and your seed:
|
||||
|
||||
1. the public URL customers reach this till at,
|
||||
2. your shop's website URL (used to build the webhook URL),
|
||||
3. **your wallet password** — you choose it, entered twice and confirmed to
|
||||
3. the local listen address (default `127.0.0.1:8080`; keep it loopback behind
|
||||
your reverse proxy),
|
||||
4. the Grin node for confirmations and balance (press Enter to auto-pick a
|
||||
healthy node from the curated list, or enter your own `https://` node),
|
||||
5. **your wallet password**: you choose it, entered twice and confirmed to
|
||||
match (hidden input; it is never auto-generated). It encrypts the seed at
|
||||
rest and is not recoverable, so if you forget it you restore from the seed;
|
||||
4. **the Grin seed** — press Enter to generate a fresh 24-word seed, which is
|
||||
6. **the Grin seed**: press Enter to generate a fresh 24-word seed, which is
|
||||
shown once and gated behind an acknowledgement that you wrote it down (exactly
|
||||
like `grin-wallet init`), or paste your existing recovery phrase;
|
||||
5. **restart mode** — how the till comes back after a reboot (default
|
||||
7. **restart mode**: how the till comes back after a reboot (default
|
||||
**unattended**; see below);
|
||||
6. the currencies your shop prices in (default `usd`),
|
||||
7. an advanced yes/no for the grin1/Tor rail (default no).
|
||||
8. the currencies your shop prices in (default `usd`),
|
||||
9. an advanced yes/no for the grin1/Tor rail (default no).
|
||||
|
||||
Everything else it does for you:
|
||||
|
||||
@@ -97,8 +106,8 @@ Everything else it does for you:
|
||||
- creates the encrypted wallet on the spot from the seed, so the seed is
|
||||
consumed once and never lives in the service environment afterwards (it
|
||||
exists only encrypted at rest and in your written backup);
|
||||
- probes a curated list of healthy mainnet Grin nodes and picks the first that
|
||||
answers, falling back automatically;
|
||||
- when you accept the node default, probes a curated list of healthy mainnet
|
||||
Grin nodes and picks the first that answers, falling back automatically;
|
||||
- defaults the relays to an external vetted pool (the wallet's proven relays);
|
||||
- writes `/etc/goblinpay.env` (mode 0640, holds the config plus the bearer
|
||||
tokens) exactly where the shipped `gp-server.service` looks (`EnvironmentFile`),
|
||||
@@ -138,7 +147,9 @@ default. Both are honest about their trade-off:
|
||||
Re-running is safe: the wizard refuses to overwrite an existing wallet or config
|
||||
unless you pass `--reconfigure` (which keeps the existing seed and password — the
|
||||
money — untouched and never re-prompts for them, only rewriting the config/tokens).
|
||||
If you run manual restart mode, re-apply it after a reconfigure.
|
||||
A reconfigure keeps your current restart mode by default: the prompt shows it as
|
||||
the default so pressing Enter preserves it, and you pick the other option to
|
||||
switch.
|
||||
Flags: `--reconfigure`, `--prefix DIR` (write under a prefix instead of `/`),
|
||||
`--node URL` (skip the node probe), `--batch` (read scripted answers from a
|
||||
non-terminal stdin).
|
||||
|
||||
+145
-6
@@ -28,6 +28,8 @@
|
||||
|
||||
use rand::RngCore;
|
||||
|
||||
use crate::config::DEFAULT_BIND;
|
||||
|
||||
/// Curated known-good mainnet Grin nodes, in preference order. Every entry was
|
||||
/// verified to answer `get_tip` on its `/v2/foreign` endpoint at build time
|
||||
/// (2026-07-05, all three reporting the same tip height). GoblinPay only ever
|
||||
@@ -58,6 +60,12 @@ pub const DEFAULT_CURRENCY: &str = "usd";
|
||||
/// `GP_WEBHOOK_URL` by appending this.
|
||||
pub const WEBHOOK_PATH: &str = "/wp-json/goblinpay/v1/webhook";
|
||||
|
||||
/// Where the shipped `gp-server.service` reads its `EnvironmentFile` from, and
|
||||
/// where the wizard writes the rendered config. Used both as the wizard's
|
||||
/// output path and as the "already configured" signal on a bare `gp-server`
|
||||
/// first run (its presence means an operator has set this instance up already).
|
||||
pub const DEFAULT_ENV_FILE: &str = "/etc/goblinpay.env";
|
||||
|
||||
/// Fill `buf` with cryptographically-secure random bytes (the OS CSPRNG).
|
||||
pub fn fill_random(buf: &mut [u8]) {
|
||||
rand::rng().fill_bytes(buf);
|
||||
@@ -144,17 +152,25 @@ impl RestartMode {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the restart-mode answer. Pressing Enter (empty) takes the default,
|
||||
/// UNATTENDED. Accepts `1`/`u`/`unattended` and `2`/`m`/`manual`
|
||||
/// (case-insensitive); anything else falls back to UNATTENDED rather than
|
||||
/// trapping the operator.
|
||||
pub fn parse_restart_mode(input: &str) -> RestartMode {
|
||||
/// Parse the restart-mode answer, falling back to `default` on an empty (Enter)
|
||||
/// or unrecognized answer rather than trapping the operator. Accepts
|
||||
/// `1`/`u`/`unattended` and `2`/`m`/`manual` (case-insensitive). Reconfigure
|
||||
/// passes the till's CURRENT mode as the default so pressing Enter preserves it;
|
||||
/// a fresh setup passes UNATTENDED.
|
||||
pub fn parse_restart_mode_default(input: &str, default: RestartMode) -> RestartMode {
|
||||
match input.trim().to_lowercase().as_str() {
|
||||
"1" | "u" | "unattended" => RestartMode::Unattended,
|
||||
"2" | "m" | "manual" => RestartMode::Manual,
|
||||
_ => RestartMode::Unattended,
|
||||
_ => default,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the restart-mode answer with the fresh-setup default (UNATTENDED).
|
||||
/// Pressing Enter (empty) or an unknown answer takes UNATTENDED.
|
||||
pub fn parse_restart_mode(input: &str) -> RestartMode {
|
||||
parse_restart_mode_default(input, RestartMode::Unattended)
|
||||
}
|
||||
|
||||
/// Parse a seed-acknowledgement answer. Unlike `parse_yes_no`, there is no
|
||||
/// affirmative default: the operator must explicitly type `y`/`yes` to confirm
|
||||
/// they wrote the seed down. Everything else (including Enter) is "not yet".
|
||||
@@ -236,6 +252,43 @@ pub fn base_path(public_url: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize an operator-entered listen address: trim surrounding whitespace and
|
||||
/// fall back to the loopback default (`127.0.0.1:8080`) on an empty answer, so
|
||||
/// pressing Enter is a valid response. The address stays behind the reverse
|
||||
/// proxy, so no scheme or validation beyond non-empty is imposed here (the
|
||||
/// config layer rejects an empty bind).
|
||||
pub fn normalize_bind(input: &str) -> String {
|
||||
let t = input.trim();
|
||||
if t.is_empty() {
|
||||
DEFAULT_BIND.to_string()
|
||||
} else {
|
||||
t.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a rendered env file (the wizard's own `render_env` output, or an
|
||||
/// equivalent hand-written one) into `(KEY, VALUE)` pairs. Blank lines and
|
||||
/// `#`-comment lines are skipped; each remaining line is split on the FIRST `=`,
|
||||
/// with the key and value trimmed. This lets a bare `gp-server` first run boot
|
||||
/// directly from the file the wizard just wrote, without shelling out to a
|
||||
/// systemd `EnvironmentFile` reload.
|
||||
pub fn parse_env_file(contents: &str) -> Vec<(String, String)> {
|
||||
let mut pairs = Vec::new();
|
||||
for line in contents.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((key, value)) = line.split_once('=') {
|
||||
let key = key.trim().to_string();
|
||||
if !key.is_empty() {
|
||||
pairs.push((key, value.trim().to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
pairs
|
||||
}
|
||||
|
||||
/// Parse a comma-separated currency answer into lowercased ISO codes, dropping
|
||||
/// blanks. An empty answer yields the default (`usd`), so pressing Enter is a
|
||||
/// valid response.
|
||||
@@ -280,6 +333,9 @@ pub fn select_node<F: FnMut(&str) -> bool>(candidates: &[&str], mut probe: F) ->
|
||||
/// place they must be written out); callers zeroize/scope them.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SetupParams {
|
||||
/// Local listen address the server binds (`GP_BIND`); loopback by default,
|
||||
/// kept behind the operator's reverse proxy.
|
||||
pub bind: String,
|
||||
/// Public URL customers reach the till at (`GP_PUBLIC_URL`).
|
||||
pub public_url: String,
|
||||
/// Full webhook URL on the shop (`GP_WEBHOOK_URL`).
|
||||
@@ -326,6 +382,9 @@ impl SetupParams {
|
||||
"# seed is NOT here (it lives encrypted at rest and in your written backup).\n\n",
|
||||
);
|
||||
|
||||
s.push_str("# --- local listen address (loopback; keep it behind your proxy) ---\n");
|
||||
s.push_str(&format!("GP_BIND={}\n\n", self.bind));
|
||||
|
||||
s.push_str("# --- public URL customers reach this till at ---\n");
|
||||
s.push_str(&format!("GP_PUBLIC_URL={}\n\n", self.public_url));
|
||||
|
||||
@@ -470,6 +529,83 @@ mod tests {
|
||||
assert_eq!(RestartMode::Manual.as_str(), "manual");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restart_mode_with_default_preserves_on_empty() {
|
||||
// Empty / unknown -> the caller's default (reconfigure preserves the
|
||||
// current mode rather than silently forcing unattended).
|
||||
assert_eq!(
|
||||
parse_restart_mode_default("", RestartMode::Manual),
|
||||
RestartMode::Manual
|
||||
);
|
||||
assert_eq!(
|
||||
parse_restart_mode_default(" ", RestartMode::Manual),
|
||||
RestartMode::Manual
|
||||
);
|
||||
assert_eq!(
|
||||
parse_restart_mode_default("what", RestartMode::Manual),
|
||||
RestartMode::Manual
|
||||
);
|
||||
// An explicit answer overrides the default in either direction.
|
||||
assert_eq!(
|
||||
parse_restart_mode_default("1", RestartMode::Manual),
|
||||
RestartMode::Unattended
|
||||
);
|
||||
assert_eq!(
|
||||
parse_restart_mode_default("unattended", RestartMode::Manual),
|
||||
RestartMode::Unattended
|
||||
);
|
||||
assert_eq!(
|
||||
parse_restart_mode_default("2", RestartMode::Unattended),
|
||||
RestartMode::Manual
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_bind_trims_and_defaults() {
|
||||
assert_eq!(normalize_bind(""), "127.0.0.1:8080");
|
||||
assert_eq!(normalize_bind(" "), "127.0.0.1:8080");
|
||||
assert_eq!(normalize_bind(" 0.0.0.0:9000 "), "0.0.0.0:9000");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_env_file_skips_comments_and_splits_first_eq() {
|
||||
let text = "\
|
||||
# a comment\n\
|
||||
\n\
|
||||
GP_BIND=127.0.0.1:8080\n\
|
||||
GP_PUBLIC_URL=https://pay.shop.com\n\
|
||||
#GP_GRIN1_RAIL=on\n\
|
||||
GP_RELAYS=wss://a,wss://b\n\
|
||||
GP_WEBHOOK_URL=https://s/wp-json?x=1=2\n";
|
||||
let pairs = parse_env_file(text);
|
||||
assert_eq!(pairs.len(), 4);
|
||||
assert!(pairs.contains(&("GP_BIND".to_string(), "127.0.0.1:8080".to_string())));
|
||||
assert!(pairs.contains(&("GP_RELAYS".to_string(), "wss://a,wss://b".to_string())));
|
||||
// Split on the FIRST '=' only, so values may contain '='.
|
||||
assert!(pairs.contains(&(
|
||||
"GP_WEBHOOK_URL".to_string(),
|
||||
"https://s/wp-json?x=1=2".to_string()
|
||||
)));
|
||||
// The commented rail line is not a pair.
|
||||
assert!(!pairs.iter().any(|(k, _)| k == "#GP_GRIN1_RAIL"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn render_env_round_trips_through_parse_env_file() {
|
||||
// The wizard's output parses back into the same non-secret values, which
|
||||
// is exactly how a bare first run boots from what it just wrote.
|
||||
let env = sample_params().render_env();
|
||||
let pairs: std::collections::HashMap<String, String> =
|
||||
parse_env_file(&env).into_iter().collect();
|
||||
assert_eq!(pairs.get("GP_BIND").unwrap(), "127.0.0.1:8080");
|
||||
assert_eq!(
|
||||
pairs.get("GP_PUBLIC_URL").unwrap(),
|
||||
"https://pay.myshop.com"
|
||||
);
|
||||
assert_eq!(pairs.get("GP_RELAY_MODE").unwrap(), "external");
|
||||
assert_eq!(pairs.get("GP_NODE_URL").unwrap(), "https://main.gri.mw");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seed_ack_requires_explicit_yes() {
|
||||
// No affirmative default: only y/yes acknowledge the written backup.
|
||||
@@ -573,6 +709,7 @@ mod tests {
|
||||
|
||||
fn sample_params() -> SetupParams {
|
||||
SetupParams {
|
||||
bind: "127.0.0.1:8080".into(),
|
||||
public_url: "https://pay.myshop.com".into(),
|
||||
webhook_url: "https://myshop.com/wp-json/goblinpay/v1/webhook".into(),
|
||||
node_url: "https://main.gri.mw".into(),
|
||||
@@ -592,6 +729,8 @@ mod tests {
|
||||
#[test]
|
||||
fn render_env_has_the_right_couplings_and_no_seed() {
|
||||
let env = sample_params().render_env();
|
||||
// The listen address is written (loopback default).
|
||||
assert!(env.contains("GP_BIND=127.0.0.1:8080"));
|
||||
// External relay pool, both relays present.
|
||||
assert!(env.contains("GP_RELAY_MODE=external"));
|
||||
assert!(env.contains("GP_RELAYS=wss://relay.floonet.dev,wss://offchain.pub"));
|
||||
|
||||
@@ -293,6 +293,58 @@ fn run_setup(args: &[String]) -> i32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// First-run guided setup for a bare `gp-server` (no subcommand), the complement
|
||||
/// to configuring by env var. On a fresh, unconfigured box attached to a
|
||||
/// terminal, run the wizard and then boot from what it wrote. A configured
|
||||
/// deploy returns the config unchanged (headless, exactly as before), and a
|
||||
/// non-terminal first run also falls through to today's fail-fast at wallet
|
||||
/// open. The process exits here only when the wizard fails, or after a
|
||||
/// manual-mode setup that deliberately leaves no password on disk to boot from.
|
||||
fn first_run_or_boot(cfg: Config) -> Config {
|
||||
use std::io::IsTerminal;
|
||||
use std::path::Path;
|
||||
|
||||
use gp_server::setup::{self, SetupOptions};
|
||||
|
||||
let env_file = Path::new(gp_core::setup::DEFAULT_ENV_FILE);
|
||||
if !setup::needs_first_run_setup(&cfg, env_file.exists()) {
|
||||
return cfg;
|
||||
}
|
||||
// Non-TTY without config: keep today's behavior (fall through and fail fast
|
||||
// at wallet open with the existing message).
|
||||
if !std::io::stdin().is_terminal() || !std::io::stdout().is_terminal() {
|
||||
return cfg;
|
||||
}
|
||||
|
||||
println!("No configuration found. Starting the GoblinPay setup wizard.\n");
|
||||
let opts = SetupOptions {
|
||||
reconfigure: false,
|
||||
prefix: None,
|
||||
node_override: None,
|
||||
force_run: false,
|
||||
stdin_is_tty: true,
|
||||
};
|
||||
let stdin = std::io::stdin();
|
||||
let mut stdout = std::io::stdout();
|
||||
if let Err(e) = setup::run(stdin.lock(), &mut stdout, &opts) {
|
||||
eprintln!("setup: {e}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Boot in-process from the freshly written env file. A manual-mode run keeps
|
||||
// no wallet password on disk, so there is nothing to open unattended here:
|
||||
// print how to start the service and exit cleanly.
|
||||
match setup::config_from_written_env(env_file) {
|
||||
Ok(cfg) => cfg,
|
||||
Err(_) => {
|
||||
println!(
|
||||
"\nSetup complete. Start the service when you are ready:\n \
|
||||
sudo systemctl start gp-server"
|
||||
);
|
||||
std::process::exit(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[actix_web::main]
|
||||
async fn main() -> io::Result<()> {
|
||||
// Subcommand branch (manual argv, no clap): `gp-server setup` runs the
|
||||
@@ -317,6 +369,10 @@ async fn main() -> io::Result<()> {
|
||||
std::process::exit(2);
|
||||
}
|
||||
};
|
||||
// Complement env/config setup: on a fresh box with an interactive terminal,
|
||||
// guide the operator through the wizard, then boot from what it wrote. No-op
|
||||
// for configured deploys and non-terminal runs (zero behavior change).
|
||||
let cfg = first_run_or_boot(cfg);
|
||||
println!(
|
||||
"gp-server {} starting: {}",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
|
||||
+353
-93
@@ -28,7 +28,7 @@ use std::io::{BufRead, Write};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gp_core::config::Chain;
|
||||
use gp_core::config::{Chain, Config};
|
||||
use gp_core::setup as core_setup;
|
||||
use gp_core::setup::SetupParams;
|
||||
use gp_wallet::GpWallet;
|
||||
@@ -224,38 +224,62 @@ pub fn run<R: BufRead, W: Write>(
|
||||
"2) Your shop's website URL (the WooCommerce site), e.g. https://myshop.com",
|
||||
)?;
|
||||
|
||||
// Q3: local listen address (default loopback). It stays behind the reverse
|
||||
// proxy, so the default is right for almost everyone; pressing Enter takes
|
||||
// it.
|
||||
writeln!(
|
||||
out,
|
||||
"3) Local address the server listens on. Keep it loopback behind your\n\
|
||||
\x20 reverse proxy unless you know you need otherwise. [127.0.0.1:8080]"
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write!(out, " > ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
let bind = core_setup::normalize_bind(&read_line(&mut input)?);
|
||||
|
||||
// Q4: Grin node URL (read-only: confirmations + balance). An explicit
|
||||
// `--node` override skips the question; otherwise pressing Enter auto-picks a
|
||||
// healthy node from the curated list, or the operator can name their own.
|
||||
let node_url = resolve_node(&mut input, out, opts)?;
|
||||
|
||||
// Hidden password entry only when stdin is a real terminal; scripted/batch
|
||||
// runs (and tests) read the password as plain lines from `input`.
|
||||
let hidden = opts.stdin_is_tty;
|
||||
|
||||
// Q3–Q5 concern the wallet secrets and the restart mode, and are only asked
|
||||
// for a NEW wallet. On a --reconfigure with an existing wallet we keep the
|
||||
// seed, password, and restart arrangement at rest untouched (that is the
|
||||
// money), so prompting for any of them would be a trap.
|
||||
// Q5-Q7 concern the wallet secrets and are only asked for a NEW wallet. On a
|
||||
// --reconfigure with an existing wallet we keep the seed and password at rest
|
||||
// untouched (that is the money), so prompting for them would be a trap. The
|
||||
// restart mode IS still offered on reconfigure, defaulting to the till's
|
||||
// current mode, so it is preserved unless the operator explicitly changes it.
|
||||
let wallet_exists = seed_path.exists();
|
||||
let (seed_plan, chosen_password, restart_mode) = if wallet_exists {
|
||||
writeln!(
|
||||
out,
|
||||
"Existing till wallet found; keeping its seed, password, and restart \
|
||||
mode unchanged."
|
||||
"Existing till wallet found; keeping its seed and password."
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
(
|
||||
SeedPlan::Existing,
|
||||
None,
|
||||
core_setup::RestartMode::Unattended,
|
||||
)
|
||||
// Detect the current restart mode from disk: the MANUAL-mode drop-in
|
||||
// exists only when the till was set up manual. Prompt with it as the
|
||||
// default so pressing Enter preserves it (this was previously hardcoded
|
||||
// to unattended, which silently rewrote a manual till's config).
|
||||
let current_mode = if paths.dropin_file.exists() {
|
||||
core_setup::RestartMode::Manual
|
||||
} else {
|
||||
core_setup::RestartMode::Unattended
|
||||
};
|
||||
let mode = prompt_restart_mode(&mut input, out, None, current_mode)?;
|
||||
(SeedPlan::Existing, None, mode)
|
||||
} else {
|
||||
// Q3: the operator CHOOSES the wallet password (grin-wallet-faithful:
|
||||
// Q5: the operator CHOOSES the wallet password (grin-wallet-faithful:
|
||||
// entered twice, must match). It encrypts the seed at rest.
|
||||
let password = prompt_wallet_password(&mut input, out, hidden)?;
|
||||
|
||||
// Q4: the seed. Fresh (generate, show ONCE, acknowledge) or paste an
|
||||
// Q6: the seed. Fresh (generate, show ONCE, acknowledge) or paste an
|
||||
// existing recovery phrase (acknowledge the written backup). Both paths
|
||||
// are gated behind an explicit acknowledgement, like grin-wallet init.
|
||||
writeln!(
|
||||
out,
|
||||
"4) Grin seed: press Enter to generate a FRESH 24-word till seed,\n\
|
||||
"6) Grin seed: press Enter to generate a FRESH 24-word till seed,\n\
|
||||
\x20 or paste your existing recovery phrase."
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
@@ -292,35 +316,27 @@ pub fn run<R: BufRead, W: Write>(
|
||||
SeedPlan::Pasted(m)
|
||||
};
|
||||
|
||||
// Q5: restart mode (owner ruling: offer both, default UNATTENDED).
|
||||
writeln!(
|
||||
// Q7: restart mode (owner ruling: offer both, default UNATTENDED).
|
||||
let mode = prompt_restart_mode(
|
||||
&mut input,
|
||||
out,
|
||||
"5) After a reboot, how should the till restart?\n\
|
||||
\x20 [1] Unattended (default) — your password is sealed to THIS host, so\n\
|
||||
\x20 the service auto-restarts. Honest trade-off: whoever fully\n\
|
||||
\x20 controls this machine controls the wallet; keep it a small hot\n\
|
||||
\x20 float and sweep to your own wallet regularly.\n\
|
||||
\x20 [2] Manual — the password lives only in your head; you re-enter it\n\
|
||||
\x20 after every restart. Maximum protection against disk/machine theft."
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write!(out, " > [1] ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
let mode = core_setup::parse_restart_mode(&read_line(&mut input)?);
|
||||
Some(7),
|
||||
core_setup::RestartMode::Unattended,
|
||||
)?;
|
||||
|
||||
(plan, Some(password), mode)
|
||||
};
|
||||
|
||||
// Q6: currencies (default usd).
|
||||
writeln!(out, "6) Currencies your shop prices in? [usd]").map_err(io_err)?;
|
||||
// Q8: currencies (default usd).
|
||||
writeln!(out, "8) Currencies your shop prices in? [usd]").map_err(io_err)?;
|
||||
write!(out, " > ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
let currencies = core_setup::parse_currencies(&read_line(&mut input)?);
|
||||
|
||||
// Q7: advanced grin1/Tor rail (default no).
|
||||
// Q9: advanced grin1/Tor rail (default no).
|
||||
writeln!(
|
||||
out,
|
||||
"7) (advanced) Also accept payments from any Grin wallet over Tor? [y/N]"
|
||||
"9) (advanced) Also accept payments from any Grin wallet over Tor? [y/N]"
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write!(out, " > ").map_err(io_err)?;
|
||||
@@ -333,33 +349,6 @@ pub fn run<R: BufRead, W: Write>(
|
||||
let admin_token = core_setup::gen_admin_token();
|
||||
let webhook_secret = core_setup::gen_webhook_secret();
|
||||
|
||||
// Pick a healthy Grin node: an explicit override, else probe the curated
|
||||
// list and take the first that answers.
|
||||
writeln!(out).map_err(io_err)?;
|
||||
let node_url = match &opts.node_override {
|
||||
Some(url) => {
|
||||
writeln!(out, "Using Grin node {url} (override).").map_err(io_err)?;
|
||||
url.clone()
|
||||
}
|
||||
None => {
|
||||
writeln!(out, "Probing curated Grin nodes for a healthy one...").map_err(io_err)?;
|
||||
let chosen = core_setup::select_node(core_setup::CURATED_NODES, gp_wallet::probe_node);
|
||||
match chosen {
|
||||
Some(url) => {
|
||||
writeln!(out, "Using Grin node {url}.").map_err(io_err)?;
|
||||
url
|
||||
}
|
||||
None => {
|
||||
return Err(
|
||||
"none of the curated Grin nodes answered. Check this host's \
|
||||
network, or pass --node <url> to use a node you trust."
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Create (or, on reconfigure, reopen) the encrypted wallet. Creating
|
||||
// consumes the seed once: it lives encrypted at rest afterwards, never in
|
||||
// the service environment (owner ruling O2). Reopening an existing wallet
|
||||
@@ -380,8 +369,14 @@ pub fn run<R: BufRead, W: Write>(
|
||||
.trim_end_matches(['\n', '\r'])
|
||||
.to_string();
|
||||
writeln!(out, "Reopening the existing till wallet...").map_err(io_err)?;
|
||||
GpWallet::create_at(&paths.data_dir, None, &password, &node_url, Chain::Mainnet)
|
||||
.map_err(|e| format!("could not reopen the existing wallet: {e}"))?
|
||||
let w =
|
||||
GpWallet::create_at(&paths.data_dir, None, &password, &node_url, Chain::Mainnet)
|
||||
.map_err(|e| format!("could not reopen the existing wallet: {e}"))?;
|
||||
// Re-apply the (possibly changed) restart mode. Preserving the mode
|
||||
// leaves these files as they were; switching mode moves the password
|
||||
// on/off disk accordingly.
|
||||
apply_restart_persistence(&paths, restart_mode, &password)?;
|
||||
w
|
||||
}
|
||||
SeedPlan::Fresh(m) | SeedPlan::Pasted(m) => {
|
||||
let password = chosen_password
|
||||
@@ -396,23 +391,7 @@ pub fn run<R: BufRead, W: Write>(
|
||||
Chain::Mainnet,
|
||||
)
|
||||
.map_err(|e| format!("wallet creation failed: {e}"))?;
|
||||
// Persist per restart mode. UNATTENDED seals the chosen password to
|
||||
// this host as a 0400 credential file (systemd LoadCredential reads
|
||||
// it, so the service auto-restarts). MANUAL persists NOTHING: it
|
||||
// writes only the drop-in that repoints the credential to a tmpfs
|
||||
// path the operator populates by hand at each start.
|
||||
match restart_mode {
|
||||
core_setup::RestartMode::Unattended => {
|
||||
write_secret_file(&paths.wallet_password_file, password)?;
|
||||
}
|
||||
core_setup::RestartMode::Manual => {
|
||||
let runtime_pw = paths.runtime_password_file.display().to_string();
|
||||
write_dropin_file(
|
||||
&paths.dropin_file,
|
||||
&core_setup::render_manual_dropin(&runtime_pw),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
apply_restart_persistence(&paths, restart_mode, password)?;
|
||||
w
|
||||
}
|
||||
};
|
||||
@@ -431,6 +410,7 @@ pub fn run<R: BufRead, W: Write>(
|
||||
|
||||
// Render + write the env file (0640) the service loads.
|
||||
let params = SetupParams {
|
||||
bind,
|
||||
public_url: public_url.clone(),
|
||||
webhook_url: webhook_url.clone(),
|
||||
node_url,
|
||||
@@ -663,6 +643,153 @@ fn require_ack<R: BufRead, W: Write>(
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the Grin node URL. An explicit `--node` override is used as-is (no
|
||||
/// prompt, for deterministic/offline runs and tests). Otherwise the operator is
|
||||
/// asked: pressing Enter probes the curated list for the first healthy node (the
|
||||
/// sensible default), or they can name their own `http(s)://` node.
|
||||
fn resolve_node<R: BufRead, W: Write>(
|
||||
input: &mut R,
|
||||
out: &mut W,
|
||||
opts: &SetupOptions,
|
||||
) -> Result<String, String> {
|
||||
if let Some(url) = &opts.node_override {
|
||||
writeln!(out, "Using Grin node {url} (override).").map_err(io_err)?;
|
||||
return Ok(url.clone());
|
||||
}
|
||||
writeln!(
|
||||
out,
|
||||
"4) Grin node for confirmations and balance (read-only). Press Enter to\n\
|
||||
\x20 auto-pick a healthy node from the curated list, or enter your own\n\
|
||||
\x20 https:// node."
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write!(out, " > ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
let answer = read_line(input)?;
|
||||
if answer.trim().is_empty() {
|
||||
writeln!(out, "Probing curated Grin nodes for a healthy one...").map_err(io_err)?;
|
||||
match core_setup::select_node(core_setup::CURATED_NODES, gp_wallet::probe_node) {
|
||||
Some(url) => {
|
||||
writeln!(out, "Using Grin node {url}.").map_err(io_err)?;
|
||||
Ok(url)
|
||||
}
|
||||
None => Err(
|
||||
"none of the curated Grin nodes answered. Check this host's \
|
||||
network, or pass --node <url> to use a node you trust."
|
||||
.into(),
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let url = core_setup::normalize_url(&answer)?;
|
||||
writeln!(out, "Using Grin node {url}.").map_err(io_err)?;
|
||||
Ok(url)
|
||||
}
|
||||
}
|
||||
|
||||
/// Prompt for the restart mode, offering both options and defaulting to `default`
|
||||
/// (UNATTENDED for a fresh setup; the till's current mode on reconfigure, so
|
||||
/// pressing Enter preserves it). `number`, when set, prefixes the question so it
|
||||
/// lines up with the numbered fresh-setup flow.
|
||||
fn prompt_restart_mode<R: BufRead, W: Write>(
|
||||
input: &mut R,
|
||||
out: &mut W,
|
||||
number: Option<u8>,
|
||||
default: core_setup::RestartMode,
|
||||
) -> Result<core_setup::RestartMode, String> {
|
||||
let (u_tag, m_tag, hint) = match default {
|
||||
core_setup::RestartMode::Unattended => (" (default)", "", "[1]"),
|
||||
core_setup::RestartMode::Manual => ("", " (default)", "[2]"),
|
||||
};
|
||||
let lead = match number {
|
||||
Some(n) => format!("{n}) "),
|
||||
None => String::new(),
|
||||
};
|
||||
writeln!(
|
||||
out,
|
||||
"{lead}After a reboot, how should the till restart?\n\
|
||||
\x20 [1] Unattended{u_tag}: your password is sealed to THIS host, so the\n\
|
||||
\x20 service auto-restarts. Honest trade-off: whoever fully controls\n\
|
||||
\x20 this machine controls the wallet; keep it a small hot float and\n\
|
||||
\x20 sweep to your own wallet regularly.\n\
|
||||
\x20 [2] Manual{m_tag}: the password lives only in your head; you re-enter\n\
|
||||
\x20 it after every restart. Maximum protection against disk/machine theft."
|
||||
)
|
||||
.map_err(io_err)?;
|
||||
write!(out, " > {hint} ").map_err(io_err)?;
|
||||
out.flush().map_err(io_err)?;
|
||||
Ok(core_setup::parse_restart_mode_default(
|
||||
&read_line(input)?,
|
||||
default,
|
||||
))
|
||||
}
|
||||
|
||||
/// Persist the wallet password per restart mode, idempotently, so this also
|
||||
/// handles a reconfigure that SWITCHES modes. UNATTENDED seals the chosen
|
||||
/// password to this host as a 0400 credential file (systemd LoadCredential reads
|
||||
/// it, so the service auto-restarts) and clears any stale manual drop-in. MANUAL
|
||||
/// keeps NOTHING sensitive on disk: it writes only the drop-in that repoints the
|
||||
/// credential to a tmpfs path the operator populates by hand, and removes the
|
||||
/// persistent password file if one was there.
|
||||
fn apply_restart_persistence(
|
||||
paths: &Paths,
|
||||
mode: core_setup::RestartMode,
|
||||
password: &str,
|
||||
) -> Result<(), String> {
|
||||
match mode {
|
||||
core_setup::RestartMode::Unattended => {
|
||||
write_secret_file(&paths.wallet_password_file, password)?;
|
||||
remove_if_exists(&paths.dropin_file)?;
|
||||
}
|
||||
core_setup::RestartMode::Manual => {
|
||||
let runtime_pw = paths.runtime_password_file.display().to_string();
|
||||
write_dropin_file(
|
||||
&paths.dropin_file,
|
||||
&core_setup::render_manual_dropin(&runtime_pw),
|
||||
)?;
|
||||
remove_if_exists(&paths.wallet_password_file)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove a file if it exists (ignoring a not-found race); used when a
|
||||
/// reconfigure switches restart mode and the other mode's artifact must go.
|
||||
fn remove_if_exists(path: &Path) -> Result<(), String> {
|
||||
match fs::remove_file(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||
Err(e) => Err(format!("cannot remove {}: {e}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a bare `gp-server` invocation (no subcommand) should offer the
|
||||
/// first-run wizard. True only for a genuinely fresh, unconfigured install: the
|
||||
/// ingest service is on (the default) but there is no way to open a wallet yet
|
||||
/// (no seed at rest, no mnemonic and no wallet password in the environment) and
|
||||
/// no env file has been written. Every configured deploy fails at least one of
|
||||
/// these, so the headless boot path is taken exactly as before.
|
||||
pub fn needs_first_run_setup(cfg: &Config, env_file_exists: bool) -> bool {
|
||||
cfg.ingest
|
||||
&& cfg.mnemonic.is_none()
|
||||
&& cfg.wallet_password.is_none()
|
||||
&& !GpWallet::seed_path(Path::new(&cfg.data_dir)).exists()
|
||||
&& !env_file_exists
|
||||
}
|
||||
|
||||
/// Load a `Config` from the env file the wizard just wrote, so a first run can
|
||||
/// boot in-process from it (parsing the file directly rather than round-tripping
|
||||
/// through a systemd `EnvironmentFile` reload). The `*_FILE` secret indirection
|
||||
/// is honored by `Config::from_lookup`, so an UNATTENDED run resolves the 0400
|
||||
/// wallet-password file; a MANUAL run has no on-disk password and so returns an
|
||||
/// error here (the caller then prints how to start the service by hand).
|
||||
pub fn config_from_written_env(path: &Path) -> Result<Config, String> {
|
||||
let contents =
|
||||
fs::read_to_string(path).map_err(|e| format!("cannot read {}: {e}", path.display()))?;
|
||||
let map: std::collections::HashMap<String, String> =
|
||||
core_setup::parse_env_file(&contents).into_iter().collect();
|
||||
Config::from_lookup(&|key| map.get(key).cloned())
|
||||
}
|
||||
|
||||
/// Create a directory (and parents) with the given mode.
|
||||
fn ensure_dir(path: &Path, mode: u32) -> Result<(), String> {
|
||||
fs::create_dir_all(path).map_err(|e| format!("cannot create {}: {e}", path.display()))?;
|
||||
@@ -670,8 +797,11 @@ fn ensure_dir(path: &Path, mode: u32) -> Result<(), String> {
|
||||
.map_err(|e| format!("cannot chmod {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// Write a secret to `path` with mode 0400 (create-or-truncate, then chmod).
|
||||
/// Write a secret to `path` with mode 0400. Any existing file is removed first:
|
||||
/// a prior 0400 secret has no write bit even for its owner, so an in-place
|
||||
/// rewrite (as on a reconfigure) would be denied.
|
||||
fn write_secret_file(path: &Path, contents: &str) -> Result<(), String> {
|
||||
remove_if_exists(path)?;
|
||||
fs::write(path, format!("{contents}\n"))
|
||||
.map_err(|e| format!("cannot write {}: {e}", path.display()))?;
|
||||
fs::set_permissions(path, fs::Permissions::from_mode(0o400))
|
||||
@@ -751,10 +881,11 @@ mod tests {
|
||||
fn full_run_writes_files_with_modes_and_paste_block() {
|
||||
let dir = TempDir::new("full");
|
||||
let opts = opts_with(&dir.0);
|
||||
// Answers: public url, shop url, chosen password (twice), paste the dev
|
||||
// seed, acknowledge, restart mode (Enter = unattended), currencies, rail.
|
||||
// Answers: public url, shop url, bind (Enter = default; node is skipped
|
||||
// via the override), chosen password (twice), paste the dev seed,
|
||||
// acknowledge, restart mode (Enter = unattended), currencies, rail.
|
||||
let answers = format!(
|
||||
"https://pay.myshop.com\nhttps://myshop.com\n\
|
||||
"https://pay.myshop.com\nhttps://myshop.com\n\n\
|
||||
walletpass1\nwalletpass1\n{DEV_SEED_24}\nyes\n\nusd,eur\nn\n"
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
@@ -766,6 +897,7 @@ mod tests {
|
||||
let env = fs::read_to_string(&paths.env_file).unwrap();
|
||||
let mode = fs::metadata(&paths.env_file).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o640);
|
||||
assert!(env.contains("GP_BIND=127.0.0.1:8080")); // bind default written
|
||||
assert!(env.contains("GP_PUBLIC_URL=https://pay.myshop.com"));
|
||||
assert!(env.contains("GP_WEBHOOK_URL=https://myshop.com/wp-json/goblinpay/v1/webhook"));
|
||||
assert!(env.contains("GP_RELAY_MODE=external"));
|
||||
@@ -809,9 +941,9 @@ mod tests {
|
||||
fn fresh_seed_default_shows_the_seed_once() {
|
||||
let dir = TempDir::new("fresh");
|
||||
let opts = opts_with(&dir.0);
|
||||
// password (twice), empty seed line -> generate fresh, acknowledge,
|
||||
// Enter for restart (unattended) + currencies + rail.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\npw\npw\n\nyes\n\n\n\n";
|
||||
// bind (Enter), password (twice), empty seed line -> generate fresh,
|
||||
// acknowledge, Enter for restart (unattended) + currencies + rail.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\npw\npw\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let transcript = String::from_utf8(out).unwrap();
|
||||
@@ -831,8 +963,9 @@ mod tests {
|
||||
fn manual_mode_writes_dropin_and_no_password_file() {
|
||||
let dir = TempDir::new("manual");
|
||||
let opts = opts_with(&dir.0);
|
||||
// Fresh seed, acknowledge, restart mode 2 (manual), defaults after.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\npw\npw\n\nyes\n2\n\n\n";
|
||||
// bind (Enter), fresh seed, acknowledge, restart mode 2 (manual),
|
||||
// defaults after.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\npw\npw\n\nyes\n2\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let transcript = String::from_utf8(out).unwrap();
|
||||
@@ -866,8 +999,9 @@ mod tests {
|
||||
fn password_mismatch_reprompts_then_succeeds() {
|
||||
let dir = TempDir::new("mismatch");
|
||||
let opts = opts_with(&dir.0);
|
||||
// First pair mismatches (a/b), then a matching pair (c/c) succeeds.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\na\nb\nc\nc\n\nyes\n\n\n\n";
|
||||
// bind (Enter), first password pair mismatches (a/b), then a matching
|
||||
// pair (c/c) succeeds.
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\na\nb\nc\nc\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let transcript = String::from_utf8(out).unwrap();
|
||||
@@ -886,7 +1020,7 @@ mod tests {
|
||||
let dir = TempDir::new("clobber");
|
||||
let opts = opts_with(&dir.0);
|
||||
let answers = format!(
|
||||
"https://pay.myshop.com\nhttps://myshop.com\n\
|
||||
"https://pay.myshop.com\nhttps://myshop.com\n\n\
|
||||
pw\npw\n{DEV_SEED_24}\nyes\n\nusd\nn\n"
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
@@ -901,17 +1035,143 @@ mod tests {
|
||||
let token_before = fs::read_to_string(&paths.env_file).unwrap();
|
||||
let mut recfg = opts_with(&dir.0);
|
||||
recfg.reconfigure = true;
|
||||
// Existing wallet => the seed question is skipped; feed 4 answers.
|
||||
let recfg_answers = "https://pay.myshop.com\nhttps://myshop.com\ngbp\nn\n";
|
||||
// Existing wallet => the seed/password questions are skipped, but bind,
|
||||
// restart mode (Enter = preserve current), currencies, and rail are asked.
|
||||
let recfg_answers = "https://pay.myshop.com\nhttps://myshop.com\n\n\ngbp\nn\n";
|
||||
let mut out3 = Vec::new();
|
||||
run(Cursor::new(recfg_answers), &mut out3, &recfg).unwrap();
|
||||
let transcript = String::from_utf8(out3).unwrap();
|
||||
assert!(transcript.contains("Existing till wallet found"));
|
||||
// Preserved the unattended mode it was set up with (Enter kept it).
|
||||
assert!(transcript.contains("Restart mode: UNATTENDED"));
|
||||
let token_after = fs::read_to_string(&paths.env_file).unwrap();
|
||||
assert!(token_after.contains("GP_RATE_CURRENCIES=gbp"));
|
||||
assert_ne!(token_before, token_after, "reconfigure regenerates config");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconfigure_can_switch_restart_mode() {
|
||||
// The wart being fixed: reconfigure hardcoded UNATTENDED, so it silently
|
||||
// rewrote (and could never change) the restart mode. Now the operator is
|
||||
// prompted with the current mode as the default, so they can switch. An
|
||||
// unattended till HAS its password on the 0400 file, so it can reopen and
|
||||
// move that password off disk into manual mode.
|
||||
let dir = TempDir::new("recfg-switch");
|
||||
let opts = opts_with(&dir.0);
|
||||
// Fresh UNATTENDED till (Enter on the restart prompt).
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\npw\npw\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let paths = Paths::resolve(Some(&dir.0));
|
||||
assert!(paths.wallet_password_file.exists());
|
||||
assert!(!paths.dropin_file.exists());
|
||||
|
||||
// Reconfigure and answer 2 (manual): it must switch, writing the drop-in
|
||||
// and taking the password off disk.
|
||||
let mut recfg = opts_with(&dir.0);
|
||||
recfg.reconfigure = true;
|
||||
let recfg_answers = "https://pay.myshop.com\nhttps://myshop.com\n\n2\ngbp\nn\n";
|
||||
let mut out2 = Vec::new();
|
||||
run(Cursor::new(recfg_answers), &mut out2, &recfg).unwrap();
|
||||
let transcript = String::from_utf8(out2).unwrap();
|
||||
assert!(transcript.contains("Restart mode: MANUAL"));
|
||||
assert!(paths.dropin_file.exists());
|
||||
assert!(
|
||||
!paths.wallet_password_file.exists(),
|
||||
"password moved off disk"
|
||||
);
|
||||
let env = fs::read_to_string(&paths.env_file).unwrap();
|
||||
assert!(env.contains("MANUAL"));
|
||||
assert!(env.contains("run/goblinpay/wallet_password"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn node_prompt_accepts_an_explicit_url() {
|
||||
let dir = TempDir::new("node");
|
||||
// No --node override, so the node question is asked; the operator names
|
||||
// their own node instead of pressing Enter (which would probe the net).
|
||||
let mut opts = opts_with(&dir.0);
|
||||
opts.node_override = None;
|
||||
let answers = "https://pay.myshop.com\nhttps://myshop.com\n\n\
|
||||
http://127.0.0.1:3413\npw\npw\n\nyes\n\n\n\n";
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
let paths = Paths::resolve(Some(&dir.0));
|
||||
let env = fs::read_to_string(&paths.env_file).unwrap();
|
||||
assert!(env.contains("GP_NODE_URL=http://127.0.0.1:3413"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn needs_first_run_setup_only_on_a_fresh_box() {
|
||||
let dir = TempDir::new("firstrun");
|
||||
let data_dir = dir.0.join("gp-data");
|
||||
let base = Config {
|
||||
data_dir: data_dir.display().to_string(),
|
||||
..Config::default()
|
||||
};
|
||||
|
||||
// Genuinely fresh: ingest on, no secrets, no seed, no env file.
|
||||
assert!(needs_first_run_setup(&base, false));
|
||||
|
||||
// An env file already present means an operator configured this box: the
|
||||
// headless boot path must be taken (wizard never triggers).
|
||||
assert!(!needs_first_run_setup(&base, true));
|
||||
|
||||
// A wallet password (or mnemonic) in the environment is a configured
|
||||
// deploy; boot headless.
|
||||
let with_pw = Config {
|
||||
wallet_password: Some(gp_core::config::Secret::new("pw".into())),
|
||||
..Config {
|
||||
data_dir: data_dir.display().to_string(),
|
||||
..Config::default()
|
||||
}
|
||||
};
|
||||
assert!(!needs_first_run_setup(&with_pw, false));
|
||||
|
||||
// Ingest disabled: HTTP-only, no wallet needed, so do not force setup.
|
||||
let no_ingest = Config {
|
||||
ingest: false,
|
||||
..Config {
|
||||
data_dir: data_dir.display().to_string(),
|
||||
..Config::default()
|
||||
}
|
||||
};
|
||||
assert!(!needs_first_run_setup(&no_ingest, false));
|
||||
|
||||
// An existing seed at rest is a configured wallet: boot headless.
|
||||
let seed_path = GpWallet::seed_path(&data_dir);
|
||||
if let Some(parent) = seed_path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(&seed_path, b"encrypted-seed").unwrap();
|
||||
assert!(!needs_first_run_setup(&base, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_from_written_env_round_trips_the_wizard_output() {
|
||||
let dir = TempDir::new("written-env");
|
||||
let opts = opts_with(&dir.0);
|
||||
let answers = format!(
|
||||
"https://pay.myshop.com\nhttps://myshop.com\n\n\
|
||||
walletpass1\nwalletpass1\n{DEV_SEED_24}\nyes\n\nusd,eur\nn\n"
|
||||
);
|
||||
let mut out = Vec::new();
|
||||
run(Cursor::new(answers), &mut out, &opts).unwrap();
|
||||
|
||||
// The env file the wizard wrote loads back into a Config the server can
|
||||
// boot from: an UNATTENDED run resolves the 0400 wallet-password file.
|
||||
let paths = Paths::resolve(Some(&dir.0));
|
||||
let cfg = config_from_written_env(&paths.env_file).unwrap();
|
||||
assert_eq!(cfg.public_url, "https://pay.myshop.com");
|
||||
assert_eq!(cfg.node_url, "http://127.0.0.1:3413");
|
||||
assert_eq!(cfg.relay_mode, gp_core::config::RelayMode::External);
|
||||
assert_eq!(cfg.rate_currencies, vec!["usd", "eur"]);
|
||||
assert!(
|
||||
cfg.wallet_password.is_some(),
|
||||
"the 0400 password file is resolved via GP_WALLET_PASSWORD_FILE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_tty_without_batch_prints_guidance_and_errs() {
|
||||
let dir = TempDir::new("tty");
|
||||
|
||||
Reference in New Issue
Block a user