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>
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>
- 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>
- 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>
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>
- 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>
Follow-up to the connections work already committed in 2e1bdbc + c1d4653
(swept in by a concurrent commit). Commits the two remaining edits:
- docs/CONNECTIONS_ROADMAP.md: mark #8 (SMTP relay) done — a real Postfix
smarthost (relayhost + SASL) with an OS-agnostic smtplib connection test.
- ConnectProviderModal: drop the unused `provider` arg from EmailBody (lint).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Introduce a new Connections hub for Settings: adds presentational components and catalog for external providers (ProviderBrands, ProviderCard, ConnectionsHub, ConnectProviderModal, providerCatalog) plus SCSS (_connections.scss) and brand icons. Replaces the old GitHub-only SourceConnectionsTab (removed) and wires ConnectionsHub into Settings. Also imports the new styles in main.scss and tweaks settings styles. Provides UI to list/manage GitHub/DNS providers, show scopes, and handle connect/manage flows; keeps several providers marked comingSoon for future work.
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.
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>
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>
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>
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>
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>
Wire the previously-unused Feed/FeedItem ds primitives to the existing /admin/activity/feed endpoint as a Recent Activity panel beside Applications (span 8 + span 4). Admin-gated via AuthContext isAdmin so non-admins neither see it nor trigger the 403; actions map to tone+icon, with relative timestamps and a link to the audit log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fetch each active jail's status (getFail2banJailStatus) and render a per-jail card grid (Banned / Failed / Total banned) above the banned-IPs table, matching the prototype. Per-jail counts already existed behind the /fail2ban/jails/<jail> endpoint — the tab just never called it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the dense plugin/theme tables with the prototype's card grids. Plugins: ServiceTile avatar + name/slug + version + update-flag, and a working activate/deactivate Switch. Themes: preview placeholder + Active pill + Activate. Wires the already-existing backend WP-CLI endpoints via new activatePlugin/deactivatePlugin/activateTheme api methods (these were never backend-blocked — only the frontend api methods were missing).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an Owner column to the list view (header + FileRow cell + grid). Convert the file preview from a fixed overlay-drawer into a flush inline 3rd pane within the file-manager body via an opt-in 'inline' prop on PreviewDrawer (the shared .preview-drawer overlay used by Terminal/Docker is untouched). All preview features, tree, uploads, bulk and context-menu preserved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
OverviewTab gains a ScoreGauge posture ring + hardening checklist computed client-side from real signals (ClamAV state, file-integrity baseline, recent integrity changes, notifications) — no invented backend number. IntegrityTab's plain path lists become a dense sk-dtable with change-kind state chips (modified/deleted/new/permission). Fail2ban per-jail cards left out (payload has no per-jail counts).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The domain drawer punted DNS to a /dns link; now it loads the domain's zone records and renders them inline as a dense sk-dtable (colored .dns-rtype chip + mono name/value + TTL), reusing the DNSZones records API. Handles loading/empty/no-zone/error; the Manage DNS records link is kept for full editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the static hostname h1 + separate select with a .srv-switch button that opens a popover server list (status dot + name + provider/IP meta + active check), matching the prototype env-menu. Reuses the existing server-change handler verbatim; shown even for a single server. (Recent-activity feed stays backend-blocked.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a count subtitle, per-filter counts on the status SegControl, a client-side site search, and per-site ServiceTile gradient avatars (replacing the flat static Globe). Bulk-select, tag filter, status pills and links are preserved; enriched columns (plugins/visits/uptime) stay backend-blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Convert the vertical 3-card mode strip into the prototype's .method-grid (icon tile + title + sub, accent border/wash on the selected card, hover lift). All three create flows and form logic are unchanged; Docker-image/from-scratch stay backend-blocked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the prototype's .git-hint accent-gradient explainer banner atop the Git page (icon tile + headline + sub). The providers OAuth grid remains backend-blocked and is intentionally not added.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a quick-scan .cat-chips row above the browse grid (All + per-category), wired to the existing category filter. The right sidebar list and select are preserved (additive).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DKIM/SPF/DMARC presence pills passed dot={false}, rendering as flat chips; the prototype dtable uses dot+label status pills. Drop dot={false} so the colored status dot shows. A 3rd pending/verifying state remains backend-blocked (API reports boolean presence only).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Settings nav active item used a heavy solid accent fill; switch to the subtle accent-bg wash + accent-bright text/icon used by .sk-topbar__tab (theme-safe in light and dark). Tighten the nav column 240->206px and widen the workflow palette rail 212->248px to match the prototype.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Cron Jobs still used the legacy .page-header (plain h1 + subtitle). Swap to the shared PageTopbar (icon + title + actions) used by the other migrated pages, matching the prototype's topbar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Agent Fleet and Fleet Monitor used the generic .table style while every other migrated list page uses the dense .sk-dtable look (mono uppercase headers, token row borders). Swap the className so they match their siblings under the Servers tab group.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- 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>
The Agent Versions tab rendered an "Add Version" button and per-row Edit/Delete
buttons with no handlers — there are no edit/delete endpoints. Remove the dead
controls (and now-unused Plus/Trash2 imports); the version list is unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
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>
The updater could install an unverified binary three ways: it skipped
verification when no checksums URL was given, returned success when no checksum
line matched the platform, and ignored the HTTP status of the checksums fetch
(a 404 body parsed to an empty map → skipped). Since the updater runs as the
agent process (root on packaged installs), that was a code-execution primitive
that bypassed every feature gate.
- Refuse to install without a checksums URL.
- Extract a pure, fail-closed matchChecksum: empty/unparseable body, no entry
for this GOOS/GOARCH, or a mismatch all error (was a silent skip).
- Check the checksums fetch HTTP status.
- requireSecureURL rejects non-HTTPS update/checksum URLs (dev override via
SERVERKIT_INSECURE_TLS).
- updater_test.go: 6 tests covering match/mismatch/no-entry/empty/case/https.
Cryptographic release signing remains the recommended next layer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The installers ran `serverkit-agent register --token <token>`, putting the
registration token in the process argument list — world-readable via `ps` /
/proc/<pid>/cmdline while register runs. The documented curl|bash one-liner also
landed the token in shell history.
- register now falls back to SERVERKIT_REGISTRATION_TOKEN /
SERVERKIT_CONNECTION_STRING / SERVERKIT_SERVER_URL when the flags are absent
(a process's environment is readable only by its owner and root).
- install.sh passes the token via env (drops the old `eval`) and accepts it from
the environment so it need not appear on the curl line.
- install.ps1 passes the token to the child via env, and makes -Token optional
with the same env fallback.
Verified: agent reads the env token at runtime (register proceeds past flag
validation to the network call); install.sh `bash -n` and install.ps1 parser
both clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Nothing ran the agent's tests or the backend suite automatically, so breakage
was only found by hand. Add two workflows:
- agent-ci.yml: go vet + go test on linux, plus a cross-compile build matrix
(linux amd64/arm64, windows amd64) to catch platform-specific breakage at
PR time. darwin is excluded — fyne.io/systray needs the macOS Cocoa toolchain
and can't cross-compile from Linux (it's built on a mac at release time).
- backend-ci.yml: install requirements and run the full pytest suite, including
the new end-to-end agent command-loop test.
Verified locally: go vet clean, all three cross-builds succeed, agent unit
tests and the full backend suite (136 passed) are green.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Severity-ranked, file:line-referenced audit of the agent fleet (Go agent,
gateway/registry/long-poll, pairing, panel→agent APIs, install/packaging, UI,
docs/CI) plus a remediation-status section recording the M1/M2/M3 fixes and the
M2 correction (compose allowlisting was already enforced at the handler layer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The packaged systemd units ran the agent as User=root with a self-contradictory
ProtectSystem=strict, while the script installer (install.sh) runs a dedicated,
hardened serverkit-agent account. Align the packages with install.sh:
- User/Group=serverkit-agent; NoNewPrivileges=yes, ProtectSystem=strict,
ProtectHome=yes, PrivateTmp=yes, ReadWritePaths for config/log/data.
- Create the account (deb postinst / rpm %pre), add it to the docker group,
create+chown the data dirs, and re-chown the config dir on (re)install so an
existing root-owned agent.key becomes readable after upgrade.
- ExecStartPre=+chown fixes credential ownership at start regardless of how the
operator ran `register`; install docs now use `sudo -u serverkit-agent`.
Tradeoff: packaged agents match install.sh's capability set (Docker + metrics +
file access) and no longer do systemd/package management or self-update by
default (both need root). Disabling self-update also routes updates through the
OS package manager's signed path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>