GET /api/keys returned each key's AssignedCount as a raw
CountDocuments over every non-revoked assignment, with no scope filter —
a tag-restricted token reading the list saw a nonzero count for a key it
can see nothing assigned to in its own scope, which is enough to tell it
an assignment exists on a host it must not know about. Same class of leak
getKey's assignment-list filter closed on the detail route, surviving on
the list route through a count instead of a server object.
services.ListKeys now takes the caller's tokenScope. An unrestricted
caller (empty scope) takes the original unfiltered per-key
CountDocuments with no extra work, so the common case is not slower. A
restricted caller resolves the visible fleet once via ListServers before
the per-key loop, then counts each key's assignments with an added
server_id $in filter — one extra query total, not one per key.
ListKeys had exactly one caller (listKeys), so the parameter went there
rather than adding a second entry point.
Recorded GET /api/keys in serverScopedRoutes as true; its path, like GET
/api/keys/:id, matches none of serverTouchingRoutes' substrings, so the
entry is not boot-enforced. Deliberately did not widen the filter to
catch "keys" — that would sweep in create/delete/private-key routes with
no server data at all. The real fix for this shape of gap is the
declare-by-default inversion already recorded as a follow-up.
serverTouchingRoutes in cmd/main.go filtered on "server"/"console"/an exact
workflows-run match, which is how POST /api/keys/:id/assign reached
production with no scope check and no boot-time signal at all: its path
names neither. Widen the filter to also match ":serverId" and "assign",
and document at the filter why a substring match is the weak part of this
design — a route that acts on a server without saying so in its path stays
invisible to it — noting that inverting the model (every /api route
declares itself, with an exemption list) would be the stronger fix and is
left as a follow-up. Re-running the mechanical check against the widened
filter swept in no route beyond what serverScopedRoutes already declared.
GET /api/keys/:id also leaked out-of-scope hostnames: it returned every
assignment for a key, server attached, unfiltered by the caller's tag
restriction. getKey now drops any assignment whose server fails
services.ServerInTokenScope before returning the list — silently, so the
response carries no count of what was removed — while still returning the
key itself, since a restricted token may legitimately hold a key also
assigned outside its scope. GetAssignmentsWithServers has exactly one
caller (getKey), so the filtering is done in the handler rather than
threaded into the service. Recorded in serverScopedRoutes; its path
matches none of the filter's substrings either, so it is not boot-enforced
and is kept as a considered decision, same as the assign/revoke entries.
Every early return from a write-tool handler skipped both the tool's own
LogCall and transport.go's gated LogCall (which only fires for reads), so a
blocked mutation attempt left no audit trail. registerSDKTool now routes
every write-tool error through LogDenied (fan-out and tag-scope refusals,
by gate name) or LogFailure (everything else), keeping the successful-write
path logging its own resolved server count exactly as before.
Also close a live scope gap surfaced while reviewing this: POST
/api/keys/:id/assign called services.AssignKey with an unscoped GetServer
lookup, so a tag-restricted token could assign a key to a server outside
its restriction. The handler now resolves the target through
GetServerScoped first, matching its sibling revoke route, and the route is
recorded in serverScopedRoutes.
summariseServer compared Status against "online", a value never assigned
anywhere (the real vocabulary is pending/active/offline), so every server
misreported as offline. search_fleet's version_below used a lexicographic
comparison across dpkg/rpm/apk version schemes with no common ordering, so
it refuses that filter now and returns all matches instead of a wrong
answer. listAuditResult's Total carried the "shown" JSON tag and the
capped count; it now reports the real total alongside shown.
vantagectl/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-ctl.
The command keeps the name vantagectl; only the repository and the image
path change, to vantage/vantage-ctl.
chart-release.yml's render checks are repointed at the new image. The
chart itself has no default backup.image and fails loudly without one, so
an existing cluster keeps working until someone changes the value.
go.work stays, now with a single use ./server entry: without it a go.work
further up the developer's filesystem is picked up instead.
agent/ becomes the root of gitea.hostxtra.co.uk/vantage/vantage-agent,
with installer/ alongside it, and agent-release.yml goes with them.
Releases now come from that repository, so the six places this server
generates or reads a release URL are repointed: both install scripts,
both update scripts, and the latest-version lookup in dispatch.go. The
agent/v* tag prefix is unchanged — those scripts grep for it.
Agents built before this move have the old mrhid6/vantage path compiled
into their self-update and will 404 on the push-button update. The
remedy is the /update one-liner, which this server generates and which
therefore has to ship first.
vantage.proto documents the hand-written types in shared/grpc/pb, and
nothing compiles it. Keeping it in a different repository from the Go
types it describes meant the one rule holding them together — add the
message to both in the same commit — could not be followed at all.
server's rebuild trigger loses proto/, which it only carried as
insurance against exactly that split.
site/ and sitesvc/ become web/ and server/ in vantage-site; docsite/
becomes the root of vantage-docs. Their images move with them, to
vantage/vantage-site/{web,server} and vantage/vantage-docs.
Nothing here imported any of them, and sitesvc turned out to read no
database at all, so both cuts are clean. docker-compose.site.yml is
deleted rather than emptied: every service it held now ships with the
repository that builds it, and deploy/docker/docker-compose.yml is once
again exactly a self-hosted install.
Corrects four comments that named sitesvc for work it no longer does.
admin/ and adminsite/ are extracted with their history to
gitea.hostxtra.co.uk/vantage/vantage-admin, where they are named server/
and web/ for what they are rather than for the services they run. Their
images move with them, to vantage/vantage-admin/{server,web}.
Nothing here imported them, so the cut is clean: the only coupling was
always at runtime, through admin writing into the control plane's
database. The parts of that contract this side enforces are unchanged and
still documented here — hq-sourced users, POST /license answering 409
cloud_managed, and FREE_INSTANCE_REAP_AFTER needing to match.
LICENSE_SIGNING_KEY now appears in no compose file in this repository.
Keeping it out used to be a rule someone had to remember; it is the
repository boundary now.
docker-compose.site.yml loses both services and gains a note on how the
host composes the three files together.
go list -m -f '{{.Dir}}' reports an empty Dir and exits 0 for a module that
is not in the cache, so on a cold runner swag was handed an empty --dir and
failed several steps later with 'dir: does not exist'. Download first, and
fail loudly if the path is still not there.
shared/ is extracted to gitea.hostxtra.co.uk/vantage/vantage-shared and
pinned at v0.1.0 by server, agent, admin, sitesvc and vantagectl. The
replace directives and the ./shared entry in go.work are gone.
Every Go build now needs a credential for the private module: CI writes a
netrc per job from REGISTRY_USER + RELEASE_TOKEN and sets GOPRIVATE, and
the four Go Dockerfiles take it as a BuildKit secret rather than a build
arg, which would survive in the builder layer's history. RELEASE_TOKEN
needs read access to the vantage org.
admin, sitesvc and vantagectl now build from their own directory; only
server still needs the repository root, for default_steps/. The rebuild
triggers in server-deploy.yml lose their shared/ patterns, since a
service now moves when its own go.mod pin does.