20 Commits
Author SHA1 Message Date
Juan Denis 6296887bad Remove internal plan citations from app code
Comments, docstrings and a few strings cited plan numbers ("plan 29
#9", "plan 86 §B2") from a git-ignored folder, so they pointed at
nothing; one reached users in a bottleneck hint. Code and frontend
strings are unchanged apart from those citations (checked by AST and
token comparison). A vhost still carrying the old asset comment reads
as undrifted, and a guard test keeps new citations out.
2026-10-10 15:50:45 -04:00
Juan Denis 8fddc31e20 Normalize builtin extension copy
Applies the panel copy rules to the five builtin extensions. Tab,
page-title and command-palette labels go to sentence case (Cloud
servers, FTP server, Remote access, Status pages); display names stay
as extension names.

Walkthrough Studio: no period on short toasts, "Couldn't" errors, a
delete confirm that states the consequence, and four hardcoded strings
wrapped in t() (kicker, step eyebrow, default duration and action),
with Spanish for the new keys. 30 strings in all: Studio 16, cloud
provisioning 4, FTP 4, remote access 3, status 3.

Synced into frontend/src/plugins and the tracked live serverkit-status
manifest.
2026-10-04 17:33:01 -04:00
Juan DenisandClaude Opus 5 30b780f4e4 fix(status): a null JSON body no longer 500s the status-page routes
PR #122 review. Four routes passed request.get_json() straight into the
service, which does `if field in data` and raises TypeError on None.

Copilot reported the trigger as "empty/invalid JSON bodies"; measured on
Flask 3.1.3 that is wrong — an empty body is already a 400 and a wrong
content-type a 415, both raised by Flask before the view runs. The body
that actually reaches the view is literal `null`, which is valid JSON and
decodes to None. Same fix, accurate reason.

Also corrects plugin.json, which claimed installing the extension serves
the public /status/<slug> pages; the registry catalog says that page
stays core, and the two contradicted each other.

Both copies edited — app/plugins/ is the live copy the loader imports,
builtin-extensions/ is the source — and a test pins that they agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 00:34:20 -04:00
Juan DenisandClaude Opus 5 18767c2244 test(drift): re-anchor the builtin-copy guard on serverkit-status
serverkit-email's live copy under app/plugins/ was the only git-TRACKED
one, which is what kept test_builtin_extension_drift non-vacuous on a
fresh clone. Plan 52's cutover deletes it, so on CI the guard had zero
pairs to compare and test_drift_check_is_not_vacuous fired -- correctly.
It still passed on a dev box, where the gitignored live copies exist, so
the failure only reproduces in a clean checkout.

Track serverkit-status's live copy instead (verified byte-identical to
builtin-extensions/serverkit-status/backend), un-ignore just that path,
and update both docstrings to name the new anchor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:07:53 -04:00
Juan DenisandClaude Fable 5 43fec8030c cutover(email): serverkit-email leaves the tree (plan 52 Phase 2, D8)
builtin-extensions/serverkit-email + both tracked live copies removed; the
bundled registry entry flips to bundled:false with the local zip's sha256;
the gated auto-install (_email_was_configured) moves from
GATED_BUILTIN_SLUGS to REGISTRY_GATED_SLUGS so mail users still get it back
once the entry is published.

Panel-core tests re-home onto the sibling checkout via the new
conftest.sibling_extension_dir helper (skip cleanly when absent): the
extraction suite installs from the serverkit-email repo, the dovecot-probe
suite loads its module from there. All 39 affected tests green with the
sibling present.

OPERATOR: publish jhd3197/serverkit-email, sign, sync-registry-index.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 14:45:22 -04:00
Juan DenisandClaude Opus 5 fe02ce8eeb fix(auth): resolve the caller through one identity door, not the raw JWT
Plan 76 milestone A, drift/bug row 2 — scheduled as a fix, not a refactor,
because it is a behaviour difference shipping today.

rbac.get_current_user() is the only lookup that knows about both auth
mechanisms: it returns g.api_key_user for an X-API-Key request and falls back
to the JWT. 73 call sites answered "who is calling" with
`User.query.get(get_jwt_identity())` instead, behind 19 near-identical private
helpers (12 named get_current_user(), shadowing the real door).

The audit that scheduled this row predicted those return None for API-key
callers. Measured, it is worse: auth_required() deliberately skips
verify_jwt_in_request() once the key middleware has authenticated, so there is
no JWT context and get_jwt_identity() *raises*. Nine handlers reached through
an API-key-capable policy decorator therefore returned 500 to every API-key
caller:

  bandwidth.get_app_bandwidth          @viewer_required
  servers.set_server_workspace         @developer_required
  servers.create_server                @developer_required
  sso.get_sso_config / update_provider_config / update_general_settings
  source_connections.update_{github,gitlab,bitbucket}_admin_config
  source_connections.complete_github_app_manifest
  databases.execute_{mysql,pg,sqlite,docker}_query   (see below)

Three service helpers had the same lookup inside a blanket `except Exception`,
which converted the raise into a silent user_id=None — the write landed and
the audit trail lost the actor (shared_resource_service, cf_ops_change_service,
secrets_webhooks).

The four databases.execute_*_query sites were a second admin check *inside* an
already-@admin_required route: dead code that always passed for JWT callers and
crashed for API-key ones. They now call require_admin_user() rather than
re-deriving the answer.

Also folds in a sixth _require_admin() found in the cloudflare-ops extension
source (29 gates, the same inverted contract as jobs.py), and fixes
serverkit-gui's blueprint at its sibling-repo source rather than in this repo's
live copy.

Ratchet (second-wave rule 1 — a migration without one regrows):
tests/identity_door_census.py counts both bypass shapes by AST, direct and the
two-line `uid = get_jwt_identity(); User.query.get(uid)` variant. The ceiling is
87, all of them on @jwt_required()-only routes where reading the JWT is correct
today; the ratchet's third assertion holds the API-key-capable population at
exactly zero, so migrating one of those routes to a policy decorator without
fixing its lookup now fails. Verified non-vacuous: an injected bypass fails both
assertions and passes again on revert.

The first wave's controller-boundary baseline is regenerated — this removes 74
persistence crossings (593 -> 519), a debt reduction with no new entries.

Gate: tests/test_api_key_identity.py (7, each red before the fix),
test_identity_door_ratchet.py (10), test_api_controller_boundaries.py (4), plus
the 65 test files covering every touched module: 1024 passed, 10 skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 19:33:57 -04:00
Juan DenisandClaude Opus 5 28d26a73a2 refactor: finish the §G1 subprocess migration (census 195 -> 28)
Plan 75 §G1, the remaining 33 files. Every raw subprocess call site in
backend/app that can go through a door now does. What is left is what
should be:

  8  app/utils/system.py        the doors themselves
  8  database_service           mysqldump|gzip, gunzip|mysql, pg_dump|gzip,
                                gunzip|psql — a pipe is two processes
  7  build/docker/db_sync/ftp   streaming output, gzip pipes
  1  log_service                tail -f
  2  serverkit-analytics        sibling repo; see below

Extensions were migrated in BOTH trees. builtin-extensions/ is the source
and backend/app/plugins/ the live copy, compared by normalised hash;
test_builtin_extension_drift stays green. serverkit-email has both sides
tracked, serverkit-ftp and serverkit-cloud-provision only the source.

serverkit-analytics (2 calls) is deliberately untouched: it has no source
in builtin-extensions/ at all, and backend/app/plugins/serverkit-analytics
is a gitignored materialised copy. Editing it would be a change that
cannot be committed and is overwritten on the next sync — that fix belongs
in the serverkit-analytics repo.

Exit-code semantics are preserved wherever they carry meaning, with a
comment at each site, because they are the thing a mechanical migration
destroys: clamscan 1 = INFECTED, grype 1 = vulnerabilities FOUND, yara
1 = matches, cron_runner 127 = never launched. Those read
result['returncode'], never result['success'].

run_checked gained two things real call sites needed: it yields when the
caller directs the streams itself (writing straight to a log file), rather
than adding capture_output on top and raising ValueError; and text/errors
pass through, so site_importers/ssh keeps decoding with errors='replace'
instead of gaining a UnicodeDecodeError on a bad byte.

A regression this migration introduced, caught by the suite and fixed:
install_profile_service._docker_usable() is deliberately TRI-STATE —
None means the probe could not run, because inside LXC the docker client
is usually present while the daemon never started, and callers "must not
treat a broken probe as proof of absence". Writing it as
run_checked(...)['success'] turned an exec failure into False, i.e. "docker
is not usable" — a fact nobody established. That is the §A violation this
plan exists to remove, reintroduced by the migration meant to prevent it.
test_probe_honesty_doctor caught it. Every other run_checked(...)['success']
caller was then audited; the rest are genuinely two-state, and their
originals also returned False on a missing binary.

Seven more slips of the same shape were caught before running by an AST
check for `x = run_checked(...)` followed by `x.stdout/.stderr/.returncode`
— a dict read as an object, which raises only on the failure path, so
happy-path tests stay green.

api_controller_boundary_baseline.json: pure deletion. Routing api/docker.py
through DockerService removed the last 6 subprocess crossings, so the
`subprocess` category is gone from the API layer entirely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 16:38:25 -04:00
Juan DenisandClaude Opus 5 7e551da45c fix(email): a dovecot probe that cannot run reports unknown, not stopped
ServiceControl.is_active raises FileNotFoundError when systemctl is absent --
deliberately, so a host without systemd reads as "could not check" rather than
"not running". DovecotService.get_status called it unguarded, so
GET /api/v1/email/status returned a 500 on every such host. Email.jsx catches
that and renders "Mail server not installed" with an Install button, on a box
already running Postfix and Dovecot.

Guarding only the is_active line -- the literal review comment -- leaves the
second escape route open one statement later: both wrappers carry
timeout=PROBE_TIMEOUT, so a systemd that stops answering raises TimeoutExpired
straight through is_enabled, which swallows only the missing binary. Hence
(subprocess.SubprocessError, FileNotFoundError), covering both probes -- the
same pair dkim, spamassassin, ftp and postfix already catch.

running/enabled report None on an unreadable probe. Fabricating False here is
exactly the dishonesty this round removed from the firewall and SSL services.
email_service keeps its top-level `running` boolean with bool(), since
`True and None` is None and that field is documented as a boolean; the honest
tri-state stays in the per-component dicts.

Mutation-checked by line number: the narrow `except FileNotFoundError` fails 2
of the 4 new tests, a guard catching neither fails 3, and the healthy path
passes throughout.

Reported by Copilot on the plan-75 PR (is_active only).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 01:56:59 -04:00
Juan DenisandClaude Opus 5 b51d9c2018 fix(services): close the rest of the bare-name sbin exec class
The ufw fix repaired every run_privileged caller centrally, but three sites
reached subprocess by other routes and were broken the same way. All are
"$PATH omits sbin + the panel runs as root, so nothing prepends sudo to
resolve it".

- run_command() got the same argv[0] resolution as privileged_cmd. It does no
  privilege escalation, so nothing else would ever find these commands.
- nginx_advanced_service.test_config() ran `nginx -t` (/usr/sbin/nginx) via
  run_command, so it raised FileNotFoundError and reported every config
  invalid. It also returned valid=True unconditionally, ignoring the exit code
  entirely — so when it DID run, a broken config passed. Now run_privileged and
  the returncode is checked. reload_nginx() likewise reports a failed reload
  instead of swallowing it.
- dovecot_service.get_status() detected dovecot with `which`, which searches
  $PATH only and false-negatives on /usr/sbin/dovecot, then read the version by
  bare name under a shared except. Now is_command_available() + a separate try,
  so a version read that fails cannot unsay "installed". Patched in both the
  builtin-extensions source and the app/plugins live copy.
- SecurityService.get_lynis_status() inferred presence solely from exec'ing
  `lynis --version`; presence and version are now separate questions.

Adds test_sbin_command_guard.py so this cannot come back: an AST scan fails CI
on any raw subprocess call using a known sbin-resident command by bare name
(it caught the dovecot site), plus per-command proof that both helpers resolve
under the exact PATH the panel unit ships.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:37:59 -04:00
Juan DenisandClaude Opus 5 cc72494d59 refactor(gui): route agent commands through the SDK, and declare them all
serverkit-gui called the agent registry directly, so its manifest permissions
were decoration — the install dialog said "screenshot and capabilities" while
/synthetic also asked for system:info and system:processes, neither declared.
Going through plugins_sdk.agents enforces the declaration, so the first thing
the retrofit needed was for the manifest to tell the truth: all four actions
are now declared, in both permissions and requires_agent_actions.

Per-endpoint behaviour is deliberately unchanged. /capabilities still answers
200 with the synthetic fallback when the agent can't do it, /frame still 502s a
failed capture and 503s an offline agent.

One case needed care. declared_permissions() reads the manifest captured in the
database at install time, not plugin.json on disk, so an install predating this
lists only the two gui actions and would have started 403-ing on /synthetic.
That endpoint exists to render something for a host with no display, and it
already degrades when the agent won't answer — so a permission this plugin
hasn't been granted now lands in the same place, with a log line naming the
fix. An older install keeps working with less detail instead of breaking, and
regains it on update. There is a test for exactly that install.

Two things improve for free: a handler failure arriving inside a
transport-successful reply now reports the agent's real error instead of the
vague "agent returned no frame", and a non-dict payload can no longer crash the
capabilities handler.

The registry index still advertises the published 0.1.0-beta.1 zip and its
sha256, so it is left alone — permissions there move when the release is cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-25 14:19:06 -04:00
Juan DenisandClaude Opus 4.8 9639288494 refactor(extensions): extract seven builtins to standalone repos
Analytics, CrowdSec, DNS Server, GPU Monitor, Kubernetes, Mail Server and
Automations (tramo) now live in their own repositories and install from the
registry as versioned, sha256-pinned zips. Their sources leave
builtin-extensions/, their pre-bundled frontends leave frontend/src/plugins/,
and registry_index.json gains a `bundled: false` entry for each so the
Marketplace offers them as normal registry installs.

The panel keeps only the wiring: comments at the former mount points explain
where each surface now comes from, and the gpu auto-install gate in
extension_migration.py is dropped since a host-probe can no longer decide to
re-vendor an extension that is not in this tree.

Also untracks backend/app/plugins/serverkit-{gpu,k8s,tramo}/, which were the
panel's own install artifacts rather than source. Committing those shipped a
second copy of code that now lives in three other repos, guaranteed to drift.
.gitignore now excludes the install path for every extracted slug on both the
backend and frontend side, so a local install can never be committed back.

Four tests from test_tramo_extension.py exercise panel core rather than the
extension (the retired /api/v1/workflows blueprint, the plan-45 event emitters
ported to EventService, and the docker_service double-emit fix); they move to
test_tramo_panel_core.py and stay here. The rest of the extension suites go
with their repos.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 01:16:16 -04:00
Juan DenisandClaude Opus 4.8 f909103656 Add per-row busy state to Marketplace plugin actions
Track the in-flight plugin id/action so uninstall/update/enable/disable can't be double-fired and each row shows live progress; await loadExtensions() before clearing busy. Includes matching tramo editor and workflow_store tweaks (source + live copies) and tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 06:14:45 -04:00
Juan Denis 053d987721 Add Kubernetes plugin backend and manifest entry
Introduces the new `serverkit-k8s` backend plugin with full extension wiring: plugin metadata, model registration, lifecycle hooks, kubectl execution service, cluster CRUD/default handling, connectivity checks, normalized read endpoints (nodes/namespaces/workloads/pods/services/ingresses/logs/overview), and admin mutations (scale/restart/delete/apply). It also registers the plugin in the frontend plugins manifest (including plugin styles) so the Kubernetes page can be surfaced by the plugin system.
2026-07-12 03:06:16 -04:00
Juan DenisandClaude Opus 4.8 0ed76e1d7c feat(tramo): add serverkit-tramo Automations plugin backend
Node-based automation builder powered by the tramo engine, embedded as an
opt-in builtin plugin (Automations, /automations). Panel is system of record
for workflow docs; a managed @tramo/server Docker container on the host runs
them headless with run history, approvals, and inbound webhooks proxied
through the panel. Replaces the older Workflow Builder.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 00:27:41 -04:00
Juan DenisandClaude Opus 4.8 d482c6d261 feat(email): commit serverkit-email extension backend
The Email Server builtin extension's backend package was present on disk (and
exercised by the green test suite) but never tracked. Adds it alongside the
already-tracked serverkit-gpu / serverkit-gui backend plugins: the Flask
blueprint (email.py) plus the Postfix/Dovecot/DKIM/SpamAssassin/Roundcube
service modules. __pycache__ stays gitignored.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-11 01:10:30 -04:00
Juan Denis 4e1b7da5f6 Add NVIDIA GPU monitor plugin (backend + manifests)
Introduce serverkit-gpu extension: backend plugin with GPU API and service (nvidia-smi based) providing per-GPU metrics and best-effort container resolution for GPU processes. Files added: backend/app/plugins/serverkit-gpu/{__init__.py,gpu.py,gpu_service.py,plugin.json}. Also update several frontend plugin.json files (including serverkit-gpu) to match manifest formatting (trailing newline adjustments). The GPU service degrades gracefully when nvidia-smi or Docker info is unavailable.
2026-07-09 08:47:28 -04:00
Juan DenisandClaude Opus 4.8 ea07a1ec14 fix(recovery): restore agent scopes, DNS preflight, catalog events, thread guards
Assorted lost-code fixes surfaced by surviving tests.

- server.py: restore 'probe'/'survey' READ_ACTION_VERBS + is_read_action (agent
  scope classification, plan 28); servers.py scoped profiles grant doctor/
  systemd/survey read scopes
- site_domain_service: restore _host_resolves (bounded DNS check) + creation_
  warnings/_GAP_FIX_LINKS so setup preflight flags host_unresolved (plan 22/31)
- notifications/catalog: register backup drill/verify restore-proof events
- metrics_history + api_analytics: guard the eager background-thread start under
  the testing config (no threads leak per create_app)
- remove a stale partial app/plugins/serverkit-email/ package that shadowed the
  runtime-installed email extension (broke route registration)

test_server_permissions 5, test_setup_preflight 7, test_backup_alerts 6,
test_no_background_threads 1, test_email_extraction 3 — green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 02:06:01 -04:00
Juan Denis 6e32519f7e restore: remaining reconstructed July 5-7 changes (clean recovered files not attributable to one plan) 2026-07-07 20:29:31 -04:00
Juan Denis 4db9300001 Introduce sk1 connection string format and GUI plugin
Switch connection string encoding from the old base64 blob to a readable sk1:// URL format (host/token[?exp&insecure]) across agent, backend, and frontend. Update encode/decode logic and extensive tests to validate port preservation, insecure/http handling, expiry parsing, and input validation; adjust agent pairing UI to accept the new scheme. Add ServerKit Agent GUI plugin: backend blueprint endpoints for capabilities, frame capture and synthetic desktop, plugin metadata; frontend plugin components (ServerGui, Launcher, SyntheticDesktop), styles, and manifest entry to enable streaming screenshots with a synthetic fallback. Also add Windows service stop waiting to avoid start races after pairing, and document SERVERKIT_PUBLIC_URL in env examples.
2026-05-05 14:03:31 -04:00
Juan Denis 71b341ef77 Add plugin system (backend + frontend)
Introduce a plugin installation and management system.

Backend: add InstalledPlugin model, plugin_service with download/install/enable/disable/uninstall, dynamic blueprint registration and frontend manifest generation, and plugins API endpoints (list/get/install/enable/disable/uninstall). Register plugins blueprint and attempt to hot-load installed plugin blueprints at app startup.

Frontend: add PluginLoader component (Vite import.meta.glob) to render plugin widgets, extend Marketplace UI to install/manage plugins, add API client methods for plugins, and update styles for the marketplace plugin UI. This enables installing plugins from GitHub/repos/zips and managing them from the app.
2026-03-28 15:58:34 -04:00