Skip to main content

acme_proxy/cli/
profile.rs

1//! `profile list` — the ACME endpoints this configuration mounts.
2//!
3//! The terminal's half of `GET /api/profiles`. Both render
4//! [`acme_proxy_admin::admin::render_profile_json`], and they reach it from opposite
5//! directions: the API describes a **mounted** [`acme_proxy_protocol::profile::Profile`],
6//! where this describes what the configuration on disk *would* mount.
7//!
8//! That difference is deliberate rather than a shortcut. Building the real
9//! thing means `server::profile::build_all`, which constructs every signer backend —
10//! generating a CA key that does not exist yet, and contacting a relay's
11//! upstream — which is not a price a read-only listing should make an operator
12//! pay. `filter show` already draws the same line, and it cuts both ways: the
13//! panel is right about what is running, and this is the only one of the two
14//! that can be pointed at a configuration the server would refuse to start on.
15
16use std::sync::Arc;
17
18use clap::Subcommand;
19
20use crate::cli::CliError;
21use crate::cli::render;
22use acme_proxy_admin::admin;
23use acme_proxy_admin::admin::ProfileSummary;
24use acme_proxy_core::config::Config;
25use acme_proxy_core::palette::Palette;
26
27#[derive(Subcommand)]
28pub enum ProfileCommand {
29    /// List the ACME endpoints this configuration mounts, name-sorted.
30    List {
31        /// Print it as JSON.
32        #[arg(long)]
33        json: bool,
34    },
35}
36
37pub async fn run_profile_command(
38    command: ProfileCommand,
39    palette: Palette,
40    config: &Arc<Config>,
41) -> Result<(), CliError> {
42    match command {
43        ProfileCommand::List { json } => {
44            // The same call `serve` makes, so every startup refusal reaches an
45            // operator here too -- `filter show`'s reason for building rather
46            // than reading back. `resolve_profiles` has already dropped anything
47            // `enabled = false`, so there is no filter here: the list is the
48            // mounted set.
49            let resolved = config
50                .resolve_profiles()
51                .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
52
53            let profiles: Vec<ProfileSummary> = resolved
54                .iter()
55                .map(|profile| ProfileSummary::configured(&config.server.base_url, profile))
56                .collect();
57
58            // Not `print_page`: this is a list an operator writes by hand in one
59            // file, so it has no page and nothing to report a total against --
60            // the argument the three paged listings had outgrown and this one
61            // has not.
62            if json {
63                let rendered: Vec<_> = profiles.iter().map(admin::render_profile_json).collect();
64                println!("{}", serde_json::Value::Array(rendered));
65            } else {
66                for profile in &profiles {
67                    println!("{}", render::render_profile_line(profile, palette));
68                }
69            }
70        }
71    }
72    Ok(())
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use acme_proxy_core::config::ENV_LOCK;
79
80    /// Loads a `Config` the way the server does, so `resolve_profiles` has the
81    /// raw sources per-key inheritance needs — `cli::upstream`'s helper, and
82    /// for its reason.
83    fn config_from(body: &str) -> Arc<Config> {
84        let _lock = ENV_LOCK
85            .lock()
86            .unwrap_or_else(std::sync::PoisonError::into_inner);
87        let dir = acme_proxy_core::testutil::TempDir::new("profile");
88        std::fs::write(dir.join("config.toml"), body).unwrap();
89        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
90        unsafe {
91            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
92        }
93        let config = Config::load().expect("the configuration must load");
94        unsafe {
95            std::env::remove_var("ACME_PROXY_CONFIG");
96        }
97        Arc::new(config)
98    }
99
100    /// Name-sorted, `enabled = false` absent, and each summary carrying the
101    /// profile's *own* merged sections rather than the global ones.
102    #[test]
103    fn the_listing_is_the_profiles_this_configuration_would_mount() {
104        let config = config_from(
105            r#"
106            [server]
107            base_url = "https://ca.example.com"
108
109            [challenge]
110            bypass = false
111
112            [profiles.staging]
113            challenge.bypass = true
114
115            [profiles.le]
116            eab.enabled = true
117
118            [profiles.parked]
119            enabled = false
120            "#,
121        );
122
123        let resolved = config.resolve_profiles().unwrap();
124        let summaries: Vec<ProfileSummary> = resolved
125            .iter()
126            .map(|profile| ProfileSummary::configured(&config.server.base_url, profile))
127            .collect();
128
129        let names: Vec<&str> = summaries.iter().map(|p| p.name.as_str()).collect();
130        assert_eq!(names, ["le", "staging"], "parked is not mounted");
131
132        let le = &summaries[0];
133        assert_eq!(le.base_url, "https://ca.example.com/profile/le");
134        assert_eq!(
135            le.directory_url(),
136            "https://ca.example.com/profile/le/directory"
137        );
138        assert!(le.eab_enabled);
139        // Inherited from the global section, not reverted to the compiled
140        // default -- the per-key merge `Config::merged_sections` performs.
141        assert!(!le.challenge_bypass);
142        assert!(summaries[1].challenge_bypass, "staging overrode it");
143    }
144
145    /// Both output shapes run, and a configuration that resolves no profiles is
146    /// reported in words rather than printing an empty list -- `resolve_profiles`
147    /// refuses it, and this command is meant to surface exactly the startup
148    /// refusals `serve` would hit.
149    #[tokio::test]
150    async fn both_shapes_render_and_a_profileless_configuration_is_refused() {
151        let config = config_from("[profiles.default]\n");
152        for json in [false, true] {
153            run_profile_command(ProfileCommand::List { json }, Palette::plain(), &config)
154                .await
155                .unwrap();
156        }
157
158        let empty = Arc::new(Config::default());
159        let error = run_profile_command(
160            ProfileCommand::List { json: false },
161            Palette::plain(),
162            &empty,
163        )
164        .await
165        .expect_err("a configuration mounting nothing is not a listing of nothing");
166        assert!(
167            error.to_string().starts_with("configuration error: "),
168            "{error}"
169        );
170    }
171}