Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/app/tui.rs

26.0 KiB, 155 runs

created by r1870400018:961, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1#![forbid(unsafe_code)]
2
3use crate::{
4 app::{
5 cfg::AppConfig,
6 constant,
7 dev,
8 ext::{
9 AppExtension,
10 NoExtension,
11 },
12 repl::AppShellContext,
13 syntax as app_syntax,
14 },
15 srv::{
16 api::ApiHandlerRegistry,
17 webhook::WebhookRegistry,
18 },
19};
20
21use std::sync::{
22 Arc,
23 RwLock,
24};
25
26use oxedyne_fe2o3_core::{
27 prelude::*,
28 file as core_file,
29 log::{
30 bot::FileConfig,
31 },
32};
33use oxedyne_fe2o3_crypto::keystore::{
34 DEFAULT_WALLET_KDF_NAME,
35 Wallet,
36};
37use oxedyne_fe2o3_jdat::{
38 prelude::*,
39 cfg::Config,
40 file::{
41 JdatFile,
42 JdatMapFile,
43 },
44 string::{
45 dec::DecoderConfig,
46 enc::EncoderConfig,
47 },
48};
49use oxedyne_fe2o3_tui::lib_tui::{
50 repl::{
51 Evaluation,
52 ShellConfig,
53 },
54 input::UserInput,
55};
56
57use std::{
58 collections::BTreeMap,
59 io::Write,
60 path::{
61 Path,
62 PathBuf,
63 },
64};
65
66use secrecy::{
67 ExposeSecret,
68 Secret,
69};
70
71
72#[derive(Clone)]
73pub enum State {
74 NotStarted,
75 Running,
76 NotResponsive,
77}
78
79impl Default for State { fn default() -> Self { Self::NotStarted } }
80
81#[derive(Clone, Default)]
82pub struct AppStatus {
83 pub first: bool,
84 pub log: State,
85 pub db: State,
86 pub web: State,
87}
88
89pub fn run() -> Outcome<()> {
90 run_with_extension(NoExtension)
91}
92
93/// Start the Steel application with a custom app extension.
94///
95/// App binaries implement `AppExtension` for their integration
96/// surface and call this entry point:
97/// ```rust
98/// struct MyApp;
99/// impl AppExtension for MyApp { /* ... */ }
100///
101/// fn main() -> Outcome<()> {
102/// run_with_extension(MyApp)
103/// }
104/// ```
105///
106/// Steel uses the extension to:
107/// * populate the shell Syntax tree with the app's own commands
108/// (so `./steel help` lists them with proper categories and args);
109/// * build the server-wide webhook handler registry from
110/// `AppExtension::webhook_handlers`;
111/// * build the server-wide API handler registry from
112/// `AppExtension::api_handlers`;
113/// * dispatch any shell command not owned by Steel through
114/// `AppExtension::dispatch_cmd`.
115pub fn run_with_extension<E: AppExtension>(extension: E) -> Outcome<()> {
116
117 let mut app_status = AppStatus::default();
118 let cwd = res!(std::env::current_dir());
119 let cwd_str = res!(cwd.to_str().ok_or(err!(
120 "Converting the current working directory path '{:?}' to a string.", cwd;
121 Conversion, String)));
122 let err_str = fmt!("Failed to obtain the directory name from the current working path '{:?}'.",
123 cwd);
124 let this_dir = res!(
125 res!(cwd.file_name().ok_or(err!("{}", &err_str; Conversion, String)))
126 .to_str().ok_or(err!("{}", &err_str; Conversion, String))
127 );
128
129 // ┌───────────────────────────────────────────────────────────────────────────────────────────┐
130 // │ LOGIN STEP │
131 // │ The app executable, configuration and wallet files must be co-located but can exist │
132 // │ separately from the application root directory which contains all other data including │
133 // │ the database and logs. We first try and load the configuration, then the wallet. │
134 // └───────────────────────────────────────────────────────────────────────────────────────────┘
135
136 // ┌───────────────────────┐
137 // │ Load the config file. │
138 // │ It contains the app │
139 // │ root directory, │
140 // │ among other things. │
141 // └───────────────────────┘
142 let cfg_path = Path::new("./").join(constant::CONFIG_NAME);
143
144 if !cfg_path.is_file() {
145 app_status.first = true;
146 let mut cfg = res!(AppConfig::new());
147
148 println!("Welcome to the Hematite Steel Server, this appears to be a new app.");
149 println!("You'll now be asked to enter a human name and a description...");
150 for (field, prompt) in [
151 (&mut cfg.app_human_name, "App human name"),
152 (&mut cfg.app_description, "App description"),
153 ] {
154 print!("{}: ", prompt);
155 res!(std::io::stdout().flush());
156 let mut input = String::new();
157 res!(std::io::stdin().read_line(&mut input));
158 *field = input.trim().to_string();
159 }
160 cfg.app_name = this_dir.to_string();
161
162 res!(cfg.save(&cfg_path, " ", false));
163 println!(
164 "There is no {} file, a default has been created at {:?}.",
165 constant::CONFIG_NAME, cfg_path,
166 );
167 }
168 let mut cfg = res!(AppConfig::load(cfg_path));
169 res!(cfg.check_and_fix());
170 println!("Welcome to {}.", cfg.app_human_name);
171
172 // ┌───────────────────────┐
173 // │ Start logging. │
174 // └───────────────────────┘
175 let mut log_cfg = log_get_config!();
176 log_cfg.console = None;
177 log_cfg.level = match LogLevel::from_str(&cfg.app_log_level) {
178 Ok(level) => level,
179 _ => res!(LogLevel::from_str(constant::DEFAULT_LOG_LEVEL)),
180 };
181 log_cfg.file = Some(FileConfig::new(
182 PathBuf::from(cfg.app_root.clone()),
183 cfg.app_name.clone(),
184 constant::LOG_FILE_EXTENSION.to_string(),
185 0,
186 None, // No multiple log file archiving, just use same file.
187 ));
188 log_set_config!(log_cfg);
189 println!("Shell now logging at {:?}", log_get_file_path!());
190 info!("┌───────────────────────┐");
191 info!("│ New shell session. │");
192 info!("└───────────────────────┘");
193
194 // ┌───────────────────────┐
195 // │ Hear a stop request. │
196 // └───────────────────────┘
197 //
198 // Here and nowhere else. A signal arrives at a process, so there is one
199 // listener to a process and the second is refused; and this function is the
200 // real `main` of every Steel application -- the stock binary reaches it
201 // through `run`, and an app with its own commands calls it directly.
202 // Installing it in `src/main.rs` instead would leave every app built on
203 // Steel as a library deaf to a reboot, which is most of the Steel processes
204 // that exist.
205 //
206 // After the logging is configured, so that what the listener says on the
207 // way out is written down rather than lost.
208 //
209 // A failure to install is not a failure to start. The server runs exactly
210 // as it did before -- and is killed rather than asked when the machine
211 // goes, which is the fault this exists to fix, so it is said at error
212 // level and not swallowed.
213 if let Err(e) = crate::srv::stop::listen() {
214 error!(e, "Installing the stop request listener. A Ctrl-C or a service \
215 manager's stop will kill this process where it stands, with \
216 whatever it holds open still open.");
217 }
218
219 // ┌───────────────────────────────────────────────────────────────────────┐
220 // │ BOOTSTRAP BYPASS FOR WALLET MIGRATE │
221 // │ │
222 // │ If the caller is invoking `wallet --migrate`, the wallet is still in │
223 // │ the legacy passphrase-only layout and the normal unlock flow cannot │
224 // │ read it. Detect this one subcommand here and dispatch straight into │
225 // │ the migration routine, bypassing the unlock step entirely. All other │
226 // │ subcommands require a successful unlock first. │
227 // └───────────────────────────────────────────────────────────────────────┘
228 let invocation_cmds: Vec<String> = std::env::args().skip(1).collect();
229 let is_wallet_migrate = {
230 let mut saw_wallet = false;
231 let mut saw_migrate = false;
232 for tok in &invocation_cmds {
233 if tok == "wallet" { saw_wallet = true; }
234 if tok == "-m" || tok == "--migrate" { saw_migrate = true; }
235 }
236 saw_wallet && saw_migrate
237 };
238 if is_wallet_migrate {
239 res!(migrate_legacy_wallet_inline(&cfg));
240 return Ok(());
241 }
242
243 // ┌───────────────────────────────────────────────────────────────────────┐
244 // │ WALLET LOAD (NOT UNLOCK) │
245 // │ │
246 // │ The wallet holds one or more admin entries. Each admin has its own │
247 // │ password-wrapped copy of the same wallet master key; any of them can │
248 // │ unlock the wallet with their own password. The master key is used as │
249 // │ the Ozone database encryption key. │
250 // │ │
251 // │ The wallet is *loaded* here but deliberately NOT unlocked. Steel │
252 // │ starts sealed. The wrapped keys are useless without a passphrase, so │
253 // │ the file is safe to read, and reading it is enough to authenticate an │
254 // │ admin later -- which is what lets the dashboard offer an unseal form │
255 // │ that needs no database behind it. │
256 // │ │
257 // │ Demanding the passphrase here would make the *database* key a │
258 // │ precondition for the *websites* being up, which is backwards: a │
259 // │ static site does not touch Ozone. It also made every restart an │
260 // │ outage that waited on a human at a terminal, and made a headless │
261 // │ start (systemd, cron) impossible -- no tty, no prompt, crash loop. │
262 // │ │
263 // │ So: no prompt at start-up. `STEEL_ADMIN_PASS` is still honoured for │
264 // │ development and scripted tests, and the operator can type `unseal` at │
265 // │ the shell before `server` if they want the databases open from the │
266 // │ first request. Otherwise Steel binds, serves, renews certificates, │
267 // │ and waits for an admin to unseal at /admin. │
268 // │ │
269 // │ There is still deliberately no disk-resident fallback. A wallet that │
270 // │ can be unlocked with a secret stored on the same disk it protects │
271 // │ provides no real defence against the threat model it was built for -- │
272 // │ disk theft. │
273 // └───────────────────────────────────────────────────────────────────────┘
274 let wallet_path = Path::new("./").join(constant::WALLET_NAME);
275 let (wallet, db_default_enc_key, unlocked_admin_name, unlocked_admin_scopes) =
276 if wallet_path.is_file() {
277 // Tighten a wallet that predates `save_secret` before loading it.
278 res!(core_file::restrict_secret(&wallet_path));
279 let wallet = res!(Wallet::load(
280 wallet_path,
281 Some(DecoderConfig::<(), ()>::default()),
282 ));
283 // Only the environment variable unlocks eagerly. Absent it, stay
284 // sealed -- `AppShellContext::require_master_key` prompts if and
285 // when a command actually needs the key.
286 match std::env::var(constant::ADMIN_PASS_ENV) {
287 Ok(s) => {
288 let pass = Secret::new(s);
289 let unlocked = match wallet.unlock(pass.expose_secret().as_bytes()) {
290 Ok(u) => u,
291 Err(e) => {
292 println!("Wallet unlock failed: {}.", e);
293 return Ok(());
294 }
295 };
296 info!("Wallet unlocked by admin '{}' via {}.",
297 unlocked.admin_name, constant::ADMIN_PASS_ENV);
298 let key = unlocked.master_key.expose_secret().clone();
299 let name = unlocked.admin_name.clone();
300 let scopes = unlocked.admin_scopes.clone();
301 (wallet, Some(key), name, scopes)
302 }
303 Err(_) => {
304 info!("Wallet loaded, sealed. {} admin entr{} available to unseal.",
305 wallet.admins().len(),
306 if wallet.admins().len() == 1 { "y" } else { "ies" });
307 (wallet, None, String::new(), Vec::new())
308 }
309 }
310 } else {
311 // ┌───────────────────────┐
312 // │ Wallet not found. │
313 // └───────────────────────┘
314 println!(
315 "There is no {} file.\nYou can replace it with a backup and restart, or create a new one.",
316 constant::WALLET_NAME,
317 );
318 println!("What would you like to do?");
319 println!(" 1. Exit, replace with a backup file, and restart.");
320 println!(" 2. Create a new {} file.", constant::WALLET_NAME);
321 print!("Please choose: ");
322 res!(std::io::stdout().flush());
323 let mut choice = String::new();
324 res!(std::io::stdin().read_line(&mut choice));
325 match choice.trim() {
326 "1" => {
327 println!("Ok, good luck!");
328 return Ok(());
329 },
330 "2" => {
331 // ┌───────────────────────┐
332 // │ Create the wallet │
333 // │ from scratch with one │
334 // │ operator admin entry. │
335 // └───────────────────────┘
336 println!("Ok, let's create the first wallet admin and passphrase.");
337 print!("Admin name (default 'operator'): ");
338 res!(std::io::stdout().flush());
339 let mut name_in = String::new();
340 res!(std::io::stdin().read_line(&mut name_in));
341 let admin_name = match name_in.trim() {
342 "" => "operator".to_string(),
343 s => s.to_string(),
344 };
345 let pass = res!(UserInput::create_pass(constant::MAX_CREATE_PASS_ATTEMPTS));
346 let pass_bytes = pass.expose_secret().as_bytes();
347
348 let mut metadata = BTreeMap::new();
349 metadata.insert(dat!("app_name"), dat!(cfg.app_name.clone()));
350 metadata.insert(dat!("app_root"), dat!(cfg.app_root.clone()));
351 metadata.insert(dat!("this_dir"), dat!(cwd_str));
352
353 let (wallet, unlocked) = res!(Wallet::create_with_first_admin(
354 metadata,
355 admin_name,
356 pass_bytes,
357 DEFAULT_WALLET_KDF_NAME,
358 ));
359 res!(wallet.save_secret(
360 &wallet_path,
361 " ",
362 Some(EncoderConfig::<(), ()>::default()),
363 ));
364 println!("Thank you, {:?} created.", wallet_path);
365 // A wallet just created from a passphrase the operator typed
366 // is, by definition, unlocked -- start unsealed.
367 let db_default_enc_key = unlocked.master_key.expose_secret().clone();
368 let name = unlocked.admin_name.clone();
369 let scopes = unlocked.admin_scopes.clone();
370 (wallet, Some(db_default_enc_key), name, scopes)
371 },
372 _ => return Err(err!(
373 "Invalid response, goodbye!";
374 Invalid, Input)),
375 }
376 };
377
378 // ┌───────────────────────────────────────────────────────────────────────────────────────────┐
379 // │ EXECUTION STEP │
380 // │ Functions can be executed directly from the command line, or within a shell. If no │
381 // │ commands are supplied, the user is presented with the shell. The command line is │
382 // │ technically part of the shell. │
383 // └───────────────────────────────────────────────────────────────────────────────────────────┘
384
385 let app_root = Path::new(&cfg.app_root);
386 match dev::setup(&app_root) {
387 Ok(s) => {
388 if !s.is_empty() {
389 warn!("{}", s);
390 }
391 }
392 Err(e) => return Err(err!(e, "While setting up dev environment."; Init)),
393 }
394
395 // Build the syntax tree, then let the extension contribute its
396 // own commands so they show up in `help` alongside Steel's
397 // built-ins.
398 let mut syntax_builder = res!(app_syntax::new_shell_raw(
399 &cfg.app_human_name,
400 &constant::VERSION,
401 &fmt!("{} app: {}", cfg.app_human_name, cfg.app_description),
402 ));
403 syntax_builder = res!(extension.extend_syntax(syntax_builder));
404 let syntax = oxedyne_fe2o3_syntax::core::SyntaxRef::new(syntax_builder);
405
406 // Wrap the extension once and use the Arc clones from here on:
407 // one clone lives in AppShellContext for CLI dispatch; another
408 // is consumed below to drain its handlers into the registries.
409 let extension_arc: Arc<dyn AppExtension> = Arc::new(extension);
410
411 // Drain webhook + API handlers from the extension into their
412 // registries.
413 let mut webhook_registry = WebhookRegistry::new();
414 for (name, h) in extension_arc.webhook_handlers() {
415 webhook_registry.insert_boxed(name, h);
416 }
417 let mut api_handler_registry = ApiHandlerRegistry::new();
418 for (name, h) in extension_arc.api_handlers() {
419 api_handler_registry.insert_boxed(name, h);
420 }
421
422 let mut context = AppShellContext {
423 stat: app_status,
424 app_cfg: cfg.clone(),
425 syntax,
426 ws: BTreeMap::new(),
427 db_enc_key: db_default_enc_key,
428 wallet: Arc::new(RwLock::new(wallet)),
429 unlocked_admin_name,
430 unlocked_admin_scopes,
431 webhook_registry: Arc::new(webhook_registry),
432 api_handler_registry: Arc::new(api_handler_registry),
433 extension: extension_arc,
434 };
435
436 let mut shell_cfg = ShellConfig::default();
437
438 if invocation_cmds.len() > 0 {
439 match context.execute(invocation_cmds, &shell_cfg) {
440 Ok(evals) => for eval in evals {
441 match eval {
442 Evaluation::Output(s) => println!("{}", s),
443 Evaluation::Exit => {
444 println!("Exiting {} now.", cfg.app_human_name);
445 }
446 _ => (),
447 }
448 }
449 Err(e) => {
450 //println!("{} error: {}", cfg.app_human_name, e);
451 return Err(e);
452 }
453 }
454 } else {
455 shell_cfg.greeting_msg =
456 fmt!("Welcome, type \"help\" for a help menu.");
457 res!(context.start_shell(&shell_cfg, None));
458 }
459
460 Ok(())
461}
462
463
464fn migrate_legacy_wallet_inline(_cfg: &AppConfig) -> Outcome<()> {
465 use std::io::Write;
466 use oxedyne_fe2o3_core::mem::Extract;
467 use oxedyne_fe2o3_hash::kdf::KeyDerivationScheme;
468 use oxedyne_fe2o3_iop_hash::kdf::KeyDeriver;
469 use oxedyne_fe2o3_crypto::keystore::{
470 AdminUser,
471 DEFAULT_WALLET_KDF_NAME,
472 };
473
474 let wallet_path = Path::new("./").join(constant::WALLET_NAME);
475 if !wallet_path.is_file() {
476 println!("No wallet file to migrate at {:?}.", wallet_path);
477 return Ok(());
478 }
479 let text = res!(std::fs::read_to_string(&wallet_path));
480 let mut dat = res!(Dat::decode_string(&text));
481 if dat.kind() != Kind::OrdMap && dat.kind() != Kind::Map {
482 return Err(err!(
483 "Legacy wallet at {:?} is not a map (kind={:?}).",
484 wallet_path, dat.kind();
485 Input, Invalid, Mismatch));
486 }
487 // Already new layout?
488 if let Ok(_) = dat.map_get_must(&dat!("admins")) {
489 println!("Wallet at {:?} is already in the admin-user layout.",
490 wallet_path);
491 return Ok(());
492 }
493
494 let pass = res!(UserInput::ask_for_secret(
495 Some("Enter the current wallet passphrase: "),
496 ));
497 let pass_bytes = pass.expose_secret().as_bytes();
498
499 // Verify the passphrase against the legacy passhash ring buffer.
500 let ring = res!(dat.map_remove_must(&dat!("wallet_pass_hashes")));
501 let current_hash_dat = {
502 // The ring buffer serialises as `Tup2(list, index)` where
503 // `list` is a Vek of `Opt<Tup2(data, timestamp)>`. Extract
504 // the current slot's `data` daticle, which is the kdf map.
505 let mut ring_parts = oxedyne_fe2o3_jdat::try_extract_tup2dat!(ring);
506 let index: u64 = match ring_parts[1].extract() {
507 Dat::U64(n) => n,
508 other => return Err(err!(
509 "Legacy ring buffer index must be u64 (got {:?}).", other.kind();
510 Invalid, Input)),
511 };
512 let list = oxedyne_fe2o3_jdat::try_extract_dat!(
513 ring_parts[0].extract(),
514 Vek,
515 );
516 let slot = ok!(list.into_iter().nth(index as usize).ok_or_else(|| err!(
517 "Legacy ring buffer index {} out of range.", index;
518 Input, Invalid, Mismatch)));
519 let some = match slot {
520 Dat::Opt(inner) => match *inner {
521 Some(d) => d,
522 None => return Err(err!(
523 "Legacy ring buffer current slot is None.";
524 Input, Missing)),
525 },
526 other => return Err(err!(
527 "Legacy ring buffer slot must be Opt (got {:?}).", other.kind();
528 Input, Invalid, Mismatch)),
529 };
530 let mut slot_parts = oxedyne_fe2o3_jdat::try_extract_tup2dat!(some);
531 slot_parts[0].extract()
532 };
533 let app_kdf_name = try_extract_dat!(
534 res!(current_hash_dat.map_get_must(&dat!("kdf_name"))),
535 Str,
536 );
537 let app_kdf_hash = try_extract_dat!(
538 res!(current_hash_dat.map_get_must(&dat!("kdf_hash"))),
539 Str,
540 );
541 let mut app_kdf = res!(KeyDerivationScheme::from_str(&app_kdf_name));
542 res!(app_kdf.decode_from_string(&app_kdf_hash));
543 if !res!(app_kdf.verify(pass_bytes)) {
544 println!("Passphrase rejected -- nothing migrated.");
545 return Ok(());
546 }
547
548 // Derive the legacy database encryption key -- becomes the new
549 // master key unchanged.
550 let app_hashes = res!(dat.map_remove_must(&dat!("app_hashes")));
551 let default_entry = res!(app_hashes.map_get_must(&dat!("default"))).clone();
552 let db_kdf_name = try_extract_dat!(
553 res!(default_entry.map_get_must(&dat!("kdf_name"))),
554 Str,
555 );
556 let db_kdf_cfg = try_extract_dat!(
557 res!(default_entry.map_get_must(&dat!("kdf_cfg"))),
558 Str,
559 );
560 let mut db_kdf = res!(KeyDerivationScheme::from_str(&db_kdf_name));
561 res!(db_kdf.decode_cfg_from_string(&db_kdf_cfg));
562 res!(db_kdf.derive(pass_bytes));
563 let master_key = res!(db_kdf.get_hash()).to_vec();
564
565 let metadata = match dat.map_remove(&dat!("metadata")) {
566 Ok(Some(d)) => try_extract_dat!(d, Map),
567 _ => DaticleMap::new(),
568 };
569
570 print!("New admin name (default 'operator'): ");
571 res!(std::io::stdout().flush());
572 let mut name_in = String::new();
573 res!(std::io::stdin().read_line(&mut name_in));
574 let admin_name = match name_in.trim() {
575 "" => "operator".to_string(),
576 s => s.to_string(),
577 };
578
579 let admin = res!(AdminUser::new(
580 admin_name.clone(),
581 pass_bytes,
582 &master_key,
583 DEFAULT_WALLET_KDF_NAME,
584 vec!["*".to_string()],
585 0,
586 ));
587 let new_wallet = Wallet::new(metadata, vec![admin], DaticleMap::new());
588
589 // A plain `fs::copy` would carry the source's mode, so the backup holds
590 // the same passphrase verifier and key-derivation material at a wider
591 // mode than the original.
592 let backup_path = Path::new("./").join(fmt!("{}.pre-admins", constant::WALLET_NAME));
593 let old_wallet_bytes = match std::fs::read(&wallet_path) {
594 Ok(b) => b,
595 Err(e) => return Err(err!(e,
596 "Reading {:?} to back it up before the admin-user migration.", wallet_path;
597 IO, File, Read)),
598 };
599 res!(core_file::save_secret(&backup_path, &old_wallet_bytes));
600 res!(new_wallet.save_secret(
601 &wallet_path,
602 " ",
603 Some(EncoderConfig::<(), ()>::default()),
604 ));
605 println!();
606 println!("Migrated wallet to the admin-user layout.");
607 println!(" New admin: '{}'", admin_name);
608 println!(" Passphrase: unchanged (the one you just typed)");
609 println!(" Backup: {:?}", backup_path);
610 println!();
611 Ok(())
612}
613