Skip to main content

acme_proxy/cli/
eab.rs

1use std::sync::Arc;
2
3use clap::Subcommand;
4
5use crate::admin;
6use crate::cli::CliError;
7use crate::cli::render;
8use crate::cli::style::Palette;
9use crate::cli::window::{DEFAULT_LIMIT, Window};
10use crate::sqlite::db::Database;
11use crate::sqlite::eab::Eab;
12
13#[derive(Subcommand)]
14pub enum EabCommand {
15    /// Generate a new EAB key and print its kid + secret ONCE.
16    Create {
17        #[arg(long)]
18        label: Option<String>,
19        /// Bind the credential to one ACME endpoint. Omitted, it is accepted
20        /// at every profile — which is what an unscoped credential means.
21        #[arg(long)]
22        profile: Option<String>,
23        #[arg(long)]
24        json: bool,
25    },
26    /// List EAB keys, newest first. Never shows the secret.
27    List {
28        #[arg(long, default_value_t = DEFAULT_LIMIT)]
29        limit: i64,
30        #[arg(long, default_value_t = 0)]
31        offset: i64,
32        #[arg(long)]
33        json: bool,
34    },
35    /// Show one EAB key. Never shows the secret.
36    Show {
37        kid: String,
38        #[arg(long)]
39        json: bool,
40    },
41    /// Revoke a key.
42    Revoke { kid: String },
43}
44
45pub async fn run_eab_command(
46    command: EabCommand,
47    palette: Palette,
48    database: Arc<Database>,
49) -> Result<(), CliError> {
50    match command {
51        EabCommand::Create {
52            label,
53            profile,
54            json,
55        } => {
56            let eab = Eab::create(label, profile, &database).await?;
57            if json {
58                println!("{}", admin::render_eab_created_json(&eab));
59            } else {
60                print!("{}", render::render_eab_created_text(&eab, palette));
61            }
62        }
63        EabCommand::List {
64            limit,
65            offset,
66            json,
67        } => {
68            let window = Window::resolve(limit, offset);
69            let (keys, total) = Eab::search(window.limit, window.offset, &database).await?;
70            render::print_page(&keys, total, window, json, admin::render_eab_json, |eab| {
71                render::render_eab_line(eab, palette)
72            });
73        }
74        EabCommand::Show { kid, json } => match Eab::find_any_by_kid(&kid, &database).await? {
75            None => return Err(not_found(&kid)),
76            Some(eab) if json => println!("{}", admin::render_eab_json(&eab)),
77            Some(eab) => println!("{}", render::render_eab_line(&eab, palette)),
78        },
79        EabCommand::Revoke { kid } => {
80            if !Eab::revoke(&kid, &database).await? {
81                return Err(not_found(&kid));
82            }
83            println!("Revoked EAB key {kid}.");
84        }
85    }
86    Ok(())
87}
88
89fn not_found(kid: &str) -> CliError {
90    CliError(format!("no such EAB credential: {kid}"))
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[tokio::test]
98    async fn show_and_revoke_refuse_an_unknown_kid() {
99        let database = Arc::new(Database::connect_in_memory().await.unwrap());
100        let expected = CliError("no such EAB credential: kid-nope".to_string());
101
102        for command in [
103            EabCommand::Show {
104                kid: "kid-nope".to_string(),
105                json: false,
106            },
107            EabCommand::Revoke {
108                kid: "kid-nope".to_string(),
109            },
110        ] {
111            let error = run_eab_command(command, Palette::plain(), database.clone())
112                .await
113                .expect_err("an unknown kid must fail");
114            assert_eq!(error, expected);
115        }
116    }
117
118    /// `revoke` matches on the `kid` alone, so revoking twice is idempotent
119    /// and still reports success — only an unknown `kid` is an error.
120    #[tokio::test]
121    async fn a_created_key_shows_lists_and_revokes() {
122        let database = Arc::new(Database::connect_in_memory().await.unwrap());
123        let eab = Eab::create(Some("test".to_string()), None, &database)
124            .await
125            .unwrap();
126
127        for command in [
128            EabCommand::Create {
129                label: None,
130                profile: Some("default".to_string()),
131                json: true,
132            },
133            EabCommand::List {
134                limit: DEFAULT_LIMIT,
135                offset: 0,
136                json: true,
137            },
138            EabCommand::Show {
139                kid: eab.kid.to_string(),
140                json: true,
141            },
142            EabCommand::Show {
143                kid: eab.kid.to_string(),
144                json: false,
145            },
146            EabCommand::Revoke {
147                kid: eab.kid.to_string(),
148            },
149        ] {
150            run_eab_command(command, Palette::plain(), database.clone())
151                .await
152                .unwrap();
153        }
154
155        run_eab_command(
156            EabCommand::Revoke {
157                kid: eab.kid.to_string(),
158            },
159            Palette::plain(),
160            database.clone(),
161        )
162        .await
163        .expect("revoking an already-revoked key is a no-op, not a failure");
164
165        assert_eq!(
166            Eab::find_any_by_kid(eab.kid.to_string().as_str(), &database)
167                .await
168                .unwrap()
169                .unwrap()
170                .status,
171            "revoked"
172        );
173    }
174}