security: encrypt the wallet password at rest + deprecate inline secrets

Close the remaining "secrets touch a plaintext file" gap. The unattended
restart mode now SEALS the operator-chosen wallet password to the host with
`systemd-creds encrypt` (ciphertext at rest, read via LoadCredentialEncrypted,
decrypted into a tmpfs credentials dir at each start), so no plaintext wallet
key is written to disk. When systemd-creds is unavailable it falls back to the
legacy root-owned 0400 plaintext file with a loud warning, and re-running
`setup --reconfigure` once it is available upgrades to encryption.

- wizard: seal via systemd-creds (injectable for tests), write the
  encrypted.conf drop-in, keep the manual.conf tmpfs path unchanged;
  reconfiguring a till that keeps no readable password on disk (encrypted or
  manual) now prompts for the existing password to reopen the wallet.
- startup: loud deprecation warning when a money secret (GP_MNEMONIC,
  GP_WALLET_PASSWORD, GP_NSEC) is supplied INLINE rather than via a *_FILE or
  systemd credential; the value is never logged, only the variable name.
- docs: README, .env.example, install.sh, and the systemd unit updated to the
  encrypted-at-rest default and the honest fallback/trade-off.

Tests: gp-core config + gp-core/gp-server setup cover the inline-secret warning,
the encrypted drop-in, the sealed-vs-plaintext shapes, and encrypted
reconfigure. Full workspace suite green; fmt introduces no new drift; clippy
-D warnings clean on the workspace crates.
This commit is contained in:
2ro
2026-07-06 20:17:52 -04:00
parent 90e8312a46
commit b2e11ff1fe
8 changed files with 584 additions and 72 deletions
+31 -13
View File
@@ -111,8 +111,9 @@ Everything else it does for you:
- 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`),
and in unattended mode`/etc/goblinpay/secrets/wallet_password` (mode 0400)
where its `LoadCredential` reads it;
and, in unattended mode, seals your wallet password to this host with
`systemd-creds` as an encrypted-at-rest credential (`wallet_password.cred`,
read via `LoadCredentialEncrypted`), so no plaintext key is written to disk;
- prints the webhook URL and the three values to paste into WooCommerce
(GoblinPay URL, API Token, Webhook Secret) plus the private admin token.
@@ -121,12 +122,20 @@ Everything else it does for you:
The wizard asks how the till should restart after a reboot; press Enter for the
default. Both are honest about their trade-off:
- **Unattended (default).** Your chosen password is sealed to *this host* as a
systemd credential (the 0400 file above), so the service auto-restarts with no
human in the loop. Be clear-eyed about the trade-off: whoever fully controls
this machine controls the wallet. Treat the till as a small hot wallet — hold
only a working balance and sweep to your own wallet regularly (see *Secrets and
the wallet seed*).
- **Unattended (default).** Your chosen password is sealed to *this host* and the
service auto-restarts with no human in the loop. By default it is **encrypted at
rest**: the wizard runs `systemd-creds encrypt` to write a ciphertext blob
(`/etc/goblinpay/secrets/wallet_password.cred`) that only this host (and its TPM,
when present) can decrypt, and systemd decrypts it into a tmpfs credentials
directory at each start, so no plaintext key is ever written to disk. If
`systemd-creds` is unavailable (older systemd, or no host credential key) it
falls back to a root-owned 0400 plaintext file and prints a loud warning; re-run
`gp-server setup --reconfigure` once systemd-creds is available to encrypt it.
Be clear-eyed about the trade-off: whoever fully controls the *running* machine
can still have systemd decrypt the credential, so a live-host compromise means
wallet compromise. Treat the till as a small hot wallet: hold only a working
balance and sweep to your own wallet regularly (see *Secrets and the wallet
seed*).
- **Manual.** The password lives only in your head; nothing sensitive is written
to disk. The wizard drops in `gp-server.service.d/manual.conf`, which repoints
the credential to a tmpfs path (`/run/goblinpay/wallet_password`). You supply
@@ -297,17 +306,26 @@ Deliver both secrets as files rather than plain environment variables:
`GP_NCRYPTSEC_FILE` (mode 0400 recommended). Setting both a variable and its
`_FILE` variant is an error, as is setting both `GP_NSEC` and `GP_NCRYPTSEC`.
An environment variable is visible to the whole process (and via `/proc` to the
same user and root) for the life of the service; a file is not. The shipped
`deploy/gp-server.service` reads the seed and password with systemd
`LoadCredential` (they land under `$CREDENTIALS_DIRECTORY`, pointed at by the
same user and root) for the life of the service; a file is not. **Supplying
`GP_MNEMONIC`, `GP_WALLET_PASSWORD`, or `GP_NSEC` inline is deprecated: the
server prints a loud warning at startup and the path may be removed.** The
shipped `deploy/gp-server.service` reads the seed and password with systemd
credentials (they land under `$CREDENTIALS_DIRECTORY`, pointed at by the
`_FILE` variables), and `deploy/docker-compose.yml` mounts them under
`/run/secrets`, so with either deployment nothing sensitive is in the
environment.
You choose `GP_WALLET_PASSWORD` yourself (the wizard prompts for it twice and
confirms the match; it is never auto-generated). How it reaches the service on
restart is the restart-mode choice above: sealed to the host for unattended
auto-restart, or re-entered by hand each start in manual mode.
restart is the restart-mode choice above. In **unattended** mode the wizard seals
it with `systemd-creds` into an encrypted-at-rest credential
(`wallet_password.cred`), which the unit reads via `LoadCredentialEncrypted` and
systemd decrypts into a tmpfs credentials dir at each start, so the key exists as
plaintext only in the memory of the running service, never as a plaintext file on
disk (with a 0400-plaintext fallback, plus a warning, when `systemd-creds` is
unavailable). In **manual** mode nothing is stored on disk and you re-enter it at
each start. Reconfiguring an encrypted or manual till (which keeps no readable
password on disk) prompts you for the existing password to reopen the wallet.
Treat the till as a small hot wallet. Grin receives are interactive, so the
till must hold live keys; keep the risk small by giving it a seed of its own,
+85
View File
@@ -254,8 +254,22 @@ pub struct Config {
/// onion service proxies to (`GP_GRIN1_FOREIGN_PORT`, default 3416). Only
/// used when `grin1_rail` is on.
pub grin1_foreign_port: u16,
/// Names of the money/at-rest-encryption secrets that were supplied INLINE
/// (the bare `GP_MNEMONIC` / `GP_WALLET_PASSWORD` / `GP_NSEC` env vars)
/// rather than via their `_FILE` variant or a systemd credential. An inline
/// env var is visible to the whole process and (via `/proc`) to root for the
/// life of the service, so the startup path warns loudly when this is
/// non-empty. Never holds a secret VALUE, only variable names.
#[serde(skip)]
pub inline_secret_vars: Vec<String>,
}
/// The money/at-rest-encryption secrets whose INLINE env-var form is deprecated
/// (deliver them as a `_FILE`/systemd credential instead). The bearer tokens and
/// webhook secret are deliberately excluded: owner ruling O4 keeps those in the
/// root-owned env file, so warning on them would be noise.
pub const DEPRECATED_INLINE_SECRETS: &[&str] = &["GP_MNEMONIC", "GP_WALLET_PASSWORD", "GP_NSEC"];
/// Default supported fiat currency when `GP_RATE_CURRENCIES` is unset.
pub const DEFAULT_RATE_CURRENCY: &str = "usd";
/// Default rate cache freshness (seconds).
@@ -314,6 +328,7 @@ impl Default for Config {
confirmations_required: DEFAULT_CONFIRMATIONS,
grin1_rail: false,
grin1_foreign_port: DEFAULT_GRIN1_FOREIGN_PORT,
inline_secret_vars: Vec::new(),
}
}
}
@@ -404,6 +419,16 @@ impl Config {
let nsec = secret(get, "GP_NSEC")?;
let ncryptsec = secret(get, "GP_NCRYPTSEC")?;
// Record which deprecated money secrets arrived INLINE (the bare var is
// set, not its `_FILE` variant). `secret()` already rejected setting both
// for a key, so a present bare var means the value came from the process
// environment, which the startup path warns about.
let inline_secret_vars = DEPRECATED_INLINE_SECRETS
.iter()
.filter(|k| get(k).is_some())
.map(|k| k.to_string())
.collect::<Vec<_>>();
let public_url = get("GP_PUBLIC_URL")
.map(|u| u.trim_end_matches('/').to_string())
.filter(|u| !u.is_empty())
@@ -497,11 +522,37 @@ impl Config {
confirmations_required,
grin1_rail,
grin1_foreign_port,
inline_secret_vars,
};
cfg.validate()?;
Ok(cfg)
}
/// A loud, single-line-per-secret warning when any money secret was supplied
/// inline (the bare env var) instead of via its `_FILE` variant or a systemd
/// credential. `None` when every secret was delivered as a file/credential
/// (the wizard and both shipped deployments), so a hardened deploy is silent.
/// Never contains a secret value, only the offending variable names.
pub fn inline_secret_warning(&self) -> Option<String> {
if self.inline_secret_vars.is_empty() {
return None;
}
let names = self.inline_secret_vars.join(", ");
Some(format!(
"DEPRECATED: money secret(s) supplied inline as environment variables: {names}. \
An inline env var is readable by the whole process and, via /proc, by root for \
the life of the service. Deliver them as files instead ({names_file}) or via a \
systemd credential; see the shipped gp-server.service. The inline path still \
works but is deprecated and may be removed.",
names_file = self
.inline_secret_vars
.iter()
.map(|n| format!("{n}_FILE"))
.collect::<Vec<_>>()
.join(", "),
))
}
/// The QR center logo to render: the inlined Goblin mark by default, an
/// external image when the operator sets a custom URL, or none.
pub fn qr_logo(&self) -> crate::qr::Logo<'_> {
@@ -997,6 +1048,40 @@ mod tests {
assert!(load(&[("GP_RATE_STALE_MAX", "-5")]).is_err());
}
#[test]
fn inline_money_secrets_are_flagged_but_files_are_silent() {
// Inline money secrets are recorded and produce a loud, value-free warning.
let cfg = load(&[
("GP_MNEMONIC", "topsecret words"),
("GP_WALLET_PASSWORD", "hushhush"),
])
.unwrap();
assert_eq!(
cfg.inline_secret_vars,
vec!["GP_MNEMONIC".to_string(), "GP_WALLET_PASSWORD".to_string()]
);
let warn = cfg.inline_secret_warning().unwrap();
assert!(warn.contains("GP_MNEMONIC"));
assert!(warn.contains("GP_WALLET_PASSWORD"));
assert!(warn.contains("GP_MNEMONIC_FILE"));
assert!(warn.contains("DEPRECATED"));
// The warning never carries the secret VALUES.
assert!(!warn.contains("topsecret"));
assert!(!warn.contains("hushhush"));
// A file-delivered secret is NOT flagged (the hardened path is silent).
let path = std::env::temp_dir().join(format!("gp-pw-{}", std::process::id()));
std::fs::write(&path, "hushhush\n").unwrap();
let cfg = load(&[("GP_WALLET_PASSWORD_FILE", path.to_str().unwrap())]).unwrap();
assert!(cfg.inline_secret_vars.is_empty());
assert!(cfg.inline_secret_warning().is_none());
std::fs::remove_file(&path).unwrap();
// Bearer tokens/webhook secret inline are deliberately NOT flagged (O4).
let cfg = load(&[("GP_API_TOKEN", "gp_live_x"), ("GP_ADMIN_TOKEN", "gpadm_x")]).unwrap();
assert!(cfg.inline_secret_warning().is_none());
}
#[test]
fn debug_and_summary_never_leak_secrets() {
let cfg = load(&[
+125 -11
View File
@@ -183,6 +183,54 @@ pub fn parse_ack(input: &str) -> bool {
/// tmpfs, so the password is gone on reboot: a powered-off disk holds no key.
pub const RUNTIME_WALLET_PASSWORD_FILE: &str = "/run/goblinpay/wallet_password";
/// The systemd unit name, used to derive the runtime credentials directory.
pub const SYSTEMD_UNIT_NAME: &str = "gp-server.service";
/// Where the wizard writes the host-SEALED (ciphertext) wallet password in the
/// UNATTENDED-encrypted path. `systemd-creds encrypt` produces this blob, bound
/// to this host's key (and the TPM when present); it is NOT plaintext, so unlike
/// the legacy 0400 file a stolen or copied disk yields no wallet key. The unit
/// decrypts it with `LoadCredentialEncrypted` into the tmpfs credentials dir at
/// each start.
pub const ENCRYPTED_WALLET_PASSWORD_FILE: &str = "/etc/goblinpay/secrets/wallet_password.cred";
/// The runtime path systemd exposes the decrypted wallet password at for the
/// shipped unit (`$CREDENTIALS_DIRECTORY/gp_wallet_password`, i.e.
/// `%d/gp_wallet_password`). Written into the env file as `GP_WALLET_PASSWORD_FILE`
/// for the encrypted-unattended path so it matches the unit exactly: absent at
/// setup time (so an in-process first run defers to `systemctl start`), present
/// and decrypted once systemd starts the service.
pub const CREDENTIALS_WALLET_PASSWORD_PATH: &str =
"/run/credentials/gp-server.service/gp_wallet_password";
/// Render the systemd drop-in that switches the UNATTENDED unit to an ENCRYPTED
/// credential at rest: it resets the base unit's plaintext `LoadCredential` and
/// reads the host-sealed ciphertext via `LoadCredentialEncrypted`, which systemd
/// decrypts into the tmpfs credentials dir at each start. Written to
/// `gp-server.service.d/encrypted.conf` by the wizard whenever `systemd-creds`
/// could seal the password (the default, secure path). No plaintext wallet
/// password ever touches the disk in this mode.
pub fn render_encrypted_dropin(cred_source_file: &str) -> String {
format!(
"# GoblinPay UNATTENDED restart mode, ENCRYPTED at rest (the secure default),\n\
# generated by `gp-server setup`. The wallet password was sealed to THIS host\n\
# with `systemd-creds encrypt` (host key, plus the TPM when present), so the\n\
# file below is ciphertext, not plaintext: a stolen or copied disk yields no\n\
# wallet key. systemd decrypts it into the tmpfs credentials directory at each\n\
# start, so the service still auto-restarts with no human.\n\
#\n\
# Honest trade-off (unchanged): whoever fully controls this RUNNING host can\n\
# still have systemd decrypt the credential, so a live-machine compromise means\n\
# wallet compromise. Keep the till a small hot float and sweep regularly.\n\
[Service]\n\
# Reset the base unit's plaintext credential, then read the sealed one.\n\
LoadCredential=\n\
LoadCredentialEncrypted=\n\
LoadCredentialEncrypted=gp_wallet_password:{src}\n",
src = cred_source_file
)
}
/// Render the systemd drop-in that switches the unit to MANUAL restart mode: it
/// repoints `LoadCredential` from the persistent 0400 file to the tmpfs path the
/// operator populates by hand at each start, and documents exactly how. Written
@@ -365,6 +413,12 @@ pub struct SetupParams {
pub wallet_password_file: String,
/// How the till restarts (operator's choice; default UNATTENDED).
pub restart_mode: RestartMode,
/// In UNATTENDED mode, whether the password was sealed with `systemd-creds`
/// (ciphertext at rest, the secure default) or fell back to the legacy 0400
/// plaintext file (when `systemd-creds` was unavailable). Ignored in MANUAL
/// mode. Only changes the rendered narrative + which credential the unit
/// reads; the password value is never inlined either way.
pub unattended_encrypted: bool,
}
impl SetupParams {
@@ -415,14 +469,35 @@ impl SetupParams {
// Wallet password: operator-chosen (grin-wallet-faithful), never
// inlined here. How it reaches the service depends on the restart mode.
match self.restart_mode {
RestartMode::Unattended => {
s.push_str("# --- wallet password: restart mode UNATTENDED (host-sealed) ---\n");
RestartMode::Unattended if self.unattended_encrypted => {
s.push_str(
"# You chose this password; it is sealed to THIS host as a systemd\n\
# credential (the 0400 file below), so the service auto-restarts with\n\
# no human. Honest trade-off: a full-machine compromise means wallet\n\
# compromise. Mitigate by keeping the till a small hot float and\n\
# sweeping to your own wallet regularly.\n",
"# --- wallet password: restart mode UNATTENDED (ENCRYPTED at rest) ---\n",
);
s.push_str(
"# You chose this password; it was sealed to THIS host with\n\
# `systemd-creds encrypt` (see the gp-server.service.d/encrypted.conf\n\
# drop-in). The file on disk is CIPHERTEXT, not plaintext, so a stolen\n\
# or copied disk yields no wallet key; systemd decrypts it into a tmpfs\n\
# credentials dir at each start, so the service still auto-restarts.\n\
# Honest trade-off: a compromise of the RUNNING host still means wallet\n\
# compromise (systemd can decrypt it there), so keep the till a small hot\n\
# float and sweep to your own wallet regularly. The GP_WALLET_PASSWORD_FILE\n\
# below is the runtime (decrypted) credential path; the systemd unit sets\n\
# it too.\n",
);
}
RestartMode::Unattended => {
s.push_str(
"# --- wallet password: restart mode UNATTENDED (plaintext fallback) ---\n",
);
s.push_str(
"# WARNING: `systemd-creds` was not available at setup, so the chosen\n\
# password fell back to a root-owned 0400 PLAINTEXT file (below). It is\n\
# off the world-readable env file, but it is still plaintext at rest.\n\
# For encryption at rest, install systemd >= 250 with a host credential\n\
# key and re-run `gp-server setup --reconfigure`. Honest trade-off: a\n\
# full-machine compromise means wallet compromise; keep the till a small\n\
# hot float and sweep to your own wallet regularly.\n",
);
}
RestartMode::Manual => {
@@ -629,6 +704,26 @@ mod tests {
assert!(d.contains("[Service]"));
}
#[test]
fn encrypted_dropin_reads_the_sealed_credential() {
let d = render_encrypted_dropin(ENCRYPTED_WALLET_PASSWORD_FILE);
// Resets the base unit's plaintext credential, then reads the sealed one.
assert!(d.contains("LoadCredential=\n"));
assert!(d.contains("LoadCredentialEncrypted=\n"));
assert!(d.contains(&format!(
"LoadCredentialEncrypted=gp_wallet_password:{ENCRYPTED_WALLET_PASSWORD_FILE}"
)));
// Documents the ciphertext-at-rest rationale and the running-host caveat.
assert!(d.to_lowercase().contains("ciphertext"));
assert!(d.contains("systemd-creds"));
assert!(d.contains("[Service]"));
// The runtime credential path matches the unit's %d/gp_wallet_password.
assert_eq!(
CREDENTIALS_WALLET_PASSWORD_PATH,
"/run/credentials/gp-server.service/gp_wallet_password"
);
}
#[test]
fn normalize_url_trims_and_requires_scheme() {
assert_eq!(
@@ -721,8 +816,9 @@ mod tests {
webhook_secret: "whsec_abcdef0123456789abcdef0123456789".into(),
data_dir: "/var/lib/goblinpay/gp-data".into(),
db_path: "/var/lib/goblinpay/goblinpay.db".into(),
wallet_password_file: "/etc/goblinpay/secrets/wallet_password".into(),
wallet_password_file: CREDENTIALS_WALLET_PASSWORD_PATH.into(),
restart_mode: RestartMode::Unattended,
unattended_encrypted: true,
}
}
@@ -741,14 +837,32 @@ mod tests {
assert!(env.contains("GP_API_TOKEN=gp_live_"));
assert!(env.contains("GP_ADMIN_TOKEN=gpadm_"));
// Password by file reference, never inlined; operator-chosen, not
// auto-generated (no such wording anywhere).
assert!(env.contains("GP_WALLET_PASSWORD_FILE=/etc/goblinpay/secrets/wallet_password"));
// auto-generated (no such wording anywhere). The encrypted-unattended
// default points GP_WALLET_PASSWORD_FILE at the runtime (decrypted)
// credential path, and never at a plaintext file.
assert!(env.contains(&format!(
"GP_WALLET_PASSWORD_FILE={CREDENTIALS_WALLET_PASSWORD_PATH}"
)));
assert!(!env.contains("GP_WALLET_PASSWORD="));
assert!(!env.to_lowercase().contains("auto-generat"));
// Unattended mode is disclosed honestly (host-sealed + the trade-off).
// Unattended-encrypted mode is disclosed honestly (ciphertext at rest,
// sealed to this host, plus the running-host trade-off).
assert!(env.contains("UNATTENDED"));
assert!(env.contains("ENCRYPTED at rest"));
assert!(env.to_lowercase().contains("ciphertext"));
assert!(env.to_lowercase().contains("host"));
assert!(env.to_lowercase().contains("compromise"));
// The plaintext fallback (systemd-creds unavailable) is disclosed just
// as honestly, and still never inlines the password.
let mut fb = sample_params();
fb.unattended_encrypted = false;
fb.wallet_password_file = "/etc/goblinpay/secrets/wallet_password".into();
let env_fb = fb.render_env();
assert!(env_fb.contains("plaintext fallback"));
assert!(env_fb.to_lowercase().contains("still plaintext at rest"));
assert!(env_fb.contains("GP_WALLET_PASSWORD_FILE=/etc/goblinpay/secrets/wallet_password"));
assert!(!env_fb.contains("GP_WALLET_PASSWORD="));
// The Grin seed is NEVER in the env (O2).
assert!(!env.contains("GP_MNEMONIC"));
// grin1 rail off -> commented out, not armed.
+8
View File
@@ -245,6 +245,7 @@ fn run_setup(args: &[String]) -> i32 {
node_override: None,
force_run: false,
stdin_is_tty: std::io::stdin().is_terminal(),
seal: None,
};
let mut i = 0;
while i < args.len() {
@@ -323,6 +324,7 @@ fn first_run_or_boot(cfg: Config) -> Config {
node_override: None,
force_run: false,
stdin_is_tty: true,
seal: None,
};
let stdin = std::io::stdin();
let mut stdout = std::io::stdout();
@@ -373,6 +375,12 @@ async fn main() -> io::Result<()> {
// 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);
// Loud deprecation warning if a money secret arrived inline (the bare env
// var) rather than as a file/systemd credential. The wizard and both shipped
// deployments use the file path, so a hardened deploy is silent here.
if let Some(warning) = cfg.inline_secret_warning() {
eprintln!("warning: {warning}");
}
println!(
"gp-server {} starting: {}",
env!("CARGO_PKG_VERSION"),
+282 -30
View File
@@ -33,6 +33,16 @@ use gp_core::setup as core_setup;
use gp_core::setup::SetupParams;
use gp_wallet::GpWallet;
/// Seals the wallet password to this host as an encrypted credential at rest.
/// Given the plaintext password and the destination `.cred` path, returns
/// `Ok(true)` when it wrote a ciphertext blob (the secure path), `Ok(false)`
/// when sealing is unavailable on this host (the caller then falls back to the
/// legacy 0400 plaintext file with a loud warning), or `Err` on an unexpected
/// failure. The real implementation shells out to `systemd-creds encrypt`; tests
/// inject a deterministic fake so neither the encrypted nor the fallback path
/// depends on the host's systemd.
pub type Sealer = dyn Fn(&str, &Path) -> Result<bool, String>;
/// Options parsed from `gp-server setup [flags]` in `main.rs`.
pub struct SetupOptions {
/// `--reconfigure`: proceed even if a wallet/env already exists.
@@ -49,6 +59,47 @@ pub struct SetupOptions {
/// TTY and `force_run` is false, the wizard prints guidance and exits
/// rather than hanging on a prompt.
pub stdin_is_tty: bool,
/// How the unattended wallet password is sealed at rest. `None` uses the real
/// `systemd-creds` sealer; tests inject a deterministic fake.
pub seal: Option<Box<Sealer>>,
}
/// Seal `password` to this host with `systemd-creds encrypt`, writing a ciphertext
/// blob to `dest`. Returns `Ok(false)` (fall back to the plaintext file) when
/// `systemd-creds` is missing or cannot access a host credential key (e.g. an
/// older systemd, or a non-root dry run), so the wizard degrades gracefully
/// instead of failing. The plaintext password is passed on stdin, never as an
/// argv arg (which would be visible in the process table).
fn systemd_creds_seal(password: &str, dest: &Path) -> Result<bool, String> {
use std::io::Write as _;
use std::process::{Command, Stdio};
let mut child = match Command::new("systemd-creds")
.arg("encrypt")
.arg("--name=gp_wallet_password")
.arg("-")
.arg(dest)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
{
Ok(c) => c,
// No systemd-creds on this host: fall back to the plaintext file.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
Err(e) => return Err(format!("could not run systemd-creds: {e}")),
};
if let Some(mut stdin) = child.stdin.take() {
stdin
.write_all(password.as_bytes())
.map_err(|e| format!("could not pass the password to systemd-creds: {e}"))?;
}
let status = child
.wait()
.map_err(|e| format!("systemd-creds did not complete: {e}"))?;
// A non-zero exit (commonly: no host credential key without root) is a
// graceful fallback, not a hard error.
Ok(status.success())
}
/// What the wizard does about the wallet seed on this run.
@@ -67,11 +118,21 @@ enum SeedPlan {
struct Paths {
env_file: PathBuf,
secrets_dir: PathBuf,
/// The legacy PLAINTEXT 0400 wallet-password file (unattended fallback when
/// `systemd-creds` is unavailable).
wallet_password_file: PathBuf,
/// The host-SEALED (ciphertext) wallet-password credential
/// (`wallet_password.cred`), written in the encrypted-unattended default.
encrypted_cred_file: PathBuf,
data_dir: PathBuf,
db_path: PathBuf,
/// The MANUAL-mode systemd drop-in (`gp-server.service.d/manual.conf`).
/// The MANUAL-mode systemd drop-in (`gp-server.service.d/manual.conf`): its
/// presence is also how a reconfigure detects the till is in manual mode.
dropin_file: PathBuf,
/// The encrypted-unattended systemd drop-in
/// (`gp-server.service.d/encrypted.conf`), switching the unit to
/// `LoadCredentialEncrypted`.
encrypted_dropin_file: PathBuf,
/// The tmpfs credential the MANUAL mode reads the password from at start.
runtime_password_file: PathBuf,
}
@@ -83,13 +144,16 @@ impl Paths {
None => PathBuf::from(abs),
};
let secrets_dir = join("etc/goblinpay/secrets");
let dropin_dir = "etc/systemd/system/gp-server.service.d";
Paths {
env_file: join("etc/goblinpay.env"),
wallet_password_file: secrets_dir.join("wallet_password"),
encrypted_cred_file: secrets_dir.join("wallet_password.cred"),
secrets_dir,
data_dir: join("var/lib/goblinpay/gp-data"),
db_path: join("var/lib/goblinpay/goblinpay.db"),
dropin_file: join("etc/systemd/system/gp-server.service.d/manual.conf"),
dropin_file: join(&format!("{dropin_dir}/manual.conf")),
encrypted_dropin_file: join(&format!("{dropin_dir}/encrypted.conf")),
runtime_password_file: join(core_setup::RUNTIME_WALLET_PASSWORD_FILE),
}
}
@@ -354,20 +418,31 @@ pub fn run<R: BufRead, W: Write>(
// the service environment (owner ruling O2). Reopening an existing wallet
// reuses the password already on file, so a --reconfigure never re-encrypts
// (which would need the old password) or touches the seed.
// The at-rest password sealer: the real systemd-creds one in production, a
// deterministic fake when tests inject it.
let default_seal: Box<Sealer> = Box::new(systemd_creds_seal);
let seal: &Sealer = opts.seal.as_deref().unwrap_or(default_seal.as_ref());
ensure_dir(&paths.data_dir, 0o700)?;
ensure_dir(&paths.secrets_dir, 0o700)?;
let wallet = match &seed_plan {
let (wallet, unattended_encrypted) = match &seed_plan {
SeedPlan::Existing => {
let password = fs::read_to_string(&paths.wallet_password_file)
.map_err(|e| {
format!(
"existing wallet found but its password file {} is unreadable ({e}); \
cannot reopen. This looks like a partial install, not a reconfigure.",
paths.wallet_password_file.display()
// Reopen the existing wallet. Its password may be on the legacy 0400
// plaintext file (plaintext-unattended tills); the secure encrypted
// and manual shapes keep no readable password on disk, so prompt for
// it. A wrong password simply fails the reopen below.
let password = match fs::read_to_string(&paths.wallet_password_file) {
Ok(s) => s.trim_end_matches(['\n', '\r']).to_string(),
Err(_) => {
writeln!(
out,
"This till keeps no readable password on disk. Enter its wallet\n\
password to reopen the wallet and reconfigure it."
)
})?
.trim_end_matches(['\n', '\r'])
.to_string();
.map_err(io_err)?;
prompt_existing_password(&mut input, out, hidden)?
}
};
writeln!(out, "Reopening the existing till wallet...").map_err(io_err)?;
let w =
GpWallet::create_at(&paths.data_dir, None, &password, &node_url, Chain::Mainnet)
@@ -375,8 +450,8 @@ pub fn run<R: BufRead, W: Write>(
// 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
let enc = apply_restart_persistence(&paths, restart_mode, &password, seal, out)?;
(w, enc)
}
SeedPlan::Fresh(m) | SeedPlan::Pasted(m) => {
let password = chosen_password
@@ -391,8 +466,8 @@ pub fn run<R: BufRead, W: Write>(
Chain::Mainnet,
)
.map_err(|e| format!("wallet creation failed: {e}"))?;
apply_restart_persistence(&paths, restart_mode, password)?;
w
let enc = apply_restart_persistence(&paths, restart_mode, password, seal, out)?;
(w, enc)
}
};
match wallet.slatepack_address() {
@@ -404,6 +479,13 @@ pub fn run<R: BufRead, W: Write>(
// restart mode: the persistent 0400 file (unattended) or the tmpfs
// credential the operator populates at each start (manual).
let wallet_password_file = match restart_mode {
// Encrypted default: the env points at the runtime (decrypted) credential
// path systemd populates, matching the unit's %d/gp_wallet_password. It is
// absent at setup time (so an in-process first run defers to systemctl)
// and present once the service starts.
core_setup::RestartMode::Unattended if unattended_encrypted => {
core_setup::CREDENTIALS_WALLET_PASSWORD_PATH.to_string()
}
core_setup::RestartMode::Unattended => paths.wallet_password_file.display().to_string(),
core_setup::RestartMode::Manual => paths.runtime_password_file.display().to_string(),
};
@@ -427,6 +509,7 @@ pub fn run<R: BufRead, W: Write>(
db_path: paths.db_path.display().to_string(),
wallet_password_file,
restart_mode,
unattended_encrypted,
};
write_env_file(&paths.env_file, &params.render_env())?;
@@ -454,10 +537,22 @@ pub fn run<R: BufRead, W: Write>(
// Final screen: how to start (per restart mode), and the WooCommerce block.
writeln!(out, "\nGoblinPay is set up.").map_err(io_err)?;
match restart_mode {
core_setup::RestartMode::Unattended if unattended_encrypted => writeln!(
out,
"Restart mode: UNATTENDED (encrypted at rest). Start it:\n\
\x20 sudo systemctl daemon-reload && sudo systemctl start gp-server\n\
The password is sealed to this host with systemd-creds (ciphertext on\n\
disk); systemd decrypts it at each start, so the service auto-restarts.\n\
(Keep the till a small hot float and sweep to your own wallet often:\n\
a compromise of the RUNNING host still means wallet compromise.)\n"
)
.map_err(io_err)?,
core_setup::RestartMode::Unattended => writeln!(
out,
"Restart mode: UNATTENDED. Start it: sudo systemctl start gp-server\n\
It will auto-restart after reboots using the host-sealed password.\n\
"Restart mode: UNATTENDED (plaintext fallback). Start it:\n\
\x20 sudo systemctl start gp-server\n\
systemd-creds was unavailable, so the password is a root-owned 0400\n\
PLAINTEXT file. It will auto-restart, but the key is plaintext at rest.\n\
(Keep the till a small hot float and sweep to your own wallet often:\n\
a full-machine compromise means wallet compromise.)\n"
)
@@ -508,9 +603,17 @@ pub fn run<R: BufRead, W: Write>(
}
match restart_mode {
core_setup::RestartMode::Unattended if unattended_encrypted => writeln!(
out,
"\nWrote {} and {} (sealed credential, ciphertext) plus the\n\
encrypted.conf drop-in. No plaintext wallet password is stored on disk.",
paths.env_file.display(),
paths.encrypted_cred_file.display()
)
.map_err(io_err)?,
core_setup::RestartMode::Unattended => writeln!(
out,
"\nWrote {} and {} (password file, mode 0400).",
"\nWrote {} and {} (PLAINTEXT password file, mode 0400).",
paths.env_file.display(),
paths.wallet_password_file.display()
)
@@ -611,6 +714,26 @@ fn prompt_wallet_password<R: BufRead, W: Write>(
}
}
/// Prompt once for an EXISTING wallet password (reconfiguring a till that keeps
/// no readable password on disk: the encrypted or manual shapes). No confirm
/// entry: correctness is checked by the wallet reopen that follows, which fails
/// on a wrong password. Fails cleanly at end of input (batch/scripted runs).
fn prompt_existing_password<R: BufRead, W: Write>(
input: &mut R,
out: &mut W,
hidden: bool,
) -> Result<String, String> {
loop {
write!(out, " wallet password > ").map_err(io_err)?;
out.flush().map_err(io_err)?;
match read_secret_line(input, out, hidden)? {
None => return Err("no wallet password provided (end of input)".into()),
Some(s) if !s.is_empty() => return Ok(s),
Some(_) => writeln!(out, " ! password must not be empty\n").map_err(io_err)?,
}
}
}
/// Require an explicit yes/y acknowledgement before proceeding (the operator
/// confirming they wrote the seed down, like grin-wallet init). Re-prompts on
/// any other non-empty answer; fails cleanly at end of input.
@@ -724,21 +847,58 @@ fn prompt_restart_mode<R: BufRead, W: Write>(
}
/// 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(
/// handles a reconfigure that SWITCHES modes. Returns whether an UNATTENDED
/// password was ENCRYPTED at rest (the secure default) versus the plaintext
/// fallback; the flag is meaningless (false) for MANUAL.
///
/// UNATTENDED first tries to SEAL the chosen password to this host with
/// `systemd-creds` (ciphertext at rest, read by `LoadCredentialEncrypted`); no
/// plaintext wallet password ever touches the disk. If sealing is unavailable it
/// falls back to the legacy root-owned 0400 PLAINTEXT file (read by the base
/// unit's `LoadCredential`) with a loud warning. 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. Every stale artifact of the other shapes
/// is cleared so a reconfigure that switches modes leaves exactly one in place.
fn apply_restart_persistence<W: Write>(
paths: &Paths,
mode: core_setup::RestartMode,
password: &str,
) -> Result<(), String> {
seal: &Sealer,
out: &mut W,
) -> Result<bool, String> {
match mode {
core_setup::RestartMode::Unattended => {
write_secret_file(&paths.wallet_password_file, password)?;
remove_if_exists(&paths.dropin_file)?;
ensure_dir(&paths.secrets_dir, 0o700)?;
if seal(password, paths.encrypted_cred_file.as_path())? {
// Encrypted at rest (secure default): write the drop-in that reads
// the sealed credential, and clear the plaintext + manual shapes.
let src = paths.encrypted_cred_file.display().to_string();
write_dropin_file(
&paths.encrypted_dropin_file,
&core_setup::render_encrypted_dropin(&src),
)?;
remove_if_exists(&paths.wallet_password_file)?;
remove_if_exists(&paths.dropin_file)?;
Ok(true)
} else {
// Fallback: systemd-creds unavailable. Root-owned 0400 plaintext
// file, matching the base unit's LoadCredential. Warn loudly.
writeln!(
out,
"WARNING: systemd-creds is unavailable on this host, so the wallet\n\
password could not be encrypted at rest. Falling back to a root-owned\n\
0400 PLAINTEXT file ({}). It is off the world-readable env file, but it\n\
is still plaintext on disk. For encryption at rest, install systemd >= 250\n\
with a host credential key and re-run: sudo gp-server setup --reconfigure",
paths.wallet_password_file.display()
)
.map_err(io_err)?;
write_secret_file(&paths.wallet_password_file, password)?;
remove_if_exists(&paths.encrypted_cred_file)?;
remove_if_exists(&paths.encrypted_dropin_file)?;
remove_if_exists(&paths.dropin_file)?;
Ok(false)
}
}
core_setup::RestartMode::Manual => {
let runtime_pw = paths.runtime_password_file.display().to_string();
@@ -747,9 +907,11 @@ fn apply_restart_persistence(
&core_setup::render_manual_dropin(&runtime_pw),
)?;
remove_if_exists(&paths.wallet_password_file)?;
remove_if_exists(&paths.encrypted_cred_file)?;
remove_if_exists(&paths.encrypted_dropin_file)?;
Ok(false)
}
}
Ok(())
}
/// Remove a file if it exists (ignoring a not-found race); used when a
@@ -870,9 +1032,23 @@ mod tests {
node_override: Some("http://127.0.0.1:3413".into()),
force_run: true,
stdin_is_tty: false,
// Force the PLAINTEXT fallback deterministically (independent of the
// host's systemd), so these tests exercise the 0400-file shape. The
// encrypted path is covered by its own test with a fake sealer.
seal: Some(Box::new(|_, _| Ok(false))),
}
}
/// A fake sealer that "succeeds", writing a non-secret marker to the `.cred`
/// path so the encrypted-unattended shape can be tested without real
/// systemd-creds (and without ever writing the password to that file).
fn fake_sealer_ok() -> Box<Sealer> {
Box::new(|_pw, dest| {
fs::write(dest, b"(sealed by test)\n").map_err(|e| format!("fake seal failed: {e}"))?;
Ok(true)
})
}
/// All-zero 32-byte entropy -> the well-known dev seed (24 words). Used only
/// as a test fixture; never a real till seed.
const DEV_SEED_24: &str = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art";
@@ -959,6 +1135,82 @@ mod tests {
assert!(transcript.contains("Restart mode: UNATTENDED"));
}
#[test]
fn encrypted_unattended_seals_and_writes_no_plaintext() {
let dir = TempDir::new("encrypted");
let mut opts = opts_with(&dir.0);
opts.seal = Some(fake_sealer_ok());
// bind (Enter), password (twice), fresh seed, acknowledge, restart
// (Enter = 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();
let paths = Paths::resolve(Some(&dir.0));
// The sealed ciphertext credential + the encrypted.conf drop-in exist;
// NO plaintext password file and NO manual drop-in.
assert!(paths.encrypted_cred_file.exists());
assert!(paths.encrypted_dropin_file.exists());
assert!(
!paths.wallet_password_file.exists(),
"no plaintext password on disk"
);
assert!(!paths.dropin_file.exists());
// The sealed file never contains the plaintext password.
let cred = fs::read_to_string(&paths.encrypted_cred_file).unwrap();
assert!(!cred.contains("pw") || cred.contains("(sealed"));
let dropin = fs::read_to_string(&paths.encrypted_dropin_file).unwrap();
assert!(dropin.contains("LoadCredentialEncrypted=gp_wallet_password:"));
assert!(dropin.contains("LoadCredential=\n")); // resets the plaintext one
// Env points GP_WALLET_PASSWORD_FILE at the runtime credential path and
// never inlines the password; the narrative says encrypted-at-rest.
let env = fs::read_to_string(&paths.env_file).unwrap();
assert!(env.contains(&format!(
"GP_WALLET_PASSWORD_FILE={}",
core_setup::CREDENTIALS_WALLET_PASSWORD_PATH
)));
assert!(!env.contains("GP_WALLET_PASSWORD="));
assert!(env.contains("ENCRYPTED at rest"));
assert!(GpWallet::seed_path(&paths.data_dir).exists());
assert!(transcript.contains("Restart mode: UNATTENDED (encrypted at rest)"));
}
#[test]
fn reconfigure_encrypted_till_prompts_for_the_password() {
let dir = TempDir::new("recfg-enc");
let mut opts = opts_with(&dir.0);
opts.seal = Some(fake_sealer_ok());
// Fresh encrypted till, password "pw".
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(),
"encrypted: no plaintext"
);
// Reconfigure: there is no readable password on disk, so the wizard must
// prompt for it. Supplying the right password reopens the wallet.
let mut recfg = opts_with(&dir.0);
recfg.reconfigure = true;
recfg.seal = Some(fake_sealer_ok());
// public, webhook, bind (Enter), restart (Enter = preserve), currencies
// gbp, rail n, then the EXISTING password prompt (pw) at wallet reopen.
let recfg_answers = "https://pay.myshop.com\nhttps://myshop.com\n\n\ngbp\nn\npw\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("keeps no readable password on disk"));
assert!(transcript.contains("Reopening the existing till wallet"));
let env = fs::read_to_string(&paths.env_file).unwrap();
assert!(env.contains("GP_RATE_CURRENCIES=gbp"));
assert!(env.contains("ENCRYPTED at rest"));
}
#[test]
fn manual_mode_writes_dropin_and_no_password_file() {
let dir = TempDir::new("manual");
+12 -4
View File
@@ -1,14 +1,22 @@
# GoblinPay environment. Copy to /etc/goblinpay.env (bare metal) or deploy/.env
# (docker compose), then edit. NON-SECRET config only: the Grin seed and the
# wallet password live as mode-0400 files (systemd LoadCredential / the compose
# wallet password live as separate files (a systemd credential / the compose
# ./secrets mount), never in this file.
#
# SECURITY: never put GP_MNEMONIC or GP_WALLET_PASSWORD (the seed and the key
# that decrypts it) inline in this file or the environment. An inline env var is
# readable by the whole process and, via /proc, by root for the life of the
# service; the server prints a loud deprecation warning if you do. Deliver them
# as files (GP_MNEMONIC_FILE / GP_WALLET_PASSWORD_FILE) or a systemd credential.
#
# The recommended path is `gp-server setup`, which has YOU choose the wallet
# password (entered twice, grin-wallet-faithful; never auto-generated), shows a
# fresh 24-word seed once for you to write down (or takes your recovery phrase),
# and asks how the till should restart: UNATTENDED (default; the password is
# host-sealed so the service auto-restarts) or MANUAL (nothing on disk; you
# re-enter the password after every restart). This file is the by-hand path.
# and asks how the till should restart: UNATTENDED (default) or MANUAL. In
# UNATTENDED mode the password is sealed to this host with `systemd-creds encrypt`
# (ENCRYPTED at rest, decrypted at each start) so the service auto-restarts with
# no plaintext key on disk; MANUAL keeps nothing on disk and you re-enter it after
# every restart. This file is the by-hand path.
# --- domain / URLs ---
# docker-compose serves GoblinPay on GP_DOMAIN and the bundled relay on
+32 -11
View File
@@ -14,10 +14,14 @@
# exposed read-only to the dynamic service user) rather than left world-readable.
#
# Restart mode (the wizard asks; default UNATTENDED):
# UNATTENDED the chosen password is sealed to THIS host as the 0400 file
# below, so the service auto-restarts with no human. Honest trade-off: a
# full-machine compromise means wallet compromise; keep the till a small hot
# float and sweep to your own wallet regularly.
# UNATTENDED: the chosen password is sealed to THIS host and the service
# auto-restarts with no human. By default (systemd-creds present) it is
# ENCRYPTED at rest: a `systemd-creds encrypt` ciphertext blob, decrypted
# into a tmpfs credentials dir at each start, so no plaintext key is on disk.
# If systemd-creds is unavailable it falls back to a root-owned 0400 plaintext
# file (with a loud warning). Honest trade-off: a compromise of the RUNNING
# host still means wallet compromise; keep the till a small hot float and
# sweep to your own wallet regularly.
# MANUAL — nothing is stored on disk; the operator re-enters the password after
# every restart. The wizard drops in gp-server.service.d/manual.conf, which
# repoints LoadCredential to a tmpfs path (/run/goblinpay/wallet_password)
@@ -46,13 +50,30 @@ DynamicUser=yes
# as root, so a 0640 root:root file is fine even under DynamicUser.
EnvironmentFile=/etc/goblinpay.env
# The wallet password as a credential: the source file stays root-owned 0400
# (in UNATTENDED mode the wizard writes the operator's CHOSEN password there);
# systemd exposes a copy under $CREDENTIALS_DIRECTORY (%d), readable by the
# dynamic service user. The wallet is pointed at it via the *_FILE variant. This
# overrides any GP_WALLET_PASSWORD_FILE in the env file above, so the service
# always reads the credential copy. In MANUAL mode the manual.conf drop-in resets
# this line and reads /run/goblinpay/wallet_password (tmpfs) instead.
# The wallet password as a credential. systemd exposes it under
# $CREDENTIALS_DIRECTORY (%d), readable only by the dynamic service user, and the
# wallet is pointed at it via the *_FILE variant. This overrides any
# GP_WALLET_PASSWORD_FILE in the env file above, so the service always reads the
# credential copy at %d/gp_wallet_password.
#
# The wizard (`gp-server setup`) writes a drop-in that picks the delivery:
# * UNATTENDED, ENCRYPTED (the secure default): gp-server.service.d/encrypted.conf
# resets the line below and uses `LoadCredentialEncrypted=` to read a
# host-sealed CIPHERTEXT blob (wallet_password.cred, made with
# `systemd-creds encrypt`). No plaintext wallet password is on disk.
# * UNATTENDED, plaintext fallback (only when systemd-creds is unavailable):
# no drop-in; the line below reads the root-owned 0400 PLAINTEXT file.
# * MANUAL: gp-server.service.d/manual.conf resets the line and reads
# /run/goblinpay/wallet_password (tmpfs), which the operator populates by hand.
#
# The line below is the plaintext-fallback default so a by-hand install works out
# of the box. To harden a by-hand install, seal the password and switch to the
# encrypted directive:
# printf '%s' 'YOUR-WALLET-PASSWORD' \
# | sudo systemd-creds encrypt --name=gp_wallet_password - \
# /etc/goblinpay/secrets/wallet_password.cred
# # then, in a drop-in, replace the LoadCredential line below with:
# # LoadCredentialEncrypted=gp_wallet_password:/etc/goblinpay/secrets/wallet_password.cred
LoadCredential=gp_wallet_password:/etc/goblinpay/secrets/wallet_password
Environment=GP_WALLET_PASSWORD_FILE=%d/gp_wallet_password
# First-boot-only seed bootstrap WITHOUT the wizard (remove after the wallet
+9 -3
View File
@@ -84,9 +84,15 @@ else
Skipped the wizard. Finish setup either way:
Guided (recommended): $SUDO gp-server setup
By hand (advanced): copy deploy/.env.example to $ENV_FILE and edit it,
write $SECRETS_DIR/wallet_password (mode 0400), and
bootstrap the wallet once with GP_MNEMONIC_FILE.
By hand (advanced): copy deploy/.env.example to $ENV_FILE and edit it, then
deliver the wallet password. Prefer ENCRYPTED at rest:
printf '%s' 'YOUR-PASSWORD' | $SUDO systemd-creds encrypt \\
--name=gp_wallet_password - $SECRETS_DIR/wallet_password.cred
and switch the unit to LoadCredentialEncrypted (see
deploy/gp-server.service). Or fall back to a 0400 plaintext
$SECRETS_DIR/wallet_password. Bootstrap the wallet once with
GP_MNEMONIC_FILE (a file, never the inline env var), then
remove it.
Then start it: $SUDO systemctl start gp-server
Check it: curl -s http://127.0.0.1:8080/health
EOF