/home/runner/work/lyquor/lyquor/toolchain/ladle/src/main.rs
Line | Count | Source |
1 | | //! Ladle - A CLI tool for Lyquor node operators |
2 | | //! |
3 | | //! Ladle provides a set of utilities for node operators to manage and interact with |
4 | | //! Lyquor nodes. It simplifies common operations such as key generation and management. |
5 | | |
6 | | use std::{path::PathBuf, str::FromStr}; |
7 | | |
8 | | use anyhow::{Context as _, Result}; |
9 | | use barstrainer_client::{SigningIdentity, connect, fqdn_for_node, normalize_subdomain}; |
10 | | use clap::{ArgGroup, Command, arg, command}; |
11 | | use lyquor_config::{NetworkType, validate_domain_name}; |
12 | | use lyquor_tls::TlsConfig; |
13 | | |
14 | | #[tokio::main] |
15 | 8 | async fn main() -> Result<()> { |
16 | 8 | let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); |
17 | 8 | let subscriber = tracing_subscriber::FmtSubscriber::builder() |
18 | 8 | .with_env_filter( |
19 | 8 | tracing_subscriber::EnvFilter::try_from_default_env() |
20 | 8 | .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), |
21 | | ) |
22 | 8 | .finish(); |
23 | 8 | tracing::subscriber::set_global_default(subscriber)?0 ; |
24 | | |
25 | | // Parse command line arguments |
26 | 8 | let matches = command!("ladle") |
27 | 8 | .version(lyquor_cli::build_version!()) |
28 | 8 | .about("CLI tool for Lyquor node operators") |
29 | 8 | .subcommand_required(true) |
30 | 8 | .subcommand( |
31 | 8 | Command::new("certificates") |
32 | 8 | .about("Certificate management operations") |
33 | 8 | .subcommand_required(true) |
34 | 8 | .subcommand( |
35 | 8 | Command::new("generate") |
36 | 8 | .about("Generate a new key pair and certificates") |
37 | 8 | .arg( |
38 | 8 | arg!(-o --output <DIR> "Output directory for the generated key and certificates") |
39 | 8 | .default_value("."), |
40 | | ) |
41 | 8 | .arg( |
42 | 8 | arg!(-s --seed <SEED> "Seed used to derive the root key") |
43 | 8 | .required(false) |
44 | 8 | .default_value("000102030405060708090a0b0c0d0e0f") |
45 | 8 | .value_parser(parse_seed), |
46 | | ) |
47 | 8 | .arg( |
48 | 8 | arg!(-n --network <NETWORK> "Select the network to generate certificates for. (Available: devnet/testnet/mainnet)") |
49 | 8 | .required(false) |
50 | 8 | .default_value("devnet") |
51 | 8 | .value_parser(NetworkType::from_str), |
52 | | ), |
53 | | ), |
54 | | ) |
55 | 8 | .subcommand( |
56 | 8 | Command::new("dns") |
57 | 8 | .about("Interact with the barstrainer DNS control plane") |
58 | 8 | .subcommand_required(true) |
59 | 8 | .subcommand( |
60 | 8 | Command::new("upsert") |
61 | 8 | .about("Upsert a DNS record via barstrainer") |
62 | 8 | .arg(arg!(--addr <ADDR> "Barstrainer gRPC address (host:port or http(s)://...)").required(true)) |
63 | 8 | .arg( |
64 | 8 | arg!(--"dns-suffix" <SUFFIX> "DNS suffix namespace bound to the authorization (canonical hostname suffix, no leading/trailing '.')") |
65 | 8 | .required(true), |
66 | | ) |
67 | 8 | .arg(arg!(--subdomain <NAME> "Optional subdomain prefix (e.g. api or _acme-challenge.api)").required(false)) |
68 | 8 | .arg(arg!(--a <IPV4> "A record value (IPv4 address)")) |
69 | 8 | .arg(arg!(--txt <TXT> "TXT record value (unescaped)")) |
70 | 8 | .arg( |
71 | 8 | arg!(--ttl <TTL> "TTL in seconds (0 uses server default)") |
72 | 8 | .required(false) |
73 | 8 | .default_value("0") |
74 | 8 | .value_parser(clap::value_parser!(u32)), |
75 | | ) |
76 | 8 | .arg( |
77 | 8 | arg!(--cert <PATH> "Path to node certificate chain PEM") |
78 | 8 | .required(false) |
79 | 8 | .default_value("node_cert_chain.pem"), |
80 | | ) |
81 | 8 | .arg( |
82 | 8 | arg!(--key <PATH> "Path to node private key PEM") |
83 | 8 | .required(false) |
84 | 8 | .default_value("node_key.pem"), |
85 | | ) |
86 | 8 | .group(ArgGroup::new("record").required(true).args(["a", "txt"])), |
87 | | ), |
88 | | ) |
89 | 8 | .get_matches(); |
90 | | |
91 | | // Execute the appropriate subcommand |
92 | 8 | match matches.subcommand() { |
93 | 8 | Some(("certificates"2 , sub_matches2 )) => match sub_matches.subcommand()2 { |
94 | 8 | Some(("generate"2 , gen_matches2 )) => { |
95 | 8 | let output_dir2 = gen_matches2 .get_one2 ::<String>("output"2 ).unwrap2 (); |
96 | 8 | let seed2 = *2 gen_matches2 .get_one2 ::<[u8; 32]>("seed").unwrap(); |
97 | 8 | let network2 = *2 gen_matches2 .get_one2 ::<NetworkType>("network").unwrap(); |
98 | 8 | generate_certificates2 (output_dir2 , &seed2 , network2 ) |
99 | 8 | } |
100 | 8 | _ => unreachable!0 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
101 | 8 | }, |
102 | 8 | Some(("dns"0 , sub_matches0 )) => match sub_matches.subcommand()0 { |
103 | 8 | Some(("upsert"0 , upsert_matches0 )) => dns_upsert0 (upsert_matches).await, |
104 | 8 | _ => unreachable!0 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
105 | 8 | }, |
106 | 8 | _ => unreachable!6 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
107 | 8 | } |
108 | 8 | } |
109 | | |
110 | 2 | fn generate_certificates(output_dir: &str, seed: &[u8; 32], network: NetworkType) -> Result<()> { |
111 | 2 | tracing::info!("Generating key pair in directory: {} with seed: {:?}", output_dir, seed); |
112 | | |
113 | 2 | let (node_id, node_ca, issuer) = |
114 | 2 | lyquor_tls::generator::generate_node_ca_cert(seed, network.default_network_domain())?0 ; |
115 | 2 | let cert_dir = PathBuf::from(output_dir); |
116 | | |
117 | 2 | let node_cert_key = lyquor_tls::generator::generate_node_cert(&node_id, &issuer, network.default_network_domain())?0 ; |
118 | | |
119 | 2 | let key_path = cert_dir.join("node_key.pem"); |
120 | 2 | let cert_path = cert_dir.join("node_cert_chain.pem"); |
121 | | |
122 | 2 | std::fs::write(&key_path, node_cert_key.signing_key.serialize_pem())?0 ; |
123 | 2 | std::fs::write(&cert_path, format!("{}{}", node_cert_key.cert.pem(), node_ca.pem()))?0 ; |
124 | | |
125 | 2 | Ok(()) |
126 | 2 | } |
127 | | |
128 | 4 | fn parse_seed(input: &str) -> Result<[u8; 32], String> { |
129 | 4 | let raw = input.strip_prefix("0x").unwrap_or(input); |
130 | | let normalized; |
131 | 4 | let raw = if raw.len().is_multiple_of(2) { |
132 | 2 | raw |
133 | | } else { |
134 | 2 | normalized = format!("0{raw}"); |
135 | 2 | normalized.as_str() |
136 | | }; |
137 | 4 | let seed3 = const_hex::decode(raw).map_err(|err| format!1 ("Invalid hex: {err}"))?1 ; |
138 | 3 | if seed.len() > 32 { |
139 | 1 | return Err(format!( |
140 | 1 | "Invalid seed length: expected at most 32 bytes, got {}", |
141 | 1 | seed.len() |
142 | 1 | )); |
143 | 2 | } |
144 | | |
145 | 2 | let mut padded_seed = [0u8; 32]; |
146 | 2 | padded_seed[..seed.len()].copy_from_slice(&seed); |
147 | 2 | Ok(padded_seed) |
148 | 4 | } |
149 | | |
150 | 0 | async fn dns_upsert(matches: &clap::ArgMatches) -> Result<()> { |
151 | 0 | let addr = matches.get_one::<String>("addr").unwrap(); |
152 | 0 | let dns_suffix_raw = matches.get_one::<String>("dns-suffix").unwrap(); |
153 | 0 | let subdomain_raw = matches.get_one::<String>("subdomain").map(String::as_str); |
154 | 0 | let ttl = *matches.get_one::<u32>("ttl").unwrap(); |
155 | 0 | let cert_path = matches.get_one::<String>("cert").unwrap(); |
156 | 0 | let key_path = matches.get_one::<String>("key").unwrap(); |
157 | | |
158 | 0 | let (record_data, record_kind) = if let Some(value) = matches.get_one::<String>("a") { |
159 | 0 | (barstrainer_client::proto::record::Data::A(value.clone()), "A") |
160 | 0 | } else if let Some(value) = matches.get_one::<String>("txt") { |
161 | 0 | (barstrainer_client::proto::record::Data::Txt(value.clone()), "TXT") |
162 | | } else { |
163 | 0 | unreachable!("clap ArgGroup ensures one of --a/--txt is present"); |
164 | | }; |
165 | | |
166 | 0 | let tls_config = TlsConfig::from_pem_files(cert_path, key_path).with_context(|| "failed to load TLS keypair")?; |
167 | 0 | let identity = SigningIdentity::from_tls_config(&tls_config) |
168 | 0 | .with_context(|| "failed to build barstrainer signing identity")?; |
169 | 0 | let dns_suffix = validate_domain_name(dns_suffix_raw)?; |
170 | 0 | let subdomain = normalize_subdomain(subdomain_raw)?; |
171 | 0 | let fqdn = fqdn_for_node(identity.node_id(), &dns_suffix, subdomain.as_deref()); |
172 | 0 | tracing::debug!( |
173 | | addr = %addr, |
174 | 0 | node_id = %identity.node_id(), |
175 | | dns_suffix = %dns_suffix, |
176 | 0 | subdomain = %subdomain.as_deref().unwrap_or(""), |
177 | | fqdn = %fqdn, |
178 | | record_kind, |
179 | | ttl, |
180 | | "ladle dns upsert request prepared" |
181 | | ); |
182 | | |
183 | 0 | let record = barstrainer_client::proto::Record { |
184 | 0 | fqdn: fqdn.clone(), |
185 | 0 | data: Some(record_data), |
186 | 0 | ttl, |
187 | 0 | }; |
188 | | |
189 | 0 | let request = identity.build_upsert_request(&dns_suffix, vec![record])?; |
190 | 0 | tracing::debug!(signature_len = request.signature.len(), "ladle dns request built"); |
191 | | |
192 | 0 | tracing::debug!(addr = %addr, "ladle dns connecting to barstrainer"); |
193 | 0 | let mut client = connect(addr)?; |
194 | 0 | client.upsert_record(request).await?; |
195 | | |
196 | 0 | println!("Upsert accepted for {}", fqdn); |
197 | 0 | Ok(()) |
198 | 0 | } |