257 lines
10 KiB
Markdown
257 lines
10 KiB
Markdown
# SSL Certificate Renewal & Expiry Monitoring
|
|
|
|
This document covers Let's Encrypt certificate renewal on the production server,
|
|
the root cause of a past silent-expiry incident, the permanent fix, and the
|
|
in-app expiry monitor that guards against it happening again.
|
|
|
|
## Background: the 2026-06-27 silent expiry incident
|
|
|
|
The production `timebank.cc` certificate (covering `timebank.cc` and
|
|
`www.timebank.cc`) expired on 2026-06-26 without renewing. The other certs
|
|
(`dev.timebank.cc`, `ws.timebank.cc`) renewed fine.
|
|
|
|
**Root cause:** the `timebank.cc` certificate's renewal config used the
|
|
`standalone` authenticator:
|
|
|
|
```
|
|
# /etc/letsencrypt/renewal/timebank.cc.conf
|
|
authenticator = standalone
|
|
```
|
|
|
|
The `standalone` authenticator starts its own temporary web server on port 80
|
|
to answer the ACME HTTP-01 challenge. But Apache permanently owns port 80, so
|
|
every unattended renewal failed with:
|
|
|
|
```
|
|
Could not bind TCP port 80 because it is already in use by another process
|
|
```
|
|
|
|
Because the renewal ran via cron/systemd with output discarded (and the box's
|
|
local Postfix does not relay mail externally — see "Mail delivery" below), the
|
|
failure was completely silent until the certificate actually expired.
|
|
|
|
## The permanent fix: switch to the `apache` authenticator
|
|
|
|
The `apache` authenticator answers the ACME challenge *through the already-running
|
|
Apache* (it temporarily drops the challenge file into the webroot via the Apache
|
|
module). No port conflict, no stopping Apache, zero downtime.
|
|
|
|
```bash
|
|
# Reissue the cert using the apache authenticator (one-time switch).
|
|
# --force-renewal is required if the cert was recently renewed, otherwise
|
|
# certbot reports "not yet due" and does NOT rewrite the authenticator.
|
|
sudo certbot certonly --apache \
|
|
--cert-name timebank.cc \
|
|
-d timebank.cc -d www.timebank.cc \
|
|
--non-interactive --force-renewal
|
|
```
|
|
|
|
Verify:
|
|
|
|
```bash
|
|
# Renewal config should now read: authenticator = apache
|
|
grep authenticator /etc/letsencrypt/renewal/timebank.cc.conf
|
|
|
|
# Dry-run the unattended path — must succeed with NO port-80 error.
|
|
sudo certbot renew --cert-name timebank.cc --dry-run
|
|
```
|
|
|
|
The `python3-certbot-apache` plugin must be installed (`certbot plugins` lists
|
|
`* apache`). It is installed on production.
|
|
|
|
### If a cert ever silently expires again
|
|
|
|
1. Run `sudo certbot certificates` to see expiry dates and which cert is affected.
|
|
2. Check `authenticator` in `/etc/letsencrypt/renewal/<name>.conf`.
|
|
3. If it is `standalone` on this Apache box, switch it to `apache` as above.
|
|
|
|
### Emergency manual renewal (if needed before the fix)
|
|
|
|
```bash
|
|
# Brief (~10s) Apache stop so standalone can bind port 80.
|
|
sudo certbot renew --cert-name timebank.cc \
|
|
--pre-hook "systemctl stop apache2" \
|
|
--post-hook "systemctl start apache2"
|
|
```
|
|
|
|
## Renewal timing
|
|
|
|
- `certbot` renews at **~30 days before expiry** (`renew_before_expiry = 30 days`).
|
|
- The systemd `certbot.timer` runs **twice daily**.
|
|
- There is also a separate cron in root's crontab as a second renewal attempt:
|
|
`0 3 * * * /usr/bin/certbot renew --quiet && systemctl restart elasticsearch`
|
|
(redundant with the systemd timer; kept, harmless now that the authenticator
|
|
is fixed).
|
|
|
|
So a healthy cert is renewed ~30 days out and never reaches the monitor's warning
|
|
window. The monitor only fires if renewal has already been failing for ~10+ days.
|
|
|
|
## Expiry monitor (in-app)
|
|
|
|
A Laravel artisan command provides an early-warning safety net, independent of
|
|
certbot, so a renewal failure is caught well before the cert lapses.
|
|
|
|
- **Command:** `php artisan cert:check`
|
|
(`app/Console/Commands/CheckCertExpiry.php`)
|
|
- **Config:** `cert_monitor.*` in `config/timebank_cc.php`, overridable via `.env`
|
|
(`CERT_MONITOR_*` keys, documented in `.env.example`).
|
|
- **Schedule:** daily at 08:00 via the Laravel scheduler
|
|
(`app/Console/Kernel.php`), running as `www-data`.
|
|
|
|
### Behaviour
|
|
|
|
- Reads every cert under `cert_monitor.live_path` (`/etc/letsencrypt/live`).
|
|
- Two tiers:
|
|
- **NOTICE** when a cert has fewer than `notice_days` (20) left.
|
|
- **URGENT** when fewer than `urgent_days` (10) left.
|
|
- Sends a single summary email to `cert_monitor.alert_email`
|
|
(`admin@timebank.cc`), **synchronously via `Mail::raw`** (never queued, so the
|
|
alert does not depend on a running queue worker).
|
|
- Mirrors every warning to the Laravel log as a fallback.
|
|
- Returns a non-zero exit code on URGENT.
|
|
- Stays silent (and exits 0) when all certs are outside the warning window.
|
|
|
|
### Testing overrides
|
|
|
|
```bash
|
|
# Always run from the app directory, as the web user:
|
|
cd /var/www/timebank_cc
|
|
|
|
# Dry run (reads certs, prints findings, sends no email):
|
|
sudo -u www-data php artisan cert:check --dry-run
|
|
|
|
# Force a NOTICE/URGENT to test email delivery (bypasses config thresholds):
|
|
sudo -u www-data php artisan cert:check --notice-days=999
|
|
sudo -u www-data php artisan cert:check --urgent-days=999
|
|
```
|
|
|
|
### Config keys
|
|
|
|
| Key (`cert_monitor.*`) | `.env` override | Default | Purpose |
|
|
|---|---|---|---|
|
|
| `enabled` | `CERT_MONITOR_ENABLED` | `true` | Enable/disable the monitor + schedule |
|
|
| `alert_email` | `CERT_MONITOR_EMAIL` | `admin@timebank.cc` | Recipient |
|
|
| `notice_days` | `CERT_MONITOR_NOTICE_DAYS` | `20` | First (NOTICE) threshold |
|
|
| `urgent_days` | `CERT_MONITOR_URGENT_DAYS` | `10` | Escalated (URGENT) threshold |
|
|
| `live_path` | `CERT_MONITOR_LIVE_PATH` | `/etc/letsencrypt/live` | Where to scan for certs |
|
|
| `run_at` | `CERT_MONITOR_RUN_AT` | `08:00` | Daily run time |
|
|
| `healthcheck_url` | `CERT_MONITOR_HEALTHCHECK_URL` | (unset) | healthchecks.io ping URL |
|
|
|
|
> **Production note:** the active `config/timebank_cc.php` on production has the
|
|
> non-`healthcheck_url` keys as **hardcoded literals** (added via an early
|
|
> `config:merge`), so those are NOT `.env`-overridable on prod. The values are
|
|
> correct, so this is cosmetic. `config:merge` only adds missing keys and will
|
|
> not rewrite existing ones — change these by editing the file directly if ever
|
|
> needed. Only `healthcheck_url` is wired to `env()`.
|
|
|
|
## Dead-man's-switch (healthchecks.io)
|
|
|
|
The email alert depends on the scheduler actually running. If the scheduler,
|
|
cron, or the whole server dies, no email is sent and silence looks like "all
|
|
healthy" — the exact silent-failure mode of the original incident.
|
|
|
|
A healthchecks.io dead-man's-switch inverts this: the scheduler pings a URL on
|
|
each successful run; if the ping stops arriving, healthchecks.io (external to the
|
|
server) notifies you.
|
|
|
|
- Wired in `app/Console/Kernel.php` via `->thenPing($url)`, attached only when
|
|
`cert_monitor.healthcheck_url` is set.
|
|
- The URL is stored in production `.env` as `CERT_MONITOR_HEALTHCHECK_URL`.
|
|
|
|
### Setup
|
|
|
|
1. Create a free check at https://healthchecks.io.
|
|
2. Set **Period = 1 day**, **Grace = 1 day** (matches the daily 08:00 run, tolerant
|
|
of one late run, alerts within ~2 days if the scheduler truly dies).
|
|
3. Add a notification method (e.g. email) under the check's integrations —
|
|
otherwise it tracks status but never notifies.
|
|
4. Put the ping URL in production `.env`:
|
|
```
|
|
CERT_MONITOR_HEALTHCHECK_URL=https://hc-ping.com/<uuid>
|
|
```
|
|
5. Clear config and verify the app reads it:
|
|
```bash
|
|
cd /var/www/timebank_cc
|
|
sudo -u www-data php artisan config:clear
|
|
sudo -u www-data php artisan tinker --execute "var_export(timebank_config('cert_monitor.healthcheck_url'));"
|
|
```
|
|
6. Test the ping manually:
|
|
```bash
|
|
curl -fsS "https://hc-ping.com/<uuid>" && echo " pinged"
|
|
```
|
|
|
|
## Certificate file access for `www-data`
|
|
|
|
The app runs as `www-data`, but `/etc/letsencrypt/live` and
|
|
`/etc/letsencrypt/archive` are `drwx------ root root`, so `www-data` cannot
|
|
traverse them to read certs. The individual cert files (`cert.pem`,
|
|
`fullchain.pem`) are already world-readable; only directory traversal is blocked.
|
|
|
|
**Fix** — grant the `ssl-cert` group read/traverse on those two directories only
|
|
(private keys remain `0600` root-only and are never exposed):
|
|
|
|
```bash
|
|
sudo usermod -aG ssl-cert www-data
|
|
sudo chgrp ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive
|
|
sudo chmod g+rx /etc/letsencrypt/live /etc/letsencrypt/archive
|
|
```
|
|
|
|
**Survive renewals** — certbot may recreate these dirs on renewal, so a deploy
|
|
hook re-applies the permissions after each renewal:
|
|
|
|
```bash
|
|
# /etc/letsencrypt/renewal-hooks/deploy/50-group-read.sh (chmod +x)
|
|
#!/bin/bash
|
|
chgrp ssl-cert /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null
|
|
chmod g+rx /etc/letsencrypt/live /etc/letsencrypt/archive 2>/dev/null
|
|
```
|
|
|
|
Verify www-data can read certs but not keys:
|
|
|
|
```bash
|
|
sudo -u www-data php artisan cert:check --dry-run # lists certs
|
|
sudo -u www-data cat /etc/letsencrypt/live/timebank.cc/privkey.pem # should be denied
|
|
```
|
|
|
|
## Scheduler & user notes
|
|
|
|
- The Laravel scheduler must run as **`www-data`** (the web user), not root.
|
|
Production's `www-data` crontab:
|
|
```
|
|
* * * * * cd /var/www/timebank_cc && php artisan schedule:run >> /dev/null 2>&1
|
|
```
|
|
Running it as root leaves root-owned files in `storage/` that later break the
|
|
web user. (This was the case from ~2026-06-15 to 2026-06-27 and has been fixed;
|
|
root-owned `storage/logs/*.log` were chowned back to www-data.)
|
|
- Always run manual `php artisan` commands from `/var/www/timebank_cc` (not `~`),
|
|
or `artisan` won't be found.
|
|
|
|
## Mail delivery caveat
|
|
|
|
The production box's local Postfix delivers mail **locally only** — it does not
|
|
relay outbound. `mail`/sendmail-based alerts therefore fail silently (this is why
|
|
the original cron failures were never seen; they piled up in `/var/mail/root`).
|
|
|
|
The expiry monitor deliberately sends through the **Laravel app's configured SMTP**
|
|
(Greenhost: `smtp.greenhost.nl:465`), which is a known-good external delivery
|
|
path with correct SPF/DKIM, independent of local Postfix.
|
|
|
|
## Quick reference
|
|
|
|
```bash
|
|
# Status of all certs
|
|
sudo certbot certificates
|
|
|
|
# Verify unattended renewal works (no port-80 error)
|
|
sudo certbot renew --cert-name timebank.cc --dry-run
|
|
|
|
# Run the monitor manually
|
|
cd /var/www/timebank_cc && sudo -u www-data php artisan cert:check --dry-run
|
|
|
|
# Force an alert email (test)
|
|
cd /var/www/timebank_cc && sudo -u www-data php artisan cert:check --notice-days=999
|
|
|
|
# Confirm the monitor is scheduled
|
|
cd /var/www/timebank_cc && sudo -u www-data php artisan schedule:list | grep -i cert
|
|
```
|