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 std::env::var("REDDB_CERTIFICATE")
60 .ok()
61 .filter(|s| !s.is_empty())
62 .is_none()
63 && std::env::var("REDDB_VAULT_KEY")
64 .ok()
65 .filter(|s| !s.is_empty())
66 .is_none()
67 {
68 return Err("vault requires REDDB_CERTIFICATE or REDDB_VAULT_KEY (use the *_FILE companion to read from a mounted secret)".to_string());
69 }
70
71 if args.username.trim().is_empty() {
72 return Err(
73 "username is required (use --username, or set REDDB_USERNAME / REDDB_USERNAME_FILE)"
74 .to_string(),
75 );
76 }
77
78 let password = resolve_password(&args)?;
79 if password.is_empty() {
80 return Err("password is required (use --password-stdin or REDDB_PASSWORD_FILE)".into());
81 }
82
83 let opts = RedDBOptions::persistent(&args.path)
88 .with_storage_profile(crate::storage::StorageProfileSelection {
89 deploy_profile: crate::storage::DeployProfile::Embedded,
90 packaging: crate::storage::StoragePackaging::OperationalDirectory,
91 replica_count: 0,
92 managed_backup: false,
93 wal_retention: false,
94 })
95 .map_err(|err| format!("storage profile: {err}"))?;
96 let runtime = RedDBRuntime::with_options(opts).map_err(|err| format!("open db: {err}"))?;
97
98 let pager = runtime
99 .db()
100 .store()
101 .pager()
102 .cloned()
103 .ok_or_else(|| "vault requires a paged database (persistent mode)".to_string())?;
104
105 let config = AuthConfig {
109 vault_enabled: true,
110 ..AuthConfig::default()
111 };
112
113 let store =
114 AuthStore::with_vault(config, pager, None).map_err(|err| format!("open vault: {err}"))?;
115
116 if !store.needs_bootstrap() {
117 let _ = runtime.checkpoint();
120 return Err("already bootstrapped — bootstrap is one-shot and irreversible".into());
121 }
122
123 let result = store
124 .bootstrap(&args.username, &password)
125 .map_err(|err| format!("bootstrap: {err}"))?;
126
127 let certificate = result.certificate.clone().ok_or_else(|| {
128 "bootstrap succeeded but no certificate was issued (vault not configured?)".to_string()
129 })?;
130 let api_key = result.api_key.key.clone();
131
132 drop(store);
140 drop(runtime);
141
142 Ok(BootstrapOutcome {
143 username: result.user.username,
144 api_key,
145 certificate,
146 })
147}
148
149fn resolve_password(args: &BootstrapArgs) -> Result<String, String> {
153 if args.password_stdin {
154 let mut buf = String::new();
155 let stdin = std::io::stdin();
156 stdin
157 .lock()
158 .read_line(&mut buf)
159 .map_err(|err| format!("read password from stdin: {err}"))?;
160 let trimmed = buf.trim_end_matches(['\n', '\r']).to_string();
163 return Ok(trimmed);
164 }
165 if let Some(p) = args.password.as_ref() {
166 let _ = writeln!(
167 std::io::stderr(),
168 "warning: --password leaks credentials to /proc/<pid>/cmdline; prefer --password-stdin or REDDB_PASSWORD_FILE"
169 );
170 return Ok(p.clone());
171 }
172 if let Ok(env_pwd) = std::env::var("REDDB_PASSWORD") {
173 if !env_pwd.is_empty() {
174 return Ok(env_pwd);
175 }
176 }
177 Ok(String::new())
178}
179
180pub fn render_success(outcome: &BootstrapOutcome, args: &BootstrapArgs) {
183 if args.json {
184 println!(
187 "{{\"username\":\"{}\",\"token\":\"{}\",\"certificate\":\"{}\"}}",
188 json_escape(&outcome.username),
189 json_escape(&outcome.api_key),
190 json_escape(&outcome.certificate),
191 );
192 return;
193 }
194 if args.print_certificate {
195 println!("{}", outcome.certificate);
197 return;
198 }
199 eprintln!(
200 "[reddb] bootstrapped admin user `{}` — SAVE THIS CERTIFICATE (only way to unseal):",
201 outcome.username
202 );
203 println!("{}", outcome.certificate);
204}
205
206fn json_escape(s: &str) -> String {
207 let mut out = String::with_capacity(s.len() + 2);
208 for c in s.chars() {
209 match c {
210 '"' => out.push_str("\\\""),
211 '\\' => out.push_str("\\\\"),
212 '\n' => out.push_str("\\n"),
213 '\r' => out.push_str("\\r"),
214 '\t' => out.push_str("\\t"),
215 c if (c as u32) < 0x20 => {
216 out.push_str(&format!("\\u{:04x}", c as u32));
217 }
218 c => out.push(c),
219 }
220 }
221 out
222}
223
224#[cfg(test)]
225mod tests {
226 use super::*;
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 json_escape_handles_control_chars() {
245 assert_eq!(json_escape("a\"b"), "a\\\"b");
246 assert_eq!(json_escape("a\\b"), "a\\\\b");
247 assert_eq!(json_escape("x\n"), "x\\n");
248 assert_eq!(json_escape("\t"), "\\t");
249 }
250}