1use 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 {
41 #[arg(long = "eab-kid")]
44 eab_kid: Option<String>,
45 #[arg(long = "eab-hmac-key-file")]
48 eab_hmac_key_file: Option<PathBuf>,
49 #[arg(long)]
52 profile: Option<String>,
53 },
54 Show {
56 #[arg(long)]
58 json: bool,
59 #[arg(long)]
62 profile: Option<String>,
63 },
64 Order {
68 #[command(subcommand)]
69 command: UpstreamOrderCommand,
70 },
71}
72
73#[derive(Subcommand)]
74pub enum UpstreamOrderCommand {
75 List {
77 #[arg(long)]
79 profile: Option<String>,
80 #[arg(long)]
83 status: Option<String>,
84 #[arg(long, default_value_t = DEFAULT_LIMIT)]
86 limit: i64,
87 #[arg(long, default_value_t = 0)]
89 offset: i64,
90 #[arg(long)]
92 json: bool,
93 },
94 Show {
97 id: String,
99 #[arg(long)]
101 json: bool,
102 },
103}
104
105pub 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 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 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
244fn 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 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 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 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 #[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 #[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 assert_eq!(resolve_profile(&config, Some("le")).unwrap().name, "le");
354 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 #[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 #[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 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 #[test]
418 fn an_empty_stdin_is_reported() {
419 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 #[tokio::test]
443 async fn registering_without_an_upstream_is_refused() {
444 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 #[tokio::test]
469 async fn an_unreachable_upstream_is_reported() {
470 let dir = acme_proxy_core::testutil::TempDir::new("upstream");
471
472 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 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 #[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 #[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}