a1cd14a1cddbb45fb635cd885b8d98dbc5b498a1
7
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a1cd14a1cd |
Scan images for known vulnerabilities (0.59.0)
The Images page knew what was running and whether it was current. It could not say whether any of it was exploitable, which is the question people actually have about a homelab full of images they pulled once and forgot. Trivy runs as a throwaway container rather than being installed into StackPilot's image, reusing the helper-container pattern backups already use for volume contents. Three reasons: a 100 MB security tool and a vulnerability database that changes weekly have no business in a release artifact, pinning SCANNER_IMAGE is then a real version control, and the scanner updates itself by pulling a newer tag. It gets the socket read-only so it inspects images the daemon already has instead of pulling them again, and a named volume for its database so the ~50 MB download happens once rather than per scan. The number the UI leads with is "fixable", not the total. A base image with 300 unfixable low-severity CVEs is not a task and a page that shows 300 in red teaches people to ignore it; three findings with a fixed version available are something to do this afternoon. Counts are stored per severity, findings are sorted worst-first and capped at 200 — every finding is counted, only the list is trimmed, so the cap can never hide the severity distribution. The failure mode this had to avoid is a security feature that reads as clean when it is broken. A scanner that cannot run stores the error and *keeps the previous counts* rather than resetting to zero, so a transient daemon problem does not silently turn a bad image green. There is a test for exactly that, and another for unparseable output. Staleness is handled the same way: the local image id is recorded with the scan, and pulling the image marks the result stale instead of presenting yesterday's numbers for today's bytes. Sweeps are deliberately serial and singly-locked. Scanning is CPU- and IO-heavy, and running eight at once on a homelab box would starve the very containers the scan is meant to protect. docker-py is synchronous, so the scan itself goes to a thread — otherwise a ten-minute scan blocks every other request on the loop. Reading results is allowed for the read-only role, which the authorization matrix made me justify in writing: CVE ids and package versions for images whose tags and compose files that role can already see, and polling them is the monitoring use case a read-only API token exists for. Running a scan stays admin-only because it spends real CPU. 20 tests against a report shaped like Trivy's real output, covering the counting, the fixable number, worst-first ordering, the cap, both failure paths, staleness, and that two sweeps cannot overlap. Verified end to end through the API as well, including that a failed rescan keeps its previous counts and shows the error. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9247ff9621 |
Deploy stacks from a Git repository (0.58.0)
StackPilot's stacks were already plain folders on disk, which makes GitOps less
of an architectural change than it would be elsewhere: a sync is "make these
files match that repo, then compose up". Almost all of the design effort went
into the word "these", because getting it wrong destroys data.
A stack folder is not just the compose file. Compose creates bind-mount
directories in it — ./config, ./data — and those hold the live state of whatever
is running. So the obvious implementation, clone into the stack folder and
git reset --hard, is a data-loss bug waiting for its first `git clean`. Instead
the clone lives in a cache under ${DATA_DIR}/git/<stack> where reset and clean
are safe, and the configured subtree is copied across. No .git ends up in the
stack folder, so backups and the file browser are unaffected too.
Deletion is the other half. Making a folder "match" a repo naively means
removing what the repo does not have, which is exactly the application data
above. So each sync records the paths it wrote, and the next sync may delete
only those — a file the repository never provided cannot be touched by any code
path here. Tested directly: a database file and a hand-written .env survive a
sync that replaces the compose file and removes a file the repo dropped.
What the repo does provide is overwritten, hand edits included. That is the
point of GitOps rather than a wart, but it is a surprise if you attach a repo to
a stack you have been editing, so the connect form says it before the first sync
and the first sync is never automatic.
The webhook is the only route in StackPilot with no bearer token, because a Git
forge has none to present. It authenticates with an HMAC over the body —
X-Hub-Signature-256 for GitHub/Gitea/Forgejo, X-Gitlab-Token for GitLab, both
compared in constant time — and answers 404, not 403, to anything unsigned. A
403 would confirm that a given stack exists and is connected to a repository,
which an unauthenticated caller has not earned. The authorization matrix test
caught this route being public and made me write that reasoning down in it,
which is exactly what that test is for.
Credentials never reach a command line: ps is readable by every process on the
host, and this runs in a container next to everything else. The HTTPS token goes
to git through GIT_ASKPASS and the environment, the SSH key through a 0600 file
kept outside the working tree, and everything git prints is scrubbed of both —
plus any credential-carrying URL — before it is stored in last_error or shown.
Auto-deploy takes the same per-stack lock as every other lifecycle action, so a
webhook firing mid-deploy reports "files synced, stack busy" instead of racing a
second compose run at the same project.
The image needed git and openssh-client, which is the only reason this release
touches the Dockerfile.
26 tests against real repositories created with the real git binary, none of
them touching the network — mocking git would mostly test the mock. Verified end
to end as well: connect, sync, a push that changes one file and deletes another,
a wrongly signed webhook, a correctly signed one, and the live data still there
afterwards.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b629d1b2c2 |
Use the apps' real logos as stack icons, fetched server-side (0.52.0)
0.51.0 gave every stack an icon, but a generic one: jellyfin got a clapperboard,
not the Jellyfin logo. Glyphs make a list readable; they do not make a stack
recognisable, which was the point. This resolves stacks against the selfh.st
icon catalog (~2900 self-hosted apps, the set Homarr and Homepage draw on), so
the row shows the thing people already recognise. All 83 bundled templates
resolve to their own logo.
The whole design question was *who* talks to the CDN. If the <img> points at
jsdelivr, then every client needs internet, every page load leaks the names of
somebody's stacks to a third party, and an air-gapped box gets nothing. So the
backend does it: the catalog on startup and weekly after, each logo once on
first use, both into ${DATA_DIR}/stack-icons/. Browsers keep reading icons from
the authenticated endpoint that already existed for uploads, and after the first
fetch the feature is fully offline. Logos are cached per *app*, not per stack —
verified: two stacks resolving to jellyfin produce one download.
Nothing here can fail loudly. Every entry point returns None rather than raising
when the network is absent, the catalog refresh is a task the lifespan does not
await, and an install with no outbound internet simply keeps 0.51.0's glyphs.
That fallback is also what covers a name the catalog does not know
("Mediaserver Wohnzimmer" is still a clapperboard), and the seconds after a
fresh install before the catalog lands. The glyph is derived even for stacks
that *do* have a logo, so an image that cannot be fetched degrades to something
meaningful instead of a box.
Matching gained a second source that turned out to matter more than expected:
the compose images. A stack called "medienserver" says nothing, but it pulls
lscr.io/linuxserver/jellyfin — strip the registry, the vendor and the tag and
the app is right there. Name first, then the longest run of words inside it,
then the images. It is deliberately cautious: a single word shorter than four
characters never claims a logo, because "web", "app" and "db" are all catalog
entries and a *wrong* logo is worse than a neutral glyph. A short alias table
covers what the catalog spells differently from Docker Hub (postgres →
postgresql, pihole → pi-hole, wg-easy → wireguard).
A slug arrives from the database and from query strings and then becomes a
filename, so it is pattern-checked before it is ever joined to a path, catalog
entries that are not slug-shaped are dropped on load, and a downloaded logo is
verified to start with the PNG magic bytes before being cached.
The picker searches the catalog too — pre-seeded with the stack's own name, so
opening it on "jellyfin" offers the Jellyfin logo first — which is how a wrong
match gets corrected, and how a stack can be given any app's logo on purpose.
Verified end to end against the live catalog and real downloads: list rows carry
the resolved logo, the icon endpoint serves real PNG bytes, an unmatched stack
404s (and falls through to its glyph), a hand-picked logo round-trips, reset
clears it, and a traversal slug 404s. 30 new backend tests and 12 new frontend
ones run without any network at all.
0.52.0 rather than amending 0.51.0: those images are already in the registry,
and rebuilding a published version tag with different content is exactly what
breaks the self-update checker.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7682460b4f |
Give every stack an icon, and put the status on it (0.51.0)
Stacks were a name and a coloured dot. The dot carried the status but nothing
carried identity, so a list of twenty stacks read as twenty identical rows.
This gives each one an icon in front of its name and moves the status onto that
icon as a halo in the status colour, which is the thing the eye lands on anyway.
The constraint that shaped the design: people already have stacks. Asking them
to pick an icon for each one before the feature does anything would mean it
never gets used, so the icon is *derived* from the stack's name and the column
stays empty until somebody overrides it. ~700 keywords in 79 groups cover the
self-hosted long tail (jellyfin -> clapperboard, vaultwarden -> key,
home-assistant -> house) plus generic English and German terms; the longest
match wins, so photoprism beats a bare photo, and short keywords like "tv" only
match as whole words. No backfill, no migration, and a rename moves the icon
with it.
That is also why the catalog and the matcher live in the frontend. It is the
only place that can render an icon, so a copy in the backend would be a list to
keep in sync and nothing else. The server validates the shape of the stored
value and stores uploads; it never needs to know what "lucide:database" looks
like. An icon name that later leaves the catalog falls back to the derived one
rather than blanking the row.
Overriding happens in two places, because there are two moments: the editor
(holding a chosen file until the stack exists, since uploading needs an id) and
a click on the icon on the detail page, which is how a stack that has existed
for a year gets one without a trip through the editor.
Uploads are classified by their bytes, not by the filename or Content-Type the
browser claims, and land in ${DATA_DIR}/stack-icons/ under the stack id. SVG is
allowed — <img> does not execute it — but the endpoint serves every icon as an
attachment so one can never be opened as a document in the API's own origin. A
client-supplied "custom:" value is refused: the server mints those, so a stack
cannot be pointed at a file it does not own. Files follow the stack: replaced on
re-upload (including across formats, or the old one orphans), copied on clone,
removed on delete.
The one piece of plumbing worth knowing about: the icon endpoint needs the
bearer token like everything else, and an <img src> would not carry it. So
StackIcon fetches the bytes through the API client and renders the blob, keyed
on the stored value — which carries an upload timestamp precisely so a re-upload
changes the key and retires the cached image.
Covered by 22 backend tests (the value rules, byte-sniffing, the file lifecycle,
the API round-trip, and that the read-only role cannot change an icon) and 29
frontend ones for the matcher. The schema change was verified against a
hand-built pre-0.51 database: the column is added on start and existing rows
come back NULL, i.e. automatic. Not click-tested in a browser — no Docker in
this environment — so the row height the taller icon produces is unverified.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
51d1998307 |
Remove the remote-host (agent) integration (0.48.0)
StackPilot now manages exactly one Docker host: the one it runs on. The
stackpilot-agent sidecar and everything that proxied to it are gone — 4721
lines deleted against 657 added.
Deleted outright: agent/ (image, compose, env), agent_app.py, models/agent.py,
routers/agents.py (1200 lines), services/agent_service.py, the agent API client,
RemoteStackDetail, the host components and AgentStacksSection. That removes 57
API routes and the three /ws/agent-* proxies.
Threaded out everywhere else, which was the bulk of the work. Every API module
carried an optional agentId that switched the base path; every page that listed
Docker objects rendered one section per host behind a HostHeader; Files had a
host switcher; the New Stack editor and the template dialog had host selectors;
schedules, auto-update policies and stack summaries carried agent_id. All of it
is gone, and the typechecker drove the sweep — 85 files touched, tsc and the
build clean.
Two things the removal exposed as dead weight rather than merely unused:
compose_service kept an in-process busy set purely because the agent needed a
lock and has no database. With the agent gone that was a second source of truth
next to the real DB lock, so it is deleted; compute_status now reports only what
the containers say and the two callers that want "updating" overlay the lock.
StacksTable's linkBase prop only ever existed to point at /hosts/{id}/stacks.
The dashboard's "Hosts 1/1 online" KPI can no longer say anything else, so the
tile and the KPIs behind it are gone and the row is five wide.
Upgrading matters here. An existing install still has an agent table holding
each remote host's URL and bearer token — full Docker control of that host,
sitting in the database with nothing left to use it. _drop_removed_schema drops
it on first start, and drops the agent_id columns where the SQLite build
supports DROP COLUMN. Each statement runs in its own transaction on purpose: a
failed DDL poisons the transaction it is in, so sharing one would let an
unsupported column drop take the table drop down with it. test_agent_removal
covers both branches plus the fresh-install and idempotent cases, and an
end-to-end run against a seeded pre-0.48 database confirms the table is gone and
every /api/agents route answers 404.
Docstrings that justified a design by "shared with the agent, which has no
database" were rewritten rather than left lying: update_service's persistence
callback and image_status_store are still the right split (registry logic stays
testable without a database), but for that reason now, not the old one. The
README's multi-host sections are removed and an upgrade note explains what to do
with running agent containers; ROADMAP keeps its history behind a note saying
the feature it describes no longer exists.
CI no longer builds or pushes stackpilot-agent.
735 tests pass, ruff and tsc clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|
||
|
|
41a21b5a25 |
Make tokens revocable and move the refresh token out of localStorage (0.46.0)
F5 — A token was valid until it expired, full stop. Resetting a compromised account's password changed nothing for whoever held its tokens (up to 30 days for a refresh token), demoting or disabling an account only took effect once the same clock ran out, and logout was purely client-side. Every account now has a token_version, every token is minted carrying it, and every request compares the two. Bumping it is the revoke switch, pulled on the three changes that alter what an account may do: password, role, active flag. "Sign out everywhere" in the user menu bumps your own. Plain "Sign out" only drops the cookie, because signing out on your phone should not kill your desktop session. The refresh token left localStorage for an httpOnly cookie (SameSite=Lax, scoped to /api/auth), and the access token is now held in memory only. A successful XSS can still act inside the open page but can no longer walk off with 30 days of access. The cookie is marked Secure only when the request arrived over HTTPS — request.url.scheme is trustworthy since the F4 fix — so a plain-HTTP homelab keeps working. Any refresh token an older build left in localStorage is deleted on first load. Scripted clients that cannot hold a cookie can still ask for it in the body with ?in_body=true. F9 comes with it, as predicted: the WebSocket helpers read the role off the live user instead of the token's claim. /ws/exec is root-equivalent on the host, and a token minted while the account was an admin stayed syntactically valid after a demotion. The sharp edge was the migration, not the feature. _ensure_model_columns emits ADD COLUMN without a DEFAULT, so SQLite would have filled token_version with NULL on every existing install, every version check would have failed against it, and the upgrade would have locked out every user everywhere. The helper now renders NOT NULL DEFAULT <literal> for scalar defaults; test_schema_migration builds a genuinely old-shaped user table and asserts the backfill. The version comparison also tolerates NULL as 1, so a database migrated by some other route still works. Writing that test surfaced an undocumented precondition: _ensure_model_columns does nothing unless `models` has been imported, since SQLModel.metadata is empty until then. It holds in production because init_db imports first; now it says so. The authorization matrix did its job — adding two auth routes failed the suite until both were classified, which is exactly the review moment it exists for. 30 new tests (698 total). Upgrading signs everyone out once. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG |
||
|
|
60a7ccff93 |
Add a test suite, a linter and a CI gate in front of the build (0.45.0)
The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
|