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    // Vault bootstrap needs the paged storage path because AuthStore seals
84    // credentials through the pager. The default embedded single-file profile
85    // intentionally hides that pager, so bootstrap opts into the operational
86    // local layout while keeping the caller-provided database path.
87    let opts = RedDBOptions::persistent(&args.path)
88        .with_storage_profile(crate::storage::StorageProfileSelection {
89            deploy_profile: crate::storage::DeployProfile::Embedded,
90            packaging: crate::storage::StoragePackaging::OperationalDirectory,
91            replica_count: 0,
92            managed_backup: false,
93            wal_retention: false,
94        })
95        .map_err(|err| format!("storage profile: {err}"))?;
96    let runtime = RedDBRuntime::with_options(opts).map_err(|err| format!("open db: {err}"))?;
97
98    let pager = runtime
99        .db()
100        .store()
101        .pager()
102        .cloned()
103        .ok_or_else(|| "vault requires a paged database (persistent mode)".to_string())?;
104
105    // AuthConfig defaults are fine — we only need the vault wired up.
106    // Bootstrap doesn't depend on `enabled = true` because needs_bootstrap()
107    // checks the user table directly, not the AuthConfig flag.
108    let config = AuthConfig {
109        vault_enabled: true,
110        ..AuthConfig::default()
111    };
112
113    let store =
114        AuthStore::with_vault(config, pager, None).map_err(|err| format!("open vault: {err}"))?;
115
116    if !store.needs_bootstrap() {
117        // Flush so the freshly-opened pager doesn't leave stray pages
118        // behind on disk; we still error out non-zero.
119        let _ = runtime.checkpoint();
120        return Err("already bootstrapped — bootstrap is one-shot and irreversible".into());
121    }
122
123    let result = store
124        .bootstrap(&args.username, &password)
125        .map_err(|err| format!("bootstrap: {err}"))?;
126
127    let certificate = result.certificate.clone().ok_or_else(|| {
128        "bootstrap succeeded but no certificate was issued (vault not configured?)".to_string()
129    })?;
130    let api_key = result.api_key.key.clone();
131
132    // Vault::save() inside bootstrap() already calls pager.flush()
133    // and writes the vault pages directly. We deliberately do NOT
134    // call runtime.checkpoint() here because the runtime checkpoint
135    // path can rewrite reserved pages (vault occupies pages 2-3,
136    // which the engine treats as off-limits during normal commit but
137    // may touch during a fresh checkpoint on a brand-new file).
138    // Dropping the runtime closes file handles cleanly.
139    drop(store);
140    drop(runtime);
141
142    Ok(BootstrapOutcome {
143        username: result.user.username,
144        api_key,
145        certificate,
146    })
147}
148
149/// Resolve the password from `--password-stdin`, `--password`, or
150/// `REDDB_PASSWORD` (already populated by the *_FILE expansion at
151/// boot). Order: stdin > flag > env.
152fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
153    if args.password_stdin {
154        let mut buf = String::new();
155        let stdin = std::io::stdin();
156        stdin
157            .lock()
158            .read_line(&mut buf)
159            .map_err(|err| format!("read password from stdin: {err}"))?;
160        // Strip the line-ending; preserve any internal whitespace
161        // (passwords like `   ` are unusual but legal).
162        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
163        return Ok(trimmed);
164    }
165    if let Some(p) = args.password.as_ref() {
166        let _ = writeln!(
167            std::io::stderr(),
168            "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
169        );
170        return Ok(p.clone());
171    }
172    if let Ok(env_pwd) = std::env::var("REDDB_PASSWORD") {
173        if !env_pwd.is_empty() {
174            return Ok(env_pwd);
175        }
176    }
177    Ok(String::new())
178}
179
180/// Render the outcome to stdout, honouring the requested format. This
181/// is the only place the dispatcher prints success output.
182pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
183    if args.json {
184        // Hand-built JSON — we already do the same in red.rs and adding
185        // serde_json round-trip here would pull a dep we don't have.
186        println!(
187            "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
188            json_escape(&outcome.username),
189            json_escape(&outcome.api_key),
190            json_escape(&outcome.certificate),
191        );
192        return;
193    }
194    if args.print_certificate {
195        // Just the cert — useful for `cert=$(red bootstrap ... --print-certificate)`.
196        println!("{}", outcome.certificate);
197        return;
198    }
199    eprintln!(
200        "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
201        outcome.username
202    );
203    println!("{}", outcome.certificate);
204}
205
206fn json_escape(s: &str) -> String {
207    let mut out = String::with_capacity(s.len() + 2);
208    for c in s.chars() {
209        match c {
210            '"' => out.push_str("\\\""),
211            '\\' => out.push_str("\\\\"),
212            '\n' => out.push_str("\\n"),
213            '\r' => out.push_str("\\r"),
214            '\t' => out.push_str("\\t"),
215            c if (c as u32) < 0x20 => {
216                out.push_str(&format!("\\u{:04x}", c as u32));
217            }
218            c => out.push(c),
219        }
220    }
221    out
222}
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227
228    #[test]
229    fn vault_flag_required() {
230        let args = BootstrapArgs {
231            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
232            vault: false,
233            username: "admin".into(),
234            password: Some("hunter2".into()),
235            password_stdin: false,
236            print_certificate: false,
237            json: false,
238        };
239        let err = run(args).unwrap_err();
240        assert!(err.contains("--vault"), "got: {err}");
241    }
242
243    #[test]
244    fn json_escape_handles_control_chars() {
245        assert_eq!(json_escape("a\"b"), "a\\\"b");
246        assert_eq!(json_escape("a\\b"), "a\\\\b");
247        assert_eq!(json_escape("x\n"), "x\\n");
248        assert_eq!(json_escape("\t"), "\\t");
249    }
250}