6 Commits
Author SHA1 Message Date
Juan Denis 76c57c19d6 fix(security): harden nginx write path and certbot issuance trust bugs
- run wildcard/SAN certbot privileged and fail on non-zero exit instead
  of reporting unissued cert paths as success
- cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600,
  always deleted (was predictable /tmp path, never cleaned up)
- route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg)
- validate domains before they reach certbot argv or cert file paths
- validate reverse-proxy builder input (domains, upstreams, headers,
  locations, rate-limit/cache values) against nginx config injection
- write_vhost: refuse non-plain-filename site names and roll back the
  vhost when the reload itself fails, not only when nginx -t fails
- create_proxy API returns 400 on service errors instead of always 201
2026-08-27 23:54:01 -04:00
Juan Denis c242d4cab5 fix(ssl): a failed certificate check no longer reports fine
Plan 75 §A5. The frontend could always render unknown (a real 'unknown'
state, an n/a chip) — the backend starved it of the signal:

- Doctor's cert check read Domain.ssl_expires_at, a column nothing in
  production writes — structurally incapable of anything but a green
  'No certificates tracked.' Empty now warns: expiry is not monitored.
- openssl -checkend exits non-zero for 'expires soon' AND for
  'unreadable' — an unreadable cert reported needs_renewal=True, and
  the error path vanished the cert from the expiring list entirely.
  Unreadable now returns an error result, and the status endpoint
  surfaces check_errors instead of dropping them.
- A failed monitor cert probe bumped cert_checked_at while keeping the
  old cert_expires_at — a green 'valid / N days' chip from weeks-old
  data next to a fresh checked timestamp. The stale value is cleared;
  the UI falls back to n/a until a probe succeeds.
- get_expiry_alerts dropped unprobeable certs by omission; they now
  appear with severity 'unknown'. get_cert_health reported grade 'F'
  for a DNS timeout; probe failure now yields grade None + error.
- A failed certbot inventory reported '0 certificates, nothing
  expiring' with HTTP 200; list_certificates_report carries the
  errors and the status endpoint exposes them as list_errors.
2026-08-17 22:48:27 -04:00
Juan DenisandClaude Opus 5 8c81617179 refactor(utils): rename run_command -> run_unprivileged
plan 75 §B1. Two helpers did overlapping jobs with different semantics, and the
one that says nothing about privilege sat one autocomplete from the one that
does. nginx_advanced_service reached for it, so `nginx -t` ran unprivileged and
by bare name — it could neither read root-owned config nor find /usr/sbin/nginx,
and reported every config as invalid.

Renamed rather than folded. Folding into run_privileged(privileged=False) would
change sudo semantics at all six call sites — `sudo -n dig` fails where plain
dig worked, if sudoers does not permit it — and the failure this fixes was
choosing the wrong helper, not the helper's behaviour. The rename removes the
ambiguity with zero behavioural risk; the return type (dict, not
CompletedProcess) stays as callers expect.

`run_unprivileged(['nginx', '-t'])` reads as obviously wrong. `run_command` did
not. The old name was ambiguous in a second way: PythonService has an unrelated
method of the same name, which keeps it.

Six call sites, three test modules. Two patch targets were written as strings
('app.utils.system.run_command', setattr(mod, 'run_command')) so a word-boundary
rename skipped them and they would have failed at runtime, not import.

No alias: an alias leaves the second door open, which is the thing being closed.
run_command was never exported through plugins_sdk, so there is no public SDK
surface to break; a third-party extension importing it gets a loud ImportError
rather than silent misbehaviour. A guard test asserts the name stays gone.

417 tests green across monitor, ssl, dns, nginx, firewall and the guards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 20:07:39 -04:00
Juan DenisandClaude Opus 5 81928ec7ff fix(ssl): resolve certbot outside $PATH and preflight the nginx plugin
Plan 73 item 1. SSLService hardcoded /usr/bin/certbot, so the install method
certbot itself recommends -- snap, which lands in /snap/bin -- produced a
FileNotFoundError the panel surfaced as an opaque failure. is_command_available
had the same blind spot: it probed /usr/bin, /usr/sbin and the two local dirs
but never /snap/bin, which is routinely absent from a service's $PATH.

resolve_certbot_bin() now tries the CERTBOT_BIN override, then shutil.which,
then the explicit candidate list including the snap path. Positive results are
cached (an install location does not move mid-process) but negative ones are
not, so installing certbot is picked up without a panel restart;
install_certbot clears the cache outright. CERTBOT_BIN as a class attribute is
gone -- it captured the value at import time, which is exactly why an override
set later never took effect -- replaced by certbot_bin() plus CERTBOT_FALLBACK
so a command built on a host with no certbot still fails cleanly.

The second half is the failure the reviewer actually hit on stream: with
certbot present but its nginx plugin missing, `certbot --nginx` dies deep
inside certbot with "the nginx plugin is not working / binary doesn't exist",
and we passed that through raw. obtain_certificate now preflights -- nginx on
the box, then `certbot plugins` for an enabled `* nginx` -- and returns an
actionable error naming python3-certbot-nginx before certbot is ever invoked
for issuance. A failing plugins probe falls back to the distro package check
rather than blocking issuance on a host where the plugin is clearly present.

AdvancedSSLService's wildcard and multi-domain commands go through the same
resolution instead of the bare name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 15:59:47 -04:00
Juan DenisandClaude Opus 4.8 0a74e5670d feat(sites): wildcard HTTPS for managed subdomains
One-time operator setup that makes every managed subdomain HTTPS. New
SitesHttpsService.setup creates the *.<base> + <base> A records via a connected
DNS provider and issues a wildcard Let's Encrypt certificate over DNS-01,
reusing that provider's credentials — then flips sites_https_enabled on.

- AdvancedSSLService.issue_wildcard_cert now supports route53 (AWS env creds) as
  well as cloudflare, takes an email, installs the matching certbot DNS plugin
  best-effort, and returns the cert/key paths.
- SiteDomainService gains https_enabled / wildcard_cert_paths / covers. create_site
  installs new subdomain sites at https when enabled, and _write_app_vhost serves
  the wildcard cert on vhosts whose every domain it covers (custom domains keep
  their own cert).
- NginxService.create_site takes optional ssl_cert/ssl_key and bakes a 443 block +
  HTTP->HTTPS redirect (idempotent via full regen).
- sites_base_domain / server_public_ip / sites_https_enabled are now discoverable
  settings. Admin endpoints GET/POST /admin/sites-https/{status,setup}, surfaced
  in Settings → Site as a "Managed Sites Domain & HTTPS" panel.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:52:30 -04:00
Juan DenisandClaude Opus 4.6 d037e16d24 Add advanced SSL: wildcard, SAN, custom certs, health checks
Phase 31: DNS-01 wildcard certificates, multi-domain SAN certs, custom
cert upload, SSL health grading (protocols/ciphers), expiry alerts, and
configuration profiles (modern/intermediate/legacy).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 02:15:16 -04:00