Skip to main content

reddb_server/cli/
bootstrap.rs

1//! `red bootstrap` — headless first-admin bootstrap for containers.
2//!
3//! Designed for K8s Jobs / CI pipelines that mount a tmpfs secret with
4//! the admin password and need a one-shot binary that:
5//!   1. Opens (or creates) the database file at `--path`.
6//!   2. Opens the encrypted vault (requires `REDDB_CERTIFICATE` or
7//!      `REDDB_VAULT_KEY` — typically via the `_FILE` companion).
8//!   3. Calls `AuthStore::bootstrap` once.
9//!   4. Prints the freshly-issued certificate so the operator can
10//!      capture it (it is the ONLY way to unseal the vault later).
11//!
12//! Exits non-zero on any failure (already bootstrapped, missing vault
13//! key, file open error, ...).
14
15use std::io::{BufRead, Write};
16use std::path::PathBuf;
17
18use crate::auth::store::AuthStore;
19use crate::auth::AuthConfig;
20use crate::{RedDBOptions, RedDBRuntime};
21
22/// Parsed args for `red bootstrap`. Constructed by the bin dispatcher
23/// from the CLI flag map; kept as a plain struct so the unit tests
24/// don't have to drag in a tokenizer.
25pub struct BootstrapArgs {
26    pub path: PathBuf,
27    pub vault: bool,
28    pub username: String,
29    /// Provided by `--password`. None when `--password-stdin` will
30    /// supply it.
31    pub password: Option<String>,
32    pub password_stdin: bool,
33    pub print_certificate: bool,
34    pub json: bool,
35}
36
37/// Outcome rendered by [`run`] on success.
38#[derive(Debug)]
39pub struct BootstrapOutcome {
40    pub username: String,
41    pub api_key: String,
42    pub certificate: String,
43}
44
45/// Execute the bootstrap subcommand. Caller is responsible for
46/// process exit; we return Result so the dispatcher can format errors
47/// in the requested envelope (text vs JSON).
48pub fn run(args: BootstrapArgs) -> Result<BootstrapOutcome, String> {
49    if !args.vault {
50        // Vault is mandatory: bootstrapping without one would issue an
51        // admin password that lives only in unencrypted pages. That is
52        // never what the operator wants for a credentialled cluster.
53        return Err(
54            "bootstrap requires --vault (admin credentials must be sealed in the encrypted vault)"
55                .to_string(),
56        );
57    }
58
59    if std::env::var("REDDB_CERTIFICATE")
60        .ok()
61        .filter(|s| !s.is_empty())
62        .is_none()
63        && std::env::var("REDDB_VAULT_KEY")
64            .ok()
65            .filter(|s| !s.is_empty())
66            .is_none()
67    {
68        return Err("vault requires REDDB_CERTIFICATE or REDDB_VAULT_KEY (use the *_FILE companion to read from a mounted secret)".to_string());
69    }
70
71    if args.username.trim().is_empty() {
72        return Err(
73            "username is required (use --username, or set REDDB_USERNAME / REDDB_USERNAME_FILE)"
74                .to_string(),
75        );
76    }
77
78    let password = resolve_password(&args)?;
79    if password.is_empty() {
80        return Err("password is required (use --password-stdin or REDDB_PASSWORD_FILE)".into());
81    }
82
83    // Open the runtime in persistent mode at the requested path. The
84    // engine creates the file on first open, so this works for both
85    // green-field bootstraps and re-runs against an existing DB.
86    let opts = RedDBOptions::persistent(&args.path);
87    let runtime = RedDBRuntime::with_options(opts).map_err(|err| format!("open db: {err}"))?;
88
89    let pager = runtime
90        .db()
91        .store()
92        .pager()
93        .cloned()
94        .ok_or_else(|| "vault requires a paged database (persistent mode)".to_string())?;
95
96    // AuthConfig defaults are fine — we only need the vault wired up.
97    // Bootstrap doesn't depend on `enabled = true` because needs_bootstrap()
98    // checks the user table directly, not the AuthConfig flag.
99    let config = AuthConfig {
100        vault_enabled: true,
101        ..AuthConfig::default()
102    };
103
104    let store =
105        AuthStore::with_vault(config, pager, None).map_err(|err| format!("open vault: {err}"))?;
106
107    if !store.needs_bootstrap() {
108        // Flush so the freshly-opened pager doesn't leave stray pages
109        // behind on disk; we still error out non-zero.
110        let _ = runtime.checkpoint();
111        return Err("already bootstrapped — bootstrap is one-shot and irreversible".into());
112    }
113
114    let result = store
115        .bootstrap(&args.username, &password)
116        .map_err(|err| format!("bootstrap: {err}"))?;
117
118    let certificate = result.certificate.clone().ok_or_else(|| {
119        "bootstrap succeeded but no certificate was issued (vault not configured?)".to_string()
120    })?;
121    let api_key = result.api_key.key.clone();
122
123    // Vault::save() inside bootstrap() already calls pager.flush()
124    // and writes the vault pages directly. We deliberately do NOT
125    // call runtime.checkpoint() here because the runtime checkpoint
126    // path can rewrite reserved pages (vault occupies pages 2-3,
127    // which the engine treats as off-limits during normal commit but
128    // may touch during a fresh checkpoint on a brand-new file).
129    // Dropping the runtime closes file handles cleanly.
130    drop(store);
131    drop(runtime);
132
133    Ok(BootstrapOutcome {
134        username: result.user.username,
135        api_key,
136        certificate,
137    })
138}
139
140/// Resolve the password from `--password-stdin`, `--password`, or
141/// `REDDB_PASSWORD` (already populated by the *_FILE expansion at
142/// boot). Order: stdin > flag > env.
143fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
144    if args.password_stdin {
145        let mut buf = String::new();
146        let stdin = std::io::stdin();
147        stdin
148            .lock()
149            .read_line(&mut buf)
150            .map_err(|err| format!("read password from stdin: {err}"))?;
151        // Strip the line-ending; preserve any internal whitespace
152        // (passwords like `   ` are unusual but legal).
153        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
154        return Ok(trimmed);
155    }
156    if let Some(p) = args.password.as_ref() {
157        let _ = writeln!(
158            std::io::stderr(),
159            "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
160        );
161        return Ok(p.clone());
162    }
163    if let Ok(env_pwd) = std::env::var("REDDB_PASSWORD") {
164        if !env_pwd.is_empty() {
165            return Ok(env_pwd);
166        }
167    }
168    Ok(String::new())
169}
170
171/// Render the outcome to stdout, honouring the requested format. This
172/// is the only place the dispatcher prints success output.
173pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
174    if args.json {
175        // Hand-built JSON — we already do the same in red.rs and adding
176        // serde_json round-trip here would pull a dep we don't have.
177        println!(
178            "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
179            json_escape(&outcome.username),
180            json_escape(&outcome.api_key),
181            json_escape(&outcome.certificate),
182        );
183        return;
184    }
185    if args.print_certificate {
186        // Just the cert — useful for `cert=$(red bootstrap ... --print-certificate)`.
187        println!("{}", outcome.certificate);
188        return;
189    }
190    eprintln!(
191        "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
192        outcome.username
193    );
194    println!("{}", outcome.certificate);
195}
196
197fn json_escape(s: &str) -> String {
198    let mut out = String::with_capacity(s.len() + 2);
199    for c in s.chars() {
200        match c {
201            '"' => out.push_str("\\\""),
202            '\\' => out.push_str("\\\\"),
203            '\n' => out.push_str("\\n"),
204            '\r' => out.push_str("\\r"),
205            '\t' => out.push_str("\\t"),
206            c if (c as u32) < 0x20 => {
207                out.push_str(&format!("\\u{:04x}", c as u32));
208            }
209            c => out.push(c),
210        }
211    }
212    out
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn vault_flag_required() {
221        let args = BootstrapArgs {
222            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
223            vault: false,
224            username: "admin".into(),
225            password: Some("hunter2".into()),
226            password_stdin: false,
227            print_certificate: false,
228            json: false,
229        };
230        let err = run(args).unwrap_err();
231        assert!(err.contains("--vault"), "got: {err}");
232    }
233
234    #[test]
235    fn json_escape_handles_control_chars() {
236        assert_eq!(json_escape("a\"b"), "a\\\"b");
237        assert_eq!(json_escape("a\\b"), "a\\\\b");
238        assert_eq!(json_escape("x\n"), "x\\n");
239        assert_eq!(json_escape("\t"), "\\t");
240    }
241}