- 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>
- 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>
- 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>
- 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>
- 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>
- 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>
- Add 'topbar' to LayoutContext and a third switcher button (Sidebar |
Compact | Top bar).
- Topbar: the sidebar becomes a horizontal bar pinned to the top; top-level
items lay out in a horizontally-scrollable row with an underline active
indicator. Category headers, expand chevrons, and sub-items are hidden
(sub-nav is reached from each page's in-page navigation), which sidesteps
the horizontal-overflow vs. dropdown clipping trap. Desktop-only; the
mobile drawer is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- New LayoutContext (localStorage-persisted, sets data-layout on <html>),
mirroring ThemeContext.
- "Layout" switcher in the sidebar user menu: Sidebar | Compact.
- Compact rail: a 64px icon-only sidebar that expands to the full sidebar
on hover/keyboard focus; desktop-only, the mobile drawer is unchanged.
- Reuses existing nav markup — labels hide via font-size:0 (kept in the
accessibility tree); no nav-render changes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- 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.
- 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().
- 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.
- 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.
Simplify and clarify the MSI build comments in agent/packaging/msi/Product.wxs. Replace explicit dotnet tool/install commands with concise guidance to install WiX Toolset v5 and the matching Util extension (pinned to 5.0.2), and add a reference to agent/packaging/msi/build.ps1 for the exact CI commands. This makes the instructions shorter and points maintainers to the CI script for reproducible steps.
Use the correct WiX extension version specifier (wix extension add --global WixToolset.Util.wixext/5.0.2). Updated the GitHub workflow, MSI Product.wxs comments, and PowerShell build script message to use the slash-style version token so the extension installs correctly with wix v5.
Refactor agent GitHub Actions and build tooling to separate concerns: add a dedicated build-ui job (upload/download agent-ui-dist) and a version job to resolve the release version; make downstream jobs depend on these outputs. Update workflow steps to consume artifacts/outputs (use needs.version where appropriate), and mark multi-platform jobs to not rebuild the UI. Add Makefile UI targets (build-ui, UI_DIST dependency, cleanup) so go:embed succeeds locally. Improve Windows MSI docs and build script: require specific wix tool/version and verify WixToolset.Util extension before building.
Add a dedicated Node.js build stage to the agent Dockerfile to compile the desktop UI and embed its output into the Go binary. Copy the built UI into internal/agentui/dist before go build and make the Go build respect TARGETOS/TARGETARCH args for multi-arch builds. Also update agent/.dockerignore to exclude dist/, ui/dist/, and ui/node_modules/ to keep the image build context small and cache-friendly.
Switch MSI packaging to WiX Toolset v5: update CI to install the WiX Util extension, update Product.wxs comments and namespaces for v5 and show using the Util extension at build time, and enhance build.ps1 to pass the extension to wix build, fail loudly on non‑zero exit code, verify the MSI output exists and print the resolved output path. These changes ensure builds work with WiX v5 and provide clearer errors/output.
- 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.
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>
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>
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>
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>
The Backend CI installed only requirements.txt (which doesn't carry the
test-only pytest dependency) and then ran `python -m pytest`, failing with
"No module named pytest". Add `pip install pytest` to the install step.
pytest is the only test-only import across tests/ (everything else they
import — cryptography, flask_jwt_extended, prompture, yaml — is already a
runtime dependency). Full suite: 232 passed, 12 skipped locally.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent embeds its desktop console via //go:embed all:dist
(internal/agentui/embed.go), which needs agent/internal/agentui/dist to
exist at compile time. The CI and release workflows ran go build/vet/test
without first building the React app (agent/ui -> vite build outputs to
that dist), so a fresh checkout failed with
"pattern all:dist: no matching files found".
Add a Node setup + `npm ci && npm run build` (in agent/ui) before the go
steps in both jobs of agent-ci.yml and in the release build job. Verified
the build command produces internal/agentui/dist locally.
Not a Go-version issue: wireguard-go@2026, gvisor@2025 and the agent all
declare go 1.23.1, and gvisor's gohacks build constraints are go1.20+
(no upper bound), so CI's Go 1.23 compiles them fine.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
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>
#34 is infra-blocked and unverifiable on the Windows dev host, so spec it
rather than build it blind. A capability sweep found the load-balancing,
rate-limit, proxy-cache, per-vhost-log, wildcard-TLS, and S3-upload primitives
already exist (nginx_advanced_service / nginx_service / advanced_ssl_service /
storage_provider_service) — so #34 is wiring + a public base-domain + stateless
WP, not greenfield nginx.
Adds docs/HORIZONTAL_SCALING_SPEC.md: a shared keystone (K0-K4) that also
unblocks #21/#15-TLS/#8/#30-jail/#25, then #34 proper (H0-H4), each phase a
separately verifiable PR with a Windows-vs-real-host split. Links it from the
roadmap #34 entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>