Skip to main content

acme_proxy/cli/
order.rs

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::config::Config;
11use crate::signer;
12use crate::sqlite::authz::Authorization;
13use crate::sqlite::db::Database;
14use crate::sqlite::order::{Order, OrderQuery};
15use crate::sqlite::status::OrderStatus;
16
17#[derive(Subcommand)]
18pub enum OrderCommand {
19    /// List orders, optionally filtered.
20    List {
21        /// Restrict the listing to one ACME endpoint.
22        #[arg(long)]
23        profile: Option<String>,
24        #[arg(long = "account-id")]
25        account_id: Option<String>,
26        #[arg(long)]
27        status: Option<String>,
28        #[arg(long)]
29        json: bool,
30    },
31    /// Show one order plus its authorizations and challenges.
32    Show {
33        id: String,
34        #[arg(long)]
35        json: bool,
36    },
37    /// Hard-delete the order and everything under it.
38    Delete { id: String },
39    /// Revoke the order's issued certificate.
40    Revoke {
41        id: String,
42        #[arg(long)]
43        reason: Option<u32>,
44    },
45}
46
47pub async fn run_order_command(
48    command: OrderCommand,
49    yes: bool,
50    palette: Palette,
51    reader: &mut impl BufRead,
52    config: &Config,
53    database: Arc<Database>,
54) -> Result<(), CliError> {
55    match command {
56        OrderCommand::List {
57            profile,
58            account_id,
59            status,
60            json,
61        } => {
62            // Refused by name rather than passed through: an unknown status
63            // would match no rows, which reads exactly like "nothing is in
64            // that state". The same rule `audit list --event` follows.
65            let status = status
66                .map(|value| value.parse::<OrderStatus>())
67                .transpose()
68                .map_err(|error| CliError(format!("--status: {error}")))?;
69
70            // Filtered in SQL, by the same `Order::search` the web admin uses.
71            // It used to load every order in the database and filter the three
72            // fields in Rust, which is one policy written twice — and the two
73            // could drift into disagreeing about what `--status` means.
74            //
75            // `limit` is the whole table on purpose: a CLI listing has no page
76            // control to offer, and truncating silently would be worse than the
77            // memory.
78            let query = OrderQuery {
79                profile,
80                account_id,
81                status,
82                limit: i64::MAX,
83                offset: 0,
84            };
85            let (orders, _total) = Order::search(&query, &database).await?;
86            if json {
87                // One query for the whole listing, not one per row. This is
88                // `limit: i64::MAX` above, so the per-row form cost a query per
89                // order in the entire table — the same N+1 the web admin's
90                // `render_orders` already avoids, and what
91                // `find_ids_by_orders` exists for.
92                let ids: Vec<&str> = orders.iter().map(|o| o.id.as_str()).collect();
93                let mut authz_ids = Authorization::find_ids_by_orders(&ids, &database).await?;
94                let rendered: Vec<_> = orders
95                    .iter()
96                    .map(|order| {
97                        admin::render_order_json(
98                            order,
99                            &config.server.base_url,
100                            &authz_ids.remove(&order.id).unwrap_or_default(),
101                        )
102                    })
103                    .collect();
104                println!("{}", serde_json::Value::Array(rendered));
105            } else {
106                for order in &orders {
107                    println!("{}", render::render_order_line(order, palette));
108                }
109            }
110        }
111        OrderCommand::Show { id, json } => match admin::load_order_detail(&id, database).await? {
112            None => return Err(not_found(&id)),
113            Some(detail) if json => {
114                println!(
115                    "{}",
116                    admin::render_order_detail_json(&detail, &config.server.base_url)
117                );
118            }
119            Some(detail) => print!("{}", render::render_order_detail_text(&detail, palette)),
120        },
121        OrderCommand::Delete { id } => {
122            match admin::confirm_delete_order(&id, yes, reader, database).await? {
123                DeleteOutcome::NotFound => return Err(not_found(&id)),
124                DeleteOutcome::Cancelled => println!("Cancelled."),
125                DeleteOutcome::Deleted => println!("Deleted order {id}."),
126            }
127        }
128        OrderCommand::Revoke { id, reason } => {
129            // Revocation goes through the endpoint that issued the certificate:
130            // another profile's backend holds a different CA, or none at all.
131            let Some(order) = Order::find_by_id(&id, &database).await? else {
132                return Err(not_found(&id));
133            };
134            let profiles = config
135                .resolve_profiles()
136                .map_err(|error| CliError(format!("configuration error: {error}")))?;
137            let Some(profile) = profiles.iter().find(|p| p.name == order.profile) else {
138                return Err(CliError(format!(
139                    "order {id} was issued by profile `{}`, which this configuration does not \
140                     define — revoking it needs the endpoint that signed it",
141                    order.profile
142                )));
143            };
144            // No notifiers: this is a one-off admin invocation, not the long-
145            // running server — there is no background completion task here
146            // for a notifier to ever be reached from.
147            // A throwaway egress: this is a one-shot admin command, not the
148            // long-running server, so there is no shared resolver or proxy
149            // policy to reuse — both come from the same `[dns]`/`[proxy]`
150            // sections `serve` reads.
151            let egress = Arc::new(
152                crate::Egress::from_config(config)
153                    .map_err(|error| CliError(format!("configuration error: {error}")))?,
154            );
155            // A queue nothing drains: this command revokes, which every backend
156            // answers inline, so no job is ever enqueued. Handing over a live
157            // queue would be worse than useless — it would let a one-shot CLI
158            // invocation write rows that only the running server can work off.
159            let jobs = crate::jobs::JobQueue::new(database.clone(), &config.jobs);
160            // A registry nothing scrapes, for the same reason as the queue
161            // above: this process exits when the command does, and the counters
162            // that matter belong to the server that is serving `/metrics`.
163            let metrics = Arc::new(crate::metrics::Metrics::new(database.clone()));
164            let signer = signer::from_config(
165                &profile.sections.signer,
166                vec![profile.name.clone()],
167                &signer::SignerParts {
168                    database: database.clone(),
169                    notifiers: std::collections::HashMap::new().into(),
170                    metrics,
171                    egress,
172                    jobs,
173                },
174                // Nothing to adopt: there is no previous generation in a process
175                // that exits when this command does.
176                &signer::CarriedState::new(),
177            )
178            .map_err(|error| CliError(format!("signer error: {error}")))?;
179            // `Actor::cli` and an empty client context: there is no request
180            // here, and the audit row says so rather than inventing an address.
181            match admin::revoke_order(
182                &id,
183                reason,
184                crate::audit::Actor::cli(),
185                crate::audit::ClientContext::default(),
186                database,
187                signer,
188            )
189            .await
190            .map_err(|error| CliError(error.to_string()))?
191            {
192                admin::RevokeOutcome::NotFound => return Err(not_found(&id)),
193                admin::RevokeOutcome::NotIssued => {
194                    return Err(CliError(format!("order {id} has no issued certificate")));
195                }
196                admin::RevokeOutcome::AlreadyRevoked => {
197                    return Err(CliError(format!(
198                        "order {id}'s certificate is already revoked"
199                    )));
200                }
201                admin::RevokeOutcome::Revoked(order) => {
202                    println!("{}", render::render_order_line(&order, palette));
203                }
204            }
205        }
206    }
207    Ok(())
208}
209
210fn not_found(id: &str) -> CliError {
211    CliError(format!("no such order: {id}"))
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217    use crate::audit::ClientContext;
218    use crate::signer::{IssueOutcome, RequestedValidity, SignerBackend};
219    use crate::sqlite::account::Account;
220
221    /// A configuration whose single `default` profile signs with a local CA
222    /// living under `dir` — what `Revoke` needs, since it rebuilds the signer
223    /// from the profile that issued the certificate.
224    fn config_in(dir: impl AsRef<std::path::Path>, profile: &str) -> Config {
225        let dir = dir.as_ref();
226        let _lock = crate::config::ENV_LOCK
227            .lock()
228            .unwrap_or_else(std::sync::PoisonError::into_inner);
229        let ca = dir.join("ca");
230        std::fs::write(
231            dir.join("config.toml"),
232            format!(
233                r#"
234                [profiles.{profile}]
235                signer.local_ca.cert_path = "{ca}.pem"
236                signer.local_ca.key_path = "{ca}.key"
237                signer.local_ca.crl_path = "{ca}.crl"
238                "#,
239                ca = ca.display(),
240            ),
241        )
242        .unwrap();
243        // SAFETY: the lock above makes this the only thread touching the
244        // environment, and the variable is removed before returning.
245        unsafe {
246            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
247        }
248        let config = Config::load().expect("the configuration must load");
249        unsafe {
250            std::env::remove_var("ACME_PROXY_CONFIG");
251        }
252        config
253    }
254
255    fn temp_dir() -> crate::testutil::TempDir {
256        crate::testutil::TempDir::new("cli-order")
257    }
258
259    async fn seed_order(database: &Arc<Database>, profile: &str) -> Order {
260        let (account, _) = Account::find_or_create(
261            profile,
262            &[4, 5, 6],
263            vec![],
264            &ClientContext::default(),
265            database,
266        )
267        .await
268        .unwrap();
269        Order::create(
270            profile,
271            &account.id,
272            vec![crate::sqlite::order::Identifier::dns("example.com")],
273            crate::sqlite::nonce::now_secs() + 3600,
274            None,
275            None,
276            database,
277        )
278        .await
279        .unwrap()
280    }
281
282    /// Issues against `config`'s own CA and records the result on `order`, so
283    /// the certificate the CLI later revokes is one that CA actually signed.
284    async fn issue_onto(order: &mut Order, config: &Config, database: Arc<Database>) {
285        let profile = &config.resolve_profiles().unwrap()[0];
286        let resolver = crate::dns::resolver_addr(&config.dns)
287            .and_then(crate::challenge::build_resolver)
288            .expect("the default dns configuration must build a resolver");
289        let signer: Arc<dyn SignerBackend> = signer::from_config(
290            &profile.sections.signer,
291            vec![profile.name.clone()],
292            &crate::testutil::signer_parts(database.clone(), resolver),
293            &signer::CarriedState::new(),
294        )
295        .unwrap();
296
297        let key_pair = rcgen::KeyPair::generate().unwrap();
298        let params = rcgen::CertificateParams::new(vec!["example.com".to_string()]).unwrap();
299        let csr = params.serialize_request(&key_pair).unwrap();
300        let chain = match signer
301            .issue(
302                &order.id,
303                csr.der(),
304                &order.identifiers,
305                RequestedValidity::default(),
306            )
307            .await
308            .unwrap()
309        {
310            IssueOutcome::Issued(chain) => chain,
311            IssueOutcome::Processing => panic!("the local CA issues synchronously"),
312        };
313        let leaf = crate::cert::leaf_der_from_chain(&chain).unwrap();
314        let (serial, pubkey) = crate::cert::cert_serial_and_spki(&leaf).unwrap();
315        order
316            .finalize(chain, serial, pubkey, &database)
317            .await
318            .unwrap();
319    }
320
321    #[tokio::test]
322    async fn every_arm_refuses_an_unknown_order() {
323        let database = Arc::new(Database::connect_in_memory().await.unwrap());
324        let config = Config::default();
325        let expected = CliError("no such order: ord-nope".to_string());
326
327        let commands = vec![
328            OrderCommand::Show {
329                id: "ord-nope".to_string(),
330                json: false,
331            },
332            OrderCommand::Delete {
333                id: "ord-nope".to_string(),
334            },
335            OrderCommand::Revoke {
336                id: "ord-nope".to_string(),
337                reason: None,
338            },
339        ];
340        for command in commands {
341            let mut reader: &[u8] = &[];
342            let error = run_order_command(
343                command,
344                true,
345                Palette::plain(),
346                &mut reader,
347                &config,
348                database.clone(),
349            )
350            .await
351            .expect_err("an unknown order must fail");
352            assert_eq!(error, expected);
353        }
354    }
355
356    /// `revoke` needs the endpoint that signed the certificate. A profile the
357    /// running configuration no longer defines says so, rather than silently
358    /// revoking against some other profile's CA.
359    #[tokio::test]
360    async fn revoking_an_order_from_an_undefined_profile_is_refused() {
361        let dir = temp_dir();
362        let database = Arc::new(Database::connect_in_memory().await.unwrap());
363        // The order belongs to `default`; the configuration only mounts `other`.
364        let order = seed_order(&database, "default").await;
365        let config = config_in(&dir, "other");
366
367        let mut reader: &[u8] = &[];
368        let error = run_order_command(
369            OrderCommand::Revoke {
370                id: order.id.clone(),
371                reason: None,
372            },
373            true,
374            Palette::plain(),
375            &mut reader,
376            &config,
377            database,
378        )
379        .await
380        .expect_err("a profile this configuration does not define must be refused");
381        assert!(
382            error.to_string().contains("which this configuration"),
383            "{error}"
384        );
385    }
386
387    /// A configuration that mounts nothing at all cannot name a signer either.
388    #[tokio::test]
389    async fn revoking_without_a_resolvable_configuration_is_refused() {
390        let database = Arc::new(Database::connect_in_memory().await.unwrap());
391        let order = seed_order(&database, "default").await;
392
393        let mut reader: &[u8] = &[];
394        let error = run_order_command(
395            OrderCommand::Revoke {
396                id: order.id,
397                reason: None,
398            },
399            true,
400            Palette::plain(),
401            &mut reader,
402            &Config::default(),
403            database,
404        )
405        .await
406        .expect_err("a configuration mounting nothing must be refused");
407        assert!(
408            error.to_string().starts_with("configuration error: "),
409            "{error}"
410        );
411    }
412
413    #[tokio::test]
414    async fn revoking_an_order_with_no_certificate_is_refused() {
415        let dir = temp_dir();
416        let database = Arc::new(Database::connect_in_memory().await.unwrap());
417        let order = seed_order(&database, "default").await;
418        let config = config_in(&dir, "default");
419
420        let mut reader: &[u8] = &[];
421        let error = run_order_command(
422            OrderCommand::Revoke {
423                id: order.id.clone(),
424                reason: None,
425            },
426            true,
427            Palette::plain(),
428            &mut reader,
429            &config,
430            database,
431        )
432        .await
433        .expect_err("there is nothing to revoke");
434        assert_eq!(
435            error,
436            CliError(format!("order {} has no issued certificate", order.id))
437        );
438    }
439
440    /// The whole arm end to end: issue, revoke through the CLI, then find the
441    /// second attempt refused because the first one stuck.
442    #[tokio::test]
443    async fn an_issued_order_revokes_once() {
444        let dir = temp_dir();
445        let database = Arc::new(Database::connect_in_memory().await.unwrap());
446        let config = config_in(&dir, "default");
447        let mut order = seed_order(&database, "default").await;
448        issue_onto(&mut order, &config, database.clone()).await;
449
450        let mut reader: &[u8] = &[];
451        run_order_command(
452            OrderCommand::Revoke {
453                id: order.id.clone(),
454                reason: Some(1),
455            },
456            true,
457            Palette::plain(),
458            &mut reader,
459            &config,
460            database.clone(),
461        )
462        .await
463        .expect("a certificate issued by this profile's CA must revoke");
464
465        assert!(
466            Order::find_by_id(&order.id, &database)
467                .await
468                .unwrap()
469                .unwrap()
470                .revoked_at
471                .is_some()
472        );
473
474        let error = run_order_command(
475            OrderCommand::Revoke {
476                id: order.id.clone(),
477                reason: None,
478            },
479            true,
480            Palette::plain(),
481            &mut reader,
482            &config,
483            database.clone(),
484        )
485        .await
486        .expect_err("a second revocation has nothing left to do");
487        assert_eq!(
488            error,
489            CliError(format!(
490                "order {}'s certificate is already revoked",
491                order.id
492            ))
493        );
494    }
495
496    /// An out-of-range reason code comes back from `admin::revoke_order` as a
497    /// typed error, not a database one.
498    #[tokio::test]
499    async fn an_invalid_revocation_reason_is_refused() {
500        let dir = temp_dir();
501        let database = Arc::new(Database::connect_in_memory().await.unwrap());
502        let config = config_in(&dir, "default");
503        let mut order = seed_order(&database, "default").await;
504        issue_onto(&mut order, &config, database.clone()).await;
505
506        let mut reader: &[u8] = &[];
507        let error = run_order_command(
508            OrderCommand::Revoke {
509                id: order.id.clone(),
510                reason: Some(7),
511            },
512            true,
513            Palette::plain(),
514            &mut reader,
515            &config,
516            database,
517        )
518        .await
519        .expect_err("7 is not a defined CRLReason");
520        assert!(error.to_string().contains('7'), "{error}");
521    }
522
523    /// A declined delete leaves the order in place and is not a failure.
524    #[tokio::test]
525    async fn a_declined_delete_is_not_a_failure() {
526        let database = Arc::new(Database::connect_in_memory().await.unwrap());
527        let order = seed_order(&database, "default").await;
528
529        let mut reader: &[u8] = b"n\n";
530        run_order_command(
531            OrderCommand::Delete {
532                id: order.id.clone(),
533            },
534            false,
535            Palette::plain(),
536            &mut reader,
537            &Config::default(),
538            database.clone(),
539        )
540        .await
541        .unwrap();
542
543        assert!(
544            Order::find_by_id(&order.id, &database)
545                .await
546                .unwrap()
547                .is_some()
548        );
549    }
550
551    /// `show --json` renders through a different branch than the text form,
552    /// and `list --json` additionally walks each order's authorizations.
553    #[tokio::test]
554    async fn the_json_arms_render() {
555        let database = Arc::new(Database::connect_in_memory().await.unwrap());
556        let order = seed_order(&database, "default").await;
557
558        let mut reader: &[u8] = &[];
559        for command in [
560            OrderCommand::List {
561                profile: Some("default".to_string()),
562                account_id: None,
563                status: None,
564                json: true,
565            },
566            OrderCommand::Show {
567                id: order.id.clone(),
568                json: true,
569            },
570            OrderCommand::Show {
571                id: order.id.clone(),
572                json: false,
573            },
574        ] {
575            run_order_command(
576                command,
577                true,
578                Palette::plain(),
579                &mut reader,
580                &Config::default(),
581                database.clone(),
582            )
583            .await
584            .unwrap();
585        }
586    }
587
588    /// An unknown `--status` is refused **by name**, not passed to SQL.
589    ///
590    /// The distinction is the whole point: a typo handed through to the query
591    /// answers "no rows", which an operator cannot tell from "nothing is in
592    /// that state". The same rule `audit list --event` follows.
593    #[tokio::test]
594    async fn an_unknown_status_is_refused_by_name_rather_than_matching_nothing() {
595        let database = Arc::new(Database::connect_in_memory().await.unwrap());
596        seed_order(&database, "default").await;
597
598        let mut reader: &[u8] = &[];
599        let error = run_order_command(
600            OrderCommand::List {
601                profile: None,
602                account_id: None,
603                status: Some("readyy".to_string()),
604                json: false,
605            },
606            true,
607            Palette::plain(),
608            &mut reader,
609            &Config::default(),
610            database.clone(),
611        )
612        .await
613        .unwrap_err();
614
615        assert!(error.0.contains("--status"), "{error}");
616        assert!(error.0.contains("`readyy`"), "{error}");
617        // ...and it names the alternatives, so the operator does not guess.
618        assert!(
619            error
620                .0
621                .contains("pending, ready, processing, valid, invalid"),
622            "{error}"
623        );
624    }
625
626    /// Every status the CLI *does* accept reaches `Order::search`.
627    ///
628    /// Guards the other half: a refusal that also rejected valid input would
629    /// pass the test above and break the command.
630    #[tokio::test]
631    async fn every_order_status_is_accepted_as_a_filter() {
632        let database = Arc::new(Database::connect_in_memory().await.unwrap());
633        seed_order(&database, "default").await;
634
635        let mut reader: &[u8] = &[];
636        for status in OrderStatus::ALL {
637            run_order_command(
638                OrderCommand::List {
639                    profile: None,
640                    account_id: None,
641                    status: Some(status.as_str().to_string()),
642                    json: false,
643                },
644                true,
645                Palette::plain(),
646                &mut reader,
647                &Config::default(),
648                database.clone(),
649            )
650            .await
651            .unwrap_or_else(|error| panic!("--status {status} was refused: {error}"));
652        }
653    }
654}