Skip to main content

xianyun_cli/commands/
ca.rs

1//! `caproni ca`: manage the Caproni Dev CA certificate.
2//!
3//! Port of Go's `cmd/ca` package (`trust`, `untrust`, `inspect`).
4
5use std::path::Path;
6use std::sync::Arc;
7
8use anyhow::Context as _;
9use clap::{Args, Subcommand};
10use xianyun_cluster::clusterdomain;
11use xianyun_core::config::caproni::load_from_context;
12use xianyun_core::config::container::Container;
13use xianyun_core::config::global::Context;
14use xianyun_executor::exec::CommandRunner as ExecCommandRunner;
15use xianyun_pki::truststore::command_runner::{CmdError, CommandRunner as TruststoreCommandRunner};
16
17#[derive(Args, Debug)]
18pub struct CaArgs {
19    #[command(subcommand)]
20    pub command: CaCommands,
21}
22
23#[derive(Subcommand, Debug)]
24pub enum CaCommands {
25    /// Install the CA certificate in the system trust store.
26    Trust(TrustArgs),
27
28    /// Remove the CA certificate from the system trust store.
29    Untrust(UntrustArgs),
30
31    /// Show details about the CA certificate.
32    Inspect(InspectArgs),
33}
34
35#[derive(Args, Debug)]
36pub struct TrustArgs {}
37
38#[derive(Args, Debug)]
39pub struct UntrustArgs {}
40
41#[derive(Args, Debug)]
42pub struct InspectArgs {}
43
44pub fn run(args: &CaArgs, ctx: &Context) -> anyhow::Result<()> {
45    match &args.command {
46        CaCommands::Trust(_) => run_trust(ctx),
47        CaCommands::Untrust(_) => run_untrust(ctx),
48        CaCommands::Inspect(_) => run_inspect(ctx),
49    }
50}
51
52/// Adapts `xianyun_executor::exec::Client` to
53/// `xianyun_pki::truststore::command_runner::CommandRunner`, the minimal
54/// stand-in trait `truststore` was ported against before this crate
55/// existed (see that trait's own module doc comment).
56struct ExecTruststoreRunner {
57    exec: Arc<dyn ExecCommandRunner + Send + Sync>,
58}
59
60impl TruststoreCommandRunner for ExecTruststoreRunner {
61    fn run_sudo(&self, name: &str, args: &[String]) -> Result<(), CmdError> {
62        self.exec
63            .run_sudo(name, args)
64            .map_err(|e| CmdError(e.to_string()))
65    }
66
67    fn read_stdout(&self, name: &str, args: &[String]) -> Result<String, CmdError> {
68        self.exec
69            .read_stdout(name, args)
70            .map_err(|e| CmdError(e.to_string()))
71    }
72}
73
74/// Renders a `&[String]` the way Go's `fmt.Printf("%v", slice)` does:
75/// space-separated, unquoted, bracketed (e.g. `[.local .test]`), unlike
76/// Rust's `Debug` (`[".local", ".test"]`). Every `%v` slice this module
77/// prints is user-facing CLI output that a ported test asserts on
78/// verbatim, so the format has to match exactly, not just contain the
79/// same substrings.
80fn fmt_go_slice(items: &[String]) -> String {
81    format!("[{}]", items.join(" "))
82}
83
84/// Port of `ca.permittedDNSFromConfig`.
85fn permitted_dns_from_config(container: &Container) -> Vec<String> {
86    crate::commands::up::permitted_dns_for_cluster(
87        &container.caproni.cluster,
88        &container.global.profile_name,
89    )
90}
91
92/// Port of `ca.configuredDomain`.
93fn configured_domain(container: &Container) -> String {
94    container
95        .caproni
96        .cluster
97        .network
98        .as_ref()
99        .map(|n| n.cluster_domain.clone())
100        .unwrap_or_default()
101}
102
103/// Port of `ca.domainLine`: describes the effective cluster domain and
104/// where it came from; `skipped` is why the cluster was not consulted, if
105/// it was not.
106fn domain_line(
107    container: &Container,
108    skipped: Option<&clusterdomain::resolve::ResolveError>,
109) -> String {
110    let domain = configured_domain(container);
111    let source = &container.global.cluster_domain_source;
112
113    if !source.is_empty() && source != clusterdomain::resolve::CONFIGURED_SOURCE {
114        format!("{domain} (read from {source})")
115    } else if let Some(skipped) = skipped {
116        format!(
117            "{domain} ({}; {skipped})",
118            clusterdomain::resolve::CONFIGURED_SOURCE
119        )
120    } else {
121        format!("{domain} ({})", clusterdomain::resolve::CONFIGURED_SOURCE)
122    }
123}
124
125/// Port of `ca.loadConfig`: loads the config and resolves the cluster DNS
126/// domain when the cluster is running; the returned `Option<ResolveError>`
127/// says why it was not asked, if it was not.
128///
129/// `pub` (not private) and parameterized over `connect` — mirroring
130/// `up::run_start`'s `KubectlFactory` seam — because there is no Rust
131/// equivalent of Go's ambient-executor-through-context trick its own
132/// tests use to intercept `clusterdomain.ConnectKubectl`'s `kubectl`
133/// calls without a live cluster; `xianyun_core::config::global::Context`
134/// (config) and `xianyun_executor::exec::context::Context` (subprocess
135/// execution) are two different, unconnected context types in this port.
136/// Tests inject a fake `Connector` here instead.
137pub fn load_config(
138    ctx: &Context,
139    connect: clusterdomain::resolve::Connector,
140) -> anyhow::Result<(Container, Option<clusterdomain::resolve::ResolveError>)> {
141    crate::drivers::ensure_registered();
142
143    let mut container = load_from_context(ctx).context("loading caproni config")?;
144
145    let manager = xianyun_cluster::registry::new_manager(
146        container.caproni.cluster.clone(),
147        container.global.profile_name.clone(),
148    )
149    .context("creating cluster manager")?;
150
151    let (skipped, err) =
152        clusterdomain::resolve::resolve_running(&mut container, manager.as_ref(), connect);
153
154    if let Some(err) = err {
155        return Err(anyhow::Error::new(err).context("resolving cluster DNS domain"));
156    }
157
158    Ok((container, skipped))
159}
160
161/// Port of `ca.runTrust`.
162fn run_trust(ctx: &Context) -> anyhow::Result<()> {
163    let (container, _skipped) = load_config(ctx, crate::commands::up::connect_kubectl)?;
164
165    let pki_dir = crate::commands::up::pki_dir_for_profile(&container.global.profile_name)
166        .context("resolving PKI directory")?;
167
168    ensure_trusted_ca(&container, &pki_dir)?;
169
170    let runner = ExecTruststoreRunner {
171        exec: Arc::new(xianyun_executor::exec::Client::new()),
172    };
173
174    xianyun_pki::truststore::install(&pki_dir, &runner).context("installing CA")?;
175
176    Ok(())
177}
178
179/// Port of `ca.ensureTrustedCA`: loads or regenerates the CA at `pki_dir`
180/// and reports what changed.
181fn ensure_trusted_ca(container: &Container, pki_dir: &Path) -> anyhow::Result<()> {
182    let constraints = permitted_dns_from_config(container);
183
184    let (_, previous) =
185        xianyun_pki::ca::ensure_current(pki_dir, &constraints).context("loading CA")?;
186
187    if let Some(previous) = previous {
188        println!("CA constraints changed, re-signed the Caproni Dev CA with its existing key.");
189        println!("  Was: {}", fmt_go_slice(&previous));
190        println!("  Now: {}", fmt_go_slice(&constraints));
191    }
192
193    Ok(())
194}
195
196/// Port of `ca.createUntrustCommand`'s inline action.
197fn run_untrust(ctx: &Context) -> anyhow::Result<()> {
198    crate::drivers::ensure_registered();
199
200    let container = load_from_context(ctx).context("loading caproni config")?;
201
202    let pki_dir = crate::commands::up::pki_dir_for_profile(&container.global.profile_name)
203        .context("resolving PKI directory")?;
204
205    let runner = ExecTruststoreRunner {
206        exec: Arc::new(xianyun_executor::exec::Client::new()),
207    };
208
209    xianyun_pki::truststore::uninstall(&pki_dir, &runner).context("uninstalling CA")?;
210
211    Ok(())
212}
213
214/// Port of `ca.runInspect`: never regenerates the CA — a stale CA is
215/// reported, not cured.
216pub fn run_inspect(ctx: &Context) -> anyhow::Result<()> {
217    run_inspect_with_connector(ctx, crate::commands::up::connect_kubectl)
218}
219
220/// Parameterized over `connect`; see [`load_config`]'s doc comment for
221/// why. Tests inject a fake `Connector` to exercise the
222/// domain-read-from-a-running-cluster path without a live cluster.
223pub fn run_inspect_with_connector(
224    ctx: &Context,
225    connect: clusterdomain::resolve::Connector,
226) -> anyhow::Result<()> {
227    let (container, skipped) = load_config(ctx, connect)?;
228
229    let pki_dir = crate::commands::up::pki_dir_for_profile(&container.global.profile_name)
230        .context("resolving PKI directory")?;
231
232    let constraints = permitted_dns_from_config(&container);
233
234    let ca = xianyun_pki::ca::load_or_generate(&pki_dir, &constraints).context("loading CA")?;
235
236    let cert_path = xianyun_pki::ca::cert_path(&pki_dir).context("resolving CA certificate")?;
237
238    println!("Caproni Dev CA");
239    println!("  Path:        {}", cert_path.display());
240    println!("  Subject:     {}", ca.cert.common_name);
241    println!("  Not Before:  {:?}", ca.cert.not_before);
242    println!("  Not After:   {:?}", ca.cert.not_after);
243    println!(
244        "  Domain:      {}",
245        domain_line(&container, skipped.as_ref())
246    );
247    println!(
248        "  Constraints: {}",
249        fmt_go_slice(&ca.cert.permitted_dns_domains)
250    );
251
252    if !xianyun_pki::ca::constraints_equal(&ca.cert.permitted_dns_domains, &constraints) {
253        println!(
254            "  Status:      OUT OF DATE \u{2014} configured constraints are {}; run `caproni ca trust` to re-sign",
255            fmt_go_slice(&constraints)
256        );
257    }
258
259    Ok(())
260}