Skip to content

Commit 44d1d15

Browse files
committed
release: v0.8.0 — shimkit tls (certbot)
Container-first TLS cert lifecycle helper. Second of the deferred v0.5+ candidates from the ubuntu-migration validation report (after `shimkit cron` in v0.6.0 and `shimkit framework laravel` in v0.7.0). Surface: shimkit tls request -d D [-d D2 ...] --email E --webroot PATH [--staging] (MODERATE prompt) shimkit tls list [--json] shimkit tls status DOMAIN [--json] shimkit tls renew [-d DOMAIN] [--force-renewal] (MODERATE) shimkit tls revoke -d DOMAIN --confirm REVOKE-TLS (SEVERE) shimkit tls cron-install [--schedule S] (MODERATE) Every certbot invocation is a one-shot via the upstream `certbot/certbot:v3.0.1` image. The `/etc/letsencrypt` directory is bind-mounted from `~/.shimkit/data/tls/etc-letsencrypt/` so account keys + cert state survive container exits — daily renewal cron (default 03:17) reuses the same on-disk state. `request` only wires the webroot ACME challenge today. DNS-01 (required for wildcards) is opt-in extras land in a future release — each provider needs its own credential surface. `status` and `list` parse the cert's `notAfter` by shelling out to host `openssl x509 -enddate`; flags certs within 30 days of expiry as `expiring_soon`. Supporting changes: - `core/docker.DockerEnv.run_oneshot(image, command, ...)` — detached-then-waited container run that captures exit code + stdout + stderr and auto-removes on exit. Used by `shimkit tls` for every certbot invocation; available to any future tool that needs one-shot container semantics. Image-pull is implicit (docker-py pulls if absent). - `core/version.py` registers an `openssl` detector — the regex handles both OpenSSL and LibreSSL strings (macOS ships LibreSSL by default; brew can install OpenSSL alongside). - `tools.versions.openssl` floor at `1.1`. - `tools.tls` config block: `data_dir`, `certbot_image`, `default_method`, `default_email`, `renewal_schedule`, `revoke_severe_token`. Tests: 48 new (561 → 609 total). Pure argv builders (request with minimal/multiple/staging/dry-run/empty-domains/unsupported- method; renew default/with-cert-name/force-and-dry-run; revoke happy path + empty-name refusal; container_volumes shape with + without webroot; cert_paths convention), domain validation (parametrised valid + invalid sets), TlsManager boot creates data dirs, request (rejects missing email / missing webroot / invalid domain / passes argv + volumes to run_oneshot / propagates non-zero exit / JSON includes domains+status), renew (default / with-domain / --force-renewal), revoke (requires severe token / requires existing cert / happy path with seeded live dir), list/status (empty / with-fixture + stubbed openssl / expiring-soon flag / missing cert refusal / JSON shape), cron-install (delegates to CronManager with correct name+schedule+command / custom schedule override), command surface (--help lists all six subcommands; openssl detector registered with min 1.1). Gates: pytest 609 passed, ruff clean, mypy strict clean. No new optional extras (reuses [docker-clean]'s docker package).
1 parent f26eb96 commit 44d1d15

17 files changed

Lines changed: 1800 additions & 4 deletions

File tree

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,42 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
66

77
## [Unreleased]
88

9+
## [0.8.0] — 2026-05-15
10+
11+
### Added
12+
13+
- `shimkit tls` — TLS cert lifecycle helper via container-first
14+
certbot. Six commands: `request -d D [-d D2 ...] --email E
15+
--webroot PATH [--staging]` (MODERATE — issuance via webroot
16+
ACME challenge); `list [--json]` and `status DOMAIN [--json]`
17+
(read-only enumeration with expiry parsing); `renew [-d
18+
DOMAIN] [--force-renewal]` (MODERATE — defaults to all due);
19+
`revoke -d DOMAIN --confirm REVOKE-TLS` (SEVERE); `cron-install
20+
[--schedule S]` (MODERATE — installs daily `shimkit tls renew`
21+
via `shimkit cron`). Replaces the ubuntu/ source script's
22+
ad-hoc letsencrypt setup with a container-first lifecycle.
23+
Persists `/etc/letsencrypt/` state at
24+
`~/.shimkit/data/tls/etc-letsencrypt/` so account + cert
25+
history survive container exits. Default image
26+
`certbot/certbot:v3.0.1` (pinned, configurable). Cert expiry
27+
parsed by shelling out to host `openssl x509 -enddate`;
28+
`tools.versions.openssl` floor at `1.1`. Adds 48 tests (561 →
29+
609 total). One of the deferred v0.5+ candidates from the
30+
ubuntu migration's validation report. No new optional
31+
dependency extras (reuses `[docker-clean]`'s `docker` package).
32+
[`docs/tools/tls.md`](docs/tools/tls.md).
33+
34+
### Changed
35+
36+
- `core/docker.DockerEnv` gains `run_oneshot(image, command,
37+
...)` — detached-then-waited container run that captures exit
38+
code + stdout + stderr and auto-removes the container on exit.
39+
Used by `shimkit tls` for certbot invocations; available to
40+
any future tool that needs one-shot container semantics.
41+
- `core/version.py` registers an `openssl` detector (parses both
42+
`OpenSSL` and `LibreSSL` version strings — macOS ships
43+
LibreSSL by default).
44+
945
## [0.7.1] — 2026-05-15
1046

1147
### Fixed

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ shimkit
2020
db Container-first databases (5 engines).
2121
stack Multi-container app recipes (LEMP today).
2222
web Web-server tooling (nginx vhost generator).
23+
tls TLS cert lifecycle via container-first certbot.
2324
framework Framework-specific helpers (Laravel today).
2425
config Inspect and edit shimkit configuration.
2526
doctor Print system diagnostics useful for bug reports.
@@ -92,6 +93,11 @@ behaviour: [`docs/installation.md`](docs/installation.md).
9293
LEMP recipe (db + php-fpm + nginx). Bind-mounts `$cwd` at
9394
`/srv/app`. `up` / `down` / `status` / `logs` / `exec`. Multiple
9495
projects side-by-side via `--project`.
96+
- **[`shimkit tls`](docs/tools/tls.md)** — TLS cert lifecycle
97+
helper via container-first certbot. `request` / `list` /
98+
`status` / `renew` / `revoke` (SEVERE) / `cron-install`. State
99+
persists under `~/.shimkit/data/tls/`; pair with `shimkit cron`
100+
for daily renewals.
95101
- `shimkit shell colors` — 256-color ANSI palette diagnostic.
96102

97103
### Framework recipes
@@ -170,6 +176,7 @@ The repo root has the short version. The long version lives under
170176
| `shimkit db` deep-dive | [`docs/tools/db.md`](docs/tools/db.md) |
171177
| `shimkit stack` deep-dive | [`docs/tools/stack.md`](docs/tools/stack.md) |
172178
| `shimkit web nginx` deep-dive | [`docs/tools/web.md`](docs/tools/web.md) |
179+
| `shimkit tls` deep-dive | [`docs/tools/tls.md`](docs/tools/tls.md) |
173180
| `shimkit framework laravel` deep-dive | [`docs/tools/framework-laravel.md`](docs/tools/framework-laravel.md) |
174181

175182
Project files:

docs/README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ shimkit is a collection. Each tool gets its own page:
2424
fixer (Linux). API-first, ruamel.yaml fallback.
2525
- **[`shimkit docker-clean`](tools/docker-clean.md)** — Docker resource
2626
cleanup (Linux + macOS + WSL). docker-py SDK + buildx-aware prune.
27+
- **[`shimkit tls`](tools/tls.md)** — TLS cert lifecycle via
28+
container-first certbot. request / list / status / renew /
29+
revoke (SEVERE) / cron-install.
2730
- **[`shimkit framework laravel`](tools/framework-laravel.md)**
2831
Laravel helpers: perms, `.env` scaffold, scheduler cron-install,
2932
artisan passthrough (host or LEMP container).

docs/tools/tls.md

Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
# shimkit tls
2+
3+
TLS certificate lifecycle helper. Container-first: every certbot
4+
invocation is a one-shot through the upstream `certbot/certbot`
5+
image. The `/etc/letsencrypt` directory is bind-mounted at
6+
`~/.shimkit/data/tls/etc-letsencrypt/` so account + cert state
7+
survives container exits and host reboots.
8+
9+
## Commands
10+
11+
| Command | Purpose |
12+
|---------|---------|
13+
| `shimkit tls` | Menu. |
14+
| `shimkit tls request -d D [-d D2 ...] --email E --webroot PATH [--staging]` | MODERATE. Request a new cert via webroot ACME challenge. |
15+
| `shimkit tls list [--json]` | Enumerate local certs with expiry dates. |
16+
| `shimkit tls status DOMAIN [--json]` | Show one cert's paths + expiry. |
17+
| `shimkit tls renew [-d DOMAIN] [--force-renewal]` | MODERATE. Renew certs (all due, or one named). |
18+
| `shimkit tls revoke -d DOMAIN --confirm REVOKE-TLS` | SEVERE. Revoke a cert via the ACME CA. |
19+
| `shimkit tls cron-install [--schedule S]` | MODERATE. Install a daily `shimkit tls renew` cron entry. |
20+
21+
Universal flags before the subcommand (`--quiet`, `--verbose`,
22+
`--log-file`, `--no-color`, `--color`, `--no-input`); per-command
23+
flags after (`--json`, `--dry-run`, `--yes`, `--force`).
24+
25+
## How it works
26+
27+
`shimkit tls request` runs:
28+
29+
```
30+
docker run --rm \
31+
-v ~/.shimkit/data/tls/etc-letsencrypt:/etc/letsencrypt \
32+
-v ~/.shimkit/data/tls/var-lib-letsencrypt:/var/lib/letsencrypt \
33+
-v <webroot>:/webroot:ro \
34+
certbot/certbot:v3.0.1 \
35+
certonly --non-interactive --agree-tos \
36+
--email ops@example.com \
37+
--webroot -w /webroot \
38+
-d example.com [-d www.example.com] \
39+
[--staging]
40+
```
41+
42+
The webroot must already be served at
43+
`http://<domain>/.well-known/acme-challenge/` for the ACME challenge
44+
to succeed. The recommended layout for `shimkit web nginx vhost`-
45+
generated vhosts is the project root.
46+
47+
`certbot/certbot:v3.0.1` is the default; override via
48+
`tools.tls.certbot_image` in your user config. Pinning to a
49+
specific version rather than `:latest` keeps renewal behaviour
50+
deterministic.
51+
52+
## On-disk layout
53+
54+
```
55+
~/.shimkit/data/tls/
56+
├── etc-letsencrypt/ # mounts to /etc/letsencrypt
57+
│ ├── live/<domain>/ # symlinks (fullchain.pem, privkey.pem, ...)
58+
│ ├── archive/<domain>/ # numbered cert history
59+
│ ├── accounts/ # ACME account keys + metadata
60+
│ └── renewal/ # renewal config per cert
61+
└── var-lib-letsencrypt/ # mounts to /var/lib/letsencrypt
62+
```
63+
64+
Point nginx at `fullchain.pem` + `privkey.pem` from the `live/`
65+
directory — they're stable symlinks that get repointed each
66+
renewal, so nginx never needs to be told a new cert path.
67+
68+
## Examples
69+
70+
```bash
71+
# Request a cert in staging first (recommended — Let's Encrypt
72+
# rate-limits production aggressively, but staging is forgiving).
73+
shimkit tls request --yes --staging \
74+
--email ops@example.com \
75+
--webroot /var/www/example \
76+
-d example.com -d www.example.com
77+
78+
# Once staging works, request the real cert.
79+
shimkit tls request --yes \
80+
--email ops@example.com \
81+
--webroot /var/www/example \
82+
-d example.com -d www.example.com
83+
84+
# Enumerate local certs.
85+
shimkit tls list
86+
shimkit tls list --json
87+
88+
# Inspect a single cert.
89+
shimkit tls status example.com
90+
shimkit tls status example.com --json
91+
92+
# Renew everything that's within 30 days of expiry.
93+
shimkit tls renew --yes
94+
95+
# Force a renewal even if the cert isn't due (test, key rotation).
96+
shimkit tls renew --yes --force-renewal -d example.com
97+
98+
# Install the daily renewal cron entry (default: 03:17 every day).
99+
shimkit tls cron-install --yes
100+
101+
# Custom schedule.
102+
shimkit tls cron-install --yes --schedule "0 4 * * *"
103+
104+
# Revoke (SEVERE — confirm token required).
105+
shimkit tls revoke -d example.com --confirm REVOKE-TLS
106+
```
107+
108+
## Configuration
109+
110+
```json
111+
{
112+
"tools": {
113+
"tls": {
114+
"data_dir": "~/.shimkit/data/tls",
115+
"certbot_image": "certbot/certbot:v3.0.1",
116+
"default_method": "webroot",
117+
"default_email": null,
118+
"renewal_schedule": "17 3 * * *",
119+
"revoke_severe_token": "REVOKE-TLS"
120+
},
121+
"versions": {
122+
"openssl": {"min": "1.1"}
123+
}
124+
}
125+
}
126+
```
127+
128+
`default_email` set in the user config lets you drop `--email`
129+
from every `shimkit tls request` invocation. `tools.versions.openssl`
130+
is the floor used by `shimkit doctor` and the cert-expiry parsing
131+
in `tls list` / `tls status` (which shells out to `openssl x509
132+
-enddate`).
133+
134+
## Exit codes
135+
136+
| Code | Meaning |
137+
|-----:|---------|
138+
| 0 | success |
139+
| 1 | invalid input, missing webroot, certbot failed, missing cert, missing severe token |
140+
| 2 | Typer usage error |
141+
| 69 | `EX_UNAVAILABLE` — docker missing / daemon unreachable / out-of-range |
142+
| 130 | SIGINT |
143+
144+
## Platform support
145+
146+
| Platform | Status |
147+
|----------|--------|
148+
| macOS | full (Docker Desktop required). |
149+
| Linux | full. |
150+
| WSL | full (Docker Desktop or native Docker). |
151+
| Windows | out of charter — use WSL. |
152+
153+
## Notes
154+
155+
- **Staging first.** Let's Encrypt's production rate limits are
156+
punishing (5 failed validations / hour / hostname). Always
157+
pass `--staging` for first runs; the resulting cert isn't
158+
trusted but proves the webroot setup works.
159+
- **Webroot vs DNS-01.** Only webroot is wired today. DNS-01
160+
(required for wildcard certs) lands as opt-in extras in a
161+
future release — each provider needs its own credential
162+
surface (`cloudflare`, `route53`, etc.).
163+
- **No PyPI extra.** This tool reuses the `[docker-clean]`
164+
extra's `docker` package — no new install footprint.
165+
- **Renewal cadence.** Let's Encrypt certs are valid for 90 days;
166+
certbot's `renew` only renews within 30 days of expiry, so the
167+
daily cron is safe (and idempotent — no-op when nothing's due).

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "shimkit"
7-
version = "0.7.1"
7+
version = "0.8.0"
88
description = "A toolkit of developer utilities — Java version manager, shell upgrader, and more. Python tools, shimmed by bash."
99
readme = "README.md"
1010
license = { file = "LICENSE" }

src/shimkit/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,5 @@
33
Python tools, shimmed by bash.
44
"""
55

6-
__version__ = "0.7.1"
6+
__version__ = "0.8.0"
77
__all__ = ["__version__"]

src/shimkit/cli.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
from shimkit.tools.shell.commands import shell_app
3030
from shimkit.tools.ssh.commands import ssh_app
3131
from shimkit.tools.stack.commands import stack_app
32+
from shimkit.tools.tls.commands import tls_app
3233
from shimkit.tools.web.commands import web_app
3334

3435
app = typer.Typer(
@@ -55,6 +56,7 @@
5556
app.add_typer(stack_app)
5657
app.add_typer(web_app)
5758
app.add_typer(cron_app)
59+
app.add_typer(tls_app)
5860
app.add_typer(framework_app)
5961

6062

src/shimkit/config/defaults.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,14 @@
185185
"backup_dir": "~/.shimkit/data/cron",
186186
"max_managed_entries": 200
187187
},
188+
"tls": {
189+
"data_dir": "~/.shimkit/data/tls",
190+
"certbot_image": "certbot/certbot:v3.0.1",
191+
"default_method": "webroot",
192+
"default_email": null,
193+
"renewal_schedule": "17 3 * * *",
194+
"revoke_severe_token": "REVOKE-TLS"
195+
},
188196
"framework": {
189197
"laravel": {
190198
"web_group": "www-data",
@@ -200,7 +208,8 @@
200208
"git": {"min": "2.30"},
201209
"gpg": {"min": "2.2"},
202210
"python": {"min": "3.10", "preferred": "3.12"},
203-
"php": {"min": "8.1"}
211+
"php": {"min": "8.1"},
212+
"openssl":{"min": "1.1"}
204213
}
205214
},
206215
"package_managers": {

src/shimkit/config/schema.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ class VersionsConfig(_StrictModel):
324324
gpg: VersionConstraint = Field(default_factory=VersionConstraint)
325325
python: VersionConstraint = Field(default_factory=VersionConstraint)
326326
php: VersionConstraint = Field(default_factory=VersionConstraint)
327+
openssl: VersionConstraint = Field(default_factory=VersionConstraint)
327328

328329

329330
class FrameworkLaravelConfig(_StrictModel):
@@ -350,6 +351,29 @@ class FrameworkConfig(_StrictModel):
350351
laravel: FrameworkLaravelConfig = Field(default_factory=FrameworkLaravelConfig)
351352

352353

354+
class TlsConfig(_StrictModel):
355+
"""`shimkit tls` — TLS cert lifecycle via container-first certbot."""
356+
357+
# Volume root for /etc/letsencrypt content. Persists across renewals.
358+
data_dir: str = "~/.shimkit/data/tls"
359+
# Certbot image to run one-shot. Pin to a known-good version rather
360+
# than `:latest` so a registry-side image change can't break renewals
361+
# silently.
362+
certbot_image: str = "certbot/certbot:v3.0.1"
363+
# Default ACME challenge method. Only `webroot` is wired today;
364+
# `dns-cloudflare` etc. land as opt-in extras in a later release.
365+
default_method: Literal["webroot"] = "webroot"
366+
# ACME account email. Required by Let's Encrypt for issuance unless
367+
# `--register-unsafely-without-email` is passed (we don't expose
368+
# that flag). User config overrides per-install.
369+
default_email: str | None = None
370+
# Default cron schedule for `tls cron-install`. 03:17 daily — well
371+
# outside business hours and offset from on-the-hour cron herd.
372+
renewal_schedule: str = "17 3 * * *"
373+
# SEVERE-tier token for `tls revoke`.
374+
revoke_severe_token: str = "REVOKE-TLS"
375+
376+
353377
class CronConfig(_StrictModel):
354378
"""`shimkit cron` — generic user-crontab editor."""
355379

@@ -379,6 +403,7 @@ class ToolsConfig(_StrictModel):
379403
stack: StackConfig = Field(default_factory=StackConfig)
380404
web: WebConfig = Field(default_factory=WebConfig)
381405
cron: CronConfig = Field(default_factory=CronConfig)
406+
tls: TlsConfig = Field(default_factory=TlsConfig)
382407
framework: FrameworkConfig = Field(default_factory=FrameworkConfig)
383408
versions: VersionsConfig = Field(default_factory=VersionsConfig)
384409

0 commit comments

Comments
 (0)