Skip to main content

local_ssl/
lib.rs

1use clap::{Parser, Subcommand};
2use colored::Colorize;
3
4mod ca;
5mod cert;
6mod telemetry;
7mod trust;
8mod util;
9
10#[derive(Parser)]
11#[command(
12    name = "local-ssl",
13    about = "Local HTTPS certificates for development — trust locally, never prod",
14    version
15)]
16pub struct Cli {
17    #[command(subcommand)]
18    pub command: Commands,
19}
20
21#[derive(Subcommand)]
22pub enum Commands {
23    /// Initialize a local Certificate Authority and install system trust
24    Init,
25    /// Generate an HTTPS certificate for a development domain
26    Generate {
27        /// Domain(s) to generate cert for (first is primary, rest are SANs)
28        domains: Vec<String>,
29        /// Output directory (default: /etc/local-ssl/certs/<domain>/)
30        #[arg(short, long)]
31        output: Option<String>,
32    },
33    /// List all generated certificates
34    List,
35    /// Show certificate details
36    Show {
37        /// Domain to inspect
38        domain: String,
39    },
40    /// (Re)install CA into system trust store
41    Trust,
42    /// Show CA status and expiry
43    Status,
44    /// Check certificate validity for a domain (or "all")
45    Check {
46        /// Domain to check, or "all" for all certificates
47        domain: String,
48    },
49    /// Manage anonymous usage telemetry
50    Telemetry {
51        #[command(subcommand)]
52        action: TelemetryAction,
53    },
54}
55
56#[derive(Subcommand)]
57pub enum TelemetryAction {
58    Enable,
59    Disable,
60    Status,
61}
62
63pub fn run(args: &[String]) -> Result<String, String> {
64    let store = ca::CaStore::new();
65    let tel = telemetry::Telemetry::load(&store.dir);
66    let cli = Cli::parse_from(args);
67    let is_telemetry_cmd = matches!(&cli.command, Commands::Telemetry { .. });
68    let result = execute(cli, &tel);
69    if !is_telemetry_cmd && tel.maybe_heartbeat() {
70        tel.show_heartbeat_notice();
71    }
72    result
73}
74
75fn execute(cli: Cli, tel: &telemetry::Telemetry) -> Result<String, String> {
76    let store = ca::CaStore::new();
77
78    let cmd_name = match &cli.command {
79        Commands::Telemetry { .. } => "telemetry",
80        Commands::Init => "init",
81        Commands::Generate { .. } => "generate",
82        Commands::List => "list",
83        Commands::Show { .. } => "show",
84        Commands::Check { .. } => "check",
85        Commands::Trust => "trust",
86        Commands::Status => "status",
87    };
88    let stats = if matches!(cmd_name, "init" | "status" | "telemetry") {
89        vec![]
90    } else {
91        collect_tool_stats(&store)
92    };
93    let stats_refs: Vec<(&str, &str)> = stats.iter().map(|(k, v)| (*k, v.as_str())).collect();
94    tel.send_command_event(cmd_name, &stats_refs);
95
96    match cli.command {
97        Commands::Telemetry { action } => handle_telemetry(action, &store),
98        Commands::Init => cmd_init(&store),
99        Commands::Generate { domains, output } => cmd_generate(&store, &domains, output),
100        Commands::List => cmd_list(&store),
101        Commands::Show { domain } => cmd_show(&store, &domain),
102        Commands::Check { domain } => cmd_check(&store, &domain),
103        Commands::Trust => cmd_trust(&store),
104        Commands::Status => cmd_status(&store),
105    }
106}
107
108fn handle_telemetry(action: TelemetryAction, store: &ca::CaStore) -> Result<String, String> {
109    match action {
110        TelemetryAction::Enable => {
111            let mut t = telemetry::Telemetry::load(&store.dir);
112            t.enable()?;
113            Ok(format!(
114                "{} Anonymous telemetry enabled\n{}",
115                "✓".green(),
116                t.status()
117            ))
118        }
119        TelemetryAction::Disable => {
120            let mut t = telemetry::Telemetry::load(&store.dir);
121            t.disable()?;
122            Ok(format!(
123                "{} Anonymous telemetry disabled\n{}",
124                "✓".yellow(),
125                t.status()
126            ))
127        }
128        TelemetryAction::Status => {
129            let t = telemetry::Telemetry::load(&store.dir);
130            Ok(t.status())
131        }
132    }
133}
134
135fn cmd_init(store: &ca::CaStore) -> Result<String, String> {
136    if store.exists() {
137        println!(
138            "{}",
139            "CA already exists. Run `local-ssl trust` to reinstall trust.".yellow()
140        );
141        return Ok(String::new());
142    }
143
144    println!(
145        "{}",
146        "Generating local Certificate Authority...".cyan().bold()
147    );
148    store.init()?;
149    println!(
150        "{} CA key:  {}",
151        "✓".green(),
152        store.key_path.display().to_string().cyan()
153    );
154    println!(
155        "{} CA cert: {}",
156        "✓".green(),
157        store.cert_path.display().to_string().cyan()
158    );
159
160    println!(
161        "\n{}",
162        "Installing CA into system trust store...".cyan().bold()
163    );
164    trust::install_ca(&store.cert_path)?;
165    println!("{} CA trusted system-wide", "✓".green());
166
167    println!(
168        "\n{}",
169        "Ready. Generate certs for local development with:".bold()
170    );
171    println!("  {}", "local-ssl generate myapp.test".green());
172    println!(
173        "  {}",
174        "local-ssl generate api.test www.test --trust".green()
175    );
176
177    Ok(String::new())
178}
179
180fn cmd_generate(
181    store: &ca::CaStore,
182    domains: &[String],
183    _output: Option<String>,
184) -> Result<String, String> {
185    if !store.exists() {
186        return Err("CA not initialized. Run `local-ssl init` first.".into());
187    }
188
189    if domains.is_empty() {
190        return Err("At least one domain is required.".into());
191    }
192
193    let primary = &domains[0];
194    let sans: Vec<String> = domains[1..].to_vec();
195
196    let bundle = cert::generate(primary, store, &sans)?;
197    println!("{} Certificate for '{}'", "✓".green(), primary.bold());
198    println!("  {} {}", "Cert:".bold(), bundle.cert_path.cyan());
199    println!("  {} {}", "Key:".bold(), bundle.key_path.cyan());
200    println!();
201    println!("{}", "Example local HTTPS usage:".bold());
202    println!(
203        "  {} curl --cacert /etc/local-ssl/ca-cert.pem https://{}/",
204        "Test:".dimmed(),
205        primary
206    );
207    println!(
208        "  {} node server.js --key {} --cert {}",
209        "Node:".dimmed(),
210        bundle.key_path,
211        bundle.cert_path
212    );
213    println!("  {} local-dns add {} 127.0.0.1", "DNS:".dimmed(), primary);
214    println!();
215    println!(
216        "{}",
217        "⚠ This cert is for LOCAL DEVELOPMENT ONLY. Never use it in production."
218            .yellow()
219            .bold()
220    );
221
222    Ok(String::new())
223}
224
225fn cmd_list(store: &ca::CaStore) -> Result<String, String> {
226    let domains = cert::list(store)?;
227    if domains.is_empty() {
228        return Ok(format!("{}", "No certificates generated yet.".yellow()));
229    }
230    let mut out = format!("{}\n", "Generated certificates:".bold());
231    for d in &domains {
232        out.push_str(&format!("  {}\n", d.green()));
233    }
234    out.push_str(&format!(
235        "\nLocation: {}",
236        store.dir.join("certs").display().to_string().cyan()
237    ));
238    Ok(out)
239}
240
241fn cmd_check(store: &ca::CaStore, domain: &str) -> Result<String, String> {
242    if domain == "all" {
243        return cert::check_all_local(store);
244    }
245    // Try remote check if domain contains a colon (host:port)
246    if let Some((host, port_str)) = domain.split_once(':') {
247        if let Ok(port) = port_str.parse::<u16>() {
248            return cert::check_remote(host, port);
249        }
250    }
251    // Default: local check
252    cert::check_local(domain, store)
253}
254
255fn cmd_show(store: &ca::CaStore, domain: &str) -> Result<String, String> {
256    cert::show(domain, store)
257}
258
259fn cmd_trust(store: &ca::CaStore) -> Result<String, String> {
260    if !store.exists() {
261        return Err("CA not initialized. Run `local-ssl init` first.".into());
262    }
263    if trust::is_ca_trusted(&store.cert_path) {
264        return Ok(format!("{} CA is already trusted.", "✓".green()));
265    }
266    println!("{}", "Installing CA into system trust store...".cyan());
267    trust::install_ca(&store.cert_path)?;
268    Ok(format!("{} CA trusted system-wide", "✓".green()))
269}
270
271fn cmd_status(store: &ca::CaStore) -> Result<String, String> {
272    if !store.exists() {
273        return Ok(format!(
274            "{}",
275            "CA not initialized. Run `local-ssl init` first.".yellow()
276        ));
277    }
278
279    let trusted = trust::is_ca_trusted(&store.cert_path);
280    let trust_status: String = if trusted {
281        "trusted ✓".green().to_string()
282    } else {
283        "not trusted".red().to_string()
284    };
285
286    println!("{}", "CA Status:".bold());
287    println!("{}", store.status()?);
288    println!("{} {}", "System trust:".bold(), trust_status);
289
290    let count = cert::list(store)?.len();
291    println!("{} {}", "Certificates:".bold(), count.to_string().cyan());
292
293    Ok(String::new())
294}
295
296fn collect_tool_stats(store: &ca::CaStore) -> Vec<(&'static str, String)> {
297    let mut stats: Vec<(&'static str, String)> = Vec::new();
298    if let Ok(domains) = cert::list(store) {
299        stats.push(("certs", domains.len().to_string()));
300    }
301    stats
302}