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};
21use reddb_types::encoding::json_escape;
22
23pub struct BootstrapArgs {
27 pub path: PathBuf,
28 pub vault: bool,
29 pub username: String,
30 pub password: Option<String>,
33 pub password_stdin: bool,
34 pub print_certificate: bool,
35 pub json: bool,
36}
37
38#[derive(Debug)]
40pub struct BootstrapOutcome {
41 pub username: String,
42 pub api_key: String,
43 pub certificate: String,
44}
45
46pub fn run(args: BootstrapArgs) -> Result<BootstrapOutcome, String> {
50 if !args.vault {
51 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 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 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 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 drop(store);
128 drop(runtime);
129
130 Ok(BootstrapOutcome {
131 username: result.user.username,
132 api_key,
133 certificate,
134 })
135}
136
137fn 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 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
166pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
169 if args.json {
170 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 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}