certmate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2 @@
1
+ """certmate-cli — the CertMate SSL lifecycle from your terminal (built on certmate-sdk)."""
2
+ __version__ = "0.1.0"
certmate_cli/main.py ADDED
@@ -0,0 +1,344 @@
1
+ """CertMate CLI — a thin, pleasant terminal front-end over certmate-sdk.
2
+
3
+ certmate cert create app.example.com --dns cloudflare --wait
4
+ certmate cert ls
5
+ certmate cert renew app.example.com --force
6
+ certmate audit verify
7
+
8
+ Connection comes from --url/--token or CERTMATE_URL/CERTMATE_TOKEN.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from typing import List, Optional
14
+
15
+ import typer
16
+ from rich.console import Console
17
+ from rich.table import Table
18
+
19
+ from certmate import Client, CertMateError, Job
20
+
21
+ app = typer.Typer(no_args_is_help=True, add_completion=False,
22
+ help="CertMate — the SSL certificate lifecycle from your terminal.")
23
+ cert_app = typer.Typer(no_args_is_help=True, help="Manage certificates.")
24
+ dns_app = typer.Typer(no_args_is_help=True, help="DNS providers and accounts.")
25
+ audit_app = typer.Typer(no_args_is_help=True, help="Tamper-evident audit trail.")
26
+ backup_app = typer.Typer(no_args_is_help=True, help="Backups.")
27
+ deploy_app = typer.Typer(no_args_is_help=True, help="Post-issuance deploy hooks.")
28
+ app.add_typer(cert_app, name="cert")
29
+ app.add_typer(dns_app, name="dns")
30
+ app.add_typer(audit_app, name="audit")
31
+ app.add_typer(backup_app, name="backup")
32
+ app.add_typer(deploy_app, name="deploy")
33
+
34
+ out = Console()
35
+ err = Console(stderr=True)
36
+
37
+ # A lax hostname/wildcard check for the client-side --dry-run preflight; the
38
+ # server is the real authority, this just catches obvious typos before we
39
+ # spend an API call.
40
+ _DOMAIN_RE = re.compile(
41
+ r"^(\*\.)?([a-zA-Z0-9_](-*[a-zA-Z0-9_])*\.)+[a-zA-Z]{2,}$")
42
+
43
+
44
+ @app.callback()
45
+ def _main(
46
+ ctx: typer.Context,
47
+ url: Optional[str] = typer.Option(None, "--url", envvar="CERTMATE_URL",
48
+ help="CertMate base URL (default http://localhost:8000)."),
49
+ token: Optional[str] = typer.Option(None, "--token", envvar="CERTMATE_TOKEN",
50
+ help="API bearer token."),
51
+ ):
52
+ ctx.obj = {"url": url, "token": token}
53
+
54
+
55
+ def _client(ctx: typer.Context) -> Client:
56
+ o = ctx.obj or {}
57
+ return Client(o.get("url"), o.get("token"))
58
+
59
+
60
+ def _die(msg: str, code: int = 1):
61
+ err.print(f"[bold red]error[/]: {msg}")
62
+ raise typer.Exit(code)
63
+
64
+
65
+ def _run(fn):
66
+ """Execute an SDK call, turning SDK errors into clean CLI failures."""
67
+ try:
68
+ return fn()
69
+ except CertMateError as e:
70
+ _die(str(e))
71
+
72
+
73
+ def _records(data) -> List[dict]:
74
+ """Coerce a list/dict API response into a list of record dicts."""
75
+ if isinstance(data, list):
76
+ return [d for d in data if isinstance(d, dict)]
77
+ if isinstance(data, dict):
78
+ for key in ("items", "accounts", "backups", "results", "data", "unified"):
79
+ v = data.get(key)
80
+ if isinstance(v, list):
81
+ return [d for d in v if isinstance(d, dict)]
82
+ return []
83
+
84
+
85
+ def _table(records: List[dict], columns: List[str]) -> None:
86
+ """Print a rich table of ``records`` over ``columns`` (missing keys -> '-')."""
87
+ if not records:
88
+ out.print("[dim]nothing to show.[/]")
89
+ return
90
+ table = Table(box=None, header_style="bold")
91
+ for c in columns:
92
+ table.add_column(c.upper())
93
+ for r in records:
94
+ table.add_row(*[str(r.get(c, "-")) for c in columns])
95
+ out.print(table)
96
+
97
+
98
+ # --------------------------------------------------------------------------
99
+ # cert
100
+ # --------------------------------------------------------------------------
101
+
102
+ @cert_app.command("ls")
103
+ def cert_ls(ctx: typer.Context):
104
+ """List certificates with expiry and status."""
105
+ certs = _run(lambda: _client(ctx).list_certificates())
106
+ if not certs:
107
+ out.print("[dim]No certificates.[/]")
108
+ return
109
+ table = Table(box=None, header_style="bold")
110
+ table.add_column("DOMAIN")
111
+ table.add_column("EXPIRES")
112
+ table.add_column("DAYS", justify="right")
113
+ table.add_column("CA")
114
+ table.add_column("AUTO-RENEW")
115
+ for c in sorted(certs, key=lambda x: (x.days_until_expiry if x.days_until_expiry is not None else 1 << 30)):
116
+ days = c.days_until_expiry
117
+ days_str = "-" if days is None else str(days)
118
+ colour = "green"
119
+ if days is not None:
120
+ colour = "red" if days < 7 else ("yellow" if days < 30 else "green")
121
+ table.add_row(
122
+ c.domain,
123
+ (c.expiry_date or "-")[:10],
124
+ f"[{colour}]{days_str}[/]",
125
+ c.ca_provider or "-",
126
+ "on" if c.auto_renew else ("off" if c.auto_renew is not None else "-"),
127
+ )
128
+ out.print(table)
129
+
130
+
131
+ @cert_app.command("info")
132
+ def cert_info(ctx: typer.Context, domain: str):
133
+ """Show a certificate's details."""
134
+ c = _run(lambda: _client(ctx).get_certificate(domain))
135
+ out.print(f"[bold]{c.domain}[/]")
136
+ out.print(f" expires: {c.expiry_date or '-'} "
137
+ f"({c.days_until_expiry if c.days_until_expiry is not None else '?'} days)")
138
+ out.print(f" CA: {c.ca_provider or '-'}")
139
+ out.print(f" DNS: {c.dns_provider or '-'}")
140
+ out.print(f" auto-renew: {c.auto_renew}")
141
+ if c.san_domains:
142
+ out.print(f" SAN: {', '.join(c.san_domains)}")
143
+ if c.needs_renewal:
144
+ out.print(" [yellow]needs renewal[/]")
145
+
146
+
147
+ @cert_app.command("create")
148
+ def cert_create(
149
+ ctx: typer.Context,
150
+ domain: str,
151
+ dns: Optional[str] = typer.Option(None, "--dns", help="DNS provider (e.g. cloudflare)."),
152
+ ca: Optional[str] = typer.Option(None, "--ca", help="CA provider (e.g. letsencrypt)."),
153
+ san: Optional[str] = typer.Option(None, "--san", help="Comma-separated SAN domains."),
154
+ wait: bool = typer.Option(True, "--wait/--no-wait", help="Wait for issuance to finish."),
155
+ dry_run: bool = typer.Option(False, "--dry-run",
156
+ help="Validate inputs and preflight the DNS provider WITHOUT issuing."),
157
+ ):
158
+ """Issue a certificate (async; waits for completion by default)."""
159
+ sans: List[str] = [s.strip() for s in san.split(",")] if san else []
160
+ client = _client(ctx)
161
+
162
+ if dry_run:
163
+ problems = []
164
+ if not _DOMAIN_RE.match(domain):
165
+ problems.append(f"domain {domain!r} does not look valid")
166
+ for s in sans:
167
+ if not _DOMAIN_RE.match(s):
168
+ problems.append(f"SAN {s!r} does not look valid")
169
+ if dns:
170
+ res = _run(lambda: client.test_dns_provider(dns))
171
+ ok = bool(res.get("success", res.get("ok", True)))
172
+ msg = res.get("message") or res.get("error") or ("reachable" if ok else "failed")
173
+ out.print(f"DNS provider [bold]{dns}[/]: {'[green]OK[/]' if ok else '[red]FAIL[/]'} — {msg}")
174
+ if not ok:
175
+ problems.append("DNS provider preflight failed")
176
+ else:
177
+ out.print("[dim]No --dns given; skipping provider preflight.[/]")
178
+ if problems:
179
+ _die("dry-run found issues:\n - " + "\n - ".join(problems))
180
+ out.print(f"[green]dry-run OK[/] — would issue [bold]{domain}[/]"
181
+ f"{(' (+' + str(len(sans)) + ' SAN)') if sans else ''}"
182
+ f"{(' via ' + dns) if dns else ''}{(' on ' + ca) if ca else ''}. Nothing issued.")
183
+ return
184
+
185
+ def _issue():
186
+ with out.status(f"Issuing [bold]{domain}[/] …") as status:
187
+ def _progress(j: Job):
188
+ if j.status:
189
+ status.update(f"Issuing [bold]{domain}[/] … [{j.status}]")
190
+ return client.create_certificate(
191
+ domain, dns_provider=dns, ca_provider=ca, san_domains=sans or None,
192
+ wait=wait, on_progress=_progress)
193
+
194
+ job = _run(_issue)
195
+ if wait:
196
+ out.print(f"[green]issued[/] [bold]{domain}[/]"
197
+ f"{(' (' + job.status + ')') if job.status else ''}.")
198
+ else:
199
+ out.print(f"[yellow]accepted[/] — job [bold]{job.job_id}[/] "
200
+ f"(poll: certmate cert job {job.job_id}).")
201
+
202
+
203
+ @cert_app.command("job")
204
+ def cert_job(ctx: typer.Context, job_id: str):
205
+ """Show the status of an async issuance/renewal job."""
206
+ j = _run(lambda: _client(ctx).get_job(job_id))
207
+ out.print(f"job [bold]{j.job_id}[/]: {j.status or '?'}"
208
+ f"{(' — ' + j.error) if j.error else ''}")
209
+
210
+
211
+ @cert_app.command("renew")
212
+ def cert_renew(ctx: typer.Context, domain: str,
213
+ force: bool = typer.Option(False, "--force", help="Force renewal even if not due.")):
214
+ """Renew a certificate."""
215
+ res = _run(lambda: _client(ctx).renew_certificate(domain, force=force))
216
+ if res.get("renewed") is False:
217
+ out.print(f"[yellow]not due[/] — {domain} was not yet due for renewal.")
218
+ else:
219
+ out.print(f"[green]renewed[/] {domain}.")
220
+
221
+
222
+ @cert_app.command("rm")
223
+ def cert_rm(ctx: typer.Context, domain: str,
224
+ yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation.")):
225
+ """Delete a certificate."""
226
+ if not yes:
227
+ typer.confirm(f"Delete certificate {domain}?", abort=True)
228
+ _run(lambda: _client(ctx).delete_certificate(domain))
229
+ out.print(f"[green]deleted[/] {domain}.")
230
+
231
+
232
+ @cert_app.command("reissue")
233
+ def cert_reissue(ctx: typer.Context, domain: str,
234
+ san: Optional[str] = typer.Option(None, "--san", help="Comma-separated SAN domains.")):
235
+ """Reissue a certificate (e.g. to change its SAN list)."""
236
+ body = {}
237
+ if san is not None:
238
+ body["san_domains"] = [s.strip() for s in san.split(",") if s.strip()]
239
+ _run(lambda: _client(ctx).reissue_certificate(domain, **body))
240
+ out.print(f"[green]reissued[/] {domain}.")
241
+
242
+
243
+ # --------------------------------------------------------------------------
244
+ # dns
245
+ # --------------------------------------------------------------------------
246
+
247
+ @dns_app.command("providers")
248
+ def dns_providers(ctx: typer.Context):
249
+ """List supported DNS providers."""
250
+ out.print(_run(lambda: _client(ctx).list_dns_providers()))
251
+
252
+
253
+ @dns_app.command("accounts")
254
+ def dns_accounts(ctx: typer.Context,
255
+ provider: Optional[str] = typer.Argument(None, help="Filter by provider.")):
256
+ """List configured DNS accounts."""
257
+ data = _run(lambda: _client(ctx).list_dns_accounts(provider))
258
+ _table(_records(data), ["provider", "account_id", "name", "email"])
259
+
260
+
261
+ @dns_app.command("test")
262
+ def dns_test(ctx: typer.Context, provider: str):
263
+ """Preflight a DNS provider (does not issue)."""
264
+ res = _run(lambda: _client(ctx).test_dns_provider(provider))
265
+ ok = bool(res.get("success", res.get("ok", True)))
266
+ msg = res.get("message") or res.get("error") or ("reachable" if ok else "failed")
267
+ out.print(f"[bold]{provider}[/]: {'[green]OK[/]' if ok else '[red]FAIL[/]'} — {msg}")
268
+ if not ok:
269
+ raise typer.Exit(1)
270
+
271
+
272
+ # --------------------------------------------------------------------------
273
+ # audit
274
+ # --------------------------------------------------------------------------
275
+
276
+ @audit_app.command("verify")
277
+ def audit_verify(ctx: typer.Context):
278
+ """Verify the tamper-evident audit chain."""
279
+ res = _run(lambda: _client(ctx).audit_verify())
280
+ ok = bool(res.get("ok"))
281
+ reason = res.get("reason") or ""
282
+ cp = res.get("checkpoint_verified")
283
+ # "chain file does not exist" / "empty chain" is a fresh instance that has
284
+ # not audited anything yet — benign, not a tamper alarm. Show it neutrally.
285
+ if not ok and any(k in reason.lower() for k in ("does not exist", "empty")):
286
+ out.print(f"audit chain: [dim]none yet[/] — {reason}")
287
+ return
288
+ out.print(f"audit chain: {'[green]intact[/]' if ok else '[red]BROKEN[/]'}"
289
+ f"{(' — ' + reason) if reason else ''}")
290
+ if cp is not None:
291
+ out.print(f" signed checkpoint: {'[green]verified[/]' if cp else '[dim]not cross-checked[/]'}"
292
+ f"{(' @ seq ' + str(res.get('checkpoint_seq'))) if res.get('checkpoint_seq') is not None else ''}")
293
+ if not ok:
294
+ raise typer.Exit(1)
295
+
296
+
297
+ # --------------------------------------------------------------------------
298
+ # backup
299
+ # --------------------------------------------------------------------------
300
+
301
+ @backup_app.command("ls")
302
+ def backup_ls(ctx: typer.Context):
303
+ """List backups."""
304
+ data = _run(lambda: _client(ctx).list_backups())
305
+ # Items are {filename, metadata:{size, created, backup_reason, ...}} — flatten.
306
+ flat = [{**r, **(r.get("metadata") or {})} for r in _records(data)]
307
+ _table(flat, ["filename", "backup_reason", "created", "size"])
308
+
309
+
310
+ @backup_app.command("create")
311
+ def backup_create(ctx: typer.Context):
312
+ """Create a backup now."""
313
+ res = _run(lambda: _client(ctx).create_backup())
314
+ name = (res or {}).get("filename") or (res or {}).get("backup") or "ok"
315
+ out.print(f"[green]backup created[/] {name}")
316
+
317
+
318
+ # --------------------------------------------------------------------------
319
+ # deploy
320
+ # --------------------------------------------------------------------------
321
+
322
+ @deploy_app.command("run")
323
+ def deploy_run(ctx: typer.Context, domain: str):
324
+ """Run the configured deploy hooks for a domain now."""
325
+ res = _run(lambda: _client(ctx).deploy_certificate(domain))
326
+ ok = bool((res or {}).get("ok", True))
327
+ out.print(f"deploy [bold]{domain}[/]: {'[green]ok[/]' if ok else '[red]failed[/]'}"
328
+ f"{(' — ' + str(res.get('message'))) if isinstance(res, dict) and res.get('message') else ''}")
329
+ if not ok:
330
+ raise typer.Exit(1)
331
+
332
+
333
+ # --------------------------------------------------------------------------
334
+ # top-level convenience
335
+ # --------------------------------------------------------------------------
336
+
337
+ @app.command("health")
338
+ def health(ctx: typer.Context):
339
+ """Check the CertMate instance health."""
340
+ out.print(_run(lambda: _client(ctx).health()))
341
+
342
+
343
+ if __name__ == "__main__":
344
+ app()
@@ -0,0 +1,49 @@
1
+ Metadata-Version: 2.4
2
+ Name: certmate-cli
3
+ Version: 0.1.0
4
+ Summary: CertMate command-line interface — the SSL certificate lifecycle from your terminal
5
+ Project-URL: Homepage, https://github.com/fabriziosalmi/certmate
6
+ Project-URL: Source, https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-cli
7
+ Author-email: Fabrizio Salmi <fabrizio.salmi@gmail.com>
8
+ License: MIT
9
+ Keywords: acme,certmate,cli,letsencrypt,tls
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Security
23
+ Classifier: Topic :: System :: Systems Administration
24
+ Classifier: Topic :: Utilities
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: certmate-sdk>=0.1.0
27
+ Requires-Dist: rich>=13
28
+ Requires-Dist: typer>=0.9
29
+ Description-Content-Type: text/markdown
30
+
31
+ # certmate-cli
32
+
33
+ The [CertMate](https://github.com/fabriziosalmi/certmate) SSL certificate
34
+ lifecycle from your terminal — built on `certmate-sdk`.
35
+
36
+ ```bash
37
+ pip install certmate-cli
38
+ export CERTMATE_URL=http://localhost:8000
39
+ export CERTMATE_TOKEN=...
40
+
41
+ certmate cert create app.example.com --dns cloudflare --wait
42
+ certmate cert ls
43
+ certmate cert info app.example.com
44
+ certmate cert renew app.example.com --force
45
+ certmate cert create app.example.com --dns cloudflare --dry-run
46
+ certmate audit verify
47
+ ```
48
+
49
+ Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
@@ -0,0 +1,6 @@
1
+ certmate_cli/__init__.py,sha256=ORHTYH92NSZVLV-rzJhdnhpHIefR6r2EjZiDcGIqHG8,116
2
+ certmate_cli/main.py,sha256=2G4pduCAUL748L8PLcny9jtLdeh8iQ4XZLWkCg_bOq4,13645
3
+ certmate_cli-0.1.0.dist-info/METADATA,sha256=GfWUDxXFw0QZ-QyduCFbZKjmtD61VMneGX9dHpNmAYA,1838
4
+ certmate_cli-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
5
+ certmate_cli-0.1.0.dist-info/entry_points.txt,sha256=3ArMOEL-UOhkPlq1U5GzdXkA01LehM0tAr6_GFmDlTc,51
6
+ certmate_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ certmate = certmate_cli.main:app