_canonical_site_url feeds clone/URL-swap search-replace, so it must
return the URL actually baked into the WordPress DB. A domain-less site
is installed at localhost:<port>, not the panel apex; the panel_origin
branch (added in 652d903a) made it resolve to the base domain, so
clones/swaps would search-replace nothing and silently leave stale URLs.
Drop that branch, keep the ssl_enabled-aware scheme. Fixes the
test_canonical_site_url_prefers_primary_domain regression.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Storage tab listing and managing Cloudflare's account-scoped storage
primitives — R2 buckets, KV namespaces, and D1 databases. Management only
(list/create/delete); the object/key/row data planes are out of scope.
- CloudflareClient: r2/buckets, storage/kv/namespaces, d1/database list+create
+delete.
- CloudflareService: combined list_storage (each product fetched independently so
a missing token scope degrades to a per-product error, not a dead tab),
validated create/delete for each. R2 bucket names validated S3-style.
- /api/v1/cloudflare/zones/:id/storage* routes (read any user; writes admin).
- StoragePanel (three generic create/list/delete sections; R2 notes its
S3-compatibility for future backup targeting); api methods; SCSS.
No new tables — wiring an R2 bucket as an S3 backup backend (minting scoped R2
keys) is a deliberate follow-up, noted in the UI.
Tests: aggregate listing + per-product error degradation, R2 name validation,
create/delete, KV/D1 required-field guards (6).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Tunnels tab for creating and managing Cloudflare Tunnels (cloudflared) —
exposing a local/private service through Cloudflare's edge with no public IP.
Clearly distinct from ServerKit's WireGuard remote-access tunnels (always
labelled "Cloudflare Tunnel" in the UI). Account-scoped; account resolved from
the zone.
- CloudflareTunnel model: records the connector token (encrypted at rest) and
owning connection per (account, tunnel_id).
- CloudflareClient: cfd_tunnel create (config_src=cloudflare) / list
(is_deleted=false) / delete / token / connections / configurations get+put.
- CloudflareService: create (returns + stores the one-time token, yields the
cloudflared install command), list (managed-flagged), delete, fetch install,
and public-hostname routing — sets the ingress config (always re-appends the
required http_status:404 catch-all) and best-effort upserts the proxied CNAME
hostname→<id>.cfargotunnel.com via the shared ownership-guarded DNS path.
- /api/v1/cloudflare/zones/:id/tunnels* routes (read any user; writes admin).
- TunnelsPanel (create form + one-time install box w/ copy, tunnel list with
per-tunnel install reveal and public-hostname add/remove); api methods; SCSS.
Tests: list is_deleted param, create config_src, name required, token stored
encrypted + install string, managed-flagging, delete prunes local row, ingress
built with catch-all last (7).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Workers tab to the Cloudflare Zone Settings page for deploying edge
scripts and managing Worker routes. Workers are account-scoped, so the owning
account is resolved from the zone — the whole feature reuses the existing
Cloudflare DNS connection (no new credential).
- CloudflareWorker model: records the source ServerKit uploaded per (account,
name) so a worker can be tracked and re-deployed (created via create_all).
- CloudflareClient: zone→account lookup, list/delete scripts, and a stable
multipart PUT /scripts/{name} module upload (the still-beta resource-oriented
Workers API is deliberately avoided per the roadmap's fallback note), plus
zone Worker routes list/add/delete.
- CloudflareService: list (live scripts flagged when ServerKit-managed + routes),
deploy (validated lowercase name, records source, optional route attach),
delete, route add/delete.
- /api/v1/cloudflare/zones/:id/workers* routes (read any user; writes admin).
- WorkersPanel component (deploy form with a starter script, worker list, routes
list); api methods; SCSS.
Tests: multipart upload shape, name validation, deploy records source, list
managed-flagging, delete prunes the local row (5).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a WAF tab to the Cloudflare Zone Settings page for managing custom firewall
rules (the http_request_firewall_custom phase entry-point ruleset).
- CloudflareClient: ruleset list/get + phase-entrypoint create (PUT) + per-rule
POST/PATCH/DELETE.
- CloudflareService: list rules (empty when no custom ruleset yet, distinguished
from an auth error by listing rulesets first), add rule (creates the ruleset on
first use), update/delete, and three one-click presets — lock WordPress admin
to an IP, block common exploit paths, challenge suspicious bots. The IP in the
lock preset is validated with ipaddress so it can't inject expression syntax;
rule actions are constrained to a safe terminal subset.
- /api/v1/cloudflare/zones/:id/waf/* routes (list any user; add/preset/update/
delete admin).
- WafPanel component (preset picker + custom-rule form + active-rules list with
enable toggle and delete); api methods; SCSS.
Tests: ruleset discovery, empty vs auth-error, create-on-first-rule vs append,
action validation, preset IP validation + safe expression, update/delete (10).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add Cloudflare cache management to the Zone Settings page. Purge everything (with
a confirm) or purge specific URLs (one per line, capped at 30 to match Free/Pro
limits); hosts/prefixes/tags are accepted by the API for Enterprise plans and
their plan errors are surfaced verbatim.
- CloudflareClient.purge_cache(zone_id, payload).
- CloudflareService.purge_cache: validates intent (raises on empty), caps files
at 30, normalizes the payload.
- POST /api/v1/cloudflare/zones/:id/purge-cache (admin).
- Actions tab gains a "Purge cache" card (purge-everything button + URL list);
api method; SCSS.
Tests: client payload, purge-everything, 30-file cap, empty-raises, provider
error surfaced (5 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add a Cloudflare Zone Settings surface on top of the existing Cloudflare DNS
connection — SSL/TLS, Speed, Caching and Security toggles plus a one-click
"recommended hardening" preset. Reuses the connected DNSProviderConfig
credential and shared CloudflareClient, so auth/encryption-at-rest are not
re-implemented.
Backend:
- CloudflareClient: generic v4 request() helper + zone-settings get/update
(envelope normalized to {success, error?, result?}).
- CloudflareService: resolve zone+credential by ServerKit DNS zone id, curated
setting groups (UI metadata), get/update setting, apply recommended preset
with per-setting reporting.
- /api/v1/cloudflare blueprint: settings GET / GET-one / PATCH + apply-preset
(reads any user, writes admin; mutations captured by the audit fallback).
Frontend:
- CloudflareZoneSettings page (tabbed SSL/TLS · Speed · Caching · Security ·
Actions) with declarative toggle/select/HSTS controls and optimistic save.
- Route /cloudflare/zones/:zoneId, "Cloudflare" link from Cloudflare-managed
DNS zones, api module, SCSS.
Tests: client envelope/zone-settings + service resolution guards, indexing,
and preset reporting (11 tests).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Operator-facing guide to the Protection panel: scheduling, smart/incremental backups, retention, storage cost (free local disk vs real S3/B2), and one-click restore.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "common misconfiguration" notices stacked every hint as a banner at the top of
the dashboard while the notification bell sat empty. Now:
- SystemNotices (top banner) shows only urgent notices (error/critical, or an
explicit urgent flag), at most one — the dashboard stays clear.
- The live notices fold into the notification center (bell + /notifications) via the
shared NotificationsContext: they show with their Fix action, bump the unread
badge, and can be dismissed. Dismissal syncs across both surfaces (shared
localStorage).
New util utils/dismissedNotices.js (shared dismiss state + notice->item mapper).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Surfaces sites_dns_mode in the Managed Sites Domain & HTTPS card: a select to choose
Wildcard (point *.base once, every site instant) or Per-site (each new site gets its
own auto-created, ownership-tracked A record). Reads dns_mode from the sites-https
status and saves via updateSystemSetting.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToastContext wrapped the 2nd arg as { duration }, so the ~30 call sites that pass a
full sonner options object (toast.info(msg, { duration: 4000 })) got it
double-wrapped ({ duration: { duration } }) and their custom durations were silently
dropped. Normalize at the wrapper: a number becomes { duration }, an object passes
through. Backward-compatible with the few numeric callers.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Replace rsync with tar-based copy to avoid missing rsync on Windows/Git Bash
- Exclude backend/venv, backend/.venv, backend/.venv-wsl, and broken WSL symlinks
- Exclude ./backups, backend/dev-data/backups, backend/instance/backups, and scripts/test/output
- Exclude scratch images (PNG/JPEG/JPG), logs, and tmp files from the tarball
- Pack with tar --transform so /opt/serverkit prefix is created without cross-device moves
- Reduce release tarball from 13 GB back to ~6 MB
#12 SiteDomainService.give_subdomain(app, label): one-click publish an app at
<slug>.<base> — primary Domain row + nginx vhost + (per-site mode) an auto-created,
guarded/logged A record. Endpoints: POST /domains/give-subdomain and
GET /domains/suggest-subdomain.
#14 GET /api/v1/dns/managed: every record ServerKit owns across all provider zones,
enriched with the app that triggered each (from the ownership ledger).
#13 inline propagation reuses the existing /dns/propagation/<domain> check.
Tests: test_dns_give_subdomain.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#9 sites_dns_mode setting (wildcard | per-site) via SiteDomainService.dns_mode();
surfaced in SitesHttpsService.status().
#10 One-click wildcard already exists (SitesHttpsService.setup creates *.<base> +
issues the wildcard cert); it now also pins the mode to wildcard, and the wildcard
A record flows through the Phase 1 guarded/logged path.
#11 SiteDomainService.ensure_site_dns(): in per-site mode a new managed site gets
its own A record auto-created via a connected provider (ownership-guarded + logged),
wired into WordPressService._provision_routing. Wildcard mode stays a no-op.
Tests: test_dns_subdomain_modes.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#7 Surface the Phase 2 backend:
- DnsActivity: a per-connection "Recent changes" feed (action, record, result
badge, error) from GET /dns/changes, expandable on each DNS connection in
Settings -> Connections.
- DNS Zones page: a "ServerKit records / In your provider" toggle; the provider
view (GET /dns/<id>/mirror) lists every live record badged ServerKit vs External,
external records read-only/muted ("ServerKit never modifies external records").
- api: getDnsChanges() + getZoneMirror().
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#6 dns_changes audit log (migration 027) — every record write ServerKit sends to a
connected provider is recorded at the write choke point (guarded_upsert /
guarded_delete): create/update/delete, source, result (ok|error|conflict|skipped),
and the value set. Powers the "Changes to your Cloudflare" activity feed.
GET /api/v1/dns/changes lists it (filter by config_id / zone / result).
#8 A real write failure now surfaces an admin notice via the Notification Bus
(dns.sync_failed) instead of being swallowed to the log. Best-effort — surfacing
never breaks the write it describes.
Tests: test_dns_change_log.py. The frontend activity/mirror UI lands separately.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#3 managed_dns_records ledger (migration 026) — the single source of truth for
records ServerKit created in a connected provider zone, written by every write
path (zones page, DDNS, WordPress auto-DNS, email).
#4 DnsOwnershipService.guarded_upsert/guarded_delete: automatic paths refuse to
overwrite or delete a record ServerKit didn't create (the user's own records),
while the explicit Zones page adopts a matching record and takes ownership.
Wired into both CF paths (DNSZoneService._cloudflare_sync and
DNSProviderService._cloudflare_set_record/_delete_record). ensure_a_record now
surfaces a 'foreign_record' reason instead of clobbering.
#5 Live zone mirror: CloudflareClient.list_records +
DNSZoneService.list_provider_records classify every live record as serverkit vs
external; GET /api/v1/dns/<id>/mirror.
Tests: test_dns_ownership.py. Existing set_record mocks updated for the new
source kwarg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Phase 0 of the DNS roadmap.
#2 Shared Cloudflare client (app/services/dns/): DnsCredential + DnsRecordSpec +
CloudflareClient. Both DNSProviderService and DNSZoneService delegate to it, so
auth (scoped token / global key), the CAA structured-data wire format, MX/SRV
priority parsing, and idempotent upsert live in one place. Fixes dup-on-recreate,
silent update/delete no-op, and missing proxied/priority on the provider path.
#1 Single credential source: new FK dns_zones.dns_provider_config_id (migration
025) + DNSZoneService._resolve_credential (linked -> auto-discover-by-domain ->
legacy inline-token). create_zone accepts a connection id; link_legacy_zones()
converges inline tokens onto encrypted connections at boot. The /dns create-zone
modal now picks a connection instead of pasting a token. DDNS rides it for free.
Tests: test_dns_cloudflare_client.py, test_dns_credential_resolution.py.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fall back to cp + manual cleanup when rsync is unavailable
- Exclude .venv-wsl, test output, scratch images, and logs from the tarball
- Use tar --transform to avoid moving directories across filesystems
- Stop including root-level PNG/JPEG/JPG/log/tmp artifacts in releases
- Add GET /api/v1/system/notices endpoint (admin-only)
- Detect missing canonical domain, sites base domain, server public IP, and wildcard HTTPS
- Add SystemNotices React component with dismiss persistence in localStorage
- Render notices inside DashboardLayout so they appear on every page
- Add scoped SCSS for warning/info/error notice banners
Make email handling case-insensitive (queries + model normalization) to prevent duplicate accounts. Add SiteDomainService.panel_origin() helper and use it across services to build public URLs (Gitea, WordPress, Roundcube) and Django ALLOWED_HOSTS. Add optional custom-domain support when creating WordPress sites and propagate domain to Roundcube install. Improve environment domain fallback to prefer panel/base domains over .localhost. Small Dockerfile healthcheck tweak (use 127.0.0.1). Update install/update/build scripts to always (re)create virtualenv locally for release installs. Remove unused frontend CreateSiteModal and expose domain field in WordPress page.
Introduce a GitHub Actions workflow (release-smoke.yml) that builds the release tarball on pushes/PRs to dev/main for relevant paths, catching release-script issues early. Adjust scripts/build-release.sh rsync excludes to explicitly target backups directories (root/backups and backend instance/dev-data backups) instead of a broader exclude, preventing accidental omission of needed files during release packaging.
Telemetry: ensure sanitized payloads bigger than the 32KiB threshold are replaced with a {_truncated: True} marker before storage, and add a unit test covering that behavior.
Backend API: adjust imports in secrets_webhooks to use WorkspaceService and include User model.
Frontend: make Sidebar resilient to malformed active_workspace JSON in localStorage (try/catch), and ensure socketService.disconnect() is called when user becomes unauthenticated in NotificationsProvider to clear notifications and unread count.
Files: backend/services/telemetry_service.py, backend/tests/test_telemetry.py, backend/app/api/secrets_webhooks.py, frontend/src/components/Sidebar.jsx, frontend/src/contexts/NotificationsContext.jsx
Rename service settings 'repository' → 'git' (routes, labels, and links) and update App route comment. Reorder sidebar footer so user mini and notification bell share a single row and add accessible user-mini button. Adjust notification panel positioning, add show animation, and responsive layout tweaks. Replace Workspaces card grid with a dense table view: add search, status segment control, bulk select checkboxes, and selection state. Minor SCSS tweaks for sidebar button sizing and WordPress action width. These changes improve discoverability and bulk management UX.
- Rename service Environment tab to Env Vars and settings Environment to Environment Type
- Add Domain & SSL section to service settings with attach domain + Let's Encrypt flow
- Surface primary domain from domains array in service subtitle
- Add prominent repo connection pill to WordPress detail header
- Apply WordPress list layout to Services page (drop KPIs, tabs+search toolbar)
WorkflowEventBus.emit no longer spawns a per-event daemon thread to match +
trigger workflows. It now enqueues a single 'workflow.dispatch' job; the new
WorkflowEventBus.dispatch_event handler does the matching (active event-trigger
workflows whose eventType matches, honoring the 60s cooldown) and enqueues a
workflow.execute job per match. emit is a non-blocking enqueue, best-effort
(never raises into the caller), and the whole event -> dispatch -> execute path
now flows through the bus (observable, persistent across restart). Removes the
last ad-hoc thread on the workflow path.
Registered alongside workflow.execute in WorkflowEngine.register_jobs.
tests: dispatch handler registered; emit enqueues a dispatch job (not a thread);
dispatch fans out only to the matching event workflow.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire scheduled backups onto the unified job system and retire the orphaned
backup scheduler thread.
- BackupService.start_scheduler/_scheduler_loop were defined but NEVER called,
so scheduled backups never actually ran. Replace with check_backup_schedules()
(enqueues a backup.run job per due schedule) driven by a new
builtin.backup_scheduler tick, plus a run_backup_job handler that executes one
schedule via the existing _run_scheduled_backup. max_attempts=1 (backups
aren't idempotent); a failed run marks the job failed. This activates
scheduled backups, but stays gated by the existing backup config.enabled
(default False), so installs without configured backups are unaffected.
- Fix a latent bug in _send_backup_notification: it called
NotificationService.send_all(title=, message=, severity=) but send_all takes
a list of alert dicts — corrected (faithful: still routes to system channels
via the queue-backed bus).
- Monitoring intentionally left as-is: a user-toggled continuous stream whose
alert delivery already goes through the queued send_all. Phase 8 orchestration
is already met — send_all is a non-blocking shim over broadcast_system.
- tests: backup.run register/handler success+failure; scheduler tick enqueues
only due+enabled schedules and is inert when disabled.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add /jobs — the single pane to observe and manage all background work, built
on the /api/v1/jobs API. Mirrors the DeliveryLog/Telemetry admin pages.
- Jobs table polls every 5s: status Pills (succeeded/running/pending/failed/
cancelled), kind, owner, attempts, age. Status/kind filters. Per-row Cancel
(pending/running) and Retry (failed/cancelled) with toast feedback. Row
expands to a detail view (payload/result/error/correlation/timing) via getJob.
- Scheduled-jobs table: name, kind, schedule (cron or "every Nh"), next/last run,
with Run-now and Pause/Enable.
- Stat cards (total/running/pending/succeeded/failed) from /jobs/stats.
- Wiring: services/api/jobs.js module bound into ApiService; route + page title
in App.jsx; "Jobs" item in the sidebar "system" group; _jobs.scss imported
in main.scss. Admin-gated client-side via useAuth().isAdmin.
Lint clean (0 errors) and production build passes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Migrate workflow executions onto the unified job system (single-job wrap).
WorkflowEngine is split into _create_execution (validate graph + persist the
running WorkflowExecution row) + run_execution (the synchronous DAG core) +
enqueue_execution (create row, enqueue a 'workflow.execute' job, return the
execution id immediately). execute_workflow stays as the synchronous primitive.
- All four triggers route through enqueue_execution: manual API, webhook,
cron tick (builtin.workflow_schedules), and the WorkflowEventBus. The manual
and webhook endpoints no longer block the HTTP request on the whole DAG; the
cron tick enqueues one job per due workflow instead of running them serially
inside the tick; the event bus thread now only matches + enqueues.
- max_attempts=1 (workflows deploy/script/notify — not idempotent). A failed
run raises so the unified job is marked failed; the WorkflowExecution row
keeps the detailed per-node status/logs. Graph validation still happens
synchronously at enqueue, so an invalid workflow surfaces immediately.
- tests: register, enqueue-creates-row+job, empty workflow runs to success,
failure path marks the job failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The release workflow was publishing Docker assets and source but never
the serverkit-vX.Y.Z-linux-{amd64,arm64}.tar.gz files that install.sh,
update.sh, and 'serverkit update' expect to download. This caused release
updates to fail with 'Failed to download the release tarball.'
- Make scripts/build-release.sh honor BUILD_ARCH for CI matrix builds.
- Add a build-release job that builds tarballs on native amd64 and arm64
runners and uploads them as artifacts.
- Wire the release job to download those artifacts, include them in the
GitHub Release, and checksum them.
- Add the one-line installer to the release notes body.
Add docs/NOTIFICATIONS_ROADMAP.md and docs/ORCHESTRATION_ROADMAP.md to .gitignore so newly added roadmap documents are not tracked by Git. This keeps in-progress planning files out of commits until ready for publishing.
Migrate deployment installs off their ad-hoc daemon thread onto the unified
job system. DeploymentJobService.install_template now enqueues a
'deploy.install' Job instead of spawning a thread via _start_background_job
(retired); the JobConsumer runs it by calling the existing run_job.
- max_attempts=1: installs create containers/files/an app row and are not
idempotent, so no auto-retry (preserves prior single-shot behavior).
- The unified Job shares the DeploymentJob's correlation_id and links back via
owner_type='deployment_job'/owner_id; the DeploymentJob row stays the rich
record (plan/steps/logs/progress). A failed run raises so both rows show failed.
- Bonus: CLI `deploy-template` (no --wait) now persists to the queue and runs
when a consumer is available, instead of dying with the CLI process.
- tests: handler success/failure mapping + install_template enqueues-not-threads.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generalize the Notification Bus pattern (thin {id} message -> fat DB row
-> single consumer) into a Job/WorkUnit layer on top of the Queue Bus, so
background work flows through one pipe instead of many ad-hoc threads.
- app/jobs: Job + ScheduledJob models, handler registry, JobService +
ScheduledJobService, one generic JobConsumer, one JobScheduler ticker,
and a plugin SDK surface (app.plugins_sdk.jobs).
- Migrate the 8 per-domain daemon scheduler threads (auto-sync,
snapshot-retention, workflow, health-check, wp-update, api-background,
pairing-prune, registrar-expiry) to ScheduledJob rows + built-in
handlers that reuse the exact prior check functions: cadence and
per-task de-dup unchanged, only the trigger moved.
- Retire the orphaned in-memory BackgroundJobService; repoint
/api/v1/performance/jobs at JobService.
- Add /api/v1/jobs (list/get/cancel/retry/stats; scheduled list/run/
toggle) as a single pane over all background work.
- tests/test_jobs.py: enqueue->run->succeed, dead-letter->failed,
unknown-kind, cancel, retry, scheduler tick/advance, built-ins.
No Alembic migration (tables created via create_all), avoiding a
multi-head clash with the in-flight telemetry work. Plan lives in
docs/ORCHESTRATION_ROADMAP.md (local/git-ignored).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add SystemEvent model, TelemetryService, /api/v1 telemetry endpoints, and
a /telemetry UI page. Instrument audit, notification, webhook, backup,
deployment, and monitoring paths. Add correlation_id to Notification,
EventDelivery, and DeploymentJob so related events can be grouped.
- 18 backend tests added and passing
- Frontend production build clean