Skip to content

Cloudflare zone ops, DNS ownership overhaul, and backup protection#48

Merged
jhd3197 merged 28 commits into
mainfrom
dev
Jun 23, 2026
Merged

Cloudflare zone ops, DNS ownership overhaul, and backup protection#48
jhd3197 merged 28 commits into
mainfrom
dev

Conversation

@jhd3197

@jhd3197 jhd3197 commented Jun 23, 2026

Copy link
Copy Markdown
Owner

ServerKit has been on speaking terms with Cloudflare for a while, but the conversation never got past DNS records. This release opens up the rest of the dashboard — and tightens how the panel touches your zone in the first place. On the feature side, a new Cloudflare operations surface sits on top of the existing DNS connection (no second credential): zone settings with one-click hardening, cache purging, WAF custom rules, Workers, Tunnels, and the R2/KV/D1 storage products. Underneath it is a DNS overhaul that makes the panel safe to point at a real zone — one unified Cloudflare connection behind a shared client, an ownership ledger so ServerKit never overwrites a record you created yourself, and a "Changes to your Cloudflare" feed that shows every write it made. The third pillar is Backup Protection: scheduled, smart (full + incremental), restorable backups for both WordPress sites and generic apps, with retention, optional remote copies, verification, and a storage-cost view that's honest about local disk being free and only S3/B2 costing real money. Rounding it out, emails are matched case-insensitively so case variants can't spawn duplicate accounts, public links prefer your real domain over localhost:<port>, and common setup gaps now surface as dismissible notices instead of silent dead-ends.

Highlights

  • Cloudflare zone settings in-panel — SSL/TLS, Speed, Caching, and Security — with a one-click "recommended hardening" preset (Full strict, Always HTTPS, HSTS, TLS 1.2 floor + 1.3, Brotli, HTTP/3) and on-demand cache purge.
  • Cloudflare WAF custom rules with one-click templates: lock WordPress admin to a trusted IP, block common exploit paths (/xmlrpc.php, /.env, /.git), and challenge suspicious bots.
  • Cloudflare Workers, Tunnels, and R2/KV/D1 — deploy edge scripts and routes, create cloudflared tunnels with a copy-paste install command, and provision storage — all on the Cloudflare account you already connected.
  • One Cloudflare connection now powers DNS, and a "Changes to your Cloudflare" activity feed shows every record the panel wrote — while a never-touch-foreign guard means ServerKit never edits records you created yourself.
  • Give an app a subdomain in one click, and choose wildcard or per-site DNS for managed sites (per-site auto-creates each A record through your provider).
  • Backup Protection for WordPress sites and apps: scheduled full + incremental backups with retention, optional remote copies, one-click restore, verification, a calendar/history view, and a storage-cost estimate.
  • Public URLs (WordPress, Gitea, webmail) and new-site addresses now use your configured domain instead of localhost:<port>, logins are case-insensitive on email, and setup gaps show as dismissible notices in the notification center.
Technical changes

Cloudflare operations (/api/v1/cloudflare)

  • New CloudflareService (backend/app/services/cloudflare_service.py) + cloudflare_bp blueprint, registered at /api/v1/cloudflare. A zone is addressed by its ServerKit DNSZone id; the Cloudflare credential, zone id, and account are resolved server-side via the canonical DNSZoneService._resolve_credential, so the whole surface reuses the existing DNS connection's auth + encryption-at-rest.
  • Reads are open to any authenticated user; every mutating route is admin-gated (_require_admin). A reached-but-failed provider call maps to 502, a caller/resolution problem (CloudflareError) to 400.
  • Zone settings render from a curated SETTING_GROUPS catalog (SSL/TLS, Speed, Caching, Security); RECOMMENDED_PRESET applies one-click hardening and returns a per-setting report so the UI can show what the plan gated.
  • purge_cache supports everything / files / hosts / prefixes / tags, capping files at 30 (Free/Pro limit); empty requests raise rather than no-op.
  • WAF custom rules CRUD over the http_request_firewall_custom ruleset, creating the zone's custom ruleset on first use. Actions are restricted to a terminal-action allowlist (no skip, which can disable WAF), and the lock_wp_admin preset validates the operator-supplied IP with ipaddress so a preset can't inject Cloudflare expression syntax.
  • Workers (module upload + routes) and Tunnels (cloudflared) are account-scoped, the account read from the zone. CloudflareWorker records the uploaded source; CloudflareTunnel stores the connector token encrypted at rest and never serialized (revealed once on create, re-fetched on demand via the install endpoint). R2 buckets / KV namespaces / D1 databases get create/list/delete.
  • The shared CloudflareClient (backend/app/services/dns/cloudflare.py) gained the zone-settings, ruleset, worker, tunnel, account-id, and storage calls so all Cloudflare HTTP lives in one module.

DNS overhaul (unify, own, log)

  • New provider-agnostic client layer (backend/app/services/dns/): DnsCredential + DnsRecordSpec dataclasses (base.py), the shared CloudflareClient, and a get_client factory. Both DNSProviderService and DNSZoneService now drive the one client — the duplicated Cloudflare request/CAA code that had drifted between the two layers is deleted, so wire-format fixes (e.g. CAA's structured data object) live in one place.
  • DNSZone.dns_provider_config_id FK links a zone to a Settings → Connections credential. DNSZoneService._resolve_credential resolves linked-config → auto-discovery (and backfills the link + provider_zone_id so the next call is API-free) → legacy inline token. link_legacy_zones() runs on startup to migrate inline Cloudflare tokens onto the connection store (encrypted), logged alongside the other legacy-secret migrations in app/__init__.py.
  • Ownership ledger: ManagedDnsRecord model + DnsOwnershipService. guarded_upsert / guarded_delete are the single write choke point — they refuse to overwrite a record ServerKit didn't create (allow_foreign=False for automatic paths like WordPress auto-DNS, email, wildcard; allow_foreign=True for the explicit /dns Zones page, which adopts the record) and record ownership on success.
  • Change log: DnsChange model + DnsChangeService.record logs every provider write (ok / error / conflict / skipped) at that same choke point and fires a dns.sync_failed admin notification on real failures. DNSZoneService.list_provider_records returns a live zone mirror tagging each record serverkit vs external.
  • Per-base-domain subdomain modes: SiteDomainService.dns_mode() (wildcard default vs per-site), ensure_site_dns() (per-site auto-creates the A record through a connected provider, guarded + logged; wildcard is a no-op), and give_subdomain() (Domain row + nginx vhost + DNS in one call). WordPress custom-domain attach and SitesHttpsService set the mode accordingly.
  • New endpoints: GET /dns/<id>/mirror, GET /dns/changes, GET /dns/managed, GET /domains/suggest-subdomain, POST /domains/give-subdomain. DNSProviderService.set_record gains proxied/priority/source, and ensure_a_record distinguishes a foreign_record conflict from an api_error.
  • Migrations 025_dns_provider_link, 026_managed_dns_records, 027_dns_change_log.

Backup Protection

  • BackupPolicy and BackupRun models (backup_policies / backup_runs). One policy per target, target_type ∈ {wordpress_site, application} (unique constraint); the cron schedule is mirrored into a ScheduledJob on the unified job bus, and last_* columns cache the latest run so the panel renders status without scanning runs. BackupRun records full/incremental kind, sizes, Decimal costs, paths, verification, and the originating job_id.
  • BackupPolicyService (get_or_create_policy, update_policy, run_policy_now, list_runs, request_restore, verify_run, delete_run) registered via register_jobs() in app/__init__.py; smart backups alternate full vs incremental by full_every_n_days with chain-aware retention.
  • BackupCostService$/GB/month rates persisted in backups.json (local $0 default; s3 0.023 / b2 0.006 list-price defaults), all money in Decimal. cost-rates GET/PUT plus a cost-summary aggregate (sum of successful-run sizes × current rates) with a forward monthly projection from each policy's recent average size.
  • REST surface mirrored on apps.py and wordpress_sites.py: …/backup-policy GET/PUT, …/backups GET/POST, …/backups/<run>/restore and /verify POST, …/backups/<run> DELETE — access-gated per target (_can_edit_app / site ownership). New restore.completed / restore.failed catalog events. Migration 028_backup_policies.
  • Frontend: shared components/backups/ProtectionPanel.jsx (used by Backups.jsx, service detail SettingsTab, and WordPressDetail) with ScheduleCard, ProtectionStatusCard, BackupCalendar, BackupHistoryList, BackupDetailDrawer, RestoreDrawer, and a format.js helper; Backups.jsx adds the global cost view. New services/api/backupProtection.js + system.js cost helpers and _backups.scss.

System notices & notification center

  • GET /system/notices (admin) computes dismissible misconfiguration hints from SiteDomainService — missing canonical domain, missing sites base domain, missing server public IP, and managed-sites wildcard HTTPS not set up — each with a level, message, and a deep-link to the fix.
  • SystemNotices.jsx top banner now shows at most one urgent notice; the rest fold into the notification center via NotificationsContext (admins only). utils/dismissedNotices.js persists dismissals in localStorage and maps notices onto the bell/page item shape, so banner, bell, and Notifications page stay in sync; unreadCount becomes bus-unread + notice-count, and markAllRead leaves notices alone (they clear on fix/dismiss, not on read).
  • NotificationBell and Notifications.jsx render notice items distinctly (action label + dismiss X); notice clicks route to the fix instead of marking read.

Email normalization & domain-aware URLs

  • User.email is lowercased via a @validates hook, and all lookups (register, login, admin create/update, self-update, SSO find_or_create_user) use func.lower(...) so case variants can't create duplicate accounts or block a login.
  • New SiteDomainService.panel_origin() (canonical-domain setting → PUBLIC_URL/SERVERKIT_PUBLIC_URL → sites base domain) now backs Gitea, WordPress, and Roundcube public URLs, Django ALLOWED_HOSTS (panel host + its subdomains), and the environment-domain fallback (prefers the base/panel domain over a useless .localhost).
  • WordPress create_site accepts an optional custom domain (attached + URL-migrated on create); Roundcube install accepts a domain and otherwise auto-publishes at webmail.<panel_host> with an nginx proxy. _canonical_site_url now honors https and the panel origin. Removed the unused CreateSiteModal.jsx; WordPress.jsx exposes the domain field.
  • patch_localhost.py is a one-off patcher that applies the same panel_origin URL fixes to an already-installed /opt/serverkit tree.

Misc fixes, build & release

  • ToastContext toastOptions() accepts either a numeric duration or a full sonner options object; previously an options object was double-wrapped and custom durations were silently dropped app-wide.
  • Release tarballs no longer ship a pre-built venv (relocated absolute shebangs/paths broke systemd with 203/EXEC): install.sh and scripts/update.sh always (re)build the venv locally (update.sh adds locate_python for 3.11/3.12 + rebuild_virtualenv), and scripts/build-release.sh switches from rsync + baked venv to a tar-based copy that survives Windows/Git Bash and broken symlinks.
  • tomli fallback for Python < 3.11 (repository-manifest TOML parsing), alembic logging config, frontend Dockerfile + compose healthchecks use 127.0.0.1 (the container only listens on IPv4), dev compose mounts frontend/dist, and .gitignore ignores the new local plan/roadmap docs.

jhd3197 and others added 28 commits June 22, 2026 14:37
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.
- 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
- 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
…d client

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) <[email protected]>
… (Phase 1)

#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) <[email protected]>
#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) <[email protected]>
…ror (Phase 2 frontend)

#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) <[email protected]>
…ase 3)

#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) <[email protected]>
…backend)

#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) <[email protected]>
- 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
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) <[email protected]>
… (Phase 3 #9 UI)

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) <[email protected]>
…r; top banner = urgent only

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) <[email protected]>
…ackups, API

Add a real Protection layer for WordPress sites and applications: one BackupPolicy per target (cron mirrored into a ScheduledJob on the unified job bus), BackupRun history, cost estimation, and full/incremental smart backups with chain-aware retention and remote verify.

- models BackupPolicy + BackupRun (migration 028)
- backup_policy_service: CRUD, schedule wiring, backup.policy.run/restore.run handlers (distinct from legacy backup.run), chain-aware retention, restore chain
- backup_cost_service: storage-cost rates ($/GB/mo); local free by default
- backup_service: tar --listed-incremental + zstd/gzip compression tiers
- API: policy/runs/restore/verify/delete on /wordpress/sites and /apps; cost-rates + cost-summary on /backups
- restore.completed/failed notification events
- tests: cost, schedule sync, kind decision, chain-aware retention, restore wiring

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Shared ProtectionPanel (status, schedule, history) rendered for WordPress (Backups tab + Settings) and Services (Settings → Backups), plus a calendar view, detail/restore drawers, and storage-cost columns/settings/KPIs on the global Backups page.

- components/backups: ProtectionPanel, ProtectionStatusCard, ScheduleCard, BackupHistoryList, BackupCalendar, BackupDetailDrawer, RestoreDrawer, format helpers
- api/backupProtection: generic policy/runs methods over targetType
- WordPressDetail + service-detail SettingsTab render the shared panel
- Backups page: cost rates settings, Site/Cost columns, cost KPI cards

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
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) <[email protected]>
_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 652d903) 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) <[email protected]>
Copilot AI review requested due to automatic review settings June 23, 2026 01:59
@jhd3197 jhd3197 merged commit 14fc04f into main Jun 23, 2026
1 check passed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR significantly expands ServerKit’s Cloudflare integration beyond DNS, hardens how DNS writes are performed via a unified credential + ownership/logging layer, and introduces “Backup Protection” (scheduled + incremental backups with restore/verification and cost estimation). It also includes a set of operational/install improvements (release tarball venv rebuild), notification UX changes (dismissible system notices), and account/domain normalization fixes.

Changes:

  • Add Cloudflare “zone operations” API + UI (settings, cache purge, WAF rules, Workers, Tunnels, R2/KV/D1).
  • Overhaul DNS integration: unify Cloudflare credential resolution, add “never-touch-foreign” ownership ledger, and introduce a provider change log + UI surfaces.
  • Implement Backup Protection policies/runs with restore flows, verification, and storage cost estimation + UI.

Reviewed changes

Copilot reviewed 112 out of 113 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
VERSION Bump release version
scripts/update.sh Rebuild venv on release installs
scripts/build-release.sh Build tarball without baked venv
install.sh Always build venv locally
frontend/src/utils/dismissedNotices.js Persist/shape dismissed system notices
frontend/src/styles/pages/_service-detail.scss Styles for “give subdomain” modal
frontend/src/styles/pages/_notification-center.scss Notice action/dismiss styles (page)
frontend/src/styles/pages/_dns-zones.scss DNS mirror/managed records styles
frontend/src/styles/pages/_connections.scss Connection list + DNS activity styles
frontend/src/styles/main.scss Import system notices + CF page
frontend/src/styles/components/_system-notices.scss System notice banner styles
frontend/src/styles/components/_notification-center.scss Notice row styles (bell)
frontend/src/services/api/system.js Add system notices + backup cost APIs
frontend/src/services/api/index.js Register Cloudflare + backupProtection modules
frontend/src/services/api/files.js Add suggest/give subdomain API calls
frontend/src/services/api/dns.js Add DNS changes/mirror/managed APIs
frontend/src/services/api/cloudflare.js Cloudflare operations API client
frontend/src/services/api/backupProtection.js Backup Protection API client
frontend/src/pages/WordPressDetail.jsx Replace backups tab with ProtectionPanel
frontend/src/pages/WordPress.jsx Add optional custom domain on create
frontend/src/pages/Notifications.jsx Render/dismiss notice items
frontend/src/pages/Backups.jsx Add cost summary + rates settings UI
frontend/src/layouts/DashboardLayout.jsx Mount SystemNotices banner
frontend/src/contexts/ToastContext.jsx Fix sonner options normalization
frontend/src/contexts/NotificationsContext.jsx Merge system notices into inbox list
frontend/src/components/wordpress/index.js Remove CreateSiteModal export
frontend/src/components/wordpress/CreateSiteModal.jsx Remove unused component
frontend/src/components/SystemNotices.jsx Admin system notice banner component
frontend/src/components/settings/SiteSettingsTab.jsx Add managed-sites DNS mode setting
frontend/src/components/settings/connections/DnsActivity.jsx Connection DNS change feed UI
frontend/src/components/settings/connections/ConnectProviderModal.jsx Expandable DNS activity per connection
frontend/src/components/service-detail/SettingsTab.jsx Add app ProtectionPanel + subdomain publish UI
frontend/src/components/NotificationBell.jsx Render/dismiss notice items in bell
frontend/src/components/cloudflare/WorkersPanel.jsx Cloudflare Workers UI
frontend/src/components/cloudflare/StoragePanel.jsx Cloudflare R2/KV/D1 UI
frontend/src/components/backups/RestoreDrawer.jsx Restore flow drawer + typed confirm
frontend/src/components/backups/ProtectionStatusCard.jsx Protection status + KPIs/actions
frontend/src/components/backups/format.js Backup formatting helpers
frontend/src/components/backups/BackupHistoryList.jsx Backup run history table
frontend/src/components/backups/BackupDetailDrawer.jsx Backup run details drawer
frontend/src/components/backups/BackupCalendar.jsx Backup calendar UI
frontend/src/App.jsx Route for Cloudflare zone settings page
frontend/Dockerfile Healthcheck use 127.0.0.1
docs/BACKUP_PROTECTION.md Backup Protection documentation
docker-compose.yml Mount dist + IPv4 healthcheck
backend/tests/test_dns_subdomain_modes.py Tests for wildcard vs per-site DNS
backend/tests/test_dns_give_subdomain.py Tests for “give subdomain” + managed records
backend/tests/test_dns_credential_resolution.py Tests for unified DNS credentials
backend/tests/test_dns_change_log.py Tests for DNS change logging + notify
backend/tests/test_dns_caa.py Test update for set_record kwargs
backend/tests/test_attach_domain.py Test update for set_record kwargs
backend/requirements.txt Add tomli fallback for <3.11
backend/migrations/versions/028_backup_policies.py Backup policy/run tables migration
backend/migrations/versions/027_dns_change_log.py DNS changes table migration
backend/migrations/versions/026_managed_dns_records.py Managed DNS ownership table migration
backend/migrations/versions/025_dns_provider_link.py Link zones to provider configs migration
backend/migrations/alembic.ini Add logging config
backend/app/services/wordpress_service.py Domain-aware URLs + custom domain create + per-site DNS
backend/app/services/sso_service.py Case-insensitive email lookup
backend/app/services/sites_https_service.py Expose dns_mode + force wildcard on setup
backend/app/services/site_domain_service.py panel_origin + dns_mode + ensure_site_dns + give_subdomain
backend/app/services/roundcube_service.py Domain-aware public URL + proxy setup
backend/app/services/repository_manifest_service.py tomllib/tomli compatibility
backend/app/services/python_service.py Domain-aware Django ALLOWED_HOSTS
backend/app/services/git_service.py Domain-aware Gitea public URL
backend/app/services/environment_domain_service.py Prefer real domain over .localhost
backend/app/services/dns/base.py Provider-agnostic DNS dataclasses
backend/app/services/dns/init.py Shared DNS client factory
backend/app/services/dns_ownership_service.py Ownership ledger + guarded writes
backend/app/services/dns_change_service.py DNS change log + failure notices
backend/app/services/backup_service.py Smart backup + incremental restore helpers
backend/app/services/backup_cost_service.py Storage cost rates + projections
backend/app/notifications/catalog.py Add restore + dns sync events
backend/app/models/user.py Lowercase email normalization
backend/app/models/managed_dns_record.py Managed DNS ownership model
backend/app/models/dns_zone.py Link zones to provider config
backend/app/models/dns_change.py DNS change log model
backend/app/models/cloudflare_worker.py Worker source tracking model
backend/app/models/cloudflare_tunnel.py Tunnel token tracking model
backend/app/models/backup_run.py Backup run model
backend/app/models/backup_policy.py Backup policy model
backend/app/models/init.py Export new models
backend/app/api/wordpress.py Accept custom domain on create
backend/app/api/wordpress_sites.py Backup Protection endpoints (WP)
backend/app/api/system.py /system/notices endpoint
backend/app/api/email.py Pass Roundcube domain param
backend/app/api/domains.py Suggest/give subdomain endpoints
backend/app/api/dns_zones.py Mirror/changes/managed endpoints
backend/app/api/backups.py Backup cost rates + cost summary endpoints
backend/app/api/auth.py Case-insensitive email register/login/update
backend/app/api/apps.py Backup Protection endpoints (apps)
backend/app/api/admin.py Case-insensitive email create/update
backend/app/init.py Register Cloudflare bp + backup policy jobs + migrate zones
.gitignore Ignore new roadmap/plan docs

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +8 to +15
export function getDismissedNotices() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
} catch {
return [];
}
}
Comment thread backend/app/api/system.py
Comment on lines +229 to +233
notices = []
canonical_domain = (SiteDomainService.panel_origin() or '').replace('https://', '').replace('http://', '')
base_domain = SiteDomainService.base_domain()
server_ip = SiteDomainService.server_ip()
https_enabled = SiteDomainService.https_enabled()
Comment on lines +5 to +6
import { Button } from '@/components/ui/button';
import { getDismissedNotices, addDismissedNotice, isUrgentNotice } from '../utils/dismissedNotices';
Comment on lines +18 to +23
export default function SystemNotices() {
const navigate = useNavigate();
const [notices, setNotices] = useState([]);
const [loading, setLoading] = useState(true);
const [dismissed, setDismissedState] = useState(getDismissedNotices);

Comment on lines +40 to +42
const handleDismiss = (id) => {
setDismissedState(addDismissedNotice(id).slice());
};
Comment on lines +151 to +168
from app.services.dns_change_service import DnsChangeService
if provider_record_id:
owned = DnsOwnershipService.owns(provider_zone_id, provider_record_id=provider_record_id)
else:
owned = DnsOwnershipService.owns(provider_zone_id, record_type=record_type, name=name)
if not owned:
return {'success': True, 'skipped': True,
'message': 'No ServerKit-owned record to delete.'}
res = client.delete(provider_zone_id, record_id=provider_record_id,
record_type=record_type, name=name)
DnsOwnershipService.record_delete(provider_zone_id, record_type=record_type,
name=name, provider_record_id=provider_record_id)
DnsChangeService.record(
provider=provider, provider_zone_id=provider_zone_id, action='delete',
record_type=record_type, name=name, provider_record_id=provider_record_id,
source=source, result='ok' if res.get('success') else 'error',
error=None if res.get('success') else res.get('error'), config_id=config_id)
return res
Comment on lines +873 to +884
if not archives:
raise RuntimeError('No archives to restore')
os.makedirs(restore_path, exist_ok=True)
parent = os.path.dirname(restore_path.rstrip('/')) or '/'
for archive in archives:
if not os.path.exists(archive):
raise RuntimeError(f'Backup archive missing: {archive}')
cmd = ['tar', '--listed-incremental=/dev/null', '-xf', archive, '-C', parent]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=1800)
if result.returncode != 0:
raise RuntimeError(f'tar restore failed: {(result.stderr or "").strip()[:300]}')
return {'success': True, 'restore_path': restore_path}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants