Coverage Report

Created: 2026-10-28 18:18

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/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
}