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    // Fresh bootstrap uses policy_only. Give the first admin the same
116    // explicit policy as the production preset so it can manage the vault.
117    crate::service_cli::install_allow_all_policy(&store)?;
118    store
119        .attach_policy(
120            crate::auth::store::PrincipalRef::User(crate::auth::UserId::platform(
121                result.user.username.clone(),
122            )),
123            crate::service_cli::FIRST_ADMIN_ALLOW_ALL_POLICY,
124        )
125        .map_err(|err| format!("attach bootstrap policy: {err}"))?;
126    store
127        .persist_to_vault_result()
128        .map_err(|err| format!("persist bootstrap policy: {err}"))?;
129
130    let certificate = result.certificate.clone().unwrap_or_default();
131    let api_key = result.api_key.key.clone();
132
133    // Vault::save() inside bootstrap() already calls pager.flush()
134    // and writes the vault pages directly. We deliberately do NOT
135    // call runtime.checkpoint() here because the runtime checkpoint
136    // path can rewrite reserved pages (vault occupies pages 2-3,
137    // which the engine treats as off-limits during normal commit but
138    // may touch during a fresh checkpoint on a brand-new file).
139    // Dropping the runtime closes file handles cleanly.
140    drop(store);
141    drop(runtime);
142
143    Ok(BootstrapOutcome {
144        username: result.user.username,
145        api_key,
146        certificate,
147    })
148}
149
150/// Resolve the password from `--password-stdin`, `--password`, or
151/// `REDDB_PASSWORD` (already populated by the *_FILE expansion at
152/// boot). Order: stdin > flag > env.
153fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
154    if args.password_stdin {
155        let mut buf = String::new();
156        let stdin = std::io::stdin();
157        stdin
158            .lock()
159            .read_line(&mut buf)
160            .map_err(|err| format!("read password from stdin: {err}"))?;
161        // Strip the line-ending; preserve any internal whitespace
162        // (passwords like `   ` are unusual but legal).
163        let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
164        return Ok(trimmed);
165    }
166    if let Some(p) = args.password.as_ref() {
167        let _ = writeln!(
168            std::io::stderr(),
169            "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
170        );
171        return Ok(p.clone());
172    }
173    if let Some(env_pwd) = crate::utils::env_with_file_fallback("REDDB_PASSWORD") {
174        return Ok(env_pwd);
175    }
176    Ok(String::new())
177}
178
179/// Render the outcome to stdout, honouring the requested format. This
180/// is the only place the dispatcher prints success output.
181pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
182    if args.json {
183        // Hand-built JSON — we already do the same in red.rs and adding
184        // serde_json round-trip here would pull a dep we don't have.
185        println!(
186            "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
187            json_escape(&outcome.username),
188            json_escape(&outcome.api_key),
189            json_escape(&outcome.certificate),
190        );
191        return;
192    }
193    if args.print_certificate {
194        // Just the cert — useful for `cert=$(red bootstrap ... --print-certificate)`.
195        println!("{}", outcome.certificate);
196        return;
197    }
198    if outcome.certificate.is_empty() {
199        eprintln!(
200            "[reddb] bootstrapped admin user `{}` using the configured vault passphrase",
201            outcome.username
202        );
203        return;
204    }
205    eprintln!(
206        "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
207        outcome.username
208    );
209    println!("{}", outcome.certificate);
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use std::sync::{Mutex, OnceLock};
216
217    fn env_lock() -> &'static Mutex<()> {
218        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
219        LOCK.get_or_init(|| Mutex::new(()))
220    }
221
222    fn restore_env_var(name: &str, value: Option<std::ffi::OsString>) {
223        unsafe {
224            match value {
225                Some(value) => std::env::set_var(name, value),
226                None => std::env::remove_var(name),
227            }
228        }
229    }
230
231    #[test]
232    fn vault_flag_required() {
233        let args = BootstrapArgs {
234            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
235            vault: false,
236            username: "admin".into(),
237            password: Some("hunter2".into()),
238            password_stdin: false,
239            print_certificate: false,
240            json: false,
241        };
242        let err = run(args).unwrap_err();
243        assert!(err.contains("--vault"), "got: {err}");
244    }
245
246    #[test]
247    fn resolve_password_reads_file_env() {
248        let _guard = env_lock().lock().unwrap();
249        let old = std::env::var_os("REDDB_PASSWORD");
250        let old_file = std::env::var_os("REDDB_PASSWORD_FILE");
251        let dir =
252            std::env::temp_dir().join(format!("reddb-bootstrap-password-{}", std::process::id()));
253        std::fs::create_dir_all(&dir).unwrap();
254        let path = dir.join("password");
255        std::fs::write(&path, "from-file\n").unwrap();
256        unsafe {
257            std::env::remove_var("REDDB_PASSWORD");
258            std::env::set_var("REDDB_PASSWORD_FILE", &path);
259        }
260        let args = BootstrapArgs {
261            path: PathBuf::from("/tmp/reddb-bootstrap-test.rdb"),
262            vault: true,
263            username: "admin".into(),
264            password: None,
265            password_stdin: false,
266            print_certificate: false,
267            json: false,
268        };
269
270        assert_eq!(resolve_password(&args).unwrap(), "from-file");
271
272        restore_env_var("REDDB_PASSWORD", old);
273        restore_env_var("REDDB_PASSWORD_FILE", old_file);
274        let _ = std::fs::remove_dir_all(&dir);
275    }
276
277    #[test]
278    fn json_escape_handles_control_chars() {
279        assert_eq!(json_escape("a\"b"), "a\\\"b");
280        assert_eq!(json_escape("a\\b"), "a\\\\b");
281        assert_eq!(json_escape("x\n"), "x\\n");
282        assert_eq!(json_escape("\t"), "\\t");
283    }
284}