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 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 drop(store);
141 drop(runtime);
142
143 Ok(BootstrapOutcome {
144 username: result.user.username,
145 api_key,
146 certificate,
147 })
148}
149
150fn 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 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
179pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
182 if args.json {
183 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 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}