2263 Commits
Author SHA1 Message Date
Juan Denis 2cb2b36474 Merge pull request #120 from jhd3197/dev
Policy-driven scaling, a provider SDK, and all-25 distro coverage
v1.9.10
2026-08-29 04:39:04 -04:00
Juan Denis 4247e9270c Merge origin/dev (version bump) 2026-08-29 04:31:10 -04:00
Juan Denis 71ce9f28e8 Merge main into dev to update PR #120 2026-08-29 04:31:04 -04:00
github-actions[bot] 989a5d60e6 chore: bump version to 1.9.10 [skip ci] 2026-08-29 08:21:23 +00:00
Juan DenisandClaude Fable 5 7a87e98bb6 chore(registry): re-vendor bundled index from the published signed catalog
serverkit-extensions#3 published the signed first-party catalog and
restored serverkit-redis (re-pinned to the rebuilt v1.0.1 asset). The
bundled mirror already carried the signatures; this picks up the
serverkit-redis entry so test_matches_published_index agrees with main
again. Generated by scripts/sync-registry-index.sh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 04:09:46 -04:00
Juan DenisandClaude Fable 5 5d2b3dd5ca fix(scale): roll back on any set_policy commit failure
The rollback guard only caught ValueError from field validation, but
db.session.commit() itself can raise SQLAlchemy errors (IntegrityError,
OperationalError), leaving callers a dirty session despite the comment's
promise. Catch everything, roll back, re-raise. (Copilot review on #120)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 04:06:07 -04:00
Juan DenisandClaude Fable 5 2c7f13051f fix(scale): normalize backend nulls before they reach the policy form
The backend stores an unset service_name as null; spreading the API
response over the defaults pushed that null into a controlled <Input>,
flipping it uncontrolled. normalizeScalePolicy now coerces strings and
integer fields back to form-safe values. (Copilot review on #120)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 04:06:07 -04:00
Juan DenisandClaude Fable 5 e1f371b585 test(templates): roadmap icon contract follows the registry-hosted icons
586f556b/025fa0be deliberately moved template icons off inline data URIs
onto https://serverkit.ai/imgs/template-icons/ (proxied from the
serverkit-templates repo), but test_roadmap_template_conventions still
pinned the old offline-inline contract, failing all 41 hosted roadmap
templates in CI. The contract now accepts the registry-hosted icon URL,
keeping the inline data-URI form only for templates with no public logo
(prompture-hub).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-29 04:03:13 -04:00
Juan Denis ab27c7b058 fix(distro): patch all 6 failing distro-compatibility probes
- Harness: bootstrap find/awk in distro-compatibility.py (AL2023,
  Tumbleweed, SLES BCI minimal images lack them; the source-level
  suites legitimately use both)
- Python gate 3.11-3.14: Arch/Manjaro rolling ship only python 3.14
  with no versioned fallback packages (same trap as Debian 13/#99);
  align scripts/update.sh, which had drifted to a 3.11-3.12 gate
- Gentoo: add emerge as a supported installer package-manager path
  (install.sh, scripts/lib/pkg.sh, distro.py family, deps manifest);
  provision via slotted dev-lang/python:3.13 -> 3.12
- Tests: move out-of-range stubs above the new ceiling, shadow all
  locate_python candidate names (real python3.14/3.13 in Manjaro/
  Gentoo images were winning over fixtures), cover the 7th manager
- Catalog: Gentoo is no longer a documented expected failure

Verified: test_lib 32/32, test_install 124/124, test_update 76/76,
pytest test_serverkit_installer 46/46, and all 6 previously failing
images now pass both quick and provision container probes (12/12).
2026-08-28 23:31:36 -04:00
Juan Denis 9a09a392cf test: add all-25 distro compatibility discovery 2026-08-28 23:01:48 -04:00
Juan DenisandClaude Fable 5 69b5fbe9eb docs(api): regenerate API_SURFACE.md for the connection provider routes
The seven /connections/providers operations shipped in 939c77b6 without
the surface pin being regenerated; the diff is exactly those routes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 22:20:34 -04:00
Juan DenisandClaude Fable 5 1dfd5f52c1 test(wordpress): seed the HA drift fixture through make_application
test_wordpress_ha_foundations constructed Application(...) by hand, which
the seed-one-door ratchet refuses for new files. The factory also seeds
the owning admin, so the hand-rolled User goes too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 22:20:34 -04:00
Juan DenisandClaude Fable 5 1316feaad3 refactor(scale): ContainerScaleService owns its rollback
set_policy assigned fields to the ORM row and then raised ValueError
mid-validation, leaving the API route to clean up the dirty session with a
direct db.session.rollback() — a new controller-boundary crossing the
inventory ratchet rightly refused. The service now wraps apply+commit and
rolls back before re-raising, so callers never inherit a dirty session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 22:20:33 -04:00
Juan DenisandClaude Fable 5 fb8d6bfbf8 fix(templates): the deploy drawer's domain promise now sticks
The drawer showed '<name>.<base> — Published automatically once the deploy
finishes' whenever a managed base domain existed, but publishing was gated
on a per-template auto_domain flag the operator can't see. For most of the
catalog the install landed on host:port instead — a link a default firewall
doesn't even expose — and the domain had to be redone by hand in the
service's Domain settings.

The promise on screen is now the contract: when the drawer displays a
domain it sends auto_domain: true, which rides the install plan to the
finalizer and overrides the template's silence (the forced flag also feeds
the SERVICE_FQDN magic context, so tokens and the published host agree).
Absent flag = the template YAML still decides, as before.

The drawer also stops promising what can't be delivered: the domain field
only renders for compose templates targeting the local server — the panel's
nginx can't proxy a remote container, and the repo pipeline has no
auto-publish step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:59:26 -04:00
Juan DenisandClaude Fable 5 919b057ce0 fix(capacity): an attached volume rescues a disk-shortfall verdict
The template fit check measured ONE filesystem (the panel data path) and
declared "This server doesn't have room for it" in red while an attached
volume sat with plenty of free space — more disk than the check admitted
the box had. Local headroom now also reports the other real mounted
filesystems (host_inventory_service.enumerate_filesystems), and when the
shortfall is on disk and another writable filesystem could hold the whole
footprint, the verdict downgrades to tight and names the volume so the
operator can point the app's data there. Memory shortfalls stay red — a
volume holds files, not processes.

Also: "more disk than it has" -> "more disk than it has free", since the
number shown is free space, not the box's total.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:50:19 -04:00
Juan DenisandClaude Fable 5 3cb57ec77f fix(ui): lift modal stack above the shell statusbar
The VS Code-style bottom statusbar (z 1130) was added above the old modal
stack (40/50/60), so any full-height drawer or dialog reaching the bottom
edge had its footer buttons painted over — e.g. Deploy/Cancel in the
template deploy drawer. Modals own the screen: raise backdrop/modal/tooltip
to 1140/1150/1160, keeping their internal order and leaving toasts and the
app loader on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:50:12 -04:00
Juan DenisandClaude Fable 5 586f556b93 Reference template icons through the serverkit.ai proxy
Follow-up to 025fa0be: keep serverkit.ai as the stable icon host rather
than jsDelivr. The registry repo remains the canonical home of the logo
files; serverkit.ai proxies /imgs/template-icons/ from its raw tree, so
these URLs now resolve for all 210 logos instead of only the legacy
subset the website bundles.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:28:14 -04:00
Juan DenisandClaude Fable 5 025fa0be7d Point bundled template icons at registry-hosted logos
The offline template bundle carried the same generic stroke-icon data
URIs the registry had, and even the templates with real icons pointed at
serverkit.ai, coupling icon availability to website deploys. Icons now
live in the serverkit-templates repo itself, so reference them through
its jsDelivr mirror. Also fixes pgvector/timescaledb extensions, which
referenced a postgresql.svg that never existed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 21:18:47 -04:00
Juan DenisandClaude Opus 5 5e7bb17e9b docs(readme): sync Docker pulls and Uneed badges to translations
The es/pt/zh-CN READMEs had drifted from the English one: neither carried
the Uneed POTD3 badge, and none had the new Docker Hub pulls badge. Adds
both in the same positions, leaving the intentionally translated demo
label and the ../LICENSE relative path untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GasJpPFj8JmAV7QCJhCG52
2026-08-28 13:25:04 -04:00
Juan DenisandClaude Opus 5 6c01b6cf59 docs(readme): add live Docker Hub pulls badge
The stats row showed GitHub release downloads (1.9k) but nothing for the
Docker image, which is the larger distribution channel (5.4k pulls on
jhd3197/serverkit). Adds a shields.io docker/pulls badge next to
Downloads, linked to the Hub page.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GasJpPFj8JmAV7QCJhCG52
2026-08-28 13:10:01 -04:00
Juan Denis 49b44ab945 security(extensions): trust signed first-party catalog 2026-08-28 00:56:12 -04:00
Juan Denis 5aaaab9905 feat(config): optional Redis-backed rate-limit storage via RATELIMIT_STORAGE_URI
flask-limiter picks RATELIMIT_STORAGE_URI up from app.config at init_app,
so surfacing the env var in config (only when non-empty) lets production
deployments share limit state across processes and silences the in-memory
storage warning. Unset keeps in-memory storage, matching the deliberate
single-worker gunicorn design. Documented in .env.example with probe and
mechanism tests.
2026-08-28 00:51:51 -04:00
Juan Denis 939c77b648 feat(connections): add provider SDK contract 2026-08-28 00:48:36 -04:00
Juan Denis 76c57c19d6 fix(security): harden nginx write path and certbot issuance trust bugs
- run wildcard/SAN certbot privileged and fail on non-zero exit instead
  of reporting unissued cert paths as success
- cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600,
  always deleted (was predictable /tmp path, never cleaned up)
- route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg)
- validate domains before they reach certbot argv or cert file paths
- validate reverse-proxy builder input (domains, upstreams, headers,
  locations, rate-limit/cache values) against nginx config injection
- write_vhost: refuse non-plain-filename site names and roll back the
  vhost when the reload itself fails, not only when nginx -t fails
- create_proxy API returns 400 on service errors instead of always 201
2026-08-27 23:54:01 -04:00
Juan Denis bba169f04a feat(wordpress): shared-state contract and login rate limiting at the edge
Pin the Compose template's full-tree wordpress_html named volume as the
supported same-host shared-state contract (uploads, plugins, themes, disk
page cache), and make WordPress vhost publishing install shared
wp-login.php/xmlrpc.php limit_req zones keyed by server name + client IP.
Generic Docker vhosts are unaffected; drift repair regenerates existing
WordPress vhosts with the policy. Spec and roadmap rows move from Partial
to Shipped with their real-host boundaries recorded.
2026-08-27 22:35:54 -04:00
Juan Denis bb675f0d9a feat(apps): policy-driven replica scaling for local Compose apps
Add ContainerScaleService policies (min/max replicas, CPU thresholds,
cooldown) persisted via migration 093, with manual scale, single
evaluation, and an admin sweep endpoint. Container Ops panel exposes
policy editing; the configured minimum acts as a floor on the next
evaluation. Frontend node:test suites pin policy normalization and the
API contract.
2026-08-27 22:35:43 -04:00
Juan Denis 9ce1003c33 Merge pull request #119 from jhd3197/dev
Fix the round-trip config bugs, then gate them in CI
v1.9.9
2026-08-27 21:26:12 -04:00
github-actions[bot] 6afbc8c257 chore: bump version to 1.9.9 [skip ci] 2026-08-28 00:37:17 +00:00
Juan Denis 13e2618b54 Merge remote-tracking branch 'origin/main' into dev 2026-08-27 20:30:21 -04:00
Juan Denis 9b813b4166 fix(cron): match job lines exactly 2026-08-27 20:30:17 -04:00
Juan Denis 468a68ed96 fix(security): parse restricted authorized keys 2026-08-27 20:30:17 -04:00
github-actions[bot] 3fe730d7bc chore: bump version to 1.9.8 [skip ci] 2026-08-27 23:25:29 +00:00
Juan DenisandClaude Fable 5 030ae1f690 fix(tests): exclude extension-owned ext_* tables from migration drift
test_upgrade_head_matches_models failed in full CI runs with
"table ext_testext_thing: in models but never created by any migration".
Collecting test_extension_platform.py registers its synthetic plugin
model on the shared db.metadata for the whole process, and the builtin
serverkit-analytics extension leaves the same footprint when imported.

Extension tables (ext_<slug>_* per extension_lifecycle) are created at
plugin install and dropped on uninstall --purge - core Alembic
migrations never touch them - so the drift comparison now ignores the
whole ext_ namespace on both sides instead of chasing individual names.

Verified: test_extension_platform.py + test_migration_schema_drift.py
in one process (the failing CI condition) - 12 passed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
2026-08-27 19:18:26 -04:00
Juan DenisandClaude Fable 5 cc3f4993b6 fix(tests): make the real-nginx vhost harness runnable unprivileged
The plan 82 SE real-binaries leg failed in CI because the wrapper conf
did not stand in for what a real deployment provides: nginx -t opens
every access_log for writing (/var/log/nginx is not writable by the CI
user), `include fastcgi_params;` resolves against the conf prefix (the
tmp dir under -c), the micro-cache vhost references http-level zones
that ship as a conf.d snippet, and this nginx build's -t also binds the
listen sockets, so privileged ports fail next.

The harness now localizes /var/log/nginx, /var/cache/nginx and the
listen ports into tmp_path, writes the system fastcgi_params (or a
stub), and includes the real MICROCACHE_ZONE_SNIPPET at http level with
its cache-path parent pre-created (nginx mkdirs only the leaf). Only
path strings and port numbers are swapped - every directive still goes
through the real parser, and the garbage-rejection canary still fails.

Verified in WSL as uid 1001 against nginx/1.24.0: 8 passed under
-m real_binaries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018PReuKGW6G4bac931Xxn2q
2026-08-27 19:14:54 -04:00
Juan DenisandClaude Fable 5 ab13972cfc chore(tests): raise BASELINE_COUNT floor 4687 -> 4732
Locks in plan 82's suites: route contract, migration schema drift,
restored survey observed-mode tests, stub-adoption ratchet, php pool
guards, fail2ban orphan sweeper, WAF drift/repair, real-binaries
crontab + nginx legs, API surface inventory. Measured with
SERVERKIT_CLEAN_COLLECT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 05:08:17 -04:00
Juan DenisandClaude Fable 5 0fe6d090fd test(api): pin the served API surface to a committed inventory
OpenAPIService.generate_spec() existed but nothing snapshotted it, so a
route rename/removal/method change shipped invisibly to API-key
consumers, the CLI, and agents. docs/API_SURFACE.md now pins the sorted
METHOD /path inventory (1418 operations) with the same
regenerate-and-review contract as docs/MIGRATION_INVENTORY.md: an
intentional surface change regenerates the doc in the same commit
(SERVERKIT_UPDATE_API_SURFACE=1 pytest
tests/test_api_surface_inventory.py) so the diff is the review.
Method+path only on purpose — summaries and schemas churn without
breaking anyone. Named *_inventory so preflight's globs run it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 05:03:40 -04:00
Juan DenisandClaude Fable 5 a0a111c071 test(real-binaries): run cron and nginx service code against real parsers
The panel's crontab/nginx code only ever met the fake_subprocess stub —
the 8-distro matrix runs stubs and the container legs run 12 detection
tests, so a syntax the real parser rejects failed on a user's box, not
in CI (the #117 class).

- new `real_binaries` marker (registered in conftest); the tests also
  gate on SERVERKIT_REAL_BINARIES=1 so ordinary suite runs never mutate
  a machine's crontab by surprise. Local loop (WSL works):
  SERVERKIT_REAL_BINARIES=1 pytest tests -m real_binaries
- test_real_crontab_roundtrip: add -> list -> toggle -> remove through
  the real `crontab -`/`crontab -l`, operator lines preserved, name
  injection blocked; user crontab saved/restored around each test
  (verified green on WSL against the real binary)
- test_real_nginx_vhost_syntax: every render_site_config flavor through
  a real `nginx -t` in a tmp wrapper conf, plus a garbage probe so the
  harness can't go vacuous
- backend-ci gains a real-binaries job (ubuntu runner, apt nginx+cron)
- scripts-ci gains the probe-matrix job: scripts/test/probe-matrix.sh
  (real ufw/nginx/firewalld in 4 container shapes) existed since plan
  75 but was wired into NO workflow; the ubuntu-root-sbinless leg was
  re-verified green locally before wiring

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 05:03:31 -04:00
Juan DenisandClaude Fable 5 452a2d43ae fix(php): guard pool paths and stop fabricating unreadable pool configs
version and pool_name flow from the URL straight into a filesystem path
handed to privileged write/rm — a traversal pool_name could delete an
arbitrary *.conf via run_privileged(['rm', ...]), and a hostile version
escaped the pool.d directory the same way. Both are now validated at
the service door (create/delete/get share the helpers).

_parse_pool_config returned {} on any read error, so an unreadable
pool listed as "user: www-data, pm: dynamic" — fabricated defaults.
It now returns None and the listing reports the honest unknown
(user/listen/pm null + unreadable: true), per the probe-honesty rule.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:56:12 -04:00
Juan DenisandClaude Fable 5 1a27aa784c fix(fail2ban): make renamed-away jails findable and sweepable
The jail filename derives from the site name at enable time, so a site
renamed afterwards orphans its jail: disable_jail derives a path that
no longer exists (honestly reports removed: False) while the stale
jail keeps banning against a log nginx no longer writes — invisible
to every name-derived lookup.

- list_jails() scans JAIL_DIR for serverkit-*.conf and parses the site
  label + logpath out of the rendered header, the only view that still
  sees orphans
- cleanup_orphans(active_keys) removes ServerKit jails no live key
  derives any more (operator files never touched), reloading once
- the _sanitize collision (my.site / "my site" -> one shared jail) is
  pinned as documented behavior: changing the derivation would orphan
  every existing install's jail files

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:54:13 -04:00
Juan DenisandClaude Fable 5 67aa529197 fix(waf): keep the include through drift detection and repair
Drift byte-compared the live vhost against the bare template render,
but an app with an enforcing WAF policy carries the post-render
include — so every WAF'd app read as permanently drifted, and one
"repair" click regenerated the vhost without the include: silently
unprotected while the DB still said enforcing.

- the injector is now a text-level function (inject_include_text) so
  the live wiring and drift's expected render share one implementation
- _nginx_render_expected injects the include for enforcing policies,
  making a correctly-wired vhost compare clean
- _nginx_repair re-runs WafService.apply after regenerating, so a
  genuine repair re-wires protection instead of stripping it

Basic auth (WordPress-site-scoped) and the analytics tracker
(plugin-scoped) have the same erase-on-regen exposure and are tracked
in plan 82 §H — their state lives outside the app row this check walks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:51:38 -04:00
Juan DenisandClaude Fable 5 c7fd2f2033 test(ratchet): census hand-rolled subprocess stubs in the suite
tests/subprocess_stub.py is the shared stubbing kit — hard-failing
UnscriptedCommand, sudo/basename normalization, sbin-guard still in
force — but adoption stalled half-way: 26 sites across 9 test files
still monkeypatch subprocess by hand, execs the runtime guard never
sees. Every other convergence here has a census + ceiling; this one
did not. Same contract as the rest: AST-counted over tracked files,
ceiling 26, may only go down; patching a service door
(run_privileged etc.) is the blessed pattern and not counted.
Preflight picks both files up by its existing globs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:47:38 -04:00
Juan DenisandClaude Fable 5 39501e4262 test(migrations): gate model <-> migration schema drift
The test suite's schema comes from db.create_all() (see conftest), so
none of the 93 alembic revisions had ever been executed by a test — a
model column with no migration keeps every test green while every real
upgrade dies with "no such column". New test runs `flask db upgrade`
against a throwaway sqlite file in a subprocess and diffs the migrated
schema against db.metadata in both directions.

First run caught real drift: migrations 059/065/068 create columns
(events_json, management_mode, allow_agent_update_observed) that the
models had lost in the recovery rebuild — restored in the previous
commit, which also brought two 500ing survey routes back to life.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:45:45 -04:00
Juan DenisandClaude Fable 5 69136ef48f fix(servers): restore the Observed-mode surface lost in the recovery rebuild
Migrations 065/068 have always created servers.management_mode and
servers.allow_agent_update_observed, but the model fields,
MANAGEMENT_MODES, is_managed, and agent_registry.observed_blocked_count
were lost in the 2026-07 data loss — POST /management-mode and
GET /observed-status raised AttributeError, and a mode flip that got
past validation would land on a transient Python attribute and
evaporate. test_survey_api carried skip-stubs documenting the hole.

Restored: the two columns (specs mirror the migrations), the mode
constant and is_managed property, to_dict emission, and a minimal
observed-block counter on the registry. The four skip-stubs are now
real tests, including one proving the flip persists across sessions.
The dispatch-level Observe guard remains hollow and is tracked in
plan 82 §H. events_json on notification_preferences is likewise
re-mapped (column carried by migration 059; its resolution surface is
still lost) so pre-loss override data survives.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:45:37 -04:00
Juan DenisandClaude Fable 5 6dfbc0df38 test(contract): pin frontend API paths to real backend routes
Nothing verified that the ~1200 URL literals in
frontend/src/services/api/*.js resolve to rules in the backend url map,
so a backend route rename surfaced as a dead button found by a user.
New test statically extracts every this.request() literal (mirror of
test_socket_contract.py's cross-language pattern) and asserts each
matches an /api/v1 rule from the session app's route_rules; paths under
a builtin extension's plugin.json url_prefix are excused only while
that manifest still declares the prefix, and a second test keeps the
allowlist from rotting.

First run caught 78 unresolved paths. Extension-owned families
(email/ftp/cloud/status/tunnels) were excused; the rest were dead
frontend code calling routes that no longer exist, removed here:
- 11 workflow wrappers in apps.js (the workflow engine left the
  backend in v1.7.33; zero callers remained)
- notification catalog/org-defaults wrappers (routes never existed;
  zero callers)
- 6 WordPress standalone lifecycle wrappers (no routes, no callers;
  the live status probe stays, allowlisted as extension-provided)

Frontend suite still green after the deletions (141/141, lint clean).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:33:57 -04:00
Juan DenisandClaude Fable 5 f5e3816add ci: run frontend tests and widen the release gate
The frontend's 141 node --test assertions were invocable but invoked by
no workflow — written insurance never cashed in. frontend-ci now runs
npm test after lint (verified green locally: 141/141 in ~2s).

release.yml's ci-gate covered backend-ci only, so a release could ship
with a red frontend or drifted builtin extensions. The gate now also
requires frontend-ci and extensions-ci (both fast, both already
workflow_call-able). scripts-ci stays ungated on purpose — its
update-e2e leg takes ~45 min — with a comment showing how to add it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:24:06 -04:00
Juan DenisandClaude Fable 5 d3a7d4bd73 chore(tests): raise BASELINE_COUNT floor 4519 -> 4687
Locks in the round-trip suites added for the #117-class fixes (ssh
keys, env export/import, waf vhost injection, ufw parser parity,
postfix maps) plus tests accumulated since the floor was last set.
Measured with SERVERKIT_CLEAN_COLLECT=1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:20:27 -04:00
Juan DenisandClaude Fable 5 7211560768 fix(postfix): match virtual-map entries by address token, not substring
add_domain deduped with a raw substring test, so with sub.example.com
already mapped, adding example.com was a no-op that still reported
success and mail for the new domain never routed. remove_domain
filtered alias lines with a bare `@domain in line` test, which also
deleted example.com.br entries. Appends went through tee -a with no
trailing-newline guard, so a truncated map glued the new entry onto
the last line, corrupting both and making them unremovable.

- add_domain dedupes on the line's domain field
- remove_domain drops only lines whose address tokens are at exactly
  that domain (com.br superstrings and sub.example.com both survive)
- map appends heal a missing trailing newline first

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:10:14 -04:00
Juan DenisandClaude Fable 5 fee322734b fix(firewall): share one ufw parser between the listing and the guard
_get_ufw_rules and _ufw_numbered_rules each parsed `ufw status numbered`
with their own regex, and the listing's omitted LIMIT — a `ufw limit
22/tcp` rule was invisible in the panel's rule table while the
SSH-removal guard still counted it, so delete-by-number targeted rows
the user could not see. Both paths now match with the shared module
_UFW_NUMBERED_RE, so the table and the guard always agree row for row.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:08:12 -04:00
Juan DenisandClaude Fable 5 cc5323d8a9 fix(waf): wire the include into the serving block, not the SSL redirect
_inject_include inserted after the first "server {" in the vhost. On an
SSL site the first block is the HTTP->HTTPS redirect from
SSL_REDIRECT_TEMPLATE, so ModSecurity was wired into a server that only
issues 301s while the real HTTPS server stayed unprotected — and
apply() still reported wired: True. The micro-cache injector already
guards this exact trap; the WAF include now does too: redirect-only
blocks (a 301 return with nothing served) are skipped and the include
lands in the first block that actually serves the app.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:06:45 -04:00
Juan DenisandClaude Fable 5 de609526df fix(env-vars): make .env export/import lossless and injection-safe
export_to_env_format escaped backslashes and quotes that parse_env_file
never unescaped, so every export -> import cycle grew literal
backslashes in the value; newline values were written as raw multiline
quotes the parser mangled; and the user-supplied description landed as
a raw comment line, so a newline in a description injected brand-new
KEY=value pairs on re-import.

- exported values now escape \, " and newline onto a single line, and
  the double-quoted import path decodes exactly those three sequences
  (unknown sequences like C:\path stay as written)
- descriptions are whitespace-collapsed before being written as a
  comment, the same hardening the cron #117 marker got
- a None value exports as empty instead of raising TypeError

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-27 04:05:05 -04:00