Commit Graph
275 Commits
Author SHA1 Message Date
Juan DenisandClaude Opus 4.8 a09bf0fbbe feat(security): gate panel HSTS on installer SSL mode (keep HTTPS optional)
The Flask middleware emitted HSTS with includeSubDomains+preload whenever not in
debug mode -- even on HTTP-only or Cloudflare-Flexible installs, where preload is
a hard-to-reverse commitment. HTTPS is intentionally optional, so HSTS is now
gated on the operator's recorded SSL choice.

- config._resolve_ssl_mode(): SERVERKIT_SSL_MODE env -> /etc/serverkit/ssl-mode
  file -> safe 'insecure' default. Exposed as SSL_MODE / HSTS_ENABLED.
- security middleware sends HSTS only when HSTS_ENABLED and not debug. Behind a
  proxy Flask can't distinguish real TLS from a Flexible edge via
  X-Forwarded-Proto, so we trust the installer's choice, not the request scheme.
- install.sh records SERVERKIT_SSL_MODE in .env; existing installs are covered by
  the /etc/serverkit/ssl-mode file. The nginx edge still emits HSTS in its own
  secure server block, so secure installs are unaffected.

Proving tests in tests/test_security_headers.py. Full backend suite: 342 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:52:40 -04:00
Juan DenisandClaude Opus 4.8 49611a38f3 feat(security): Cloudflare-aware auto-CAA on certificate issuance
When a cert is issued for a domain managed by a connected DNS provider,
ServerKit now auto-creates a `CAA 0 issue "letsencrypt.org"` record at the
zone apex -- pinning issuance to our CA and clearing the "CAA not enabled"
scanner finding. Degrades to manual instructions when no provider manages the
domain; never fails issuance on a CAA hiccup.

- DNSProviderService.ensure_caa_record(): high-level helper mirroring
  ensure_a_record (find managing zone -> upsert apex CAA -> degrade to manual).
- parse_caa_value(): BIND 'flags tag "value"' -> Cloudflare/DO data object.
- Cloudflare set_record now sends the structured `data` object CAA requires
  (a flat `content` string is rejected), and matches on tag+value so it does
  not clobber a different CA's existing CAA record.
- DigitalOcean set_record sends flags/tag for CAA too.
- DNSZoneService._cloudflare_sync uses structured data for CAA, so the preset
  CAA record syncs correctly to Cloudflare-backed zones.
- ssl_service.obtain_certificate calls the hook and surfaces the result.

Proving tests in tests/test_dns_caa.py (7 cases, all green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 16:34:14 -04:00
Juan Denis 02afdec9f0 feat(security): optional SSL/TLS hardening with Cloudflare-aware configs and HTTP fallback
- Add secure nginx config with TLS 1.2+, HSTS preload, CSP, and security headers
- Add insecure HTTP fallback config for users who cannot or do not want HTTPS
- Make installer best-effort SSL: try Let's Encrypt, fall back to HTTP without failing
- Preserve secure/insecure SSL choice across updates via /etc/serverkit/ssl-mode
- Support SERVERKIT_SKIP_SSL=1 to disable HTTPS entirely
- Add CAA record to default DNS zone template
- Harden generated per-app nginx configs with HSTS preload, CSP, and security headers
- Align Flask security middleware headers with nginx edge config
2026-06-19 16:13:06 -04:00
Juan DenisandClaude Opus 4.8 ada030553a feat(security): per-app WAF (ModSecurity v3 + OWASP CRS)
- WafPolicy model (per app: mode off/detect/block, paranoia level, anomaly
  threshold, disabled CRS rule ids).
- WafService: render_rules (SecRuleEngine + CRS include + paranoia/anomaly +
  SecRuleRemoveById), nginx_snippet, install/status (distro-aware,
  best-effort), apply() that writes per-app rules under
  /etc/nginx/serverkit-conf.d/waf and idempotently injects an include into
  the app vhost (graceful manual_include fallback), and audit-log event
  parsing.
- API /api/v1/waf: per-app policy GET/PUT, apply, events, install status.
- 29 tests: rule rendering per mode, rule-disable, nginx snippet, policy
  validation, audit-log parsing, API round-trip + auth.

Backend only; WAF UI + verified install on a real nginx+ModSecurity host are
follow-ups. Integration is additive — existing nginx/site management untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 14:02:24 -04:00
Juan DenisandClaude Opus 4.8 007c4b984a feat(backups): client-side encryption (opt-in, reuses the Fernet key)
- backup_crypto.py: Fernet file encrypt/decrypt using the existing
  SERVERKIT_ENCRYPTION_KEY (no new deps) — encrypt_file (-> .enc),
  decrypt_file, is_encrypted_backup, encryption_available.
- BackupService: an opt-in `encrypt_backups` config flag; _finalize_backup
  encrypts each artifact before _auto_upload (so the stored/remote copy is
  encrypted) and records encrypted/path/size; restore_application and
  restore_database transparently decrypt a .enc backup to a temp file first.
- 9 tests: byte-exact roundtrip, wrong-key failure, availability reflects
  env, is_encrypted_backup, and an encrypted backup_files create+restore.

Notes: in-memory (streaming large archives is a follow-up); key loss =
unrecoverable backups (back the key up out of band). Additional remote
targets (WebDAV/Azure/Dropbox/SFTP) and restore drills remain follow-ups.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 13:22:43 -04:00
Juan DenisandClaude Opus 4.8 633c626d43 feat(docker): horizontal auto-scaling (CPU thresholds, min/max, cooldown)
- ContainerScalePolicy model (per-app: enabled, service_name, min/max
  replicas, cpu high/low %, cooldown, current_replicas).
- ContainerScaleService: evaluate() scales a compose service up/down by one
  replica when average CPU crosses the high/low thresholds, within bounds and
  outside the cooldown; scale_to() for manual; sweep() for cron. Uses
  `docker compose --scale`; local + scale-capable services only.
- apps.py: GET/PUT /<id>/scale-policy, POST /<id>/scale,
  POST /<id>/scale/evaluate, POST /scale-sweep (admin).
- 10 tests: bounds clamping, scale up/down, hold-at-max, cooldown, disabled,
  unknown-cpu, manual endpoint, auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:13:50 -04:00
Juan DenisandClaude Opus 4.8 d58fdd7da7 feat(system): NVIDIA GPU monitoring
- GpuService shells out to nvidia-smi for per-GPU stats (utilization, VRAM +
  memory %, temperature, power, fan, driver) and GPU compute processes with
  best-effort PID->container resolution via cgroup. Coerces [N/A] to null and
  degrades to "no GPUs" when nvidia-smi is absent.
- GET /api/v1/gpu/ returns {available, gpus, processes}.
- 6 tests: row parse, N/A coercion, availability, no-GPU info, endpoint, auth.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 10:08:58 -04:00
Juan DenisandClaude Opus 4.8 6554e79821 feat(docker): container auto-sleep (policy, manual sleep/wake, idle sweep)
- ContainerSleepPolicy model (per-app: enabled, idle_timeout_minutes,
  last_activity_at, asleep) + a sleep badge in Application.to_dict.
- ContainerSleepService: sleep/wake an app (compose_down/up or
  stop/start_container, local), record_activity, and sweep_idle() which
  stops enabled+awake apps idle past their timeout — and never sleeps an app
  with no activity baseline.
- apps.py endpoints: GET/PUT /<id>/sleep-policy, POST /<id>/sleep,
  POST /<id>/wake, POST /sweep-idle (admin, cron-drivable).
- 9 tests: policy, sleep/wake, docker-failure, remote guard, sweep
  selection, no-baseline skip, endpoints, auth.

Follow-ups: traffic-based idle detection (feed record_activity from the nginx
log) and request-triggered wake-on-demand; remote-server sleep.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:54:40 -04:00
Juan DenisandClaude Opus 4.8 2b471a39a2 feat(docker): one-click apply for image updates
- POST /api/v1/apps/<id>/image-update/apply pulls the newest image for a
  compose-managed app, then recreates it (compose_pull + compose_up),
  reusing the existing local/remote deploy path (DockerService /
  RemoteDockerService) exactly like start_app. Refreshes the image_update
  badge afterward.
- Guarded to docker-compose apps; a standalone container would need its full
  run spec to recreate, so those get a clear "update manually" message.
- 4 tests: pull-then-recreate ordering, pull-failure propagation, non-compose
  guard, auth gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:37:30 -04:00
Juan DenisandClaude Opus 4.8 7dc0bffb96 feat(docker): image-digest update detection
- ImageUpdateCheck model + ImageUpdateService: compare an app's local
  RepoDigest against the registry's current index digest (via
  `docker buildx imagetools inspect`) for the same tag, and flag
  update_available when they differ. Shells out to docker like the rest of
  the Docker layer; degrades gracefully when the image is local-only or the
  registry is unreachable.
- API: admin POST /api/v1/image-updates/applications/<id>/check and
  GET /api/v1/image-updates/applications/<id> for the latest result.
- Application.to_dict gains a lightweight image_update badge (mirrors the
  existing image_scan badge).
- 7 tests: digest diff/match/unknown, no-image, badge, endpoint, auth gate.

One-click apply (pull + recreate) to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:22:18 -04:00
Juan DenisandClaude Opus 4.8 5f28f3e700 feat(dns): dynamic DNS — token-authenticated A/AAAA updates
- DdnsHost model: a hostname bound to a DNS zone plus a secret token; the
  token is masked in API responses except when first minted.
- DdnsService.update_ip reuses DNSZoneService so a configured provider
  (e.g. Cloudflare) is synced automatically; chooses A vs AAAA by IP family;
  no-op when the IP is unchanged.
- API: admin CRUD for hosts + a public, token-authenticated /ddns/update
  endpoint that a router or cron calls when its IP changes (IP from ?ip= or
  the request source). Wrong token -> 401, bad IP -> 400.
- 8 tests covering the service flow and both endpoints.

UI panel to follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 03:12:07 -04:00
Juan Denis 2b7a98c348 feat: canonical domain + encryption key bootstrap
- Auto-generate SERVERKIT_ENCRYPTION_KEY in production when missing.
- Detect canonical domain from Host/X-Forwarded-Host; persist via SystemSettings.
- Expand CORS origins dynamically to include the canonical domain.
- Add SystemTab UI to view/save detected panel domain and HTTPS preference.
- Install script writes key, prompts for PANEL_DOMAIN, and sets nginx IP→domain redirect.
- Add backend tests for key bootstrap and domain detection helpers.
2026-06-19 01:34:02 -04:00
Juan Denis bf9fee131e feat: add secrets manager and inbound webhook gateway
- Encrypted vault + secret models with Fernet encryption
- Webhook endpoint + delivery log models with HMAC verification
- SecretVaultService / WebhookGatewayService business logic
- /api/v1/vaults, /secrets, /webhooks REST endpoints (JWT + public receive)
- Migration 020_secrets_webhooks
- Frontend Secrets & Webhooks page, API methods, sidebar, styles
- Add SERVERKIT_ENCRYPTION_KEY to .env.example
2026-06-19 01:33:46 -04:00
Juan Denis 47c92e0571 feat(auth): WebAuthn / Passkey support
- Add webauthn dependency.
- Add PasskeyCredential model and Alembic migration 019_passkey_credentials.
- Add PasskeyService for registration/authentication challenge flow.
- Add auth API endpoints:
  - POST /auth/passkeys/options/register
  - POST /auth/passkeys/register
  - GET /auth/passkeys
  - DELETE /auth/passkeys/<id>
  - POST /auth/passkeys/options/authenticate
  - POST /auth/passkeys/authenticate
- Expose passkey_enabled flag in User.to_dict().
2026-06-19 01:33:45 -04:00
Juan Denis 57cbdfd5de feat(security): per-image CVE scanning (grype) and SBOM generation (syft)
- Add ImageVulnerabilityScan and SbomArtifact models.
- Add ImageScannerService to download/install grype and syft into
  /var/lib/serverkit/scanners, run CVE scans, generate SPDX SBOMs,
  and enforce a configurable deploy gate.
- Add security API endpoints for scanner install, image scans,
  scan history, deploy-gate checks, and SBOM create/download/list.
- Include lightweight image_scan badge in Application.to_dict() for UI.
- Add Alembic migration 018_image_scan_sbom.
2026-06-19 01:33:45 -04:00
Juan Denis 31b613ade1 feat(security): encrypt system-setting secrets at rest
- Add SERVERKIT_ENCRYPTION_KEY generation in install.sh.
- Ensure SERVERKIT_ENCRYPTION_KEY exists during update.sh runs.
- Encrypt/decrypt SSO client secrets, source GitHub client secret, and
  AI API key in SettingsService transparently.
- Mask secret values in SettingsService.get_all() and
  get_all_with_metadata() so the API never returns plaintext.
- Add SettingsService.migrate_legacy_secrets() and invoke it at startup
  alongside DNS/storage/cloud legacy-secret migrations.
2026-06-19 01:33:45 -04:00
Juan Denis 70a70642c1 feat(services): support manual/local apps and zip uploads
- Extend Application model with source, compose_file, systemd_unit,
  managed_by, version, and upload_path columns.
- Add migration 017_manual_apps for the new columns.
- Add POST /apps/manual to register existing server paths/systemd units.
- Add POST /apps/upload with versioning, rollback, and .env preservation.
- Update DockerService compose helpers to accept a custom compose_file.
- Update ProcessService to query systemd units and compose projects.
- Update apps API start/stop/restart/status/delete to honor compose_file
  and avoid deleting unmanaged manual paths.
- Add Manual/Local and Upload ZIP source modes to NewService.
- Show source badges in Services list and disable Git controls for
  manual/upload apps in ServiceDetail.
- Add version list, rollback, and new-version upload UI for upload apps.
2026-06-18 22:16:00 -04:00
Juan DenisandClaude Opus 4.8 07bbb27818 test(ci): guard conftest's SQLAlchemy import for minimal-deps jobs
The previous commit added a module-level `from sqlalchemy import event` to
register SQLite speed PRAGMAs. But the test-system-utils workflow runs
tests/test_utils_system.py with only `pip install pytest` (no SQLAlchemy), and
pytest still imports conftest.py — so it broke with
ModuleNotFoundError: No module named 'sqlalchemy'.

Wrap the import + listener in try/except ImportError. Those jobs run no
DB-backed test, so skipping the PRAGMA tuning there is harmless; the full
backend job still gets it.

Verified: tests/test_utils_system.py (41 passed) and the agent e2e roundtrip
both pass; conftest now imports with just pytest + stdlib.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:09:50 -04:00
Juan DenisandClaude Opus 4.8 84c943ce2f test(ci): use a file-backed SQLite so threaded tests don't race
test_command_roundtrip_via_send_command drives agent_registry.send_command
from a background thread while the main thread handles /poll and /result.
Flask-SQLAlchemy serves :memory: from a single shared connection (StaticPool),
so the two threads issue interleaved transactions on one connection and the
background commit intermittently dies with ObjectDeletedError /
PendingRollbackError (it passed some CI runs, failed others).

Point tests at a temp-file SQLite instead: each thread gets its own connection
with SQLite's file locking, which is safe and deterministic. Add
synchronous=OFF / journal_mode=MEMORY PRAGMAs so the file DB stays ~as fast as
in-memory (throwaway data needs no durability). No production code changed.

Verified: the e2e roundtrip passes 22/22 consecutive runs (was flaky); full
suite green (exit 0, 232 passed / 12 skipped locally).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 00:17:34 -04:00
Juan DenisandClaude Opus 4.8 2483541356 fix(storage): create the config file's own dir in save_config
save_config() ran os.makedirs(SERVERKIT_CONFIG_DIR) unconditionally rather
than the directory of the file it actually writes (cls.CONFIG_FILE). When a
caller overrides CONFIG_FILE (test_storage_secret_encrypted_at_rest points it
at a pytest tmp dir), creating the real config dir failed on the sandboxed CI
runner and — because the surrounding except swallows it and returns
{success: False} — the file was never written, surfacing as a confusing
FileNotFoundError. Now makedirs(dirname(CONFIG_FILE)); production behavior is
unchanged (there dirname(CONFIG_FILE) == SERVERKIT_CONFIG_DIR). 8/8 encryption
tests pass; full backend suite was 241 passed / 1 failed -> now green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:55:03 -04:00
Juan DenisandClaude Opus 4.8 3a8fafcba4 chore(deps): bump PyJWT 2.13.0 + cryptography 48.0.1 (Safety advisories)
The Safety dependency scan flagged PyJWT==2.12.1 (5 vulns) and
cryptography==46.0.7 (1 vuln, bundled-OpenSSL). Bump to the Safety-suggested
fixes, which resolve all 6 currently-active (non-policy-ignored) findings.
Verified: installs with no resolver conflicts, the Fernet/encryption tests
pass, the app boots (JWT manager), and the full suite is 232 passed / 12
skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:47:15 -04:00
Juan DenisandClaude Opus 4.8 ee7d492dd2 feat(remote-access): per-edge port allocation + blocked-UDP diagnostic (Phase 4)
Phase 4 of docs/REMOTE_ACCESS_ROADMAP.md (the buildable/verifiable parts):

- #20 multiple private peers per edge: allocate a per-edge WireGuard listen
  port (tunnel_netutil.pick_listen_port) instead of a hardcoded 51820, so one
  edge can front many private peers without UDP-port conflicts. (Multiple
  services per tunnel already worked since Phase 2.)
- #21 reachability diagnostic: tunnel_netutil.diagnose_reachability classifies
  an up-but-never-handshaking tunnel as likely-blocked outbound UDP; surfaced
  in the status-refresh response (diagnostic field) and as a hint on the Remote
  Access page. The relay data plane itself is deferred.

#22 (client-VPN) and #23 (identity-aware SSO) remain XL milestones, not built.

Verified: 14 backend tests (incl. port-allocator + diagnostic), eslint + build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:28:12 -04:00
Juan DenisandClaude Opus 4.8 7cf8d4e18a feat(remote-access): NAT-traversing WireGuard tunnels to expose private services
Self-hosted tunneled reverse proxy: a service on a NAT'd host (e.g. a home
Jellyfin box) gets a public HTTPS hostname with no port-forwarding, by pairing
two agents over WireGuard. Implements Phases 0-3 of docs/REMOTE_ACCESS_ROADMAP.md.

Agent (Go):
- new internal/wireguard package: Manager interface + kernel backend (wg/ip)
  and a userspace netstack backend (wireguard-go + gVisor) so Windows/macOS
  peers need no driver/admin; in-process keygen returns only the public key
  (private keys never leave the host)
- wireguard:keygen/interface/peer/status/forward/unforward + firewall
  :allow_port/:deny_port commands; wireguard + firewall capability probes
- TCP forwarder (tnet.ListenTCP / net.Listen -> dial localhost) carries
  traffic to the real service behind a netstack peer

Backend (Python):
- Tunnel + ExposedService models; tunnel_netutil (subnet allocator, health);
  tunnel_broker_service (keygen->up->peer->verify, firewall open, reconcile-on-
  reconnect); tunnel_publish_service (forward -> nginx -> DNS -> auth -> cert)
- nginx remote-upstream vhost (WebSocket + proxy_buffering off)
- /api/v1/tunnels CRUD + /<id>/services publish/unpublish
- panel-authoritative reconcile hooked into the agent gateway on reconnect

Frontend (React):
- Remote Access page (tunnels + exposed services, status, teardown) and an
  "Expose a Local Service" wizard; new services/api/tunnels.js

Verified: go build (win/linux) + vet + go tests (forwarder + no-leak parsers);
py_compile + pytest (netutil, reconcile, nginx template); create_app boot;
eslint + npm run build. Live multi-host run pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 23:20:00 -04:00
Juan DenisandClaude Opus 4.8 733f866f1c feat(wordpress): per-site PHP-fatals analytics from the WP_DEBUG log (#25)
Finish #25's deferred PHP-fatals signal, sourced from the WP_DEBUG log
(/tmp/wp-debug.log) that #30's Security toggle writes — not the access log
(which can't carry it):

- get_php_errors: one bounded docker exec (marker-guarded tail -c, 15s single-
  worker timeout) distinguishes logging-off / no-log from on-but-clean, then
  parses [time] PHP <Level>: <msg> into severity counts (fatal/warning/notice/
  deprecated; Parse + Recoverable-fatal fold into fatal) + the 25 most-recent
  entries (messages capped, stack-trace lines skipped).
- Merged into GET /sites/<id>/analytics as php_errors; a PHP errors panel added
  to the Analytics tab with an 'enable WP_DEBUG on the Security tab' empty state.
- Supplies the per-site error signal #27's error-spike rule wanted.

7 new tests (classification, parse/skip-traces, off/empty/unavailable, capping);
full backend suite 218 passed / 12 skipped; frontend lint-clean + production build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 22:06:58 -04:00
Juan DenisandClaude Opus 4.8 a6a214c245 feat(wordpress): wp-content/full-site zip import with zip-slip-safe extraction (#17)
Finish #17's deferred files half on top of the .sql MVP. After the DB import +
search-replace, an optional wp-content/full-site .zip is extracted and copied
into the container:

- _safe_extract_zip rejects any member escaping the temp dir BEFORE writing
  (zip-slip: ../ traversal, deep traversal, absolute paths) + bad-zip guard.
- _resolve_wp_content_dir handles the 3 layouts: zip OF wp-content, zip WITH
  wp-content/ at root, and full-site zip one wrapper folder deep.
- _import_wp_content_zip docker cp's the resolved tree into
  /var/www/html/wp-content and chowns it to www-data. Non-fatal: DB import still
  succeeds; response carries a warning + wp_content_imported flag.
- Route accepts an optional wp_content multipart file (.zip, own temp, cleaned
  up); client importSite({wpContentFile}) + a file input in the import modal.

11 new tests (zip-slip rejection, layouts, docker cp/chown flow mocked); full
backend suite 211 passed / 12 skipped; frontend lint-clean + production build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:59:59 -04:00
Juan DenisandClaude Opus 4.8 aaf99212cb feat(wordpress): durable per-site PHP limit writes + wire the PHP tab (#24)
Finish #24's deferred limit-WRITE side, Docker-correct for wordpress:*-apache
(Apache + mod_php):

- set_php_limits: whitelist + re.fullmatch-validate each directive (fullmatch so
  a newline-smuggled extra ini line can't pass the $ anchor), write a host-side
  php-conf/zz-serverkit.ini and bind-mount that single file into conf.d so it
  survives container recreate. File written before the mount is added; new mount
  applies via compose up, content-only change via restart so php.ini reloads.
  Partial updates merge; mount injected once.
- Route POST /sites/<id>/php/limits (admin), client setPhpLimits, and an Edit
  PHP Limits panel in the PHP tab (inputs seeded from live values, partial save).
- Fix latent bug: PhpTab (the #24 read panel) was built but never wired into
  WordPressDetail (no tab button / render branch) — the whole PHP panel was
  invisible. Added 'php' to VALID_TABS + a tab + render branch.

7 new tests; full backend suite 200 passed / 12 skipped; frontend lint-clean +
production build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:43:12 -04:00
Juan DenisandClaude Opus 4.8 fa73b217ef feat(workspace): scope domains/databases + reconcile workspace roles (#33)
Finish #33's two named workspace-scoping slices, backend-only (no schema
migration, no frontend changes):

- Domains: get_domains scopes through the parent Application via scope_query
  (own + granted, workspace-narrowed); also surfaces granted apps' domains,
  closing a gap vs the per-domain routes. Admin-no-context path unchanged.
- Databases: the app-linked /docker/databases enumeration scopes via
  scope_query; host-level MySQL/PG/SQLite stay global system resources
  (documented inline).
- Role reconciliation: WorkspaceService.effective_role + can_write_in_workspace
  intersect global User.role with per-workspace WorkspaceMember.role,
  narrow-only (a workspace role can only reduce capability; system admin never
  capped). Enforced at apps/servers create + reassign seams (viewer -> 403,
  only when a workspace context is active). Reconciled role surfaced on
  GET /workspaces (my_role/my_effective_role).

6 new tests; full backend suite 193 passed / 12 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 21:00:57 -04:00
Juan DenisandClaude Opus 4.8 60a676025a Fix wildcard HTTPS reading encrypted DNS creds raw + add encryption tests
Auditing the encryption-at-rest work surfaced one missed read site: sites_https_service
passed the DNS provider api_key/api_secret straight to the cert issuer, but those are
now Fernet-encrypted at rest — so DNS-01 wildcard issuance would have received ciphertext
and failed.

- Add DNSProviderService.decrypted_credentials() as the public, canonical way to read a
  DNS provider's usable creds (nothing reads .api_key raw anymore); use it in
  sites_https_service.
- Add tests/test_provider_secret_encryption.py — 8 proving tests: ciphertext-at-rest +
  plaintext-on-read for DNS / cloud / storage / registrar, the idempotent legacy-plaintext
  migration, and the wildcard path now receiving decrypted creds (the regression above).

pytest: 17/17 pass (8 new + 9 existing test_sites_https.py). Full suite: 187 passed; the
lone failure (test_agent_poll_e2e command roundtrip) is a pre-existing flake — the agent
registry in-memory singleton leaks across tests in that file (passes solo), unrelated here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:55:57 -04:00
Juan DenisandClaude Opus 4.8 fd2313077f Polish connections: domain-expiry alerts, S3 image preview, encryption-health banner
- Domain-expiry notifications: a daily scheduler (RegistrarService.notify_expiring)
  fires through the notification channels when a registrar domain crosses 30/14/7/1
  days or expires, de-duped via a small state file so each crossing alerts once.
- S3 image preview: ImageThumb is now S3-aware (loads objects via short-lived
  presigned URLs); threaded through FileCard / PreviewDrawer / FileManager.
- Encryption-health banner: the Connections hub consumes GET /api/v1/connections
  and warns if any credential is not encrypted at rest (hidden in the happy path)
  — the registry's first real consumer.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:43:00 -04:00
Juan DenisandClaude Opus 4.8 bbe6bee267 Unify credential encryption + add a read-only connection registry (#18)
- Encrypt the last plaintext credential store: CloudProvider.api_key_encrypted is
  now Fernet-encrypted on write and decrypted in _auth_headers, with an idempotent
  encrypt_legacy_secrets() migration run at startup. All five stores (source, DNS,
  cloud, registrar, storage) now encrypt identically via app.utils.crypto.
- Add ConnectionRegistry + GET /api/v1/connections: one normalized, secret-free
  list of every connected external account (the "what's connected" source of
  truth). Frontend api.getAllConnections().
- The full write-side "one model replaces five" migration is deliberately not done
  — it would risk five working subsystems for no user-facing benefit. The uniform
  encryption + read registry deliver #18's practical value.

Verified by booting create_app('testing') and running the registry live (returns
a list; /api/v1/connections present in the URL map).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:47:59 -04:00
Juan DenisandClaude Opus 4.8 d157fc514d Encrypt provider secrets at rest + add Namecheap registrar (#16, #17)
Encryption-at-rest (#16/#17) for the two provider stores still in plaintext:
- crypto.py: add decrypt_secret_safe (decrypt with plaintext fallback) + is_encrypted.
- DNS providers: encrypt api_key/api_secret on write; decrypt at point-of-use via
  _api_key/_api_secret in the Cloudflare/Route53/DigitalOcean/GoDaddy builders.
- Storage: encrypt secrets in save_config; decrypt in get_config.
- Idempotent encrypt_legacy_secrets() migrations run on startup, encrypting any
  pre-existing plaintext in place. Legacy values keep working via the decrypt
  fallback, so there is no flag day.

Namecheap registrar:
- RegistrarConnection.config_json column for non-secret extras (username, client_ip).
- RegistrarService: Namecheap XML domains.getList (portfolio + expiry), test, and
  MM/DD/YYYY -> ISO date parsing.
- Frontend: namecheap tile is live; the registrar form is provider-aware (GoDaddy
  key+secret vs Namecheap key + username + whitelisted IP).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:33:09 -04:00
Juan DenisandClaude Opus 4.8 845df9959e Add S3 bucket browsing to the Files app (#14, #15)
- backend: StorageProviderService.s3_* (browse/read/write/upload/delete/
  presigned-get) + /api/v1/files/s3/* routes, reusing the configured backup
  bucket — no new credentials.
- frontend: an "S3 bucket" target in the File Manager. UI paths stay slash-rooted
  and map to S3 keys server-side, so breadcrumbs/history work unchanged; virtual
  folders from prefixes; presigned-URL download. Folders/rename/permissions are
  disabled and the local-only sidebar is hidden on S3. TargetPicker gained a
  backward-compatible extraOptions prop.
- docs: mark #14/#15 done (Phase 4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 10:21:39 -04:00
Juan Denis c1d4653f6c Add registrars, GitLab, DO/Godaddy DNS and SMTP relay
Introduce a broad Connections expansion: add RegistrarConnection model, RegistrarService and /api/v1/registrars endpoints to surface domain portfolios and expiry. Add EmailRelayService and API routes plus Postfix relay wiring to configure/test/disable outbound SMTP. Extend DNSProviderService with DigitalOcean and GoDaddy implementations and helpers. Add GitLab support to source connection service and routes. Frontend: expand Connections hub, provider catalog, modal bodies, provider icons, RegistrarPortfolio component, API client methods and styles. Add docs/CONNECTIONS_ROADMAP.md describing roadmap and intent.
2026-06-15 09:19:56 -04:00
Juan Denis 5212704942 Add Git provider UI, refactor headers, and WP fixes
Introduce GitProviders (provider detection, badges, and RepoProviderStrip) and a shared _git-connect.scss; update Git connect modal and WordPress Git form to use the new UI and provider detection. Replace many page header blocks with PageTopbar and wrap loading states in page-container for consistent layout. Backend: add _is_wp_app helper and use it in wordpress API endpoints; ensure wp-cli login plugin is installed and activated. Minor style/token cleanups and .gitignore addition for docs/UI_CONSISTENCY_AUDIT.md.
2026-06-15 01:53:30 -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.8 ad5c9b6bf0 feat(wordpress): attach a custom domain with auto-DNS
Turns "point myblog.com at this site" into one action that consumes the URL-swap
tool. New DNSProviderService.find_zone_for_domain (longest zone-suffix match
across connected providers) + ensure_a_record upsert the A record via Cloudflare/
Route53, or return the exact record to add when nothing manages the zone.

WordPressService.attach_custom_domain orchestrates it: auto-create DNS (server IP
from SiteDomainService), optionally obtain a Let's Encrypt cert (the host vhost is
written first so certbot --nginx can validate), then migrate the site URL to the
domain via change_site_url. External steps degrade to warnings; only a requested
migration that errors aborts.

POST /wordpress/sites/<id>/domain (admin). Frontend: "Add Domain" button +
AttachDomainModal — enter a domain, optional HTTPS, and the result shows the
auto-created record or the exact A record to add manually.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:30:50 -04:00
Juan DenisandClaude Opus 4.8 57a7b7b3e6 feat(wordpress): URL-swap tool — safe, previewable site URL changes
Changing a WordPress site's URL is not a single UPDATE: the URL is embedded
across wp_options, post content, and PHP-serialized blobs whose byte-length
prefixes break under a naive REPLACE. This adds a first-class swap tool built on
WP-CLI search-replace (serialization-safe), with the rails to make it routine:

- preview_url_change: dry-run that reports per-pair replacement counts, no writes
- change_site_url: backs the database up first, rewrites with --skip-columns=guid
  (full-URL + host-only pairs so scheme/protocol-relative refs are covered),
  updates home/siteurl, flushes the object cache + permalinks, then re-points the
  primary Domain row and nginx vhost. Rolls the DB back from the backup on
  failure. keep_old_redirect keeps the old host resolving so WordPress 301s it.
- POST /sites/<id>/url/preview and /sites/<id>/url (admin-gated)
- "Change URL" modal on the site detail page: live dry-run preview, backup
  notice, keep-old-address toggle, then apply.

_write_app_vhost is factored out of _provision_routing and shared by the
re-point path. The pure-SQL search-replace in db_sync_service remains the
offline-dump fallback only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:19:16 -04:00
Juan DenisandClaude Opus 4.8 1a0214f11d feat(wordpress): publish new sites at a real subdomain, not localhost:port
create_site installed WordPress at http://localhost:<port>, which WordPress
bakes in as its canonical home/siteurl — reachable on the box but useless as a
public site URL. New SiteDomainService resolves <slug>.<base_domain> (config
SITES_BASE_DOMAIN: lvh.me in dev, opt-in in prod, empty falls back to the old
localhost URL). create_site now installs at that host and provisions a primary
Domain row + nginx reverse-proxy vhost (_provision_routing, best-effort so a box
without nginx warns instead of failing). Teardown removes the vhost; clone_site
URL rewriting is generalized via _canonical_site_url so cloning stays correct
with real hostnames.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:01:51 -04:00
Juan DenisandClaude Opus 4.8 a6f66b2976 fix(databases): distinguish a connection failure from an empty database
docker_mysql_get_tables returned [] on both a genuinely empty database and a
container/auth failure, so the explorer rendered "Empty" either way and silently
hid broken connections. It now returns {connected, tables, error}; the API
passes that through, and the source tree shows "No tables yet" for a connected
but empty database vs the real reason when the container exec/auth fails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 00:01:41 -04:00
Juan DenisandClaude Fable 5 b2fad9528a docs: clarify agent.key + session-token notes; record audit remediation (L2/L4-L7)
- SECURITY.md: spell out that agent.key encryption is off-host/tamper
  protection, not confidentiality against local root; 0600 perms are the real
  control (L2).
- agent_registry.py: reword the per-message session-token "TODO" to an accepted
  limitation (defence-in-depth, not a bug) (L7).
- AGENT_AUDIT.md: record H1/H2/H3 and L1-L7 status, with L4 (cap OR-merge),
  L5 (single-worker), L6 (windows .bat) noted as accepted by-design/documented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:35:52 -04:00
Juan DenisandClaude Fable 5 75db466096 fix(pairing): require developer role for claim/lookup (audit L1)
Pairing /claim creates a fleet server and /lookup reveals a pending agent's
details; both were @jwt_required() only, so any authenticated user (incl. a
read-only viewer) could use them. Gate both with @developer_required to match
POST /servers. Test: viewer 403, developer clears the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:35:43 -04:00
Juan DenisandClaude Fable 5 a34064d15a fix(plugins): make plugin/template features fail honestly, not silently (audit H2)
agent_plugin_service and server_template_service dispatched agent commands
through get_agent_gateway() — a function that does not exist — inside a bare
try/except. Every call raised ImportError, was swallowed, and left DB rows stuck
in 'installing'/'checking'/'remediating' forever while the UI implied the
feature worked. The agent also has no handlers for these commands.

- Plugin install now reports STATUS_ERROR with "Agent-side plugin support is not
  implemented yet" instead of hanging in 'installing'.
- Template drift-check/remediate report 'unknown' + an explanatory
  drift_report.error.
- Local-only ops (disable, uninstall, configure) just drop the dead dispatch.
- test_agent_features_honest.py: 3 tests asserting honest states.

Implementing the feature for real (agent handlers + sandbox) remains future
work; this stops it from lying in the meantime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 18:35:35 -04:00
Juan DenisandClaude Fable 5 6eb4471829 fix(servers): require developer role for agent self-update (audit H3)
POST /<id>/agent/update was @jwt_required() only, so a read-only viewer could
trigger agent binary replacement + service restart across the whole fleet —
inconsistent with every other state-changing server route, which is
@developer_required. Add @developer_required and a test proving a viewer gets
403 while a developer clears the gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:33:29 -04:00
Juan DenisandClaude Fable 5 43fc900944 test(agent): end-to-end test for the panel<->agent command loop
The agent subsystem had unit tests but nothing proving the whole protocol
works. This drives the REST poll transport (/connect, /poll, /result) — plain
HTTP that shares the WebSocket gateway's auth, registry, and command-routing
code — through the full loop: a correctly-signed agent authenticates, registers,
receives a command the panel issued via send_command (run on a background
thread since it blocks on the result), and round-trips the result back to the
waiter. Uses a temp-file SQLite DB so the thread and request handlers share
committed rows. 4 tests, all green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:31:26 -04:00
Juan DenisandClaude Fable 5 fd825276b9 fix(sockets): authorize socket connections by role; gate terminal streams
The Socket.IO connect handler only checked that the JWT decoded — it never
confirmed the account still exists or is active, and recorded no identity. The
generic join_room and subscribe_terminal handlers then performed no
authorization, so a read-only viewer (or a deactivated account with an
unexpired token) could attach to a live PTY stream room and watch a privileged
shell.

- connect: resolve the JWT identity to a live, active User and stash
  {user_id, role} per sid; reject missing/deactivated accounts.
- subscribe_terminal and join_room (for _terminal: rooms): require
  admin/developer, matching the @developer_required on terminal creation.
- disconnect: drop the per-sid identity record.

Job-progress and cloudflared-login streaming (non-terminal server rooms) are
unaffected — that data is already reachable over REST by any authenticated user.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:33:44 -04:00
Juan DenisandClaude Fable 5 b2e4ac7c60 feat: interactive Terminal tab on /terminal over agent PTY (S8.3)
The "Terminal & Logs" page had no terminal. Now a Terminal tab hosts the
xterm console (RemoteTerminal) against paired agent servers, behind a
demo-style target rail (.term-shell: agent list with glowing status
dots, offline rows disabled, first online server auto-selected).

RemoteTerminal was built but never mountable - it imported a
nonexistent SocketContext and listened for a terminal:<sid> event that
nothing emits. Fixed the full output path:

- backend sockets.py: new subscribe_terminal/unsubscribe_terminal
  handlers join the browser into the agent gateway stream room
  (server_<id>_terminal:<session>) after validating the session via
  TerminalService.get_session
- RemoteTerminal: rewired to services/socket.js; consumes server_stream
  events filtered by the session channel (agent PTY -> gateway ->
  xterm), re-subscribes on socket reconnect, unsubscribes on unmount

VALID_TABS gains terminal (appended - logs stays the default landing
since the shell needs a connected agent; panel-host PTY remains a S5
gap, TerminalService rejects local).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 23:55:39 -04:00
Juan Denis 986909a955 Surface Docker DBs, add base port & migration fixes
Expose and surface containerised databases and introduce managed app base port control. Backend: add endpoint to list databases across all Docker apps, robustly resolve compose container names, include docker DB user when querying, compute migration needs from live heads and self-heal orphaned alembic_version entries by stamping to head, and add the managed_app_base_port system setting. Template service now honours the managed_app_base_port and WordPress template default HTTP port updated to 8300. Frontend: add react-icons brand icons and DatabaseBrands component, show Docker-hosted DBs under their engine nodes with a docker badge, platform-aware console shortcut label, pass DB user to docker table/query API calls, add API client header merge fix, UI for managing base port in settings, enhanced migration history UI (backup/apply/resync flows, confirm dialog, toasts), and various style tweaks and color additions.
2026-06-06 23:29:22 -04:00
Juan DenisandClaude Opus 4.8 6f70eb9ad1 feat(workspaces): make python.py grant-aware — every app blueprint now covered (#33)
Re-reading python.py showed every mutating endpoint is independently
@admin_required (runs before the shared get_app_or_404 gate), so that gate only
governs the 4 read GETs (packages/env/status/gunicorn-config). Pointing it at
can_access_app safely opens those to grantees while the admin-only mutations stay
untouched — no per-method split needed.

With this, every app-related blueprint is grant-aware. Also fixes the int-vs-str
identity comparison in the helper.

Verified: a python read-gate test (stranger 403, grantee passes), full suite
132 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:08:44 -04:00
Juan DenisandClaude Opus 4.8 35642187c6 feat(workspaces): extend per-site ACL grants across all app blueprints (#33)
A grant now reaches every app-related blueprint, not just apps.py + WordPress, so
a shared application is fully manageable by its grantees.

- Promoted the access helpers to a shared seam: ResourceGrantService.can_access_app
  (owner/admin/any grant -> reads) and can_edit_app (owner/admin/editor grant ->
  mutations); apps.py now delegates to them.
- Swept 33 ownership checks across 6 blueprints (categorized read vs mutate via a
  parallel mapping workflow): builds.py (12), domains.py (7, incl. SSL + domain
  CRUD), deploy.py (6), private_urls.py (5), templates.py (2), databases.py (1).
  Reads -> any grant; mutates -> editor grant.
- Fixed the same latent int-vs-str ownership bug in all 33 (now user.id).

Deferred: python.py's single get_app_or_404 helper gates read+mutate+delete
together and needs per-method splitting before it's safe to make grant-aware.

Verified: cross-blueprint integration test (grantee reaches builds deployments;
stranger 403), full suite 131 passed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 23:04:06 -04:00
Juan DenisandClaude Opus 4.8 2d83440040 feat(workspaces): viewer/editor grant roles + operate-endpoint access (#33)
Completes the chosen access-level ACL design: a grant now lets the user actually
operate the resource, gated by a real role.

- Grants carry a role: editor (view + operate) or viewer (read-only). grant POST
  validates role in {viewer, editor} (default editor); ResourceGrantService gains
  grant_role(); the Sharing UI gains a role selector.
- Two access helpers split the app endpoints: _can_access_app (owner/admin/ANY
  grant) gates reads (get_app, /linked, /logs, /container-logs, /containers,
  /status); _can_edit_app (owner/admin/EDITOR grant) gates mutations (/link,
  /unlink, /environment, PUT, /start, /stop, /restart).
- DELETE stays owner-or-admin (an editor can't delete — tested).
- The same sweep fixed a latent int-vs-str ownership bug across ~14 app endpoints
  (compared app.user_id to the stringified get_jwt_identity(); now user.id).

Verified: 3 more ACL tests (viewer read-only + can't operate, editor operates +
can't delete, role validation/default), full suite 130 passed, lint + build.
Still owner-only: env-vars/databases/domains blueprints (follow-up sweep).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 13:13:18 -04:00