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};
21use reddb_types::encoding::json_escape;
22
23/// Parsed args for `red bootstrap`. Constructed by the bin dispatcher
24/// from the CLI flag map; kept as a plain struct so the unit tests
25/// don't have to drag in a tokenizer.
26pub struct BootstrapArgs {
27    pub path: PathBuf,
28    pub vault: bool,
29    pub username: String,
30    /// Provided by `--password`. None when `--password-stdin` will
31    /// supply it.
32    pub password: Option<String>,
33    pub password_stdin: bool,
34    pub print_certificate: bool,
35    pub json: bool,
36}
37
38/// Outcome rendered by [`run`] on success.
39#[derive(Debug)]
40pub struct BootstrapOutcome {
41    pub username: String,
42    pub api_key: String,
43    pub certificate: String,
44}
45
46/// Execute the bootstrap subcommand. Caller is responsible for
47/// process exit; we return Result so the dispatcher can format errors
48/// in the requested envelope (text vs JSON).
49pub fn run(args: BootstrapArgs) -> Result<BootstrapOutcome, String> {
50    if !args.vault {
51        // Vault is mandatory: bootstrapping without one would issue an
52        // admin password that lives only in unencrypted pages. That is
53        // never what the operator wants for a credentialled cluster.
54        return Err(
55            "bootstrap requires --vault (admin credentials must be sealed in the encrypted vault)"
56                .to_string(),
57        );
58    }
59
60    if args.username.trim().is_empty() {
61        return Err(
62            "username is required (use --username, or set REDDB_USERNAME / REDDB_USERNAME_FILE)"
63                .to_string(),
64        );
65    }
66
67    let password = resolve_password(&args)?;
68    if password.is_empty() {
69        return Err("password is required (use --password-stdin or REDDB_PASSWORD_FILE)".into());
70    }
71
72    // Vault bootstrap needs the paged storage path because AuthStore seals
73    // credentials through the pager. The default embedded single-file profile
74    // intentionally hides that pager, so bootstrap opts into the operational
75    // local layout while keeping the caller-provided database path.
76    let opts = RedDBOptions::persistent(&args.path)
77        .with_storage_profile(crate::storage::StorageProfileSelection {
78            deploy_profile: crate::storage::DeployProfile::Embedded,
79            packaging: crate::storage::StoragePackaging::OperationalDirectory,
80            replica_count: 0,
81            managed_backup: false,
82            wal_retention: false,
83        })
84        .map_err(|err| format!("storage profile: {err}"))?;
85    let runtime = RedDBRuntime::with_options(opts).map_err(|err| format!("open db: {err}"))?;
86
87    let pager = runtime
88        .db()
89        .store()
90        .pager()
91        .cloned()
92        .ok_or_else(|| "vault requires a paged database (persistent mode)".to_string())?;
93
94    // AuthConfig defaults are fine — we only need the vault wired up.
95    // Bootstrap doesn't depend on `enabled = true` because needs_bootstrap()
96    // checks the user table directly, not the AuthConfig flag.
97    let config = AuthConfig {
98        vault_enabled: true,
99        ..AuthConfig::default()
100    };
101
102    let store = AuthStore::with_vault(config, pager).map_err(|err| format!("open vault: {err}"))?;
103
104    if !store.needs_bootstrap() {
105        // Flush so the freshly-opened pager doesn't leave stray pages
106        // behind on disk; we still error out non-zero.
107        let _ = runtime.checkpoint();
108        return Err("already bootstrapped — bootstrap is one-shot and irreversible".into());
109    }
110
111    let result = store
112        .bootstrap(&args.username, &password)
113        .map_err(|err| format!("bootstrap: {err}"))?;
114
115    let certificate = result.certificate.clone().ok_or_else(|| {
116        "bootstrap succeeded but no certificate was issued (vault not configured?)".to_string()
117    })?;
118    let api_key = result.api_key.key.clone();
119
120    // Vault::save() inside bootstrap() already calls pager.flush()
121    // and writes the vault pages directly. We deliberately do NOT
122    // call runtime.checkpoint() here because the runtime checkpoint
123    // path can rewrite reserved pages (vault occupies pages 2-3,
124    // which the engine treats as off-limits during normal commit but
125    // may touch during a fresh checkpoint on a brand-new file).
126    // Dropping the runtime closes file handles cleanly.
127    drop(store);
128    drop(runtime);
129
130    Ok(BootstrapOutcome {
131        username: result.user.username,
132        api_key,
133        certificate,
134    })
135}
136
137/// Resolve the password from `--password-stdin`, `--password`, or
138/// `REDDB_PASSWORD` (already populated by the *_FILE expansion at
139/// boot). Order: stdin > flag > env.
140fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
141    if args.password_stdin {
142        let mut buf = String::new();
143        let stdin = std::io::stdin();
144        stdin
145            .lock()
146            .read_line(&mut buf)
147            .map_err(|err| format!("read password from stdin: {err}"))?;
148        // Strip the line-ending; preserve any internal whitespace
149        // (passwords like `   ` are unusual but legal).
150        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
151        return Ok(trimmed);
152    }
153    if let Some(p) = args.password.as_ref() {
154        let _ = writeln!(
155            std::io::stderr(),
156            "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
157        );
158        return Ok(p.clone());
159    }
160    if let Some(env_pwd) = crate::utils::env_with_file_fallback("REDDB_PASSWORD") {
161        return Ok(env_pwd);
162    }
163    Ok(String::new())
164}
165
166/// Render the outcome to stdout, honouring the requested format. This
167/// is the only place the dispatcher prints success output.
168pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
169    if args.json {
170        // Hand-built JSON — we already do the same in red.rs and adding
171        // serde_json round-trip here would pull a dep we don't have.
172        println!(
173            "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
174            json_escape(&outcome.username),
175            json_escape(&outcome.api_key),
176            json_escape(&outcome.certificate),
177        );
178        return;
179    }
180    if args.print_certificate {
181        // Just the cert — useful for `cert=$(red bootstrap ... --print-certificate)`.
182        println!("{}", outcome.certificate);
183        return;
184    }
185    eprintln!(
186        "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
187        outcome.username
188    );
189    println!("{}", outcome.certificate);
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::sync::{Mutex, OnceLock};
196
197    fn env_lock() -> &'static Mutex<()> {
198        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
199        LOCK.get_or_init(|| Mutex::new(()))
200    }
201
202    fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
203        unsafe {
204            match value {
205                Some(value) => std::env::set_var(name, value),
206                None => std::env::remove_var(name),
207            }
208        }
209    }
210
211    #[test]
212    fn vault_flag_required() {
213        let args = BootstrapArgs {
214            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
215            vault: false,
216            username: "admin".into(),
217            password: Some("hunter2".into()),
218            password_stdin: false,
219            print_certificate: false,
220            json: false,
221        };
222        let err = run(args).unwrap_err();
223        assert!(err.contains("--vault"), "got: {err}");
224    }
225
226    #[test]
227    fn resolve_password_reads_file_env() {
228        let _guard = env_lock().lock().unwrap();
229        let old = std::env::var_os("REDDB_PASSWORD");
230        let old_file = std::env::var_os("REDDB_PASSWORD_FILE");
231        let dir =
232            std::env::temp_dir().join(format!("reddb-bootstrap-password-{}", std::process::id()));
233        std::fs::create_dir_all(&dir).unwrap();
234        let path = dir.join("password");
235        std::fs::write(&path, "from-file\n").unwrap();
236        unsafe {
237            std::env::remove_var("REDDB_PASSWORD");
238            std::env::set_var("REDDB_PASSWORD_FILE", &path);
239        }
240        let args = BootstrapArgs {
241            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
242            vault: true,
243            username: "admin".into(),
244            password: None,
245            password_stdin: false,
246            print_certificate: false,
247            json: false,
248        };
249
250        assert_eq!(resolve_password(&args).unwrap(), "from-file");
251
252        restore_env_var("REDDB_PASSWORD", old);
253        restore_env_var("REDDB_PASSWORD_FILE", old_file);
254        let _ = std::fs::remove_dir_all(&dir);
255    }
256
257    #[test]
258    fn json_escape_handles_control_chars() {
259        assert_eq!(json_escape("a\"b"), "a\\\"b");
260        assert_eq!(json_escape("a\\b"), "a\\\\b");
261        assert_eq!(json_escape("x\n"), "x\\n");
262        assert_eq!(json_escape("\t"), "\\t");
263    }
264}