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//! [`crate::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;
20
21use clap::Subcommand;
22
23use crate::cli::{CliError, resolve_profile};
24use crate::config::Config;
25use crate::signer::relay;
26
27#[derive(Subcommand)]
28pub enum UpstreamCommand {
29    /// Register this server's account at the upstream ACME server, writing the
30    /// `kid` it returns so `serve` never needs the credential again.
31    Register {
32        /// The EAB key id the upstream's operator issued. Omit when the
33        /// upstream requires no External Account Binding.
34        #[arg(long = "eab-kid")]
35        eab_kid: Option<String>,
36        /// Read the EAB HMAC secret (base64url) from this file instead of
37        /// prompting on stdin. The file is read, used, and never copied.
38        #[arg(long = "eab-hmac-key-file")]
39        eab_hmac_key_file: Option<PathBuf>,
40        /// Which profile's upstream to register with. Optional when the
41        /// configuration defines exactly one profile.
42        #[arg(long)]
43        profile: Option<String>,
44    },
45    /// Show the configured upstream and whether this server is registered.
46    Show {
47        #[arg(long)]
48        json: bool,
49        /// Which profile's upstream to describe. Optional when the
50        /// configuration defines exactly one profile.
51        #[arg(long)]
52        profile: Option<String>,
53    },
54}
55
56/// Resolves which profile's `[signer.relay]` a command should act on.
57///
58/// `signer` is a **per-profile** section, but this command read
59/// `config.signer.relay` — the global base every profile overlays, which
60/// nothing serves from. An operator who configured the upstream only under
61/// `[profiles.le].signer.relay` was told "directory_url is not set", which
62/// was simply false; and if a *different* upstream happened to be configured
63/// globally, `register` would write the account key and `.kid` sidecar for the
64pub async fn run_upstream_command(
65    command: UpstreamCommand,
66    reader: &mut impl BufRead,
67    config: &Config,
68) -> Result<(), CliError> {
69    match command {
70        UpstreamCommand::Register {
71            eab_kid,
72            eab_hmac_key_file,
73            profile,
74        } => {
75            let resolved = resolve_profile(config, profile.as_deref())?;
76            let cfg = &resolved.sections.signer.relay;
77            if cfg.directory_url.is_empty() {
78                return Err(CliError(
79                    "signer.relay.directory_url is not set: there is no upstream to register \
80                     with"
81                        .to_string(),
82                ));
83            }
84
85            // Only read a secret when a kid names one; an upstream that needs
86            // no EAB must not prompt for something the operator does not have.
87            let secret = eab_kid
88                .as_ref()
89                .map(|_| read_secret(eab_hmac_key_file.as_deref(), reader))
90                .transpose()?;
91            let eab = eab_kid.as_deref().zip(secret.as_deref());
92
93            // A throwaway resolver, for the same reason `order revoke` builds
94            // one: a one-shot command has no server around it to share the
95            // process-wide one.
96            let resolver = crate::dns::resolver_addr(&config.dns)
97                .and_then(crate::challenge::build_resolver)
98                .map_err(|error| CliError(format!("configuration error: {error}")))?;
99            let proxies = crate::proxy::from_config(&config.proxy)
100                .map_err(|error| CliError(format!("configuration error: {error}")))?;
101            let outbound = crate::http_client::Outbound::new(resolver, proxies);
102            match relay::register_upstream_account(cfg, outbound, eab).await {
103                Ok(kid) => println!("Registered. kid = {kid}"),
104                Err(error) => {
105                    return Err(CliError(format!("upstream registration failed: {error}")));
106                }
107            }
108        }
109
110        UpstreamCommand::Show { json, profile } => {
111            let resolved = resolve_profile(config, profile.as_deref())?;
112            let cfg = &resolved.sections.signer.relay;
113            let kid = relay::stored_kid(cfg);
114            let key_present = std::path::Path::new(&cfg.account_key_path).exists();
115
116            if json {
117                println!(
118                    "{}",
119                    serde_json::json!({
120                        "directoryUrl": cfg.directory_url,
121                        "accountKeyPath": cfg.account_key_path,
122                        "accountKeyPresent": key_present,
123                        "kid": kid,
124                        "registered": kid.is_some(),
125                    })
126                );
127            } else {
128                println!("directory:   {}", none_if_empty(&cfg.directory_url));
129                println!(
130                    "account key: {} ({})",
131                    cfg.account_key_path,
132                    if key_present { "present" } else { "absent" }
133                );
134                match kid {
135                    Some(kid) => println!("kid:         {kid}"),
136                    None => println!("kid:         (not registered)"),
137                }
138            }
139        }
140    }
141    Ok(())
142}
143
144fn none_if_empty(value: &str) -> &str {
145    if value.is_empty() { "(not set)" } else { value }
146}
147
148/// Reads the EAB HMAC secret from `path`, or prompts for it on stdin.
149///
150/// Accepts the base64url form `acme-proxy eab create` prints, and falls back
151/// to standard base64 so a credential from another CA's console pastes in
152/// unchanged. A value that decodes as neither is an error rather than being
153/// silently used as raw bytes — that would produce a valid-looking binding the
154/// upstream rejects for no visible reason.
155fn read_secret(
156    path: Option<&std::path::Path>,
157    reader: &mut impl BufRead,
158) -> Result<Vec<u8>, CliError> {
159    let raw = match path {
160        Some(path) => std::fs::read_to_string(path)
161            .map_err(|error| CliError(format!("cannot read {}: {error}", path.display())))?,
162        None => {
163            eprintln!("Enter the upstream EAB HMAC key (base64), then press Enter:");
164            let mut line = String::new();
165            if reader.read_line(&mut line).unwrap_or(0) == 0 {
166                return Err(CliError("no EAB key supplied".to_string()));
167            }
168            line
169        }
170    };
171
172    relay::decode_secret(raw.trim())
173        .ok_or_else(|| CliError("the EAB key is not valid base64".to_string()))
174}
175
176#[cfg(test)]
177mod tests {
178    use crate::config::ENV_LOCK;
179
180    /// Loads a `Config` from TOML the way the server does, so `resolve_profiles`
181    /// has the raw sources it needs for per-key inheritance.
182    fn config_from(body: &str) -> Config {
183        let _lock = ENV_LOCK
184            .lock()
185            .unwrap_or_else(std::sync::PoisonError::into_inner);
186        let dir = crate::testutil::TempDir::new("upstream");
187        std::fs::write(dir.join("config.toml"), body).unwrap();
188        // SAFETY: single-threaded test holding ENV_LOCK; removed before return.
189        unsafe {
190            std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
191        }
192        let config = Config::load().expect("the configuration must load");
193        unsafe {
194            std::env::remove_var("ACME_PROXY_CONFIG");
195        }
196        config
197    }
198
199    /// The bug this resolution fixes: `[signer.relay]` written *inside* a
200    /// profile was invisible, because the command read the global base section
201    /// that nothing ever serves from. An operator saw "directory_url is not
202    /// set" for a configuration where it plainly was.
203    #[test]
204    fn a_profiles_own_upstream_is_what_gets_resolved() {
205        let config = config_from(
206            r#"
207            [profiles.le]
208            signer.backend = "relay"
209            signer.relay.directory_url = "https://upstream.example/directory"
210            signer.relay.account_key_path = "/tmp/le-upstream.key"
211            "#,
212        );
213
214        let resolved = resolve_profile(&config, None).unwrap();
215        assert_eq!(resolved.name, "le");
216        assert_eq!(
217            resolved.sections.signer.relay.directory_url,
218            "https://upstream.example/directory"
219        );
220        assert_eq!(
221            resolved.sections.signer.relay.account_key_path,
222            "/tmp/le-upstream.key"
223        );
224    }
225
226    /// With several profiles there is no right answer to guess — and guessing
227    /// wrong means `register` writing an account key and `.kid` for the wrong
228    /// CA at the wrong paths.
229    #[test]
230    fn several_profiles_require_saying_which() {
231        let config = config_from(
232            r#"
233            [profiles.le]
234            [profiles.internal]
235            "#,
236        );
237
238        let error = resolve_profile(&config, None).unwrap_err().to_string();
239        assert!(error.contains("--profile"), "{error}");
240        assert!(
241            error.contains("le") && error.contains("internal"),
242            "{error}"
243        );
244
245        // Named explicitly, it resolves.
246        assert_eq!(resolve_profile(&config, Some("le")).unwrap().name, "le");
247        // And an unknown name is refused rather than falling back.
248        let error = resolve_profile(&config, Some("nope"))
249            .unwrap_err()
250            .to_string();
251        assert!(error.contains("nope"), "{error}");
252    }
253
254    use super::*;
255    use base64::prelude::*;
256
257    // `decode_secret`'s own decoding tests (base64url, standard base64, a
258    // refused non-base64 value) live in `signer::relay::eab`, which now
259    // owns the one implementation both this module and `provision()` call.
260
261    #[test]
262    fn an_empty_directory_url_renders_as_unset() {
263        assert_eq!(none_if_empty(""), "(not set)");
264        assert_eq!(none_if_empty("https://x/dir"), "https://x/dir");
265    }
266
267    /// The secret must be readable from stdin, so it never reaches argv.
268    #[test]
269    fn the_secret_can_come_from_stdin() {
270        let secret = b"01234567890123456789012345678901";
271        let encoded = BASE64_URL_SAFE_NO_PAD.encode(secret);
272        let mut reader = std::io::Cursor::new(format!("{encoded}\n").into_bytes());
273        assert_eq!(read_secret(None, &mut reader).unwrap(), secret.to_vec());
274    }
275
276    #[test]
277    fn the_secret_can_come_from_a_file() {
278        let secret = b"01234567890123456789012345678901";
279        let dir = crate::testutil::TempDir::new("eab");
280        let path = dir.join("key.b64");
281        // Trailing newline is what an editor or `echo` leaves behind.
282        std::fs::write(
283            &path,
284            format!("{}\n", BASE64_URL_SAFE_NO_PAD.encode(secret)),
285        )
286        .unwrap();
287
288        let mut empty = std::io::Cursor::new(Vec::new());
289        assert_eq!(
290            read_secret(Some(&path), &mut empty).unwrap(),
291            secret.to_vec()
292        );
293    }
294
295    #[test]
296    fn a_missing_secret_file_is_reported() {
297        let mut empty = std::io::Cursor::new(Vec::new());
298        let error = read_secret(
299            Some(std::path::Path::new("/nonexistent/eab.b64")),
300            &mut empty,
301        )
302        .expect_err("a missing file must be reported");
303        assert!(error.to_string().starts_with("cannot read "), "{error}");
304    }
305
306    /// Closed stdin means the operator has nothing to give; prompting forever
307    /// or reading an empty secret would both be worse than saying so.
308    #[test]
309    fn an_empty_stdin_is_reported() {
310        let mut empty = std::io::Cursor::new(Vec::new());
311        assert_eq!(
312            read_secret(None, &mut empty),
313            Err(CliError("no EAB key supplied".to_string()))
314        );
315    }
316
317    #[test]
318    fn a_secret_that_is_not_base64_is_reported() {
319        let mut reader = std::io::Cursor::new(b"not base64!!!\n".to_vec());
320        assert_eq!(
321            read_secret(None, &mut reader),
322            Err(CliError("the EAB key is not valid base64".to_string()))
323        );
324    }
325
326    /// Without `signer.relay.directory_url` there is no upstream at all,
327    /// so registration stops before it can prompt for a credential.
328    #[tokio::test]
329    async fn registering_without_an_upstream_is_refused() {
330        // A real profile, but one whose signer is the default `local_ca` — so
331        // there genuinely is no upstream, which is what the message must say.
332        // (`Config::default()` would now fail earlier, on having no profiles at
333        // all, and would not exercise this branch.)
334        let config = config_from("[profiles.default]\n");
335        let mut reader: &[u8] = &[];
336        let error = run_upstream_command(
337            UpstreamCommand::Register {
338                eab_kid: None,
339                eab_hmac_key_file: None,
340                profile: None,
341            },
342            &mut reader,
343            &config,
344        )
345        .await
346        .expect_err("there is no upstream to register with");
347        assert!(error.to_string().contains("directory_url"), "{error}");
348    }
349
350    /// An upstream that cannot be reached is reported, not retried forever:
351    /// `register` is a one-shot operator command.
352    #[tokio::test]
353    async fn an_unreachable_upstream_is_reported() {
354        let dir = crate::testutil::TempDir::new("upstream");
355
356        // Port 1 on loopback: nothing listens, so the directory fetch fails
357        // fast rather than hanging on a routable-but-silent address.
358        let config = config_from(&format!(
359            r#"
360            [profiles.default]
361            signer.backend = "relay"
362            signer.relay.directory_url = "http://127.0.0.1:1/directory"
363            signer.relay.account_key_path = "{}"
364            "#,
365            dir.join("upstream.key").display()
366        ));
367
368        // A kid means a secret is read first — the path that proves the
369        // credential comes off stdin and never from argv.
370        let secret = BASE64_URL_SAFE_NO_PAD.encode(b"01234567890123456789012345678901");
371        let mut reader = std::io::Cursor::new(format!("{secret}\n").into_bytes());
372        let error = run_upstream_command(
373            UpstreamCommand::Register {
374                eab_kid: Some("kid-1".to_string()),
375                eab_hmac_key_file: None,
376                profile: None,
377            },
378            &mut reader,
379            &config,
380        )
381        .await
382        .expect_err("nothing is listening on that port");
383        assert!(
384            error
385                .to_string()
386                .starts_with("upstream registration failed: "),
387            "{error}"
388        );
389    }
390
391    /// `show` reports an unconfigured, unregistered upstream in both forms
392    /// rather than failing — "nothing is set up" is a valid answer.
393    #[tokio::test]
394    async fn show_renders_an_unregistered_upstream() {
395        let config = config_from("[profiles.default]\n");
396        for json in [true, false] {
397            let mut reader: &[u8] = &[];
398            run_upstream_command(
399                UpstreamCommand::Show {
400                    json,
401                    profile: None,
402                },
403                &mut reader,
404                &config,
405            )
406            .await
407            .unwrap();
408        }
409    }
410}