Home Changelog

Changelog

Release history for soli-proxy. Binaries for Linux (amd64, arm64) and macOS (arm64) are attached to every GitHub release.

v0.34.0

latest 2026-09-15
feature

Scale to zero — sleep an idle app, wake it on the next request

Most fleets are mostly idle. On a box hosting thirty small sites a day's traffic typically touches a handful, and every one of the others holds its full runtime in memory for nothing. idle_timeout = 900 in app.infos stops such an app after fifteen quiet minutes and starts it again when someone asks for it. A reaper sweeps every 30 s; the stop goes through the same path as soli-proxy stop, so the exit is not read as a crash — no failover, no quarantine.

The next request for one of its domains is held while the app starts on its current slot and is polled for health, then forwarded as usual: the first visitor waits about a second, everyone else finds it running, and concurrent first requests share one start. A sleeping app keeps its certificate registered and keeps winning over static proxy.conf rules for its domains, exactly as a running one does. The default is 0 — never sleep — which is the right value for anything that works without being asked: cron jobs, background workers, WebSocket rooms, a cache that cannot be rebuilt in a moment. [apps] idle_timeout in config.toml sets a fleet-wide default; _admin never sleeps regardless.

feature

Per-environment sections in app.infos

One manifest, two environments. A manifest may carry [development] and [production] sections, and the proxy's --dev flag picks one — the same flag that already appends --dev to an auto-detected Soli start script and registers each app's .test alias. The alternative, a dev copy of the file and a prod copy, is two files nobody diffs until the day they disagree about something that matters.

The selected section is applied key by key over the top level, so a key it does not mention keeps the value written above, and a nested table merges into its counterpart rather than replacing it — [production.auth.users] adds accounts without discarding the noauth list under [auth]. The section that is not selected is dropped unread, so a [production] block written for a newer proxy never stops a developer's machine from starting the app. A manifest carrying neither section parses exactly as before.

fix

Discovery says which app.infos keys it did not understand

An unrecognised key was dropped without a word — worker = 4 ran the app with one worker and said nothing — and the new overlay sections raise the stakes, since a key that lands in the wrong section is ignored just as quietly. Unknown keys are now logged at startup. They stay ignored rather than fatal: refusing the manifest would take a running app off the routing table over a typo.

v0.33.0

2026-09-10
feature

HTTP Basic Auth per app, in app.infos

Apps are routed by the app manager rather than by proxy.conf rules — static rules for app-managed domains are pruned — so a route's @auth could never protect an app. An app now declares its own, with an [auth.users] table of username = "bcrypt hash" entries and an [auth] noauth list of paths served without credentials. Auth follows the app's derived domains (www.-stripped, .test in dev) and any admin-managed alias, and is enforced on WebSocket upgrades as well as plain requests.

An [auth] section the proxy cannot enforce as written — an empty hash, a pattern that does not compare literally — makes the app fail to load rather than come up unprotected. GET /api/v1/apps reports the configured usernames and carve-outs, never the hashes, and the admin UI shows an auth badge on protected apps.

fix

A contested domain resolves to one app, deterministically

The host → app lookup iterated a hash map, so when two apps declared one domain the app it returned could differ from the one actually routed to (which picks by name order). That already misattributed per-app metrics; with per-app auth reading the same lookup it would have meant answering a protected app's traffic with another app's credentials, or none.

v0.32.0

2026-09-09
feature

Exempt paths from a route's Basic Auth

A whole domain can be password-protected while the endpoints machines call — a payment webhook, a health probe — stay reachable without credentials: app.example.com -> http://app:8080 @auth:admin:$2b$12$... @noauth:/webhooks/stripe,/hooks/*. Entries are comma-separated and each is either an exact path or a prefix ending in * (which also matches the bare prefix). Paths are matched against the URL as the client sent it, before any prefix stripping, so what you write is what you see in the browser. @noauth without @auth does nothing. Editable from the admin UI (Routes → Basic Auth) and the TUI route form, and exposed as auth_exempt on the admin API.

The match is deliberately literal and fails closed: a path carrying percent-encoding or a .. segment is never exempt, so a request cannot walk out of the carve-out into a protected path that a normalising backend would resolve differently. Patterns that cannot be compared literally are refused by the admin API and dropped with a warning by the .conf parser, leaving the path protected.

v0.31.0

2026-09-04
security

Path traversal rejected before routing

Rules match on the raw path and per-route auth binds to the matched rule, so /api/../admin/users could pass an open /api/ rule and land on a protected route at any backend that normalises. Dot segments in every spelling (literal, %2e, ..;, ..\) and encoded slashes are answered 400. Backends that carry %2F as data can set [server] allow_encoded_slash = true.

security

Multi-tenant mode for untrusted apps

[apps] multi_tenant = true treats every app as untrusted code: a docker_image is mandatory, every container gets a read-only root, dropped capabilities, no-new-privileges, pid/memory/cpu ceilings and a non-root uid that tenant config cannot weaken, and docker_options is validated against an allowlist. Bind mounts may only be the app's own site directory, passed to docker by its canonical path so a running container cannot race the check with a symlink. docker_network may not join the host or another container in any mode, and the single-tenant denylist now reads docker's syntax (-v/:/host, --mount type=bind,source=/).

security

Admin API hardening

Mutating requests without X-Api-Key must carry an X-Requested-With header, which an HTML form cannot set. Route password hashes are never returned by the API; a re-submitted entry with an empty hash keeps the existing one, matched by the rule's matcher rather than its position so a whole-table update that deletes or reorders rules cannot hand a route another route's password. Empty admin credentials (api_key = "") count as unset instead of producing a server that rejects everything. Lua hook errors fail closed with a 500.

fix

Apps get their own HOME and an environment allowlist

The proxy drops privileges to the app's user but handed the child its own HOME (/root under systemd), silently breaking soli's package cache, registry credentials and the Tailwind CLI. It now comes from the passwd entry of the user the app runs as. A short allowlist (HTTP(S)_PROXY, SSL_CERT_FILE, XDG_CACHE_HOME, the soli toolchain variables) survives the cleared environment, for native and Docker apps alike, so pinned interpreter versions and egress proxies work on a server.

v0.29.1

2026-07-28
feature

Deploy trigger file

Touching restart.txt at the root of a site triggers a zero-downtime blue/green deploy, so a deploy script can end with touch <site>/restart.txt instead of an SSH-side restart. The file is polled rather than watched, because inotify does not traverse the symlinks that sites usually are. The first poll after startup only records a baseline, so an existing trigger file never redeploys everything on daemon restart. Configurable through [apps].restart_trigger_file and restart_trigger_poll_secs.

reliability

Quarantine on failed start

An app that fails to start is no longer restarted forever. The health loop previously retried every 30 seconds with no failure cap, leaving a broken deploy flapping indefinitely. The failed slot is now killed and marked failed, the previous slot keeps serving, and every automatic remediation path skips the app until an explicit deploy. Surfaced as "quarantined": true on the apps API.

fix

systemd unit and Docker entrypoint repaired

Both shipped deployment paths could never start: the systemd unit invoked a subcommand that does not exist, and the Docker CMD passed a flag that does not exist. Both now use --conf and --sites-dir, with a working directory so run/ and certs/ resolve to the mounted volumes.

v0.29.0

Security hardening
security

Streaming request-body limits

Body-size limits are enforced on chunked and HTTP/2 bodies by streaming through a limiter and returning 413, instead of blanket-rejecting chunked encoding. A body that omits or misreports its length can no longer buffer without bound.

security

Header and target validation

WebSocket upgrade requests and forwarded headers are guarded against CR/LF injection, and proxy target URLs from configuration and Lua overrides are restricted to safe schemes, closing an SSRF avenue.

performance

Shared connection pool, faster routing

All listeners share a single connection pool with a per-host idle cap. Host matching is a single case-insensitive scan again, replacing an optimization that rebuilt a throwaway hash map on every request.

Full history in CHANGELOG.md.