Coverage Report

Created: 2026-06-16 03:43

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