reddb_server/cli/
bootstrap.rs1use std::io::{BufRead, Write};
16use std::path::PathBuf;
17
18use crate::auth::store::AuthStore;
19use crate::auth::AuthConfig;
20use crate::{RedDBOptions, RedDBRuntime};
21
22pub struct BootstrapArgs {
26 pub path: PathBuf,
27 pub vault: bool,
28 pub username: String,
29 pub password: Option<String>,
32 pub password_stdin: bool,
33 pub print_certificate: bool,
34 pub json: bool,
35}
36
37#[derive(Debug)]
39pub struct BootstrapOutcome {
40 pub username: String,
41 pub api_key: String,
42 pub certificate: String,
43}
44
45pub fn run(args: BootstrapArgs) -> Result<BootstrapOutcome, String> {
49 if !args.vault {
50 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 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 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 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 drop(store);
127 drop(runtime);
128
129 Ok(BootstrapOutcome {
130 username: result.user.username,
131 api_key,
132 certificate,
133 })
134}
135
136fn 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 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
165pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
168 if args.json {
169 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 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}