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 Init,
25 Generate {
27 domains: Vec<String>,
29 #[arg(short, long)]
31 output: Option<String>,
32 },
33 List,
35 Show {
37 domain: String,
39 },
40 Trust,
42 Status,
44 Check {
46 domain: String,
48 },
49 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 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 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}