Coverage Report

Created: 2026-09-13 01:55

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_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
}