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. Prepares the encrypted vault. A fresh database does not require
7//!      an existing certificate; the bootstrap issues the certificate.
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//! 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 args.username.trim().is_empty() {
60        return Err(
61            "username is required (use --username, or set REDDB_USERNAME / REDDB_USERNAME_FILE)"
62                .to_string(),
63        );
64    }
65
66    let password = resolve_password(&args)?;
67    if password.is_empty() {
68        return Err("password is required (use --password-stdin or REDDB_PASSWORD_FILE)".into());
69    }
70
71    // Vault bootstrap needs the paged storage path because AuthStore seals
72    // credentials through the pager. The default embedded single-file profile
73    // intentionally hides that pager, so bootstrap opts into the operational
74    // local layout while keeping the caller-provided database path.
75    let opts = RedDBOptions::persistent(&args.path)
76        .with_storage_profile(crate::storage::StorageProfileSelection {
77            deploy_profile: crate::storage::DeployProfile::Embedded,
78            packaging: crate::storage::StoragePackaging::OperationalDirectory,
79            replica_count: 0,
80            managed_backup: false,
81            wal_retention: false,
82        })
83        .map_err(|err| format!("storage profile: {err}"))?;
84    let runtime = RedDBRuntime::with_options(opts).map_err(|err| format!("open db: {err}"))?;
85
86    let pager = runtime
87        .db()
88        .store()
89        .pager()
90        .cloned()
91        .ok_or_else(|| "vault requires a paged database (persistent mode)".to_string())?;
92
93    // AuthConfig defaults are fine — we only need the vault wired up.
94    // Bootstrap doesn't depend on `enabled = true` because needs_bootstrap()
95    // checks the user table directly, not the AuthConfig flag.
96    let config = AuthConfig {
97        vault_enabled: true,
98        ..AuthConfig::default()
99    };
100
101    let store = AuthStore::with_vault(config, pager).map_err(|err| format!("open vault: {err}"))?;
102
103    if !store.needs_bootstrap() {
104        // Flush so the freshly-opened pager doesn't leave stray pages
105        // behind on disk; we still error out non-zero.
106        let _ = runtime.checkpoint();
107        return Err("already bootstrapped — bootstrap is one-shot and irreversible".into());
108    }
109
110    let result = store
111        .bootstrap(&args.username, &password)
112        .map_err(|err| format!("bootstrap: {err}"))?;
113
114    let certificate = result.certificate.clone().ok_or_else(|| {
115        "bootstrap succeeded but no certificate was issued (vault not configured?)".to_string()
116    })?;
117    let api_key = result.api_key.key.clone();
118
119    // Vault::save() inside bootstrap() already calls pager.flush()
120    // and writes the vault pages directly. We deliberately do NOT
121    // call runtime.checkpoint() here because the runtime checkpoint
122    // path can rewrite reserved pages (vault occupies pages 2-3,
123    // which the engine treats as off-limits during normal commit but
124    // may touch during a fresh checkpoint on a brand-new file).
125    // Dropping the runtime closes file handles cleanly.
126    drop(store);
127    drop(runtime);
128
129    Ok(BootstrapOutcome {
130        username: result.user.username,
131        api_key,
132        certificate,
133    })
134}
135
136/// Resolve the password from `--password-stdin`, `--password`, or
137/// `REDDB_PASSWORD` (already populated by the *_FILE expansion at
138/// boot). Order: stdin > flag > env.
139fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
140    if args.password_stdin {
141        let mut buf = String::new();
142        let stdin = std::io::stdin();
143        stdin
144            .lock()
145            .read_line(&mut buf)
146            .map_err(|err| format!("read password from stdin: {err}"))?;
147        // Strip the line-ending; preserve any internal whitespace
148        // (passwords like `   ` are unusual but legal).
149        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
150        return Ok(trimmed);
151    }
152    if let Some(p) = args.password.as_ref() {
153        let _ = writeln!(
154            std::io::stderr(),
155            "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
156        );
157        return Ok(p.clone());
158    }
159    if let Some(env_pwd) = crate::utils::env_with_file_fallback("REDDB_PASSWORD") {
160        return Ok(env_pwd);
161    }
162    Ok(String::new())
163}
164
165/// Render the outcome to stdout, honouring the requested format. This
166/// is the only place the dispatcher prints success output.
167pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
168    if args.json {
169        // Hand-built JSON — we already do the same in red.rs and adding
170        // serde_json round-trip here would pull a dep we don't have.
171        println!(
172            "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
173            json_escape(&outcome.username),
174            json_escape(&outcome.api_key),
175            json_escape(&outcome.certificate),
176        );
177        return;
178    }
179    if args.print_certificate {
180        // Just the cert — useful for `cert=$(red bootstrap ... --print-certificate)`.
181        println!("{}", outcome.certificate);
182        return;
183    }
184    eprintln!(
185        "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
186        outcome.username
187    );
188    println!("{}", outcome.certificate);
189}
190
191fn json_escape(s: &str) -> String {
192    let mut out = String::with_capacity(s.len() + 2);
193    for c in s.chars() {
194        match c {
195            '"' => out.push_str("\\\""),
196            '\\' => out.push_str("\\\\"),
197            '\n' => out.push_str("\\n"),
198            '\r' => out.push_str("\\r"),
199            '\t' => out.push_str("\\t"),
200            c if (c as u32) < 0x20 => {
201                out.push_str(&format!("\\u{:04x}", c as u32));
202            }
203            c => out.push(c),
204        }
205    }
206    out
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use std::sync::{Mutex, OnceLock};
213
214    fn env_lock() -> &'static Mutex<()> {
215        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
216        LOCK.get_or_init(|| Mutex::new(()))
217    }
218
219    fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
220        unsafe {
221            match value {
222                Some(value) => std::env::set_var(name, value),
223                None => std::env::remove_var(name),
224            }
225        }
226    }
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 resolve_password_reads_file_env() {
245        let _guard = env_lock().lock().unwrap();
246        let old = std::env::var_os("REDDB_PASSWORD");
247        let old_file = std::env::var_os("REDDB_PASSWORD_FILE");
248        let dir =
249            std::env::temp_dir().join(format!("reddb-bootstrap-password-{}", std::process::id()));
250        std::fs::create_dir_all(&dir).unwrap();
251        let path = dir.join("password");
252        std::fs::write(&path, "from-file\n").unwrap();
253        unsafe {
254            std::env::remove_var("REDDB_PASSWORD");
255            std::env::set_var("REDDB_PASSWORD_FILE", &path);
256        }
257        let args = BootstrapArgs {
258            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
259            vault: true,
260            username: "admin".into(),
261            password: None,
262            password_stdin: false,
263            print_certificate: false,
264            json: false,
265        };
266
267        assert_eq!(resolve_password(&args).unwrap(), "from-file");
268
269        restore_env_var("REDDB_PASSWORD", old);
270        restore_env_var("REDDB_PASSWORD_FILE", old_file);
271        let _ = std::fs::remove_dir_all(&dir);
272    }
273
274    #[test]
275    fn json_escape_handles_control_chars() {
276        assert_eq!(json_escape("a\"b"), "a\\\"b");
277        assert_eq!(json_escape("a\\b"), "a\\\\b");
278        assert_eq!(json_escape("x\n"), "x\\n");
279        assert_eq!(json_escape("\t"), "\\t");
280    }
281}