Skip to main content

acme_proxy/cli/
mod.rs

1//! The command tree.
2//!
3//! **Nothing here exits.** Every command body returns `Result<(), CliError>`
4//! and [`dispatch`] routes to it, so each arm is a plain function a test can
5//! call and assert on rather than an unreachable dead end. `src/main.rs` is
6//! where that `Result` becomes an exit status, and it is the only place in the
7//! project that calls `std::process::exit` — a library whose failure mode is
8//! ending the process is one nothing else can use.
9//!
10//! What a command *prints* is its own: a body writes its answer to stdout,
11//! since that is the answer, and returns its refusal as a `CliError` for
12//! `main.rs` to render on stderr. A listing's rendering is `render`, and a
13//! `--json` body never sees a `Palette`.
14//!
15//! `serve` is one arm like the others: the server runtime itself lives in
16//! [`acme_proxy_server`], and [`serve`] only turns its failure into a [`CliError`].
17//!
18//! The logic behind each admin subcommand lives in [`acme_proxy_admin::admin`], not here;
19//! this module is the `clap` surface over it. [`logging`] turns `[logging]` into
20//! an installed subscriber, validating every value before installing anything.
21//!
22//! What a command *prints* is [`render`]'s, and how it is coloured is
23//! [`style`]'s. Those renderings sit here rather than in [`acme_proxy_admin::admin`]
24//! because they have exactly one consumer — the terminal — where the JSON ones
25//! beside them are a wire format the web admin parses too. [`dispatch`]
26//! resolves one [`Palette`] and threads it down; `nonce` and `upstream` take
27//! none, printing only fixed text.
28
29use std::io::{BufRead, IsTerminal};
30use std::sync::Arc;
31
32use clap::{Parser, Subcommand};
33use clap_complete::aot::Shell;
34
35pub mod account;
36pub mod audit;
37pub mod eab;
38pub mod filter;
39pub mod generate;
40pub mod jobs;
41pub(crate) mod logging;
42
43/// The `--log-level` flag and the per-invocation decision it feeds. Re-exported
44/// for `main.rs`, which is where the subscriber is installed.
45pub use logging::{LogLevel, LoggingPlan, plan_logging};
46pub mod nonce;
47pub mod order;
48pub mod profile;
49pub mod render;
50pub mod schema;
51pub mod style;
52pub mod transfer;
53pub mod upstream;
54pub mod webadmin;
55pub mod window;
56
57pub use account::AccountCommand;
58pub use audit::AuditCommand;
59pub use eab::EabCommand;
60pub use jobs::JobsCommand;
61pub use nonce::NonceCommand;
62pub use order::OrderCommand;
63pub use profile::ProfileCommand;
64pub use upstream::UpstreamCommand;
65pub use webadmin::AdminCommand;
66
67use crate::cli::filter::FilterCommand;
68pub use crate::cli::style::ColorChoice;
69use acme_proxy_core::config::Config;
70use acme_proxy_core::palette::Palette;
71use acme_proxy_store::db::Database;
72
73#[derive(Parser)]
74#[command(
75    name = "acme-proxy",
76    version = env!("CARGO_PKG_VERSION"),
77    about = "ACME server, plus admin commands for its database"
78)]
79pub struct Cli {
80    /// Skip interactive "Are you sure?" confirmation on destructive commands.
81    #[arg(short = 'y', long, global = true)]
82    pub yes: bool,
83
84    /// When to colour human-readable output. `--json` output never carries it.
85    #[arg(long, value_enum, default_value_t = ColorChoice::Auto, global = true)]
86    pub color: ColorChoice,
87
88    /// Emit log records for this run, at this level, on stderr. Without it an
89    /// admin command prints only its own output; `serve` uses `[logging]`.
90    #[arg(long, value_enum, global = true)]
91    pub log_level: Option<LogLevel>,
92
93    #[command(subcommand)]
94    pub command: Option<Command>,
95}
96
97/// `--role`'s value parser: a comma-separated role list.
98///
99/// Wired into `clap` rather than parsed in the command body so an unknown role
100/// is refused with usage, at argv time — before `Config::load` and before
101/// `Database::open`, which *creates* its file. A typo would otherwise surface
102/// as whatever the next step complained about.
103fn parse_roles(value: &str) -> Result<acme_proxy_server::RoleSet, String> {
104    acme_proxy_server::RoleSet::parse(Some(value))
105}
106
107#[derive(Subcommand)]
108pub enum Command {
109    /// Run the ACME HTTP(S) server. Default when no subcommand is given.
110    Serve {
111        /// Which of the server's three jobs this process does, comma-separated:
112        /// `acme`, `admin`, `worker`. Defaults to all three in one process.
113        ///
114        /// A split deployment runs the same binary and the same configuration
115        /// several times, each naming its own roles. Only a process running
116        /// `worker` applies migrations, generates first-run material and drains
117        /// the job queue; the others check the schema and refuse if it is not
118        /// current. An unknown role is refused with usage, exit 2.
119        #[arg(long, value_name = "ROLES", value_parser = parse_roles)]
120        role: Option<acme_proxy_server::RoleSet>,
121    },
122    /// Apply any database migrations that have not run yet, then exit.
123    ///
124    /// Opening the database no longer migrates it, so this is how a schema is
125    /// brought up to date without starting a server. Safe to run repeatedly.
126    Migrate,
127    /// Prepare a deployment: migrate, then generate whatever first-run material
128    /// the configuration calls for (the local CA, an upstream account, a
129    /// self-signed TLS certificate).
130    ///
131    /// The one place that *creates* key material. A process that does not run
132    /// the `worker` role refuses to generate any, so a split deployment runs
133    /// this once before starting anything.
134    Init,
135    /// Copy every row into another database, which must already exist, be
136    /// migrated and be empty.
137    ///
138    /// The configured `database.url` is the source, and each URL's scheme
139    /// picks its backend — so this is how a SQLite deployment becomes a
140    /// PostgreSQL one, and the reverse is the same command with the two
141    /// swapped. Stop the server first: a copy taken while something is writing
142    /// is a torn snapshot, and nothing here can detect one.
143    Transfer {
144        /// The database to copy into.
145        #[arg(long = "to", value_name = "URL")]
146        to: String,
147        /// Print the per-table row counts as JSON.
148        #[arg(long)]
149        json: bool,
150    },
151    /// Inspect and manage ACME accounts.
152    Account {
153        #[command(subcommand)]
154        command: AccountCommand,
155    },
156    /// Inspect and manage ACME orders.
157    Order {
158        #[command(subcommand)]
159        command: OrderCommand,
160    },
161    /// Read and prune the CA's audit trail.
162    Audit {
163        #[command(subcommand)]
164        command: AuditCommand,
165    },
166    /// Inspect and manage the background job queue.
167    Jobs {
168        #[command(subcommand)]
169        command: JobsCommand,
170    },
171    /// Count and prune the replay-nonce table.
172    Nonce {
173        #[command(subcommand)]
174        command: NonceCommand,
175    },
176    /// Inspect the ACME endpoints this configuration mounts.
177    Profile {
178        #[command(subcommand)]
179        command: ProfileCommand,
180    },
181    /// Manage External Account Binding (EAB) credentials.
182    Eab {
183        #[command(subcommand)]
184        command: EabCommand,
185    },
186    /// Read and test the access policy of an endpoint.
187    Filter {
188        #[command(subcommand)]
189        command: FilterCommand,
190    },
191    /// Manage this server's own account at the upstream ACME server
192    /// (`signer.backend = "relay"`).
193    Upstream {
194        #[command(subcommand)]
195        command: UpstreamCommand,
196    },
197    /// Manage the web admin's operators and their sessions. This is how the
198    /// panel is bootstrapped: it has no sign-up page.
199    Admin {
200        #[command(subcommand)]
201        command: AdminCommand,
202    },
203    /// Print a shell completion script on stdout.
204    Completions {
205        /// The shell to generate for.
206        #[arg(value_enum)]
207        shell: Shell,
208    },
209    /// Print this binary's man page, in roff, on stdout.
210    Man,
211}
212
213/// The notification dispatchers `serve` would build from this configuration —
214/// every profile's, plus the web admin's when `admin.enabled` — over a queue
215/// this process never drains.
216///
217/// A dispatcher only *writes* `notify_deliver` rows; delivering them is the
218/// running server's job runner, over the same database. So a host command
219/// whose action deserves a notification (a revocation, a deactivated account,
220/// a changed credential) queues it here and exits, and the worker sends it —
221/// the CLI never talks SMTP or a webhook itself. A row queued while no server
222/// runs waits for the next one to start.
223pub(crate) fn offline_notifiers(
224    config: &Config,
225    database: Arc<Database>,
226) -> Result<acme_proxy_jobs::notify::DispatcherMap, CliError> {
227    let failed = |error: anyhow::Error| CliError::failed(format!("configuration error: {error}"));
228    let profiles = config
229        .resolve_profiles()
230        .map_err(|error| failed(anyhow::anyhow!(error)))?;
231    let egress = acme_proxy_net::egress::Egress::from_config(config).map_err(failed)?;
232    let jobs = acme_proxy_jobs::jobs::JobQueue::new(database, &config.jobs);
233    let mut dispatchers =
234        acme_proxy_jobs::notify::build_registry(&profiles, egress.outbound(), &jobs)
235            .map_err(failed)?;
236    if config.admin.enabled {
237        dispatchers.insert(
238            acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY.to_string(),
239            acme_proxy_jobs::notify::from_config(
240                acme_proxy_jobs::notify::ADMIN_DISPATCHER_KEY,
241                &config.admin.notify,
242                egress.outbound(),
243                &jobs,
244            )
245            .map_err(failed)?,
246        );
247    }
248    Ok(dispatchers)
249}
250
251/// Picks the profile a command acts on.
252///
253/// `--profile` is optional only when the configuration defines exactly one:
254/// most per-profile sections would otherwise be acted on ambiguously, and
255/// guessing is worse than asking. Shared by `upstream` and `filter`, which had
256/// grown one copy each.
257pub(crate) fn resolve_profile(
258    config: &Config,
259    wanted: Option<&str>,
260) -> Result<acme_proxy_core::config::ProfileConfig, CliError> {
261    let profiles = config
262        .resolve_profiles()
263        .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
264
265    match wanted {
266        Some(name) => profiles
267            .into_iter()
268            .find(|profile| profile.name == name)
269            .ok_or_else(|| {
270                CliError::bad_request(format!("no profile named `{name}` in this configuration"))
271            }),
272        None if profiles.len() == 1 => Ok(profiles.into_iter().next().expect("length checked")),
273        None => {
274            let names: Vec<&str> = profiles.iter().map(|p| p.name.as_str()).collect();
275            Err(CliError::bad_request(format!(
276                "this configuration defines several profiles ({}); say which one with --profile",
277                names.join(", ")
278            )))
279        }
280    }
281}
282
283/// A command that could not complete, carrying the message to print and the
284/// [kind](CliErrorKind) that decides the process exit status.
285///
286/// Every failing branch below returns one of these instead of calling
287/// `std::process::exit` where it stands: `main.rs` is the single place that
288/// prints and exits, so each command body stays a plain function a test can
289/// call and assert on.
290#[derive(Debug, PartialEq, Eq, thiserror::Error)]
291#[error("{message}")]
292pub struct CliError {
293    /// The line printed to stderr.
294    pub message: String,
295    /// What the process exits with.
296    pub kind: CliErrorKind,
297}
298
299/// Why a command failed, in the one distinction a script cares about: was it
300/// the host that could not carry out the request, or the request itself that
301/// could not be satisfied as written? Only the first is worth retrying.
302#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
303pub enum CliErrorKind {
304    /// The host could not carry out the request — a database error, a signer
305    /// or CA failure, an unreadable file, a socket that would not bind, an
306    /// outbound network failure, invalid configuration. Exit `1`.
307    #[default]
308    Failed,
309    /// The request cannot be satisfied as written — no object with that id, an
310    /// object in the wrong state, an unknown `--status`/`--event`/`--outcome`
311    /// value or `admin user create --role`, contradictory flags. (`serve
312    /// --role` is parsed by `clap`, so its typo exits `2` with usage.) Re-running the identical command will not
313    /// help. Exit `3`.
314    BadRequest,
315}
316
317/// Parses a closed-vocabulary flag's value, refusing an unknown one by name.
318///
319/// Passed through instead, an unknown `--status` would match no rows, which
320/// reads exactly like "nothing is in that state". The vocabulary's own error
321/// names the alternatives; this prefixes the flag the operator typed.
322pub(crate) fn parse_value<T>(flag: &str, value: &str) -> Result<T, CliError>
323where
324    T: std::str::FromStr,
325    T::Err: std::fmt::Display,
326{
327    value
328        .parse::<T>()
329        .map_err(|error| CliError::bad_request(format!("{flag}: {error}")))
330}
331
332/// [`parse_value`] for a flag that may be left out.
333pub(crate) fn parse_flag<T>(flag: &str, value: Option<String>) -> Result<Option<T>, CliError>
334where
335    T: std::str::FromStr,
336    T::Err: std::fmt::Display,
337{
338    value.map(|value| parse_value(flag, &value)).transpose()
339}
340
341impl CliError {
342    /// A `Failed` error — the host could not carry out the request.
343    pub fn failed(message: impl Into<String>) -> Self {
344        Self {
345            message: message.into(),
346            kind: CliErrorKind::Failed,
347        }
348    }
349
350    /// A `BadRequest` error — the request cannot be satisfied as written.
351    pub fn bad_request(message: impl Into<String>) -> Self {
352        Self {
353            message: message.into(),
354            kind: CliErrorKind::BadRequest,
355        }
356    }
357
358    /// The kind this error carries.
359    pub fn kind(&self) -> CliErrorKind {
360        self.kind
361    }
362
363    /// The process exit status for this error: `1` for [`CliErrorKind::Failed`],
364    /// `3` for [`CliErrorKind::BadRequest`]. `main.rs` is the only caller.
365    pub fn exit_code(&self) -> u8 {
366        match self.kind {
367            CliErrorKind::Failed => 1,
368            CliErrorKind::BadRequest => 3,
369        }
370    }
371}
372
373impl From<String> for CliError {
374    fn from(message: String) -> Self {
375        Self::failed(message)
376    }
377}
378
379impl From<&str> for CliError {
380    fn from(message: &str) -> Self {
381        Self::failed(message)
382    }
383}
384
385impl From<sqlx::Error> for CliError {
386    fn from(error: sqlx::Error) -> Self {
387        Self::failed(format!("database error: {error}"))
388    }
389}
390
391/// Routes a parsed command to its handler.
392///
393/// The library's entry point. Everything above it — parsing argv, loading the
394/// configuration, installing the subscriber, opening the database, printing a
395/// failure and exiting — lives in `src/main.rs`, because those are the
396/// binary's job and not a library's: nothing that links this crate can use a
397/// function whose failure mode is `std::process::exit`.
398///
399/// Takes the `--color` *choice* rather than a resolved [`Palette`], and
400/// resolves it here: the answer depends on whether this process's stdout is a
401/// terminal and on `NO_COLOR`, and neither belongs in `main.rs`, which is
402/// excluded from the coverage floor precisely because nothing in it is
403/// reachable from a test.
404pub async fn dispatch(
405    command: Option<Command>,
406    yes: bool,
407    color: ColorChoice,
408    reader: &mut impl BufRead,
409    config: &Arc<Config>,
410    database: Arc<Database>,
411) -> Result<(), CliError> {
412    let palette = crate::cli::style::resolve(
413        color,
414        std::io::stdout().is_terminal(),
415        std::env::var("NO_COLOR").ok().as_deref(),
416    );
417    match command.unwrap_or(Command::Serve { role: None }) {
418        Command::Serve { role } => serve(role, config.clone(), database).await,
419        Command::Migrate => migrate(palette, database).await,
420        Command::Init => init(palette, config, database).await,
421        Command::Transfer { to, json } => {
422            transfer::run_transfer_command(&to, json, yes, reader, &config.database.url, database)
423                .await
424        }
425        Command::Account { command } => {
426            account::run_account_command(command, yes, palette, reader, config, database).await
427        }
428        Command::Order { command } => {
429            order::run_order_command(command, yes, palette, reader, config, database).await
430        }
431        Command::Audit { command } => {
432            audit::run_audit_command(command, yes, palette, reader, database).await
433        }
434        Command::Jobs { command } => {
435            jobs::run_jobs_command(command, yes, palette, reader, database).await
436        }
437        Command::Nonce { command } => {
438            nonce::run_nonce_command(command, yes, reader, config, database).await
439        }
440        Command::Profile { command } => {
441            profile::run_profile_command(command, palette, config).await
442        }
443        Command::Eab { command } => {
444            eab::run_eab_command(command, yes, palette, reader, config, database).await
445        }
446        Command::Filter { command } => filter::run_filter_command(command, palette, config).await,
447        Command::Upstream { command } => {
448            upstream::run_upstream_command(command, reader, palette, config, database).await
449        }
450        Command::Admin { command } => {
451            webadmin::run_admin_command(command, yes, palette, reader, config, database).await
452        }
453        // Reachable here, though `main.rs` answers both before it opens
454        // anything: an `unreachable!()` would be dead code under the coverage
455        // floor, and routing them keeps this a total function over `Command`.
456        command @ (Command::Completions { .. } | Command::Man) => {
457            generate::write(&command, &mut std::io::stdout().lock())
458        }
459    }
460}
461
462/// Runs the ACME HTTP(S) server until a shutdown signal arrives.
463///
464/// [`acme_proxy_server::run`] logs every failure it returns; this only carries the
465/// message to `main.rs` as a [`CliError`].
466pub async fn serve(
467    roles: Option<acme_proxy_server::RoleSet>,
468    config: Arc<Config>,
469    database: Arc<Database>,
470) -> Result<(), CliError> {
471    acme_proxy_server::run(roles.unwrap_or_default(), config, database)
472        .await
473        .map_err(|error| CliError::failed(error.to_string()))
474}
475
476/// `acme-proxy migrate` — applies the embedded migrations and reports what it
477/// did.
478///
479/// Idempotent: `sqlx` tracks each file by version and checksum, so a database
480/// already current is a no-op that says so.
481pub async fn migrate(palette: Palette, database: Arc<Database>) -> Result<(), CliError> {
482    let pending = database.pending_migrations().await?;
483    if pending.is_empty() {
484        println!("The schema is already up to date.");
485        return Ok(());
486    }
487
488    println!("Applying {} migration(s)…", pending.len());
489    database
490        .migrate()
491        .await
492        .map_err(|error| CliError::failed(format!("migration failed: {error}")))?;
493    println!("{}", palette.ok("The schema is up to date."));
494    Ok(())
495}
496
497/// `acme-proxy init` — migrate, then generate whatever first-run material the
498/// configuration calls for.
499///
500/// The one command that *creates* key material. A serving process that does not
501/// run the `worker` role refuses to generate any, so a split deployment runs
502/// this once, as the uid that should own the files, before starting anything.
503///
504/// Building the profiles is what generates: `server::profile::build_all` constructs
505/// every signer backend, and a `local_ca` with no key writes one, a `relay`
506/// with no account registers one. That is why this goes through the real
507/// builder rather than a separate generation path — there would be two
508/// definitions of "what a fresh deployment needs" otherwise.
509pub async fn init(
510    palette: Palette,
511    config: &Arc<Config>,
512    database: Arc<Database>,
513) -> Result<(), CliError> {
514    migrate(palette, database.clone()).await?;
515
516    let queue = acme_proxy_jobs::jobs::JobQueue::new(database.clone(), &config.jobs);
517    let profiles = acme_proxy_server::profile::build_all(config, database, &queue)
518        .map_err(|error| CliError::failed(error.to_string()))?;
519
520    for profile in &profiles {
521        println!("Profile `{}` is ready.", profile.name);
522    }
523    println!("{}", palette.ok("Initialisation complete."));
524    Ok(())
525}
526
527#[cfg(test)]
528mod tests {
529    use super::*;
530
531    /// `--version` exists and reports the crate version. The bug report
532    /// template tells people to run it, and clap generates the flag only
533    /// because `#[command(version = …)]` says so — drop that and the first
534    /// instruction on the form starts erroring out.
535    #[test]
536    fn version_flag_reports_the_crate_version() {
537        let Err(error) = Cli::try_parse_from(["acme-proxy", "--version"]) else {
538            panic!("--version parsed as a command rather than printing a version");
539        };
540        assert_eq!(error.kind(), clap::error::ErrorKind::DisplayVersion);
541        assert!(error.to_string().contains(env!("CARGO_PKG_VERSION")));
542    }
543
544    /// `eab delete`'s two account modes are exclusive: asked for both, clap
545    /// refuses rather than one silently winning.
546    #[test]
547    fn eab_delete_refuses_both_account_modes_at_once() {
548        let Err(error) = Cli::try_parse_from([
549            "acme-proxy",
550            "eab",
551            "delete",
552            "kid",
553            "--deactivate-accounts",
554            "--delete-accounts",
555        ]) else {
556            panic!("both modes at once must be refused");
557        };
558        assert_eq!(error.kind(), clap::error::ErrorKind::ArgumentConflict);
559
560        for flag in ["--deactivate-accounts", "--delete-accounts"] {
561            Cli::try_parse_from(["acme-proxy", "eab", "delete", "kid", flag]).unwrap();
562        }
563    }
564
565    /// `--log-level` is global like `--yes` and `--color`, so it may be given
566    /// on either side of the subcommand — which is the whole reason an
567    /// operator reaches for it, having already typed the command once.
568    #[test]
569    fn log_level_is_a_global_flag_with_a_closed_set_of_values() {
570        let cli = Cli::try_parse_from(["acme-proxy", "account", "list"]).unwrap();
571        assert_eq!(
572            cli.log_level, None,
573            "absent by default: an admin command says nothing unless asked",
574        );
575
576        for argv in [
577            ["acme-proxy", "--log-level", "debug", "account", "list"],
578            ["acme-proxy", "account", "list", "--log-level", "debug"],
579        ] {
580            let cli = Cli::try_parse_from(argv).unwrap();
581            assert_eq!(cli.log_level, Some(LogLevel::Debug), "{argv:?}");
582        }
583
584        let cli = Cli::try_parse_from(["acme-proxy", "serve", "--log-level", "off"]).unwrap();
585        assert_eq!(cli.log_level, Some(LogLevel::Off));
586
587        // A `value_enum`, so an unrecognised level is refused with the six
588        // spellings listed rather than treated as a filter directive.
589        let Err(error) =
590            Cli::try_parse_from(["acme-proxy", "account", "list", "--log-level", "loud"])
591        else {
592            panic!("`--log-level loud` must be refused");
593        };
594        assert_eq!(error.kind(), clap::error::ErrorKind::InvalidValue);
595    }
596
597    #[test]
598    fn parse_cli_subcommands() {
599        let cli = Cli::try_parse_from(["acme-proxy"]).unwrap();
600        assert!(cli.command.is_none());
601
602        let cli = Cli::try_parse_from(["acme-proxy", "serve"]).unwrap();
603        assert!(matches!(cli.command, Some(Command::Serve { role: None })));
604
605        let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--json"]).unwrap();
606        assert!(matches!(
607            cli.command,
608            Some(Command::Account {
609                command: AccountCommand::List {
610                    json: true,
611                    profile: None,
612                    eab_kid: None,
613                    limit: window::DEFAULT_LIMIT,
614                    offset: 0
615                }
616            })
617        ));
618
619        let cli = Cli::try_parse_from(["acme-proxy", "account", "show", "acct-1"]).unwrap();
620        assert!(matches!(
621            cli.command,
622            Some(Command::Account {
623                command: AccountCommand::Show { id, json: false }
624            }) if id == "acct-1"
625        ));
626
627        let cli = Cli::try_parse_from([
628            "acme-proxy",
629            "account",
630            "update-contact",
631            "acct-1",
632            "--contact",
633            "mailto:test@example.com",
634        ])
635        .unwrap();
636        assert!(matches!(
637            cli.command,
638            Some(Command::Account {
639                command: AccountCommand::UpdateContact { id, contact }
640            }) if id == "acct-1" && contact == vec!["mailto:test@example.com"]
641        ));
642
643        let cli = Cli::try_parse_from(["acme-proxy", "account", "deactivate", "acct-1"]).unwrap();
644        assert!(matches!(
645            cli.command,
646            Some(Command::Account {
647                command: AccountCommand::Deactivate { id }
648            }) if id == "acct-1"
649        ));
650
651        let cli = Cli::try_parse_from(["acme-proxy", "-y", "account", "delete", "acct-1"]).unwrap();
652        assert!(cli.yes);
653        assert!(matches!(
654            cli.command,
655            Some(Command::Account {
656                command: AccountCommand::Delete { id }
657            }) if id == "acct-1"
658        ));
659
660        let cli = Cli::try_parse_from([
661            "acme-proxy",
662            "order",
663            "list",
664            "--account-id",
665            "acct-1",
666            "--status",
667            "pending",
668            "--json",
669        ])
670        .unwrap();
671        assert!(matches!(
672            cli.command,
673            Some(Command::Order {
674                command: OrderCommand::List(crate::cli::order::OrderListArgs {
675                    profile: None,
676                    account_id: Some(a),
677                    status: Some(s),
678                    identifier: None,
679                    identifier_contains: None,
680                    cert_serial: None,
681                    expiring_in: None,
682                    hide_superseded: false,
683                    limit: window::DEFAULT_LIMIT,
684                    offset: 0,
685                    json: true
686                })
687            }) if a == "acct-1" && s == "pending"
688        ));
689
690        let cli = Cli::try_parse_from([
691            "acme-proxy",
692            "order",
693            "list",
694            "--expiring-in",
695            "30",
696            "--hide-superseded",
697        ])
698        .unwrap();
699        assert!(matches!(
700            cli.command,
701            Some(Command::Order {
702                command: OrderCommand::List(crate::cli::order::OrderListArgs {
703                    expiring_in: Some(30),
704                    hide_superseded: true,
705                    status: None,
706                    account_id: None,
707                    profile: None,
708                    identifier: None,
709                    identifier_contains: None,
710                    cert_serial: None,
711                    limit: window::DEFAULT_LIMIT,
712                    offset: 0,
713                    json: false
714                })
715            })
716        ));
717
718        let cli = Cli::try_parse_from(["acme-proxy", "order", "show", "ord-1"]).unwrap();
719        assert!(matches!(
720            cli.command,
721            Some(Command::Order {
722                command: OrderCommand::Show { id, json: false }
723            }) if id == "ord-1"
724        ));
725
726        let cli = Cli::try_parse_from(["acme-proxy", "order", "delete", "ord-1"]).unwrap();
727        assert!(matches!(
728            cli.command,
729            Some(Command::Order {
730                command: OrderCommand::Delete { id }
731            }) if id == "ord-1"
732        ));
733
734        let cli = Cli::try_parse_from(["acme-proxy", "order", "revoke", "ord-1", "--reason", "1"])
735            .unwrap();
736        assert!(matches!(
737            cli.command,
738            Some(Command::Order {
739                command: OrderCommand::Revoke { id, reason: Some(1), wait: 30 }
740            }) if id == "ord-1"
741        ));
742
743        let cli =
744            Cli::try_parse_from(["acme-proxy", "nonce", "cleanup", "--ttl-seconds", "60"]).unwrap();
745        assert!(matches!(
746            cli.command,
747            Some(Command::Nonce {
748                command: NonceCommand::Cleanup {
749                    ttl_seconds: Some(60)
750                }
751            })
752        ));
753
754        let cli = Cli::try_parse_from([
755            "acme-proxy",
756            "eab",
757            "create",
758            "--label",
759            "test-key",
760            "--json",
761        ])
762        .unwrap();
763        assert!(matches!(
764            cli.command,
765            Some(Command::Eab {
766                command: EabCommand::Create { label: Some(l), profile: None, json: true }
767            }) if l == "test-key"
768        ));
769
770        let cli = Cli::try_parse_from(["acme-proxy", "eab", "list"]).unwrap();
771        assert!(matches!(
772            cli.command,
773            Some(Command::Eab {
774                command: EabCommand::List {
775                    limit: 50,
776                    offset: 0,
777                    json: false
778                }
779            })
780        ));
781
782        // The four commands added for the last few asymmetries: a
783        // detail for the one listable object that had none, and the three reads
784        // the panel could already answer and the host could not.
785        let cli = Cli::try_parse_from(["acme-proxy", "order", "chain", "ord-1"]).unwrap();
786        assert!(matches!(
787            cli.command,
788            Some(Command::Order {
789                command: OrderCommand::Chain { id }
790            }) if id == "ord-1"
791        ));
792
793        let cli = Cli::try_parse_from(["acme-proxy", "nonce", "count", "--json"]).unwrap();
794        assert!(matches!(
795            cli.command,
796            Some(Command::Nonce {
797                command: NonceCommand::Count { json: true }
798            })
799        ));
800
801        let cli = Cli::try_parse_from(["acme-proxy", "profile", "list"]).unwrap();
802        assert!(matches!(
803            cli.command,
804            Some(Command::Profile {
805                command: ProfileCommand::List { json: false }
806            })
807        ));
808
809        let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "show", "alice"]).unwrap();
810        assert!(matches!(
811            cli.command,
812            Some(Command::Admin {
813                command: AdminCommand::User {
814                    command: crate::cli::webadmin::AdminUserCommand::Show { username, json: false }
815                }
816            }) if username == "alice"
817        ));
818
819        // The window the three formerly unwindowed listings grew, defaulted the
820        // same way as the four that already had one.
821        let cli =
822            Cli::try_parse_from(["acme-proxy", "admin", "user", "list", "--limit", "2"]).unwrap();
823        assert!(matches!(
824            cli.command,
825            Some(Command::Admin {
826                command: AdminCommand::User {
827                    command: crate::cli::webadmin::AdminUserCommand::List {
828                        limit: 2,
829                        offset: 0,
830                        json: false
831                    }
832                }
833            })
834        ));
835
836        let cli =
837            Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--offset=5"]).unwrap();
838        assert!(matches!(
839            cli.command,
840            Some(Command::Admin {
841                command: AdminCommand::Session {
842                    command: crate::cli::webadmin::AdminSessionCommand::List {
843                        user: None,
844                        limit: window::DEFAULT_LIMIT,
845                        offset: 5,
846                        json: false
847                    }
848                }
849            })
850        ));
851
852        let cli = Cli::try_parse_from(["acme-proxy", "eab", "show", "kid-1", "--json"]).unwrap();
853        assert!(matches!(
854            cli.command,
855            Some(Command::Eab {
856                command: EabCommand::Show { kid, json: true }
857            }) if kid == "kid-1"
858        ));
859
860        let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-kid", "kid-1"])
861            .unwrap();
862        assert!(matches!(
863            cli.command,
864            Some(Command::Upstream {
865                command: UpstreamCommand::Register { eab_kid: Some(kid), eab_hmac_key_file: None, profile: None }
866            }) if kid == "kid-1"
867        ));
868
869        // Registering against an upstream that needs no credential.
870        let cli = Cli::try_parse_from(["acme-proxy", "upstream", "register"]).unwrap();
871        assert!(matches!(
872            cli.command,
873            Some(Command::Upstream {
874                command: UpstreamCommand::Register {
875                    eab_kid: None,
876                    eab_hmac_key_file: None,
877                    profile: None,
878                }
879            })
880        ));
881
882        // The secret itself has no flag: it is stdin- or file-only, never argv.
883        assert!(
884            Cli::try_parse_from(["acme-proxy", "upstream", "register", "--eab-hmac-key", "s"])
885                .is_err(),
886            "an EAB secret must not be accepted on the command line"
887        );
888
889        let cli = Cli::try_parse_from(["acme-proxy", "upstream", "show", "--json"]).unwrap();
890        assert!(matches!(
891            cli.command,
892            Some(Command::Upstream {
893                command: UpstreamCommand::Show {
894                    json: true,
895                    profile: None
896                }
897            })
898        ));
899
900        let cli = Cli::try_parse_from(["acme-proxy", "eab", "revoke", "kid-1"]).unwrap();
901        assert!(matches!(
902            cli.command,
903            Some(Command::Eab {
904                command: EabCommand::Revoke { kid }
905            }) if kid == "kid-1"
906        ));
907
908        let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "create", "alice"]).unwrap();
909        assert!(matches!(
910            cli.command,
911            Some(Command::Admin {
912                command: AdminCommand::User {
913                    command: crate::cli::webadmin::AdminUserCommand::Create {
914                        username,
915                        password_file: None,
916                        role: _,
917                        contact: None,
918                    }
919                }
920            }) if username == "alice"
921        ));
922
923        let cli = Cli::try_parse_from([
924            "acme-proxy",
925            "admin",
926            "user",
927            "passwd",
928            "alice",
929            "--password-file",
930            "/run/secrets/pw",
931        ])
932        .unwrap();
933        assert!(matches!(
934            cli.command,
935            Some(Command::Admin {
936                command: AdminCommand::User {
937                    command: crate::cli::webadmin::AdminUserCommand::Passwd {
938                        username,
939                        password_file: Some(path)
940                    }
941                }
942            }) if username == "alice" && path == std::path::Path::new("/run/secrets/pw")
943        ));
944
945        // The password itself has no flag, for the same reason the EAB secret
946        // has none: argv is visible in `ps` and lands in shell history.
947        for command in ["create", "passwd"] {
948            assert!(
949                Cli::try_parse_from([
950                    "acme-proxy",
951                    "admin",
952                    "user",
953                    command,
954                    "alice",
955                    "--password",
956                    "hunter2",
957                ])
958                .is_err(),
959                "`admin user {command}` must not accept a password on the command line"
960            );
961        }
962
963        // `--color` is global like `--yes`, so it may sit anywhere on the line,
964        // and an unknown value is refused by clap rather than falling back to
965        // `auto` — the same rule `--status`/`--event` follow, for the same
966        // reason: a silently ignored value looks exactly like a working one.
967        let cli = Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "never"])
968            .expect("--color is global and accepts `never`");
969        assert_eq!(cli.color, ColorChoice::Never);
970
971        let cli = Cli::try_parse_from(["acme-proxy", "--color", "always", "account", "list"])
972            .expect("--color is global, so it may precede the subcommand");
973        assert_eq!(cli.color, ColorChoice::Always);
974
975        assert_eq!(
976            Cli::try_parse_from(["acme-proxy", "account", "list"])
977                .unwrap()
978                .color,
979            ColorChoice::Auto,
980            "unset means auto"
981        );
982
983        assert!(
984            Cli::try_parse_from(["acme-proxy", "account", "list", "--color", "sometimes"]).is_err(),
985            "an unknown --color value must be refused, not ignored"
986        );
987
988        let cli = Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "status", "alice"])
989            .unwrap();
990        assert!(matches!(
991            cli.command,
992            Some(Command::Admin {
993                command: AdminCommand::User {
994                    command: crate::cli::webadmin::AdminUserCommand::Totp {
995                        command: crate::cli::webadmin::AdminUserTotpCommand::Status {
996                            username,
997                            json: false
998                        }
999                    }
1000                }
1001            }) if username == "alice"
1002        ));
1003
1004        let cli = Cli::try_parse_from([
1005            "acme-proxy",
1006            "admin",
1007            "user",
1008            "totp",
1009            "recovery-codes",
1010            "alice",
1011        ])
1012        .unwrap();
1013        assert!(matches!(
1014            cli.command,
1015            Some(Command::Admin {
1016                command: AdminCommand::User {
1017                    command: crate::cli::webadmin::AdminUserCommand::Totp {
1018                        command: crate::cli::webadmin::AdminUserTotpCommand::RecoveryCodes {
1019                            username
1020                        }
1021                    }
1022                }
1023            }) if username == "alice"
1024        ));
1025
1026        // There is no `enrol` from a terminal, deliberately: it would put the
1027        // base32 secret in scrollback and shell history. See the doc comment on
1028        // `AdminUserTotpCommand`.
1029        assert!(
1030            Cli::try_parse_from(["acme-proxy", "admin", "user", "totp", "enrol", "alice"]).is_err()
1031        );
1032
1033        let cli =
1034            Cli::try_parse_from(["acme-proxy", "-y", "admin", "user", "delete", "alice"]).unwrap();
1035        assert!(cli.yes);
1036        assert!(matches!(
1037            cli.command,
1038            Some(Command::Admin {
1039                command: AdminCommand::User {
1040                    command: crate::cli::webadmin::AdminUserCommand::Delete { username }
1041                }
1042            }) if username == "alice"
1043        ));
1044
1045        let cli =
1046            Cli::try_parse_from(["acme-proxy", "admin", "session", "list", "--json"]).unwrap();
1047        assert!(matches!(
1048            cli.command,
1049            Some(Command::Admin {
1050                command: AdminCommand::Session {
1051                    command: crate::cli::webadmin::AdminSessionCommand::List {
1052                        user: None,
1053                        limit: 50,
1054                        offset: 0,
1055                        json: true
1056                    }
1057                }
1058            })
1059        ));
1060
1061        // `--user` and `--all` answer the same question two ways; clap refuses
1062        // both rather than letting one silently win.
1063        assert!(
1064            Cli::try_parse_from([
1065                "acme-proxy",
1066                "admin",
1067                "session",
1068                "revoke",
1069                "--user",
1070                "alice",
1071                "--all",
1072            ])
1073            .is_err(),
1074            "--user and --all are mutually exclusive"
1075        );
1076
1077        // `--session` names one row within one operator's sessions, so it only
1078        // means anything alongside `--user`, and never with `--all`.
1079        assert!(
1080            Cli::try_parse_from([
1081                "acme-proxy",
1082                "admin",
1083                "session",
1084                "revoke",
1085                "--session",
1086                "abc"
1087            ])
1088            .is_err(),
1089            "--session requires --user"
1090        );
1091        assert!(
1092            Cli::try_parse_from([
1093                "acme-proxy",
1094                "admin",
1095                "session",
1096                "revoke",
1097                "--all",
1098                "--session",
1099                "abc",
1100            ])
1101            .is_err(),
1102            "--session and --all are mutually exclusive"
1103        );
1104        assert!(matches!(
1105            Cli::try_parse_from([
1106                "acme-proxy",
1107                "admin",
1108                "session",
1109                "revoke",
1110                "--user",
1111                "alice",
1112                "--session",
1113                "abc",
1114            ])
1115            .unwrap()
1116            .command,
1117            Some(Command::Admin {
1118                command: AdminCommand::Session {
1119                    command: crate::cli::webadmin::AdminSessionCommand::Revoke {
1120                        user: Some(user),
1121                        all: false,
1122                        session: Some(session),
1123                    }
1124                }
1125            }) if user == "alice" && session == "abc"
1126        ));
1127    }
1128
1129    #[test]
1130    fn a_database_error_renders_as_a_cli_error() {
1131        let error = CliError::from(sqlx::Error::PoolClosed);
1132        assert!(error.to_string().starts_with("database error: "), "{error}");
1133        // A database error is the host's problem, not the request's.
1134        assert_eq!(error.kind(), CliErrorKind::Failed);
1135        assert_eq!(error.exit_code(), 1);
1136    }
1137
1138    /// The one distinction `main.rs` turns into a process status: `Failed` is
1139    /// exit `1` (the host could not carry out the request), `BadRequest` is
1140    /// exit `3` (the request cannot be satisfied as written).
1141    #[test]
1142    fn the_kind_decides_the_exit_code() {
1143        assert_eq!(CliError::failed("x").kind(), CliErrorKind::Failed);
1144        assert_eq!(CliError::bad_request("x").kind(), CliErrorKind::BadRequest);
1145        assert_eq!(CliError::failed("x").exit_code(), 1);
1146        assert_eq!(CliError::bad_request("x").exit_code(), 3);
1147        // The bare conversions default to `Failed` — a plain `?` on a DB call
1148        // must keep exiting `1`.
1149        assert_eq!(CliError::from("x").kind(), CliErrorKind::Failed);
1150        assert_eq!(CliError::from("x".to_string()).kind(), CliErrorKind::Failed);
1151    }
1152
1153    /// A configuration with no resolvable profiles is the host's to fix, so
1154    /// `resolve_profile` reports it as `Failed` (exit 1). The `BadRequest`
1155    /// branches — an unknown `--profile`, and none given where several exist —
1156    /// are covered in `src/cli/filter.rs`, its other caller, where a
1157    /// multi-profile configuration is already loadable.
1158    #[test]
1159    fn resolve_profile_reports_a_missing_profile_set_as_failed() {
1160        let config = Config::default();
1161        assert_eq!(
1162            resolve_profile(&config, None).unwrap_err().kind(),
1163            CliErrorKind::Failed
1164        );
1165    }
1166
1167    /// Every arm reaches its command handler. `Serve` is deliberately absent —
1168    /// it owns a socket, and [`serve_on`] is what the tests below drive.
1169    #[tokio::test]
1170    async fn dispatch_routes_each_command() {
1171        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1172        let config = Arc::new(Config::default());
1173        let mut reader: &[u8] = &[];
1174
1175        let commands = vec![
1176            Command::Account {
1177                command: AccountCommand::List {
1178                    profile: None,
1179                    eab_kid: None,
1180                    limit: window::DEFAULT_LIMIT,
1181                    offset: 0,
1182                    json: false,
1183                },
1184            },
1185            Command::Order {
1186                command: OrderCommand::List(crate::cli::order::OrderListArgs {
1187                    profile: None,
1188                    account_id: None,
1189                    status: None,
1190                    identifier: None,
1191                    identifier_contains: None,
1192                    cert_serial: None,
1193                    expiring_in: None,
1194                    hide_superseded: false,
1195                    limit: window::DEFAULT_LIMIT,
1196                    offset: 0,
1197                    json: false,
1198                }),
1199            },
1200            Command::Nonce {
1201                command: NonceCommand::Cleanup {
1202                    ttl_seconds: Some(1),
1203                },
1204            },
1205            Command::Nonce {
1206                command: NonceCommand::Count { json: false },
1207            },
1208            Command::Eab {
1209                command: EabCommand::List {
1210                    limit: 50,
1211                    offset: 0,
1212                    json: false,
1213                },
1214            },
1215            Command::Jobs {
1216                command: JobsCommand::List {
1217                    kind: None,
1218                    status: None,
1219                    limit: window::DEFAULT_LIMIT,
1220                    offset: 0,
1221                    json: false,
1222                },
1223            },
1224            Command::Man,
1225            Command::Completions {
1226                shell: clap_complete::aot::Shell::Bash,
1227            },
1228            // `Profile` is deliberately absent for `Upstream`'s reason below,
1229            // arrived at from the other end: it resolves the profiles, and
1230            // `Config::default()` mounts none, so it reports that rather than
1231            // listing nothing. Its arm is driven from `cli::profile`'s own
1232            // tests, against a configuration that has some.
1233            // `Upstream` is deliberately absent: it acts on a *profile's*
1234            // `[signer.relay]`, and this config has none, so it now
1235            // reports that rather than silently reading the global base
1236            // section nothing serves from. Covered in `cli::upstream`'s own
1237            // tests, which supply a configuration with profiles.
1238        ];
1239        for command in commands {
1240            dispatch(
1241                Some(command),
1242                true,
1243                ColorChoice::Never,
1244                &mut reader,
1245                &config,
1246                database.clone(),
1247            )
1248            .await
1249            .expect("every command must succeed against an empty database");
1250        }
1251    }
1252
1253    /// `Transfer`'s arm, which the list above cannot hold.
1254    ///
1255    /// Every command there has to succeed against one empty database, and a
1256    /// transfer needs a second one that exists, is migrated and is empty. So
1257    /// it is routed here instead, against the one refusal that opens nothing:
1258    /// a `--to` naming the configured database is a copy into itself. The
1259    /// command's own behaviour is `cli::transfer`'s suite.
1260    #[tokio::test]
1261    async fn dispatch_routes_transfer() {
1262        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1263        let config = Arc::new(Config::default());
1264        let mut reader: &[u8] = &[];
1265
1266        let error = dispatch(
1267            Some(Command::Transfer {
1268                to: config.database.url.clone(),
1269                json: false,
1270            }),
1271            true,
1272            ColorChoice::Never,
1273            &mut reader,
1274            &config,
1275            database,
1276        )
1277        .await
1278        .expect_err("the source and the target are one database");
1279
1280        assert_eq!(error.kind(), CliErrorKind::BadRequest);
1281    }
1282
1283    /// A failing command's message reaches [`dispatch`]'s caller rather than
1284    /// exiting the process where it was raised.
1285    #[tokio::test]
1286    async fn dispatch_propagates_a_command_failure() {
1287        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1288        let config = Arc::new(Config::default());
1289        let mut reader: &[u8] = &[];
1290
1291        let error = dispatch(
1292            Some(Command::Account {
1293                command: AccountCommand::Show {
1294                    id: "acct-nope".to_string(),
1295                    json: false,
1296                },
1297            }),
1298            true,
1299            ColorChoice::Never,
1300            &mut reader,
1301            &config,
1302            database,
1303        )
1304        .await
1305        .expect_err("an unknown account must fail");
1306        assert_eq!(
1307            error,
1308            CliError::bad_request("no such account: acct-nope".to_string())
1309        );
1310        assert_eq!(error.exit_code(), 3);
1311    }
1312
1313    /// `Serve` is a `dispatch` arm like any other: its failure travels back
1314    /// as a value instead of taking the process down where it happened.
1315    #[tokio::test]
1316    async fn dispatch_serve_reports_a_startup_failure() {
1317        let database = Arc::new(Database::connect_in_memory().await.unwrap());
1318        // Binds fine, but mounts no endpoint — so it fails inside
1319        // `serve_on` rather than at the socket.
1320        let mut config = Config::default();
1321        config.server.bind_address = "127.0.0.1:0".to_string();
1322        let mut reader: &[u8] = &[];
1323
1324        let error = dispatch(
1325            Some(Command::Serve { role: None }),
1326            true,
1327            ColorChoice::Never,
1328            &mut reader,
1329            &Arc::new(config),
1330            database,
1331        )
1332        .await
1333        .expect_err("a server with no endpoint must not start");
1334        assert!(error.to_string().contains("profile"), "{error}");
1335    }
1336}