use std::io::BufRead;
use std::path::PathBuf;
use std::sync::Arc;
use clap::Subcommand;
use crate::admin;
use crate::cli::render;
use crate::cli::style::Palette;
use crate::cli::window::{DEFAULT_LIMIT, Window};
use crate::cli::{CliError, resolve_profile};
use crate::config::Config;
use crate::signer::relay;
use crate::sqlite::db::Database;
use crate::sqlite::status::UpstreamOrderStatus;
use crate::sqlite::upstream_order::{UpstreamOrder, UpstreamOrderQuery};
#[derive(Subcommand)]
pub enum UpstreamCommand {
Register {
#[arg(long = "eab-kid")]
eab_kid: Option<String>,
#[arg(long = "eab-hmac-key-file")]
eab_hmac_key_file: Option<PathBuf>,
#[arg(long)]
profile: Option<String>,
},
Show {
#[arg(long)]
json: bool,
#[arg(long)]
profile: Option<String>,
},
Order {
#[command(subcommand)]
command: UpstreamOrderCommand,
},
}
#[derive(Subcommand)]
pub enum UpstreamOrderCommand {
List {
#[arg(long)]
profile: Option<String>,
#[arg(long)]
status: Option<String>,
#[arg(long, default_value_t = DEFAULT_LIMIT)]
limit: i64,
#[arg(long, default_value_t = 0)]
offset: i64,
#[arg(long)]
json: bool,
},
Show {
id: String,
#[arg(long)]
json: bool,
},
}
pub async fn run_upstream_command(
command: UpstreamCommand,
reader: &mut impl BufRead,
palette: Palette,
config: &Config,
database: Arc<Database>,
) -> Result<(), CliError> {
match command {
UpstreamCommand::Register {
eab_kid,
eab_hmac_key_file,
profile,
} => {
let resolved = resolve_profile(config, profile.as_deref())?;
let cfg = &resolved.sections.signer.relay;
if cfg.directory_url.is_empty() {
return Err(CliError::failed(
"signer.relay.directory_url is not set: there is no upstream to register \
with"
.to_string(),
));
}
let secret = eab_kid
.as_ref()
.map(|_| read_secret(eab_hmac_key_file.as_deref(), reader))
.transpose()?;
let eab = eab_kid.as_deref().zip(secret.as_deref());
let resolver = crate::dns::resolver_addr(&config.dns)
.and_then(crate::challenge::build_resolver)
.map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
let proxies = crate::proxy::from_config(&config.proxy)
.map_err(|error| CliError::failed(format!("configuration error: {error}")))?;
let outbound = crate::http_client::Outbound::new(resolver, proxies);
match relay::register_upstream_account(cfg, outbound, eab).await {
Ok(kid) => println!("Registered. kid = {kid}"),
Err(error) => {
return Err(CliError::failed(format!(
"upstream registration failed: {error}"
)));
}
}
}
UpstreamCommand::Show { json, profile } => {
let resolved = resolve_profile(config, profile.as_deref())?;
let cfg = &resolved.sections.signer.relay;
let kid = relay::stored_kid(cfg);
let key_present = std::path::Path::new(&cfg.account_key_path).exists();
if json {
println!(
"{}",
serde_json::json!({
"directoryUrl": cfg.directory_url,
"accountKeyPath": cfg.account_key_path,
"accountKeyPresent": key_present,
"kid": kid,
"registered": kid.is_some(),
})
);
} else {
println!("directory: {}", none_if_empty(&cfg.directory_url));
println!(
"account key: {} ({})",
cfg.account_key_path,
if key_present { "present" } else { "absent" }
);
match kid {
Some(kid) => println!("kid: {kid}"),
None => println!("kid: (not registered)"),
}
}
}
UpstreamCommand::Order { command } => match command {
UpstreamOrderCommand::List {
profile,
status,
limit,
offset,
json,
} => {
let status = status
.map(|value| value.parse::<UpstreamOrderStatus>())
.transpose()
.map_err(|error| CliError::bad_request(format!("--status: {error}")))?;
let window = Window::resolve(limit, offset);
let query = UpstreamOrderQuery {
profile,
status,
limit: window.limit,
offset: window.offset,
};
let (rows, total) = UpstreamOrder::search(&query, &database).await?;
render::print_page(
&rows,
total,
window,
json,
admin::render_upstream_order_json,
|row| render::render_upstream_order_line(row, palette),
);
}
UpstreamOrderCommand::Show { id, json } => {
let Some(detail) = admin::load_upstream_order_detail(&id, database).await? else {
return Err(CliError::bad_request(format!(
"no upstream order for local order {id}"
)));
};
if json {
println!("{}", admin::render_upstream_order_detail_json(&detail));
} else {
print!(
"{}",
render::render_upstream_order_detail_text(&detail, palette)
);
}
}
},
}
Ok(())
}
fn none_if_empty(value: &str) -> &str {
if value.is_empty() { "(not set)" } else { value }
}
fn read_secret(
path: Option<&std::path::Path>,
reader: &mut impl BufRead,
) -> Result<Vec<u8>, CliError> {
let raw = match path {
Some(path) => std::fs::read_to_string(path).map_err(|error| {
CliError::failed(format!("cannot read {}: {error}", path.display()))
})?,
None => {
eprintln!("Enter the upstream EAB HMAC key (base64), then press Enter:");
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => return Err(CliError::bad_request("no EAB key supplied".to_string())),
Ok(_) => {}
Err(error) => {
return Err(CliError::failed(format!(
"cannot read the EAB key from stdin: {error}"
)));
}
}
line
}
};
relay::decode_secret(raw.trim())
.ok_or_else(|| CliError::bad_request("the EAB key is not valid base64".to_string()))
}
#[cfg(test)]
mod tests {
use crate::config::ENV_LOCK;
fn config_from(body: &str) -> Config {
let _lock = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = crate::testutil::TempDir::new("upstream");
std::fs::write(dir.join("config.toml"), body).unwrap();
unsafe {
std::env::set_var("ACME_PROXY_CONFIG", dir.join("config").to_str().unwrap());
}
let config = Config::load().expect("the configuration must load");
unsafe {
std::env::remove_var("ACME_PROXY_CONFIG");
}
config
}
#[test]
fn a_profiles_own_upstream_is_what_gets_resolved() {
let config = config_from(
r#"
[profiles.le]
signer.backend = "relay"
signer.relay.directory_url = "https://upstream.example/directory"
signer.relay.account_key_path = "/tmp/le-upstream.key"
"#,
);
let resolved = resolve_profile(&config, None).unwrap();
assert_eq!(resolved.name, "le");
assert_eq!(
resolved.sections.signer.relay.directory_url,
"https://upstream.example/directory"
);
assert_eq!(
resolved.sections.signer.relay.account_key_path,
"/tmp/le-upstream.key"
);
}
#[test]
fn several_profiles_require_saying_which() {
let config = config_from(
r#"
[profiles.le]
[profiles.internal]
"#,
);
let error = resolve_profile(&config, None).unwrap_err().to_string();
assert!(error.contains("--profile"), "{error}");
assert!(
error.contains("le") && error.contains("internal"),
"{error}"
);
assert_eq!(resolve_profile(&config, Some("le")).unwrap().name, "le");
let error = resolve_profile(&config, Some("nope"))
.unwrap_err()
.to_string();
assert!(error.contains("nope"), "{error}");
}
use super::*;
use base64::prelude::*;
use crate::cli::CliErrorKind;
#[test]
fn an_empty_directory_url_renders_as_unset() {
assert_eq!(none_if_empty(""), "(not set)");
assert_eq!(none_if_empty("https://x/dir"), "https://x/dir");
}
#[test]
fn the_secret_can_come_from_stdin() {
let secret = b"01234567890123456789012345678901";
let encoded = BASE64_URL_SAFE_NO_PAD.encode(secret);
let mut reader = std::io::Cursor::new(format!("{encoded}\n").into_bytes());
assert_eq!(read_secret(None, &mut reader).unwrap(), secret.to_vec());
}
#[test]
fn the_secret_can_come_from_a_file() {
let secret = b"01234567890123456789012345678901";
let dir = crate::testutil::TempDir::new("eab");
let path = dir.join("key.b64");
std::fs::write(
&path,
format!("{}\n", BASE64_URL_SAFE_NO_PAD.encode(secret)),
)
.unwrap();
let mut empty = std::io::Cursor::new(Vec::new());
assert_eq!(
read_secret(Some(&path), &mut empty).unwrap(),
secret.to_vec()
);
}
#[test]
fn a_missing_secret_file_is_reported() {
let mut empty = std::io::Cursor::new(Vec::new());
let error = read_secret(
Some(std::path::Path::new("/nonexistent/eab.b64")),
&mut empty,
)
.expect_err("a missing file must be reported");
assert!(error.to_string().starts_with("cannot read "), "{error}");
}
#[test]
fn an_empty_stdin_is_reported() {
let mut empty = std::io::Cursor::new(Vec::new());
assert_eq!(
read_secret(None, &mut empty),
Err(CliError::bad_request("no EAB key supplied".to_string()))
);
}
#[test]
fn a_secret_that_is_not_base64_is_reported() {
let mut reader = std::io::Cursor::new(b"not base64!!!\n".to_vec());
assert_eq!(
read_secret(None, &mut reader),
Err(CliError::bad_request(
"the EAB key is not valid base64".to_string()
))
);
}
#[tokio::test]
async fn registering_without_an_upstream_is_refused() {
let config = config_from("[profiles.default]\n");
let mut reader: &[u8] = &[];
let error = run_upstream_command(
UpstreamCommand::Register {
eab_kid: None,
eab_hmac_key_file: None,
profile: None,
},
&mut reader,
Palette::plain(),
&config,
test_db().await,
)
.await
.expect_err("there is no upstream to register with");
assert!(error.to_string().contains("directory_url"), "{error}");
}
#[tokio::test]
async fn an_unreachable_upstream_is_reported() {
let dir = crate::testutil::TempDir::new("upstream");
let config = config_from(&format!(
r#"
[profiles.default]
signer.backend = "relay"
signer.relay.directory_url = "http://127.0.0.1:1/directory"
signer.relay.account_key_path = "{}"
"#,
dir.join("upstream.key").display()
));
let secret = BASE64_URL_SAFE_NO_PAD.encode(b"01234567890123456789012345678901");
let mut reader = std::io::Cursor::new(format!("{secret}\n").into_bytes());
let error = run_upstream_command(
UpstreamCommand::Register {
eab_kid: Some("kid-1".to_string()),
eab_hmac_key_file: None,
profile: None,
},
&mut reader,
Palette::plain(),
&config,
test_db().await,
)
.await
.expect_err("nothing is listening on that port");
assert!(
error
.to_string()
.starts_with("upstream registration failed: "),
"{error}"
);
}
#[tokio::test]
async fn show_renders_an_unregistered_upstream() {
let config = config_from("[profiles.default]\n");
for json in [true, false] {
let mut reader: &[u8] = &[];
run_upstream_command(
UpstreamCommand::Show {
json,
profile: None,
},
&mut reader,
Palette::plain(),
&config,
test_db().await,
)
.await
.unwrap();
}
}
async fn test_db() -> Arc<Database> {
Arc::new(Database::connect_in_memory().await.unwrap())
}
#[tokio::test]
async fn upstream_order_list_and_show() {
let config = config_from("[profiles.default]\n");
let db = test_db().await;
for json in [true, false] {
run_upstream_command(
UpstreamCommand::Order {
command: UpstreamOrderCommand::List {
profile: None,
status: None,
limit: 50,
offset: 0,
json,
},
},
&mut &b""[..],
Palette::plain(),
&config,
db.clone(),
)
.await
.unwrap();
}
let err = run_upstream_command(
UpstreamCommand::Order {
command: UpstreamOrderCommand::List {
profile: None,
status: Some("bogus".to_string()),
limit: 50,
offset: 0,
json: false,
},
},
&mut &b""[..],
Palette::plain(),
&config,
db.clone(),
)
.await
.unwrap_err();
assert!(err.to_string().contains("--status"), "{err}");
assert_eq!(err.kind(), CliErrorKind::BadRequest);
let err = run_upstream_command(
UpstreamCommand::Order {
command: UpstreamOrderCommand::Show {
id: "nope".to_string(),
json: false,
},
},
&mut &b""[..],
Palette::plain(),
&config,
db,
)
.await
.unwrap_err();
assert!(err.to_string().contains("no upstream order"), "{err}");
assert_eq!(err.kind(), CliErrorKind::BadRequest);
}
}