1use std::io::BufRead;
2use std::sync::Arc;
3
4use clap::Subcommand;
5
6use crate::admin::{self, DeleteOutcome};
7use crate::cli::CliError;
8use crate::cli::render;
9use crate::cli::style::Palette;
10use crate::cli::window::{DEFAULT_LIMIT, Window};
11use crate::config::Config;
12use crate::signer;
13use crate::sqlite::authz::Authorization;
14use crate::sqlite::db::Database;
15use crate::sqlite::order::{Order, OrderQuery};
16use crate::sqlite::status::OrderStatus;
17
18#[derive(Subcommand)]
19pub enum OrderCommand {
20 List {
22 #[arg(long)]
24 profile: Option<String>,
25 #[arg(long = "account-id")]
26 account_id: Option<String>,
27 #[arg(long)]
28 status: Option<String>,
29 #[arg(long = "expiring-in")]
32 expiring_in: Option<u64>,
33 #[arg(long = "hide-superseded")]
36 hide_superseded: bool,
37 #[arg(long, default_value_t = DEFAULT_LIMIT)]
38 limit: i64,
39 #[arg(long, default_value_t = 0)]
40 offset: i64,
41 #[arg(long)]
42 json: bool,
43 },
44 Show {
46 id: String,
47 #[arg(long)]
48 json: bool,
49 },
50 Delete { id: String },
52 Revoke {
54 id: String,
55 #[arg(long)]
56 reason: Option<u32>,
57 },
58}
59
60pub async fn run_order_command(
61 command: OrderCommand,
62 yes: bool,
63 palette: Palette,
64 reader: &mut impl BufRead,
65 config: &Config,
66 database: Arc<Database>,
67) -> Result<(), CliError> {
68 match command {
69 OrderCommand::List {
70 profile,
71 account_id,
72 status,
73 expiring_in,
74 hide_superseded,
75 limit,
76 offset,
77 json,
78 } => {
79 let window = Window::resolve(limit, offset);
80
81 if let Some(days) = expiring_in {
89 return run_expiring(
90 days,
91 profile,
92 account_id.as_deref(),
93 status.as_deref(),
94 hide_superseded,
95 window,
96 json,
97 palette,
98 database,
99 )
100 .await;
101 }
102 if hide_superseded {
103 return Err(CliError(
104 "--hide-superseded needs --expiring-in: it filters on the supersession \
105 annotation, which only the expiry listing carries"
106 .to_string(),
107 ));
108 }
109
110 let status = status
114 .map(|value| value.parse::<OrderStatus>())
115 .transpose()
116 .map_err(|error| CliError(format!("--status: {error}")))?;
117
118 let query = OrderQuery {
123 profile,
124 account_id,
125 status,
126 limit: window.limit,
127 offset: window.offset,
128 };
129 let (orders, total) = Order::search(&query, &database).await?;
130 if json {
139 let ids: Vec<&str> = orders.iter().map(|o| o.id.as_str()).collect();
140 let mut authz_ids = Authorization::find_ids_by_orders(&ids, &database).await?;
141 let rendered: Vec<_> = orders
142 .iter()
143 .map(|order| {
144 admin::render_order_json(
145 order,
146 &config.server.base_url,
147 &authz_ids.remove(&order.id).unwrap_or_default(),
148 )
149 })
150 .collect();
151 println!("{}", render::json_page(rendered, total, window));
152 } else {
153 for order in &orders {
154 println!("{}", render::render_order_line(order, palette));
155 }
156 render::print_footer(orders.len(), total);
157 }
158 }
159 OrderCommand::Show { id, json } => match admin::load_order_detail(&id, database).await? {
160 None => return Err(not_found(&id)),
161 Some(detail) if json => {
162 println!(
163 "{}",
164 admin::render_order_detail_json(&detail, &config.server.base_url)
165 );
166 }
167 Some(detail) => print!("{}", render::render_order_detail_text(&detail, palette)),
168 },
169 OrderCommand::Delete { id } => {
170 match admin::confirm_delete_order(&id, yes, reader, database).await? {
171 DeleteOutcome::NotFound => return Err(not_found(&id)),
172 DeleteOutcome::Cancelled => println!("Cancelled."),
173 DeleteOutcome::Deleted => println!("Deleted order {id}."),
174 }
175 }
176 OrderCommand::Revoke { id, reason } => {
177 let Some(order) = Order::find_by_id(&id, &database).await? else {
180 return Err(not_found(&id));
181 };
182 let profiles = config
183 .resolve_profiles()
184 .map_err(|error| CliError(format!("configuration error: {error}")))?;
185 let Some(profile) = profiles.iter().find(|p| p.name == order.profile) else {
186 return Err(CliError(format!(
187 "order {id} was issued by profile `{}`, which this configuration does not \
188 define — revoking it needs the endpoint that signed it",
189 order.profile
190 )));
191 };
192 let egress = Arc::new(
200 crate::Egress::from_config(config)
201 .map_err(|error| CliError(format!("configuration error: {error}")))?,
202 );
203 let jobs = crate::jobs::JobQueue::new(database.clone(), &config.jobs);
208 let metrics = Arc::new(crate::metrics::Metrics::new(database.clone()));
212 let signer = signer::from_config(
213 &profile.sections.signer,
214 vec![profile.name.clone()],
215 &signer::SignerParts {
216 database: database.clone(),
217 notifiers: std::collections::HashMap::new().into(),
218 metrics,
219 egress,
220 jobs,
221 },
222 &signer::CarriedState::new(),
225 )
226 .map_err(|error| CliError(format!("signer error: {error}")))?;
227 match admin::revoke_order(
230 &id,
231 reason,
232 crate::audit::Actor::cli(),
233 crate::audit::ClientContext::default(),
234 database,
235 signer,
236 )
237 .await
238 .map_err(|error| CliError(error.to_string()))?
239 {
240 admin::RevokeOutcome::NotFound => return Err(not_found(&id)),
241 admin::RevokeOutcome::NotIssued => {
242 return Err(CliError(format!("order {id} has no issued certificate")));
243 }
244 admin::RevokeOutcome::AlreadyRevoked => {
245 return Err(CliError(format!(
246 "order {id}'s certificate is already revoked"
247 )));
248 }
249 admin::RevokeOutcome::Revoked(order) => {
250 println!("{}", render::render_order_line(&order, palette));
251 }
252 }
253 }
254 }
255 Ok(())
256}
257
258#[allow(clippy::too_many_arguments)]
273async fn run_expiring(
274 days: u64,
275 profile: Option<String>,
276 account_id: Option<&str>,
277 status: Option<&str>,
278 hide_superseded: bool,
279 window: Window,
280 json: bool,
281 palette: Palette,
282 database: Arc<Database>,
283) -> Result<(), CliError> {
284 if status.is_some() {
285 return Err(CliError(
286 "--status does not apply with --expiring-in: the expiry listing is issued, \
287 unrevoked certificates by definition, so a status filter here would mean \
288 something other than it does everywhere else"
289 .to_string(),
290 ));
291 }
292 if account_id.is_some() {
293 return Err(CliError(
294 "--account-id does not apply with --expiring-in: the expiry listing has no \
295 account predicate, and answering as though it did would report one \
296 subscriber's certificates as every subscriber's"
297 .to_string(),
298 ));
299 }
300
301 let query = admin::ExpiringQuery {
302 profile,
303 before: admin::expiring_horizon(days),
304 include_superseded: !hide_superseded,
305 limit: window.limit,
306 offset: window.offset,
307 };
308 let (entries, total, hidden) = admin::list_expiring(&query, database).await?;
309 if json {
310 let items = entries.iter().map(admin::render_expiring_json).collect();
311 let mut envelope = render::json_page(items, total, window);
312 if let Some(object) = envelope.as_object_mut() {
313 object.insert("hidden".to_string(), serde_json::json!(hidden));
317 object.insert("days".to_string(), serde_json::json!(days));
318 }
319 println!("{envelope}");
320 } else {
321 for entry in &entries {
322 println!("{}", render::render_expiring_line(entry, palette));
323 }
324 render::print_expiring_footer(entries.len(), total, hidden);
325 }
326 Ok(())
327}
328
329fn not_found(id: &str) -> CliError {
330 CliError(format!("no such order: {id}"))
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336 use crate::audit::ClientContext;
337 use crate::signer::{IssueOutcome, RequestedValidity, SignerBackend};
338 use crate::sqlite::account::Account;
339
340 fn config_in(dir: impl AsRef<std::path::Path>, profile: &str) -> Config {
344 let dir = dir.as_ref();
345 let _lock = crate::config::ENV_LOCK
346 .lock()
347 .unwrap_or_else(std::sync::PoisonError::into_inner);
348 let ca = dir.join("ca");
349 std::fs::write(
350 dir.join("config.toml"),
351 format!(
352 r#"
353 [profiles.{profile}]
354 signer.local_ca.cert_path = "{ca}.pem"
355 signer.local_ca.key_path = "{ca}.key"
356 signer.local_ca.crl_path = "{ca}.crl"
357 "#,
358 ca = ca.display(),
359 ),
360 )
361 .unwrap();
362 unsafe {
365 std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
366 }
367 let config = Config::load().expect("the configuration must load");
368 unsafe {
369 std::env::remove_var("ACME_PROXY_CONFIG");
370 }
371 config
372 }
373
374 fn temp_dir() -> crate::testutil::TempDir {
375 crate::testutil::TempDir::new("cli-order")
376 }
377
378 async fn seed_order(database: &Arc<Database>, profile: &str) -> Order {
379 let (account, _) = Account::find_or_create(
380 profile,
381 &[4, 5, 6],
382 vec![],
383 &ClientContext::default(),
384 database,
385 )
386 .await
387 .unwrap();
388 Order::create(
389 profile,
390 &account.id,
391 vec![crate::sqlite::order::Identifier::dns("example.com")],
392 crate::sqlite::nonce::now_secs() + 3600,
393 None,
394 None,
395 database,
396 )
397 .await
398 .unwrap()
399 }
400
401 async fn issue_onto(order: &mut Order, config: &Config, database: Arc<Database>) {
404 let profile = &config.resolve_profiles().unwrap()[0];
405 let resolver = crate::dns::resolver_addr(&config.dns)
406 .and_then(crate::challenge::build_resolver)
407 .expect("the default dns configuration must build a resolver");
408 let signer: Arc<dyn SignerBackend> = signer::from_config(
409 &profile.sections.signer,
410 vec![profile.name.clone()],
411 &crate::testutil::signer_parts(database.clone(), resolver),
412 &signer::CarriedState::new(),
413 )
414 .unwrap();
415
416 let key_pair = rcgen::KeyPair::generate().unwrap();
417 let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
418 let csr = params.serialize_request(&key_pair).unwrap();
419 let chain = match signer
420 .issue(
421 &order.id,
422 csr.der(),
423 &order.identifiers,
424 RequestedValidity::default(),
425 )
426 .await
427 .unwrap()
428 {
429 IssueOutcome::Issued(chain) => chain,
430 IssueOutcome::Processing => panic!("the local CA issues synchronously"),
431 };
432 let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
433 let (serial, pubkey) = crate::cert::cert_serial_and_spki(&leaf).unwrap();
434 let not_after = crate::cert::cert_validity(&leaf).ok().map(|(_, na)| na);
435 order
436 .finalize(chain, serial, pubkey, not_after, &database)
437 .await
438 .unwrap();
439 }
440
441 #[tokio::test]
442 async fn every_arm_refuses_an_unknown_order() {
443 let database = Arc::new(Database::connect_in_memory().await.unwrap());
444 let config = Config::default();
445 let expected = CliError("no such order: ord-nope".to_string());
446
447 let commands = vec![
448 OrderCommand::Show {
449 id: "ord-nope".to_string(),
450 json: false,
451 },
452 OrderCommand::Delete {
453 id: "ord-nope".to_string(),
454 },
455 OrderCommand::Revoke {
456 id: "ord-nope".to_string(),
457 reason: None,
458 },
459 ];
460 for command in commands {
461 let mut reader: &[u8] = &[];
462 let error = run_order_command(
463 command,
464 true,
465 Palette::plain(),
466 &mut reader,
467 &config,
468 database.clone(),
469 )
470 .await
471 .expect_err("an unknown order must fail");
472 assert_eq!(error, expected);
473 }
474 }
475
476 #[tokio::test]
480 async fn revoking_an_order_from_an_undefined_profile_is_refused() {
481 let dir = temp_dir();
482 let database = Arc::new(Database::connect_in_memory().await.unwrap());
483 let order = seed_order(&database, "default").await;
485 let config = config_in(&dir, "other");
486
487 let mut reader: &[u8] = &[];
488 let error = run_order_command(
489 OrderCommand::Revoke {
490 id: order.id.clone(),
491 reason: None,
492 },
493 true,
494 Palette::plain(),
495 &mut reader,
496 &config,
497 database,
498 )
499 .await
500 .expect_err("a profile this configuration does not define must be refused");
501 assert!(
502 error.to_string().contains("which this configuration"),
503 "{error}"
504 );
505 }
506
507 #[tokio::test]
509 async fn revoking_without_a_resolvable_configuration_is_refused() {
510 let database = Arc::new(Database::connect_in_memory().await.unwrap());
511 let order = seed_order(&database, "default").await;
512
513 let mut reader: &[u8] = &[];
514 let error = run_order_command(
515 OrderCommand::Revoke {
516 id: order.id,
517 reason: None,
518 },
519 true,
520 Palette::plain(),
521 &mut reader,
522 &Config::default(),
523 database,
524 )
525 .await
526 .expect_err("a configuration mounting nothing must be refused");
527 assert!(
528 error.to_string().starts_with("configuration error: "),
529 "{error}"
530 );
531 }
532
533 #[tokio::test]
534 async fn revoking_an_order_with_no_certificate_is_refused() {
535 let dir = temp_dir();
536 let database = Arc::new(Database::connect_in_memory().await.unwrap());
537 let order = seed_order(&database, "default").await;
538 let config = config_in(&dir, "default");
539
540 let mut reader: &[u8] = &[];
541 let error = run_order_command(
542 OrderCommand::Revoke {
543 id: order.id.clone(),
544 reason: None,
545 },
546 true,
547 Palette::plain(),
548 &mut reader,
549 &config,
550 database,
551 )
552 .await
553 .expect_err("there is nothing to revoke");
554 assert_eq!(
555 error,
556 CliError(format!("order {} has no issued certificate", order.id))
557 );
558 }
559
560 #[tokio::test]
563 async fn an_issued_order_revokes_once() {
564 let dir = temp_dir();
565 let database = Arc::new(Database::connect_in_memory().await.unwrap());
566 let config = config_in(&dir, "default");
567 let mut order = seed_order(&database, "default").await;
568 issue_onto(&mut order, &config, database.clone()).await;
569
570 let mut reader: &[u8] = &[];
571 run_order_command(
572 OrderCommand::Revoke {
573 id: order.id.clone(),
574 reason: Some(1),
575 },
576 true,
577 Palette::plain(),
578 &mut reader,
579 &config,
580 database.clone(),
581 )
582 .await
583 .expect("a certificate issued by this profile's CA must revoke");
584
585 assert!(
586 Order::find_by_id(&order.id, &database)
587 .await
588 .unwrap()
589 .unwrap()
590 .revoked_at
591 .is_some()
592 );
593
594 let error = run_order_command(
595 OrderCommand::Revoke {
596 id: order.id.clone(),
597 reason: None,
598 },
599 true,
600 Palette::plain(),
601 &mut reader,
602 &config,
603 database.clone(),
604 )
605 .await
606 .expect_err("a second revocation has nothing left to do");
607 assert_eq!(
608 error,
609 CliError(format!(
610 "order {}'s certificate is already revoked",
611 order.id
612 ))
613 );
614 }
615
616 #[tokio::test]
619 async fn an_invalid_revocation_reason_is_refused() {
620 let dir = temp_dir();
621 let database = Arc::new(Database::connect_in_memory().await.unwrap());
622 let config = config_in(&dir, "default");
623 let mut order = seed_order(&database, "default").await;
624 issue_onto(&mut order, &config, database.clone()).await;
625
626 let mut reader: &[u8] = &[];
627 let error = run_order_command(
628 OrderCommand::Revoke {
629 id: order.id.clone(),
630 reason: Some(7),
631 },
632 true,
633 Palette::plain(),
634 &mut reader,
635 &config,
636 database,
637 )
638 .await
639 .expect_err("7 is not a defined CRLReason");
640 assert!(error.to_string().contains('7'), "{error}");
641 }
642
643 #[tokio::test]
645 async fn a_declined_delete_is_not_a_failure() {
646 let database = Arc::new(Database::connect_in_memory().await.unwrap());
647 let order = seed_order(&database, "default").await;
648
649 let mut reader: &[u8] = b"n\n";
650 run_order_command(
651 OrderCommand::Delete {
652 id: order.id.clone(),
653 },
654 false,
655 Palette::plain(),
656 &mut reader,
657 &Config::default(),
658 database.clone(),
659 )
660 .await
661 .unwrap();
662
663 assert!(
664 Order::find_by_id(&order.id, &database)
665 .await
666 .unwrap()
667 .is_some()
668 );
669 }
670
671 #[tokio::test]
674 async fn the_json_arms_render() {
675 let database = Arc::new(Database::connect_in_memory().await.unwrap());
676 let order = seed_order(&database, "default").await;
677
678 let mut reader: &[u8] = &[];
679 for command in [
680 OrderCommand::List {
681 profile: Some("default".to_string()),
682 account_id: None,
683 status: None,
684 expiring_in: None,
685 hide_superseded: false,
686 limit: DEFAULT_LIMIT,
687 offset: 0,
688 json: true,
689 },
690 OrderCommand::Show {
691 id: order.id.clone(),
692 json: true,
693 },
694 OrderCommand::Show {
695 id: order.id.clone(),
696 json: false,
697 },
698 ] {
699 run_order_command(
700 command,
701 true,
702 Palette::plain(),
703 &mut reader,
704 &Config::default(),
705 database.clone(),
706 )
707 .await
708 .unwrap();
709 }
710 }
711
712 #[tokio::test]
718 async fn an_unknown_status_is_refused_by_name_rather_than_matching_nothing() {
719 let database = Arc::new(Database::connect_in_memory().await.unwrap());
720 seed_order(&database, "default").await;
721
722 let mut reader: &[u8] = &[];
723 let error = run_order_command(
724 OrderCommand::List {
725 profile: None,
726 account_id: None,
727 status: Some("readyy".to_string()),
728 expiring_in: None,
729 hide_superseded: false,
730 limit: DEFAULT_LIMIT,
731 offset: 0,
732 json: false,
733 },
734 true,
735 Palette::plain(),
736 &mut reader,
737 &Config::default(),
738 database.clone(),
739 )
740 .await
741 .unwrap_err();
742
743 assert!(error.0.contains("--status"), "{error}");
744 assert!(error.0.contains("`readyy`"), "{error}");
745 assert!(
747 error
748 .0
749 .contains("pending, ready, processing, valid, invalid"),
750 "{error}"
751 );
752 }
753
754 #[tokio::test]
759 async fn every_order_status_is_accepted_as_a_filter() {
760 let database = Arc::new(Database::connect_in_memory().await.unwrap());
761 seed_order(&database, "default").await;
762
763 let mut reader: &[u8] = &[];
764 for status in OrderStatus::ALL {
765 run_order_command(
766 OrderCommand::List {
767 profile: None,
768 account_id: None,
769 status: Some(status.as_str().to_string()),
770 expiring_in: None,
771 hide_superseded: false,
772 limit: DEFAULT_LIMIT,
773 offset: 0,
774 json: false,
775 },
776 true,
777 Palette::plain(),
778 &mut reader,
779 &Config::default(),
780 database.clone(),
781 )
782 .await
783 .unwrap_or_else(|error| panic!("--status {status} was refused: {error}"));
784 }
785 }
786
787 async fn list_with(
790 expiring_in: Option<u64>,
791 account_id: Option<&str>,
792 status: Option<&str>,
793 hide_superseded: bool,
794 json: bool,
795 database: Arc<Database>,
796 ) -> Result<(), CliError> {
797 let mut reader: &[u8] = &[];
798 run_order_command(
799 OrderCommand::List {
800 profile: None,
801 account_id: account_id.map(str::to_string),
802 status: status.map(str::to_string),
803 expiring_in,
804 hide_superseded,
805 limit: DEFAULT_LIMIT,
806 offset: 0,
807 json,
808 },
809 true,
810 Palette::plain(),
811 &mut reader,
812 &Config::default(),
813 database,
814 )
815 .await
816 }
817
818 #[tokio::test]
821 async fn the_expiring_arm_lists_and_renders_both_ways() {
822 let database = Arc::new(Database::connect_in_memory().await.unwrap());
823 let acct = crate::testutil::account_id(&database).await;
824 crate::testutil::issued_order(&database, "default", &acct, &["a.example.com"], 3).await;
825 crate::testutil::issued_order(&database, "default", &acct, &["b.example.com"], 5).await;
826 crate::testutil::issued_order(&database, "default", &acct, &["a.example.com"], 90).await;
828
829 for json in [false, true] {
830 list_with(Some(30), None, None, false, json, database.clone())
831 .await
832 .unwrap();
833 list_with(Some(30), None, None, true, json, database.clone())
835 .await
836 .unwrap();
837 }
838 }
839
840 #[tokio::test]
846 async fn both_listings_take_a_window_and_clamp_a_nonsense_one() {
847 let database = Arc::new(Database::connect_in_memory().await.unwrap());
848 seed_order(&database, "default").await;
849
850 let mut reader: &[u8] = &[];
851 for expiring_in in [None, Some(30)] {
852 for (limit, offset, json) in
853 [(1, 0, false), (1, 1, false), (1, 0, true), (0, -5, false)]
854 {
855 run_order_command(
856 OrderCommand::List {
857 profile: None,
858 account_id: None,
859 status: None,
860 expiring_in,
861 hide_superseded: false,
862 limit,
863 offset,
864 json,
865 },
866 true,
867 Palette::plain(),
868 &mut reader,
869 &Config::default(),
870 database.clone(),
871 )
872 .await
873 .unwrap_or_else(|error| {
874 panic!(
875 "--expiring-in {expiring_in:?} --limit {limit} --offset {offset}: {error}"
876 )
877 });
878 }
879 }
880 }
881
882 #[tokio::test]
889 async fn the_flags_that_do_not_compose_with_expiring_in_are_refused_by_name() {
890 let database = Arc::new(Database::connect_in_memory().await.unwrap());
891 seed_order(&database, "default").await;
892
893 let error = list_with(
894 Some(30),
895 None,
896 Some("valid"),
897 false,
898 false,
899 database.clone(),
900 )
901 .await
902 .unwrap_err();
903 assert!(error.0.contains("--status"), "{error}");
904 assert!(error.0.contains("--expiring-in"), "{error}");
905
906 let error = list_with(
907 Some(30),
908 Some("acct-1"),
909 None,
910 false,
911 false,
912 database.clone(),
913 )
914 .await
915 .unwrap_err();
916 assert!(error.0.contains("--account-id"), "{error}");
917 assert!(error.0.contains("--expiring-in"), "{error}");
918
919 let error = list_with(None, None, None, true, false, database.clone())
920 .await
921 .unwrap_err();
922 assert!(error.0.contains("--hide-superseded"), "{error}");
923 assert!(error.0.contains("--expiring-in"), "{error}");
924
925 list_with(None, None, Some("valid"), false, false, database)
927 .await
928 .unwrap();
929 }
930}