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