certmate-cli 0.1.2__tar.gz → 0.1.4__tar.gz

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.
@@ -64,9 +64,11 @@ Thumbs.db
64
64
  # Logs
65
65
  *.log
66
66
 
67
- # Backup files
68
- *_old.*
69
- *_backup.*
67
+ # Backup files. Anchored to the repository root: unanchored, `*_backup.*`
68
+ # also matched tests/test_backup.py, which therefore existed on disk for
69
+ # months without ever being committed or run in CI.
70
+ /*_old.*
71
+ /*_backup.*
70
72
 
71
73
  # Planning and development documents
72
74
  NEXT_FEATURES.md
@@ -88,12 +90,15 @@ app.py.backup
88
90
  .claude/
89
91
  test_screenshots/
90
92
 
91
- # Test files with sensitive data
92
- test_real_cert_*.py
93
- test_api_*.py
94
- test_certificate_creation_*.py
95
- real_world_test_*.py
96
- integration_test_*.py
93
+ # Ad-hoc test scripts with real credentials, written at the repository root.
94
+ # Anchored (leading /) on purpose: unanchored, `test_api_*.py` swallowed
95
+ # tests/test_api_key_expiry_and_masking.py — a committed-looking test file
96
+ # that git silently refused to track, so it protected nothing in CI.
97
+ /test_real_cert_*.py
98
+ /test_api_*.py
99
+ /test_certificate_creation_*.py
100
+ /real_world_test_*.py
101
+ /integration_test_*.py
97
102
  github_response.md
98
103
 
99
104
  # local
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: certmate-cli
3
- Version: 0.1.2
3
+ Version: 0.1.4
4
4
  Summary: CertMate command-line interface — the SSL certificate lifecycle from your terminal
5
5
  Project-URL: Homepage, https://github.com/fabriziosalmi/certmate
6
6
  Project-URL: Source, https://github.com/fabriziosalmi/certmate/tree/main/clients/certmate-cli
@@ -23,7 +23,7 @@ Classifier: Topic :: Security
23
23
  Classifier: Topic :: System :: Systems Administration
24
24
  Classifier: Topic :: Utilities
25
25
  Requires-Python: >=3.9
26
- Requires-Dist: certmate-sdk>=0.1.2
26
+ Requires-Dist: certmate-sdk>=0.1.3
27
27
  Requires-Dist: rich>=13
28
28
  Requires-Dist: typer>=0.9
29
29
  Description-Content-Type: text/markdown
@@ -46,6 +46,30 @@ certmate cert create app.example.com --dns cloudflare --dry-run
46
46
  certmate audit verify
47
47
  ```
48
48
 
49
+ ## Pulling certificates onto a host
50
+
51
+ `cert download` fetches one file at a time, so a target server can pull
52
+ exactly what it deploys instead of the certificate manager pushing to it:
53
+
54
+ ```bash
55
+ certmate cert download app.example.com --file fullchain -o /etc/ssl/certs/app.pem
56
+ certmate cert download app.example.com --file privkey -o /etc/ssl/private/app.key
57
+ ```
58
+
59
+ Files are created **0600**, with the mode set at creation rather than after
60
+ the write, so the key is never briefly world-readable.
61
+
62
+ This is worth preferring over pushing when the manager would otherwise need
63
+ credentials on every target host. Give each host an API key scoped to its own
64
+ domain and run the pull on a timer: the host needs no inbound access, and the
65
+ manager holds no credentials for it. `cert`, `chain` and `fullchain` are
66
+ readable by a viewer-role key; `privkey`, `combined` and `pfx` need operator.
67
+
68
+ `--file privkey --key-format pkcs1` serves the legacy
69
+ `BEGIN RSA PRIVATE KEY` form for stacks that reject certbot's PKCS#8.
70
+ `--bundle zip` or `--bundle json` fetch the whole certificate instead, and
71
+ `-o -` writes to stdout.
72
+
49
73
  Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
50
74
  Prefer the `CERTMATE_TOKEN` environment variable over `--token`: command-line
51
75
  arguments are visible to other local processes (`ps`) and shell history.
@@ -0,0 +1,45 @@
1
+ # certmate-cli
2
+
3
+ The [CertMate](https://github.com/fabriziosalmi/certmate) SSL certificate
4
+ lifecycle from your terminal — built on `certmate-sdk`.
5
+
6
+ ```bash
7
+ pip install certmate-cli
8
+ export CERTMATE_URL=http://localhost:8000
9
+ export CERTMATE_TOKEN=...
10
+
11
+ certmate cert create app.example.com --dns cloudflare --wait
12
+ certmate cert ls
13
+ certmate cert info app.example.com
14
+ certmate cert renew app.example.com --force
15
+ certmate cert create app.example.com --dns cloudflare --dry-run
16
+ certmate audit verify
17
+ ```
18
+
19
+ ## Pulling certificates onto a host
20
+
21
+ `cert download` fetches one file at a time, so a target server can pull
22
+ exactly what it deploys instead of the certificate manager pushing to it:
23
+
24
+ ```bash
25
+ certmate cert download app.example.com --file fullchain -o /etc/ssl/certs/app.pem
26
+ certmate cert download app.example.com --file privkey -o /etc/ssl/private/app.key
27
+ ```
28
+
29
+ Files are created **0600**, with the mode set at creation rather than after
30
+ the write, so the key is never briefly world-readable.
31
+
32
+ This is worth preferring over pushing when the manager would otherwise need
33
+ credentials on every target host. Give each host an API key scoped to its own
34
+ domain and run the pull on a timer: the host needs no inbound access, and the
35
+ manager holds no credentials for it. `cert`, `chain` and `fullchain` are
36
+ readable by a viewer-role key; `privkey`, `combined` and `pfx` need operator.
37
+
38
+ `--file privkey --key-format pkcs1` serves the legacy
39
+ `BEGIN RSA PRIVATE KEY` form for stacks that reject certbot's PKCS#8.
40
+ `--bundle zip` or `--bundle json` fetch the whole certificate instead, and
41
+ `-o -` writes to stdout.
42
+
43
+ Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
44
+ Prefer the `CERTMATE_TOKEN` environment variable over `--token`: command-line
45
+ arguments are visible to other local processes (`ps`) and shell history.
@@ -1,2 +1,2 @@
1
1
  """certmate-cli — the CertMate SSL lifecycle from your terminal (built on certmate-sdk)."""
2
- __version__ = "0.1.2"
2
+ __version__ = "0.1.4"
@@ -9,8 +9,12 @@ Connection comes from --url/--token or CERTMATE_URL/CERTMATE_TOKEN.
9
9
  """
10
10
  from __future__ import annotations
11
11
 
12
+ import json
13
+ import os
12
14
  import re
15
+ import stat
13
16
  import sys
17
+ from pathlib import Path
14
18
  from typing import List, Optional
15
19
 
16
20
  import typer
@@ -35,6 +39,24 @@ app.add_typer(deploy_app, name="deploy")
35
39
  out = Console()
36
40
  err = Console(stderr=True)
37
41
 
42
+
43
+ def _version_callback(value: bool) -> None:
44
+ """`--version` prints the version and exits 0, before anything else runs.
45
+
46
+ It has to be eager: the flag must work with no server configured, no
47
+ token, and no network — reporting which build you have is the first thing
48
+ anyone does when something is wrong.
49
+ """
50
+ if value:
51
+ from certmate_cli import __version__
52
+
53
+ # Plain print, not `out.print`: a version string gets piped and
54
+ # compared, so it must not pick up Rich's wrapping or markup.
55
+ print(__version__)
56
+ raise typer.Exit(0)
57
+
58
+
59
+
38
60
  # A lax hostname/wildcard check for the client-side --dry-run preflight; the
39
61
  # server is the real authority, this just catches obvious typos before we
40
62
  # spend an API call.
@@ -67,6 +89,13 @@ def _main(
67
89
  help="API bearer token. Prefer the CERTMATE_TOKEN "
68
90
  "environment variable: --token is visible to other "
69
91
  "local processes (ps) and shell history."),
92
+ # Lives on THIS callback rather than a second one: Typer keeps only one
93
+ # root callback, so a separate `@app.callback()` silently replaces this
94
+ # whole function — including the --token warning above.
95
+ version: Optional[bool] = typer.Option(None, "--version", "-V",
96
+ callback=_version_callback,
97
+ is_eager=True,
98
+ help="Show the certmate-cli version and exit."),
70
99
  ):
71
100
  # Kept for compatibility, but discourage --token interactively: argv is
72
101
  # world-readable via ps and lands in shell history. Warn only on a TTY so
@@ -264,6 +293,91 @@ def cert_rm(ctx: typer.Context, domain: str,
264
293
  out.print(f"[green]deleted[/] {domain}.")
265
294
 
266
295
 
296
+ _FILE_DEFAULT_NAME = {
297
+ "cert": "cert.pem",
298
+ "chain": "chain.pem",
299
+ "fullchain": "fullchain.pem",
300
+ "privkey": "privkey.pem",
301
+ "combined": "combined.pem",
302
+ "pfx": "cert.pfx",
303
+ }
304
+ # Anything that can carry key material is written owner-only. The public
305
+ # files get the same treatment: a deploy script that later relaxes them is
306
+ # an explicit act, whereas a private key written 0644 is a silent one.
307
+ _DOWNLOAD_MODE = 0o600
308
+
309
+
310
+ @cert_app.command("download")
311
+ def cert_download(
312
+ ctx: typer.Context,
313
+ domain: str,
314
+ file: str = typer.Option(
315
+ "fullchain", "--file", "-f",
316
+ help="Which file: cert, chain, fullchain, privkey, combined, pfx. "
317
+ "Use --bundle for a whole-certificate archive instead."),
318
+ output: Optional[str] = typer.Option(
319
+ None, "--output", "-o",
320
+ help="Where to write it. Defaults to the file's usual name in the "
321
+ "current directory; '-' writes to stdout."),
322
+ key_format: Optional[str] = typer.Option(
323
+ None, "--key-format",
324
+ help="pkcs1 or pkcs8, for --file privkey. certbot writes pkcs8; "
325
+ "pkcs1 is the legacy 'BEGIN RSA PRIVATE KEY' form."),
326
+ bundle: Optional[str] = typer.Option(
327
+ None, "--bundle",
328
+ help="Download a bundle instead of a single file: zip or json."),
329
+ ):
330
+ """Download a certificate file, so a host can pull what it needs.
331
+
332
+ Pulling beats pushing when the certificate manager would otherwise need
333
+ credentials on every target host: give each host an API key scoped to its
334
+ own domain and let it fetch on a timer.
335
+
336
+ certmate cert download example.com --file fullchain -o /etc/ssl/certs/x.pem
337
+ certmate cert download example.com --file privkey -o /etc/ssl/private/x.key
338
+ """
339
+ # Every one of these is checked before a request goes out: being told
340
+ # "invalid key_format" by the server after a round trip is a worse error
341
+ # than being told here, and it costs an authenticated call to learn it.
342
+ if bundle is not None and bundle not in ("zip", "json"):
343
+ _die("--bundle must be zip or json")
344
+ if bundle and key_format:
345
+ _die("--key-format applies to --file privkey, not to a bundle")
346
+ if key_format is not None:
347
+ if key_format not in ("pkcs1", "pkcs8"):
348
+ _die(f"unknown --key-format {key_format!r}; use pkcs1 or pkcs8")
349
+ if not bundle and file != "privkey":
350
+ _die(f"--key-format applies to --file privkey, not to {file!r}")
351
+
352
+ if bundle:
353
+ data = _run(lambda: _client(ctx).download_certificate(domain, fmt=bundle))
354
+ default_name = f"{domain}.zip" if bundle == "zip" else f"{domain}.json"
355
+ payload = json.dumps(data, indent=2).encode() if bundle == "json" else data
356
+ else:
357
+ if file not in _FILE_DEFAULT_NAME:
358
+ _die(f"unknown --file {file!r}; use one of "
359
+ f"{', '.join(sorted(_FILE_DEFAULT_NAME))}")
360
+ payload = _run(lambda: _client(ctx).download_certificate_file(
361
+ domain, file, key_format=key_format))
362
+ default_name = _FILE_DEFAULT_NAME[file]
363
+
364
+ if output == "-":
365
+ # Binary-safe: .pfx and .zip are not text.
366
+ sys.stdout.buffer.write(payload)
367
+ return
368
+
369
+ target = Path(output or default_name)
370
+ # Create with the restrictive mode rather than chmod-ing after: between
371
+ # an open() and a chmod() the key is readable by anyone on the box.
372
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, _DOWNLOAD_MODE)
373
+ with os.fdopen(fd, "wb") as fh:
374
+ fh.write(payload)
375
+ # An existing file keeps its old mode through O_CREAT, so state what was
376
+ # actually written rather than claiming 0600 unconditionally.
377
+ mode = stat.S_IMODE(os.stat(target).st_mode)
378
+ out.print(f"[green]wrote[/] {target} ({len(payload)} bytes, mode {mode:04o})")
379
+
380
+
267
381
  @cert_app.command("reissue")
268
382
  def cert_reissue(ctx: typer.Context, domain: str,
269
383
  san: Optional[str] = typer.Option(None, "--san", help="Comma-separated SAN domains.")):
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "certmate-cli"
7
- version = "0.1.2"
7
+ version = "0.1.4"
8
8
  description = "CertMate command-line interface — the SSL certificate lifecycle from your terminal"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -33,7 +33,7 @@ classifiers = [
33
33
  # The SDK floor tracks the CLI release: 0.1.2 needs TransportError and the
34
34
  # renew/audit semantics introduced in certmate-sdk 0.1.2.
35
35
  dependencies = [
36
- "certmate-sdk>=0.1.2",
36
+ "certmate-sdk>=0.1.3",
37
37
  "typer>=0.9",
38
38
  "rich>=13",
39
39
  ]
@@ -1,21 +0,0 @@
1
- # certmate-cli
2
-
3
- The [CertMate](https://github.com/fabriziosalmi/certmate) SSL certificate
4
- lifecycle from your terminal — built on `certmate-sdk`.
5
-
6
- ```bash
7
- pip install certmate-cli
8
- export CERTMATE_URL=http://localhost:8000
9
- export CERTMATE_TOKEN=...
10
-
11
- certmate cert create app.example.com --dns cloudflare --wait
12
- certmate cert ls
13
- certmate cert info app.example.com
14
- certmate cert renew app.example.com --force
15
- certmate cert create app.example.com --dns cloudflare --dry-run
16
- certmate audit verify
17
- ```
18
-
19
- Connection comes from `--url`/`--token` or `CERTMATE_URL`/`CERTMATE_TOKEN`.
20
- Prefer the `CERTMATE_TOKEN` environment variable over `--token`: command-line
21
- arguments are visible to other local processes (`ps`) and shell history.