/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_proto::node::v1::{ |
15 | | AdoptNameRequest, DropNameRequest, ListAdoptedNamesRequest, node_admin_service_client::NodeAdminServiceClient, |
16 | | }; |
17 | | use lyquor_tls::TlsConfig; |
18 | | |
19 | | #[tokio::main] |
20 | 11 | async fn main() -> Result<()> { |
21 | 11 | let _ = rustls::crypto::aws_lc_rs::default_provider().install_default(); |
22 | 11 | lyquor_cli::setup_tracing()?0 ; |
23 | | |
24 | | // Parse command line arguments |
25 | 11 | let matches = command!("ladle") |
26 | 11 | .version(lyquor_cli::build_version!()) |
27 | 11 | .about("CLI tool for Lyquor node operators") |
28 | 11 | .subcommand_required(true) |
29 | 11 | .subcommand(admin::command()) |
30 | 11 | .subcommand( |
31 | 11 | Command::new("certificates") |
32 | 11 | .about("Certificate management operations") |
33 | 11 | .subcommand_required(true) |
34 | 11 | .subcommand( |
35 | 11 | Command::new("generate") |
36 | 11 | .about("Generate a new key pair and certificates") |
37 | 11 | .arg( |
38 | 11 | arg!(-o --output <DIR> "Output directory for the generated key and certificates") |
39 | 11 | .default_value("."), |
40 | | ) |
41 | 11 | .arg( |
42 | 11 | arg!(-s --seed <SEED> "Seed used to derive the root key") |
43 | 11 | .required(false) |
44 | 11 | .default_value("000102030405060708090a0b0c0d0e0f") |
45 | 11 | .value_parser(parse_seed), |
46 | | ) |
47 | 11 | .arg( |
48 | 11 | arg!(-n --network <NETWORK> "Select the network to generate certificates for. (Available: devnet/testnet/mainnet)") |
49 | 11 | .required(false) |
50 | 11 | .default_value("devnet") |
51 | 11 | .value_parser(NetworkType::from_str), |
52 | | ), |
53 | | ), |
54 | | ) |
55 | 11 | .subcommand( |
56 | 11 | Command::new("dns") |
57 | 11 | .about("Interact with the barstrainer DNS control plane") |
58 | 11 | .subcommand_required(true) |
59 | 11 | .subcommand( |
60 | 11 | Command::new("upsert") |
61 | 11 | .about("Upsert a DNS record via barstrainer") |
62 | 11 | .arg(arg!(--addr <ADDR> "Barstrainer gRPC address (host:port or http(s)://...)").required(true)) |
63 | 11 | .arg( |
64 | 11 | arg!(--"dns-suffix" <SUFFIX> "DNS suffix namespace bound to the authorization (canonical hostname suffix, no leading/trailing '.')") |
65 | 11 | .required(true), |
66 | | ) |
67 | 11 | .arg(arg!(--subdomain <NAME> "Optional subdomain prefix (e.g. api or _acme-challenge.api)").required(false)) |
68 | 11 | .arg(arg!(--a <IPV4> "A record value (IPv4 address)")) |
69 | 11 | .arg(arg!(--txt <TXT> "TXT record value (unescaped)")) |
70 | 11 | .arg( |
71 | 11 | arg!(--ttl <TTL> "TTL in seconds (0 uses server default)") |
72 | 11 | .required(false) |
73 | 11 | .default_value("0") |
74 | 11 | .value_parser(clap::value_parser!(u32)), |
75 | | ) |
76 | 11 | .arg( |
77 | 11 | arg!(--cert <PATH> "Path to node certificate chain PEM") |
78 | 11 | .required(false) |
79 | 11 | .default_value("node_cert_chain.pem"), |
80 | | ) |
81 | 11 | .arg( |
82 | 11 | arg!(--key <PATH> "Path to node private key PEM") |
83 | 11 | .required(false) |
84 | 11 | .default_value("node_key.pem"), |
85 | | ) |
86 | 11 | .group(ArgGroup::new("record").required(true).args(["a", "txt"])), |
87 | | ), |
88 | | ) |
89 | 11 | .subcommand( |
90 | 11 | Command::new("name") |
91 | 11 | .about("Manage node names via the NNS admin API") |
92 | 11 | .subcommand_required(true) |
93 | 11 | .subcommand( |
94 | 11 | Command::new("adopt") |
95 | 11 | .about("Adopt a node name") |
96 | 11 | .arg(arg!(<NAME> "Node name to adopt")) |
97 | 11 | .arg( |
98 | 11 | arg!(--addr <ADDR> "Node admin API address") |
99 | 11 | .required(false) |
100 | 11 | .default_value("http://127.0.0.1:10089"), |
101 | | ), |
102 | | ) |
103 | 11 | .subcommand( |
104 | 11 | Command::new("drop") |
105 | 11 | .about("Drop a previously adopted node name") |
106 | 11 | .arg(arg!(<NAME> "Node name to drop")) |
107 | 11 | .arg( |
108 | 11 | arg!(--addr <ADDR> "Node admin API address") |
109 | 11 | .required(false) |
110 | 11 | .default_value("http://127.0.0.1:10089"), |
111 | | ), |
112 | | ) |
113 | 11 | .subcommand( |
114 | 11 | Command::new("list") |
115 | 11 | .about("List all adopted node names and their states") |
116 | 11 | .arg( |
117 | 11 | arg!(--addr <ADDR> "Node admin API address") |
118 | 11 | .required(false) |
119 | 11 | .default_value("http://127.0.0.1:10089"), |
120 | | ), |
121 | | ), |
122 | | ) |
123 | 11 | .get_matches(); |
124 | | |
125 | | // Execute the appropriate subcommand |
126 | 11 | match matches.subcommand() { |
127 | 11 | Some(("admin"5 , matches3 )) => admin::run3 (matches).await, |
128 | 11 | Some(("certificates"2 , sub_matches2 )) => match sub_matches.subcommand()2 { |
129 | 11 | Some(("generate"2 , gen_matches2 )) => { |
130 | 11 | let output_dir2 = gen_matches2 .get_one2 ::<String>("output"2 ).unwrap2 (); |
131 | 11 | let seed2 = *2 gen_matches2 .get_one2 ::<[u8; 32]>("seed").unwrap(); |
132 | 11 | let network2 = *2 gen_matches2 .get_one2 ::<NetworkType>("network").unwrap(); |
133 | 11 | generate_certificates2 (output_dir2 , &seed2 , network2 ) |
134 | 11 | } |
135 | 11 | _ => unreachable!0 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
136 | 11 | }, |
137 | 11 | Some(("dns"0 , sub_matches0 )) => match sub_matches.subcommand()0 { |
138 | 11 | Some(("upsert"0 , upsert_matches0 )) => dns_upsert0 (upsert_matches).await, |
139 | 11 | _ => unreachable!0 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
140 | 11 | }, |
141 | 11 | Some(("name"0 , sub_matches0 )) => match sub_matches.subcommand()0 { |
142 | 11 | Some(("adopt"0 , adopt_matches0 )) => name_adopt0 (adopt_matches).await, |
143 | 11 | Some(("drop"0 , drop_matches0 )) => name_drop0 (drop_matches).await, |
144 | 11 | Some(("list"0 , list_matches0 )) => name_list0 (list_matches).await, |
145 | 11 | _ => unreachable!0 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
146 | 11 | }, |
147 | 11 | _ => unreachable!6 ("Exhausted list of subcommands and subcommand_required prevents `None`"), |
148 | 11 | } |
149 | 11 | } |
150 | | |
151 | 2 | fn generate_certificates(output_dir: &str, seed: &[u8; 32], network: NetworkType) -> Result<()> { |
152 | 2 | tracing::info!("Generating key pair in directory: {} with seed: {:?}", output_dir, seed); |
153 | | |
154 | 2 | let (node_id, node_ca, issuer) = |
155 | 2 | lyquor_tls::generator::generate_node_ca_cert(seed, network.default_network_domain())?0 ; |
156 | 2 | let cert_dir = PathBuf::from(output_dir); |
157 | | |
158 | 2 | let node_cert_key = lyquor_tls::generator::generate_node_cert(&node_id, &issuer, network.default_network_domain())?0 ; |
159 | | |
160 | 2 | let key_path = cert_dir.join("node_key.pem"); |
161 | 2 | let cert_path = cert_dir.join("node_cert_chain.pem"); |
162 | | |
163 | 2 | std::fs::write(&key_path, node_cert_key.signing_key.serialize_pem())?0 ; |
164 | 2 | std::fs::write(&cert_path, format!("{}{}", node_cert_key.cert.pem(), node_ca.pem()))?0 ; |
165 | | |
166 | 2 | Ok(()) |
167 | 2 | } |
168 | | |
169 | 4 | fn parse_seed(input: &str) -> Result<[u8; 32], String> { |
170 | 4 | let raw = input.strip_prefix("0x").unwrap_or(input); |
171 | | let normalized; |
172 | 4 | let raw = if raw.len().is_multiple_of(2) { |
173 | 2 | raw |
174 | | } else { |
175 | 2 | normalized = format!("0{raw}"); |
176 | 2 | normalized.as_str() |
177 | | }; |
178 | 4 | let seed3 = const_hex::decode(raw).map_err(|err| format!1 ("Invalid hex: {err}"))?1 ; |
179 | 3 | if seed.len() > 32 { |
180 | 1 | return Err(format!( |
181 | 1 | "Invalid seed length: expected at most 32 bytes, got {}", |
182 | 1 | seed.len() |
183 | 1 | )); |
184 | 2 | } |
185 | | |
186 | 2 | let mut padded_seed = [0u8; 32]; |
187 | 2 | padded_seed[..seed.len()].copy_from_slice(&seed); |
188 | 2 | Ok(padded_seed) |
189 | 4 | } |
190 | | |
191 | 0 | async fn dns_upsert(matches: &clap::ArgMatches) -> Result<()> { |
192 | 0 | let addr = matches.get_one::<String>("addr").unwrap(); |
193 | 0 | let dns_suffix_raw = matches.get_one::<String>("dns-suffix").unwrap(); |
194 | 0 | let subdomain_raw = matches.get_one::<String>("subdomain").map(String::as_str); |
195 | 0 | let ttl = *matches.get_one::<u32>("ttl").unwrap(); |
196 | 0 | let cert_path = matches.get_one::<String>("cert").unwrap(); |
197 | 0 | let key_path = matches.get_one::<String>("key").unwrap(); |
198 | | |
199 | 0 | let (record_data, record_kind) = if let Some(value) = matches.get_one::<String>("a") { |
200 | 0 | (barstrainer_client::proto::record::Data::A(value.clone()), "A") |
201 | 0 | } else if let Some(value) = matches.get_one::<String>("txt") { |
202 | 0 | (barstrainer_client::proto::record::Data::Txt(value.clone()), "TXT") |
203 | | } else { |
204 | 0 | unreachable!("clap ArgGroup ensures one of --a/--txt is present"); |
205 | | }; |
206 | | |
207 | 0 | let tls_config = TlsConfig::from_pem_files(cert_path, key_path).with_context(|| "failed to load TLS keypair")?; |
208 | 0 | let identity = SigningIdentity::from_tls_config(&tls_config) |
209 | 0 | .with_context(|| "failed to build barstrainer signing identity")?; |
210 | 0 | let dns_suffix = validate_domain_name(dns_suffix_raw)?; |
211 | 0 | let subdomain = normalize_subdomain(subdomain_raw)?; |
212 | 0 | let fqdn = fqdn_for_node(identity.node_id(), &dns_suffix, subdomain.as_deref()); |
213 | 0 | tracing::debug!( |
214 | | addr = %addr, |
215 | 0 | node_id = %identity.node_id(), |
216 | | dns_suffix = %dns_suffix, |
217 | 0 | subdomain = %subdomain.as_deref().unwrap_or(""), |
218 | | fqdn = %fqdn, |
219 | | record_kind, |
220 | | ttl, |
221 | | "ladle dns upsert request prepared" |
222 | | ); |
223 | | |
224 | 0 | let record = barstrainer_client::proto::Record { |
225 | 0 | fqdn: fqdn.clone(), |
226 | 0 | data: Some(record_data), |
227 | 0 | ttl, |
228 | 0 | }; |
229 | | |
230 | 0 | let request = identity.build_upsert_request(&dns_suffix, vec![record])?; |
231 | 0 | tracing::debug!(signature_len = request.signature.len(), "ladle dns request built"); |
232 | | |
233 | 0 | tracing::debug!(addr = %addr, "ladle dns connecting to barstrainer"); |
234 | 0 | let mut client = connect(addr)?; |
235 | 0 | client.upsert_record(request).await?; |
236 | | |
237 | 0 | println!("Upsert accepted for {fqdn}"); |
238 | 0 | Ok(()) |
239 | 0 | } |
240 | | |
241 | 0 | async fn name_adopt(matches: &clap::ArgMatches) -> Result<()> { |
242 | 0 | let name = matches.get_one::<String>("NAME").unwrap(); |
243 | 0 | let addr = matches.get_one::<String>("addr").unwrap(); |
244 | | |
245 | 0 | tracing::debug!(addr = %addr, name = %name, "ladle name adopt connecting to node admin API"); |
246 | 0 | let mut client = NodeAdminServiceClient::connect(addr.clone()).await?; |
247 | | |
248 | 0 | let request = AdoptNameRequest { name: name.clone() }; |
249 | 0 | client.adopt_name(request).await?; |
250 | | |
251 | 0 | println!("Name '{name}' adoption requested"); |
252 | 0 | Ok(()) |
253 | 0 | } |
254 | | |
255 | 0 | async fn name_drop(matches: &clap::ArgMatches) -> Result<()> { |
256 | 0 | let name = matches.get_one::<String>("NAME").unwrap(); |
257 | 0 | let addr = matches.get_one::<String>("addr").unwrap(); |
258 | | |
259 | 0 | tracing::debug!(addr = %addr, name = %name, "ladle name drop connecting to node admin API"); |
260 | 0 | let mut client = NodeAdminServiceClient::connect(addr.clone()).await?; |
261 | | |
262 | 0 | let request = DropNameRequest { name: name.clone() }; |
263 | 0 | client.drop_name(request).await?; |
264 | | |
265 | 0 | println!("Name '{name}' dropped"); |
266 | 0 | Ok(()) |
267 | 0 | } |
268 | | |
269 | 0 | async fn name_list(matches: &clap::ArgMatches) -> Result<()> { |
270 | 0 | let addr = matches.get_one::<String>("addr").unwrap(); |
271 | | |
272 | 0 | tracing::debug!(addr = %addr, "ladle name list connecting to node admin API"); |
273 | 0 | let mut client = NodeAdminServiceClient::connect(addr.clone()).await?; |
274 | | |
275 | 0 | let request = ListAdoptedNamesRequest {}; |
276 | 0 | let response = client.list_adopted_names(request).await?; |
277 | | |
278 | 0 | let names = response.into_inner().names; |
279 | 0 | if names.is_empty() { |
280 | 0 | println!("No adopted names"); |
281 | 0 | } else { |
282 | 0 | for adopted in names { |
283 | 0 | let state_str = match adopted.state { |
284 | 0 | 1 => "LIVE", |
285 | 0 | 2 => "UNCLAIMED", |
286 | 0 | 3 => "BOUND_ELSEWHERE", |
287 | 0 | _ => "UNKNOWN", |
288 | | }; |
289 | 0 | println!("{}: {}", adopted.name, state_str); |
290 | | } |
291 | | } |
292 | 0 | Ok(()) |
293 | 0 | } |