Skip to main content

acme_proxy/cli/
upstream.rs

1//! `acme-proxy upstream …` — managing this server's own ACME account at the
2//! upstream CA, when the `relay` signer backend is in use.
3//!
4//! ## Why this command exists alongside `signer.relay.eab`
5//!
6//! An upstream that requires External Account Binding hands the operator a
7//! `kid` and an HMAC secret out of band. That credential authorizes exactly
8//! one thing — a single `newAccount` call — and is useless afterwards. This
9//! command takes the secret on **stdin** (or from a file), uses it once, and
10//! never writes it anywhere — no bootstrap secret is left readable on disk
11//! for the life of the server, unlike the alternative of setting
12//! `signer.relay.eab` in configuration (see
13//! [`acme_proxy_core::config::RelayEabConfig`]), which trades that property away
14//! for not needing this separate step. The secret is deliberately not
15//! accepted as a command-line flag either way: argv is visible to every
16//! process on the host via `ps` and is routinely written to shell history.
17
18use std::io::BufRead;
19use std::path::PathBuf;
20use std::sync::Arc;
21
22use clap::Subcommand;
23
24use crate::cli::render;
25use crate::cli::window::{DEFAULT_LIMIT, Window};
26use crate::cli::{CliError, resolve_profile};
27use acme_proxy_admin::admin;
28use acme_proxy_core::config::Config;
29use acme_proxy_core::palette::Palette;
30use acme_proxy_signer::relay;
31use acme_proxy_store::db::Database;
32use acme_proxy_store::status::UpstreamOrderStatus;
33use acme_proxy_store::upstream_order::UpstreamOrder;
34use acme_proxy_store::upstream_order::UpstreamOrderQuery;
35
36#[derive(Subcommand)]
37pub enum UpstreamCommand {
38    /// Register this server's account at the upstream ACME server, writing the
39    /// `kid` it returns so `serve` never needs the credential again.
40    Register {
41        /// The EAB key id the upstream's operator issued. Omit when the
42        /// upstream requires no External Account Binding.
43        #[arg(long = "eab-kid")]
44        eab_kid: Option<String>,
45        /// Read the EAB HMAC secret (base64, URL-safe or standard, padded or
46        /// not) from this file instead of prompting on stdin. The file is read, used, and never copied.
47        #[arg(long = "eab-hmac-key-file")]
48        eab_hmac_key_file: Option<PathBuf>,
49        /// Which profile's upstream to register with. Optional when the
50        /// configuration defines exactly one profile.
51        #[arg(long)]
52        profile: Option<String>,
53    },
54    /// Show the configured upstream and whether this server is registered.
55    Show {
56        /// Print it as JSON.
57        #[arg(long)]
58        json: bool,
59        /// Which profile's upstream to describe. Optional when the
60        /// configuration defines exactly one profile.
61        #[arg(long)]
62        profile: Option<String>,
63    },
64    /// The relay backend's per-order upstream state. Read-only — abandoning an
65    /// in-flight relay is done through `acme-proxy jobs cancel` on the
66    /// `signer_relay_issue` job, which owns the state machine.
67    Order {
68        #[command(subcommand)]
69        command: UpstreamOrderCommand,
70    },
71}
72
73#[derive(Subcommand)]
74pub enum UpstreamOrderCommand {
75    /// List upstream orders, optionally filtered.
76    List {
77        /// Restrict to one ACME endpoint (the local order's profile).
78        #[arg(long)]
79        profile: Option<String>,
80        /// Only upstream orders in this state: `processing`, `valid` or
81        /// `invalid`.
82        #[arg(long)]
83        status: Option<String>,
84        /// Rows per page. A value below 1 is read as 1.
85        #[arg(long, default_value_t = DEFAULT_LIMIT)]
86        limit: i64,
87        /// Rows to skip before the page starts.
88        #[arg(long, default_value_t = 0)]
89        offset: i64,
90        /// Print the page as JSON: `{items, total, limit, offset}`.
91        #[arg(long)]
92        json: bool,
93    },
94    /// Show one upstream order by its local order id, cross-linked to its
95    /// relay job.
96    Show {
97        /// The local order id.
98        id: String,
99        /// Print it as JSON.
100        #[arg(long)]
101        json: bool,
102    },
103}
104
105/// Resolves which profile's `[signer.relay]` a command should act on.
106///
107/// `signer` is a **per-profile** section, but this command read
108/// `config.signer.relay` — the global base every profile overlays, which
109/// nothing serves from. An operator who configured the upstream only under
110/// `[profiles.le].signer.relay` was told "directory_url is not set", which
111/// was simply false; and if a *different* upstream happened to be configured
112/// globally, `register` would write the account key and `.kid` sidecar for the
113pub async fn run_upstream_command(
114    command: UpstreamCommand,
115    reader: &mut impl BufRead,
116    palette: Palette,
117    config: &Config,
118    database: Arc<Database>,
119) -> Result<(), CliError> {
120    match command {
121        UpstreamCommand::Register {
122            eab_kid,
123            eab_hmac_key_file,
124            profile,
125        } => {
126            let resolved = resolve_profile(config, profile.as_deref())?;
127            let cfg = &resolved.sections.signer.relay;
128            if cfg.directory_url.is_empty() {
129                return Err(CliError::failed(
130                    "signer.relay.directory_url is not set: there is no upstream to register \
131                     with"
132                        .to_string(),
133                ));
134            }
135
136            // Only read a secret when a kid names one; an upstream that needs
137            // no EAB must not prompt for something the operator does not have.
138            let secret = eab_kid
139                .as_ref()
140                .map(|_| read_secret(eab_hmac_key_file.as_deref(), reader))
141                .transpose()?;
142            let eab = eab_kid.as_deref().zip(secret.as_deref());
143
144            // A throwaway resolver, for the same reason `order revoke` builds
145            // one: a one-shot command has no server around it to share the
146            // process-wide one.
147            let resolver = acme_proxy_net::dns::resolver_addr(&config.dns)
148                .and_then(acme_proxy_net::challenge::build_resolver)
149                .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
150            let proxies = acme_proxy_net::proxy::from_config(&config.proxy)
151                .map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
152            let outbound = acme_proxy_net::http_client::Outbound::new(resolver, proxies);
153            match relay::register_upstream_account(cfg, outbound, eab).await {
154                Ok(kid) => println!("Registered. kid = {kid}"),
155                Err(error) => {
156                    return Err(CliError::failed(format!(
157                        "upstream registration failed: {error}"
158                    )));
159                }
160            }
161        }
162
163        UpstreamCommand::Show { json, profile } => {
164            let resolved = resolve_profile(config, profile.as_deref())?;
165            let cfg = &resolved.sections.signer.relay;
166            let kid = relay::stored_kid(cfg);
167            let key_present = std::path::Path::new(&cfg.account_key_path).exists();
168
169            if json {
170                println!(
171                    "{}",
172                    serde_json::json!({
173                        "directoryUrl": cfg.directory_url,
174                        "accountKeyPath": cfg.account_key_path,
175                        "accountKeyPresent": key_present,
176                        "kid": kid,
177                        "registered": kid.is_some(),
178                    })
179                );
180            } else {
181                println!("directory:   {}", none_if_empty(&cfg.directory_url));
182                println!(
183                    "account key: {} ({})",
184                    cfg.account_key_path,
185                    if key_present { "present" } else { "absent" }
186                );
187                match kid {
188                    Some(kid) => println!("kid:         {kid}"),
189                    None => println!("kid:         (not registered)"),
190                }
191            }
192        }
193
194        UpstreamCommand::Order { command } => match command {
195            UpstreamOrderCommand::List {
196                profile,
197                status,
198                limit,
199                offset,
200                json,
201            } => {
202                let status = super::parse_flag::<UpstreamOrderStatus>("--status", status)?;
203                let window = Window::resolve(limit, offset);
204                let query = UpstreamOrderQuery {
205                    profile,
206                    status,
207                    limit: window.limit,
208                    offset: window.offset,
209                };
210                let (rows, total) = UpstreamOrder::search(&query, &database).await?;
211                render::print_page(
212                    &rows,
213                    total,
214                    window,
215                    json,
216                    admin::render_upstream_order_json,
217                    |row| render::render_upstream_order_line(row, palette),
218                );
219            }
220            UpstreamOrderCommand::Show { id, json } => {
221                let Some(detail) = admin::load_upstream_order_detail(&id, database).await? else {
222                    return Err(CliError::bad_request(format!(
223                        "no upstream order for local order {id}"
224                    )));
225                };
226                if json {
227                    println!("{}", admin::render_upstream_order_detail_json(&detail));
228                } else {
229                    print!(
230                        "{}",
231                        render::render_upstream_order_detail_text(&detail, palette)
232                    );
233                }
234            }
235        },
236    }
237    Ok(())
238}
239
240fn none_if_empty(value: &str) -> &str {
241    if value.is_empty() { "(not set)" } else { value }
242}
243
244/// Reads the EAB HMAC secret from `path`, or prompts for it on stdin.
245///
246/// Accepts the base64url form `acme-proxy eab create` prints, and falls back
247/// to standard base64 so a credential from another CA's console pastes in
248/// unchanged. A value that decodes as neither is an error rather than being
249/// silently used as raw bytes — that would produce a valid-looking binding the
250/// upstream rejects for no visible reason.
251fn read_secret(
252    path: Option<&std::path::Path>,
253    reader: &mut impl BufRead,
254) -> Result<Vec<u8>, CliError> {
255    let raw = match path {
256        Some(path) => std::fs::read_to_string(path).map_err(|error| {
257            CliError::failed(format!("cannot read {}: {error}", path.display()))
258        })?,
259        None => {
260            eprintln!("Enter the upstream EAB HMAC key (base64), then press Enter:");
261            let mut line = String::new();
262            // `bad_request` for the same reason the unparseable-key refusal two
263            // lines below is one: nothing usable was supplied, and re-running
264            // the identical command will not change that. A read failure is the
265            // host's problem and stays `failed`.
266            match reader.read_line(&mut line) {
267                Ok(0) => return Err(CliError::bad_request("no EAB key supplied".to_string())),
268                Ok(_) => {}
269                Err(error) => {
270                    return Err(CliError::failed(format!(
271                        "cannot read the EAB key from stdin: {error}"
272                    )));
273                }
274            }
275            line
276        }
277    };
278
279    relay::decode_secret(raw.trim())
280        .ok_or_else(|| CliError::bad_request("the EAB key is not valid base64".to_string()))
281}
282
283#[cfg(test)]
284mod tests {
285    use acme_proxy_core::config::ENV_LOCK;
286
287    /// Loads a `Config` from TOML the way the server does, so `resolve_profiles`
288    /// has the raw sources it needs for per-key inheritance.
289    fn config_from(body: &str) -> Config {
290        let _lock = ENV_LOCK
291            .lock()
292            .unwrap_or_else(std::sync::PoisonError::into_inner);
293        let dir = acme_proxy_core::testutil::TempDir::new("upstream");
294        std::fs::write(dir.join("config.toml"), body).unwrap();
295        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
296        unsafe {
297            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
298        }
299        let config = Config::load().expect("the configuration must load");
300        unsafe {
301            std::env::remove_var("ACME_PROXY_CONFIG");
302        }
303        config
304    }
305
306    /// The bug this resolution fixes: `[signer.relay]` written *inside* a
307    /// profile was invisible, because the command read the global base section
308    /// that nothing ever serves from. An operator saw "directory_url is not
309    /// set" for a configuration where it plainly was.
310    #[test]
311    fn a_profiles_own_upstream_is_what_gets_resolved() {
312        let config = config_from(
313            r#"
314            [profiles.le]
315            signer.backend = "relay"
316            signer.relay.directory_url = "https://upstream.example/directory"
317            signer.relay.account_key_path = "/tmp/le-upstream.key"
318            "#,
319        );
320
321        let resolved = resolve_profile(&config, None).unwrap();
322        assert_eq!(resolved.name, "le");
323        assert_eq!(
324            resolved.sections.signer.relay.directory_url,
325            "https://upstream.example/directory"
326        );
327        assert_eq!(
328            resolved.sections.signer.relay.account_key_path,
329            "/tmp/le-upstream.key"
330        );
331    }
332
333    /// With several profiles there is no right answer to guess — and guessing
334    /// wrong means `register` writing an account key and `.kid` for the wrong
335    /// CA at the wrong paths.
336    #[test]
337    fn several_profiles_require_saying_which() {
338        let config = config_from(
339            r#"
340            [profiles.le]
341            [profiles.internal]
342            "#,
343        );
344
345        let error = resolve_profile(&config, None).unwrap_err().to_string();
346        assert!(error.contains("--profile"), "{error}");
347        assert!(
348            error.contains("le") && error.contains("internal"),
349            "{error}"
350        );
351
352        // Named explicitly, it resolves.
353        assert_eq!(resolve_profile(&config, Some("le")).unwrap().name, "le");
354        // And an unknown name is refused rather than falling back.
355        let error = resolve_profile(&config, Some("nope"))
356            .unwrap_err()
357            .to_string();
358        assert!(error.contains("nope"), "{error}");
359    }
360
361    use super::*;
362    use base64::prelude::*;
363
364    use crate::cli::CliErrorKind;
365
366    // `decode_secret`'s own decoding tests (base64url, standard base64, a
367    // refused non-base64 value) live in `signer::relay::eab`, which now
368    // owns the one implementation both this module and `provision()` call.
369
370    #[test]
371    fn an_empty_directory_url_renders_as_unset() {
372        assert_eq!(none_if_empty(""), "(not set)");
373        assert_eq!(none_if_empty("https://x/dir"), "https://x/dir");
374    }
375
376    /// The secret must be readable from stdin, so it never reaches argv.
377    #[test]
378    fn the_secret_can_come_from_stdin() {
379        let secret = b"01234567890123456789012345678901";
380        let encoded = BASE64_URL_SAFE_NO_PAD.encode(secret);
381        let mut reader = std::io::Cursor::new(format!("{encoded}\n").into_bytes());
382        assert_eq!(read_secret(None, &mut reader).unwrap(), secret.to_vec());
383    }
384
385    #[test]
386    fn the_secret_can_come_from_a_file() {
387        let secret = b"01234567890123456789012345678901";
388        let dir = acme_proxy_core::testutil::TempDir::new("eab");
389        let path = dir.join("key.b64");
390        // Trailing newline is what an editor or `echo` leaves behind.
391        std::fs::write(
392            &path,
393            format!("{}\n", BASE64_URL_SAFE_NO_PAD.encode(secret)),
394        )
395        .unwrap();
396
397        let mut empty = std::io::Cursor::new(Vec::new());
398        assert_eq!(
399            read_secret(Some(&path), &mut empty).unwrap(),
400            secret.to_vec()
401        );
402    }
403
404    #[test]
405    fn a_missing_secret_file_is_reported() {
406        let mut empty = std::io::Cursor::new(Vec::new());
407        let error = read_secret(
408            Some(std::path::Path::new("/nonexistent/eab.b64")),
409            &mut empty,
410        )
411        .expect_err("a missing file must be reported");
412        assert!(error.to_string().starts_with("cannot read "), "{error}");
413    }
414
415    /// Closed stdin means the operator has nothing to give; prompting forever
416    /// or reading an empty secret would both be worse than saying so.
417    #[test]
418    fn an_empty_stdin_is_reported() {
419        // `bad_request` (exit 3), not `failed` (exit 1): nothing usable was
420        // supplied, and re-running the identical command cannot change that --
421        // the same class as the unparseable key below.
422        let mut empty = std::io::Cursor::new(Vec::new());
423        assert_eq!(
424            read_secret(None, &mut empty),
425            Err(CliError::bad_request("no EAB key supplied".to_string()))
426        );
427    }
428
429    #[test]
430    fn a_secret_that_is_not_base64_is_reported() {
431        let mut reader = std::io::Cursor::new(b"not base64!!!\n".to_vec());
432        assert_eq!(
433            read_secret(None, &mut reader),
434            Err(CliError::bad_request(
435                "the EAB key is not valid base64".to_string()
436            ))
437        );
438    }
439
440    /// Without `signer.relay.directory_url` there is no upstream at all,
441    /// so registration stops before it can prompt for a credential.
442    #[tokio::test]
443    async fn registering_without_an_upstream_is_refused() {
444        // A real profile, but one whose signer is the default `local_ca` — so
445        // there genuinely is no upstream, which is what the message must say.
446        // (`Config::default()` would now fail earlier, on having no profiles at
447        // all, and would not exercise this branch.)
448        let config = config_from("[profiles.default]\n");
449        let mut reader: &[u8] = &[];
450        let error = run_upstream_command(
451            UpstreamCommand::Register {
452                eab_kid: None,
453                eab_hmac_key_file: None,
454                profile: None,
455            },
456            &mut reader,
457            Palette::plain(),
458            &config,
459            test_db().await,
460        )
461        .await
462        .expect_err("there is no upstream to register with");
463        assert!(error.to_string().contains("directory_url"), "{error}");
464    }
465
466    /// An upstream that cannot be reached is reported, not retried forever:
467    /// `register` is a one-shot operator command.
468    #[tokio::test]
469    async fn an_unreachable_upstream_is_reported() {
470        let dir = acme_proxy_core::testutil::TempDir::new("upstream");
471
472        // Port 1 on loopback: nothing listens, so the directory fetch fails
473        // fast rather than hanging on a routable-but-silent address.
474        let config = config_from(&format!(
475            r#"
476            [profiles.default]
477            signer.backend = "relay"
478            signer.relay.directory_url = "http://127.0.0.1:1/directory"
479            signer.relay.account_key_path = "{}"
480            "#,
481            dir.join("upstream.key").display()
482        ));
483
484        // A kid means a secret is read first — the path that proves the
485        // credential comes off stdin and never from argv.
486        let secret = BASE64_URL_SAFE_NO_PAD.encode(b"01234567890123456789012345678901");
487        let mut reader = std::io::Cursor::new(format!("{secret}\n").into_bytes());
488        let error = run_upstream_command(
489            UpstreamCommand::Register {
490                eab_kid: Some("kid-1".to_string()),
491                eab_hmac_key_file: None,
492                profile: None,
493            },
494            &mut reader,
495            Palette::plain(),
496            &config,
497            test_db().await,
498        )
499        .await
500        .expect_err("nothing is listening on that port");
501        assert!(
502            error
503                .to_string()
504                .starts_with("upstream registration failed: "),
505            "{error}"
506        );
507    }
508
509    /// `show` reports an unconfigured, unregistered upstream in both forms
510    /// rather than failing — "nothing is set up" is a valid answer.
511    #[tokio::test]
512    async fn show_renders_an_unregistered_upstream() {
513        let config = config_from("[profiles.default]\n");
514        for json in [true, false] {
515            let mut reader: &[u8] = &[];
516            run_upstream_command(
517                UpstreamCommand::Show {
518                    json,
519                    profile: None,
520                },
521                &mut reader,
522                Palette::plain(),
523                &config,
524                test_db().await,
525            )
526            .await
527            .unwrap();
528        }
529    }
530
531    async fn test_db() -> Arc<Database> {
532        Arc::new(Database::connect_in_memory().await.unwrap())
533    }
534
535    /// `upstream order list` renders both ways, and `--status` is refused by
536    /// name.
537    #[tokio::test]
538    async fn upstream_order_list_and_show() {
539        let config = config_from("[profiles.default]\n");
540        let db = test_db().await;
541        for json in [true, false] {
542            run_upstream_command(
543                UpstreamCommand::Order {
544                    command: UpstreamOrderCommand::List {
545                        profile: None,
546                        status: None,
547                        limit: 50,
548                        offset: 0,
549                        json,
550                    },
551                },
552                &mut &b""[..],
553                Palette::plain(),
554                &config,
555                db.clone(),
556            )
557            .await
558            .unwrap();
559        }
560
561        let err = run_upstream_command(
562            UpstreamCommand::Order {
563                command: UpstreamOrderCommand::List {
564                    profile: None,
565                    status: Some("bogus".to_string()),
566                    limit: 50,
567                    offset: 0,
568                    json: false,
569                },
570            },
571            &mut &b""[..],
572            Palette::plain(),
573            &config,
574            db.clone(),
575        )
576        .await
577        .unwrap_err();
578        assert!(err.to_string().contains("--status"), "{err}");
579        assert_eq!(err.kind(), CliErrorKind::BadRequest);
580
581        let err = run_upstream_command(
582            UpstreamCommand::Order {
583                command: UpstreamOrderCommand::Show {
584                    id: "nope".to_string(),
585                    json: false,
586                },
587            },
588            &mut &b""[..],
589            Palette::plain(),
590            &config,
591            db,
592        )
593        .await
594        .unwrap_err();
595        assert!(err.to_string().contains("no upstream order"), "{err}");
596        assert_eq!(err.kind(), CliErrorKind::BadRequest);
597    }
598}