GitLab CI proxy¶
Nerthus.Core (until cutover). This page describes the frozen system that runs today and is deleted at cutover. Replaced by: not yet written.
The daemon reads this family's pipelines on the panel's behalf and, behind one capability,
retries a run or presses a manual job. It is the only part of nerthusd that reads a system
outside this one, and the only surface whose answers two hosts on the same commit may
legitimately differ about — so every response states when it read upstream, and no route
here carries the replica flag.
Pipeline status is readable by any authenticated principal (entity.read, the one
capability every role bundle holds). A job trace costs gitlab.trace, and the two acts cost
gitlab.act; neither sits in any role bundle. Every executed example runs against a live daemon
— see how the reference is tested.
Why the daemon holds a GitLab credential¶
Read this before the routes. Three facts decide the shape of everything below.
The browser cannot hold it. The dashboard's only secret is a bearer scoped to one daemon. A
GitLab token that can read — and retry — CI across margonem/nerthus/it is a different class of
credential to leave in localStorage, and gitlab.com's CORS puts the API out of the page's reach
regardless.
The credential is recorded in one place and delivered from another. The sejf/ SOPS tree in
Nerthus.Core.Infra.Common carries the entry and its rotate_by; the token reaches a host as
.nerthus/local/gitlab.json { token } or NERTHUS_GITLAB_TOKEN, written by provisioning.
This page used to end that paragraph with "the daemon deliberately has no API onto that vault — a daemon-side inventory copy would be the second list the keyring work exists to delete." Operator ruling 2026-08-31 drew the line one step further in: a copy is still refused, a pass-through is allowed. The vault reads below fetch per request and write nothing, so no second inventory exists — and they serve ciphertext, because the daemon holds no age identity and cannot read a value.
The allowlist is the topology. Every route takes a topology id — the id field in
config/pipelines.json — never a GitLab path or project number. A proxy that fetches whatever
project a caller names is an SSRF-shaped hole with a credential behind it, and the topology is
already the list of what this panel is about.
What it deliberately is not: no pipeline history is stored, no webhook receiver exists, no project outside the topology can be reached, and the daemon polls nothing on its own — the cache fills when a reader asks and empties on a clock.
This is the host's second GitLab credential, and they stay apart
NERTHUS_GL_FLEET_TOKEN already exists on a fleet primary: the takeover flow uses it to open
the apex-flip merge request (Fleet mesh). It is env-delivered, scoped to one
project, and driven by a scheduled tick. The proxy's token is a different one — recorded in
sejf/, scoped to every repository the topology declares, and reached from a browser. Merging
them would either hand this reader-facing surface the takeover's merge rights or give the
takeover tick a reach across ten repositories, so both exist and both are rotated.
Routes¶
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /gitlab/config |
Get-NerthusGitLabConfig |
entity.read |
— |
| GET | /gitlab/pipelines |
Get-NerthusGitLabPipeline |
entity.read |
— |
| GET | /gitlab/pipelines/{id} |
Get-NerthusGitLabPipeline |
entity.read |
— |
| GET | /gitlab/jobs/{id}/trace |
Get-NerthusGitLabTrace |
gitlab.trace |
— |
| POST | /gitlab/pipelines/{id}/retry |
Invoke-NerthusGitLabRetry |
gitlab.act |
— |
| POST | /gitlab/jobs/{id}/play |
Invoke-NerthusGitLabJob |
gitlab.act |
— |
Paths are relative to /v1/api; the cross-cutting contract is on the
API reference index. Neither act is Write-flagged, and that is a decision rather
than an omission: the write gate's other refusals — SchemaTooOld, SchemaTooNew, SyncStale —
are claims about the lore index a CI act does not touch, so a host serving an unadopted repository
can still retry a pipeline. The replica refusal the flag would have bought is made in the service
under the same ReadOnly id.
gitlab.act is the fleet-deploy grant¶
Say it plainly, because the name does not. config/pipelines.json declares the edge
nerthus.core → nerthus.core.infra.dev as kind: trigger, when: manual — the fleet deploy is a
manual job, and POST /gitlab/jobs/{id}/play presses manual jobs. Granting gitlab.act grants
the ability to deploy the dev fleet from a browser. It sits in no role bundle, it is refused on a
read-only host, and every press is journaled as a gitlab:act event carrying who, what, and what
came back.
The failure vocabulary¶
Documented as a set, because the panel draws a different sentence for each and one added
later without a sentence renders as the generic one. No count is written here: a number in this
paragraph goes stale the first time the set grows, and the pairing is asserted rather than
counted — tests/GitLabProxy.Tests.ps1 censuses the ids the service and the handlers can emit and
holds every one of them to this table.
| Error id | Status | Means | What the reader should do |
|---|---|---|---|
GitLabNoCredential |
503 | this host holds no token, or gitlab.com rejected the one it holds | deliver or rotate the credential and restart the host |
GitLabProjectNotAllowed |
403 | the named project is not in the topology | nothing — this proxy will not fetch it, and that is not a fact about GitLab |
GitLabUnreachable |
502 | nothing answered: DNS, connect, timeout, or an unparseable body | wait; check the host's egress |
GitLabRateLimited |
429 | gitlab.com is throttling this credential; carries retryAfter where the header gives one |
back off |
GitLabRefused |
GitLab's own | gitlab.com answered a 4xx; upstreamStatus carries it |
a 403 here means the credential may not, where a plain Forbidden means the caller may not |
GitLabConfigInvalid |
500 | config/pipelines.json on this host does not parse or does not validate |
fix the file where it is edited; the daemon refuses to serve a partial graph |
GitLabProjectRequired |
400 | the call named a pipeline or a job without saying which project | pass ?project=<topology id> |
GitLabBadId |
400 | the pipeline or job id in the path is not a positive integer | fix the call |
An expired token answers GitLabNoCredential and never GitLabUnreachable. The two have
different remedies, and a lapsed credential reading as an outage sends an operator at the network.
The last two are argument errors rather than states of the world, and they are listed with the
rest because the panel branches on error and would otherwise meet them as the generic case. A
host with no credential at all answers GitLabNoCredential for the whole request — one
sentence about the daemon — rather than a 200 carrying the same error on every row: the cascade
degrades per repository only for failures that really are per repository.
The topology — GET /gitlab/config¶
Serves config/pipelines.json as the daemon holds it, validated on the way out with the same
rules the panel's own parser applies: an edge from or to an id nothing declares, an unknown edge
kind, a repeated repository, a target colliding with a repository. A partial graph renders exactly
as confidently as a complete one, so a malformed topology fails on the host that owns the file.
source is daemon. The panel keeps a bundled copy for an older host that has no such route and
says which of the two it drew.
Response 200:
{
"source": "daemon",
"topology": {
"version": 1,
"repos": [
{ "id": "nerthus.core", "title": "Nerthus.Core",
"path": "margonem/nerthus/it/nerthus.core", "role": "usługa" }
],
"targets": [{ "id": "fleet-dev", "title": "Flota dev", "kind": "fleet" }],
"links": [
{ "from": "nerthus.core", "to": "nerthus.core.infra.dev",
"kind": "trigger", "when": "manual",
"note": "przypięcie obrazu i rozstawienie floty — uruchamiane ręcznie" }
]
}
}
The route carries an ETag over the file's own stamp, so a caller whose copy is current pays a
304 instead of the read.
The cascade — GET /gitlab/pipelines¶
One row per declared repository, each with the latest pipeline on that repository's default
branch (the topology declares no ref, so the daemon reads default_branch from the project
record and answers the ref it used), plus a time-ordered recent window across the family.
?limit= bounds both, defaulted to 20 and capped.
Three things the projection guarantees, each of which a naive proxy gets wrong:
- A repository that has never run CI is a row with
pipeline: null— not omitted, not an error. The topology declares intent; a repository with no runs is a fact about the repository. statusis GitLab's own word, unfolded. The panel maps GitLab's vocabulary onto the seven states it draws and answersnullfor one it does not know; a daemon that folded first would make an unknown state invisible instead of reportable.- One broken repository does not take the cascade down. A per-project failure lands as
erroron that row, in the same vocabulary above, and the rest of the family still draws.
duration is null — never 0 — for a run that has not finished, and readAt states when the
daemon last read upstream so the panel can say stan sprzed 40 s rather than implying live.
On a host with no credential, which is the example that documents the failure path, and the state every host is in until provisioning delivers the token:
Response 503:
{
"error": "GitLabNoCredential",
"status": 503,
"detail": "this host holds no GitLab credential: deliver .nerthus/local/gitlab.json { token } or NERTHUS_GITLAB_TOKEN and restart"
}
With a credential, 200:
{
"readAt": "2026-08-19T20:14:03.1180000Z",
"cacheSeconds": 20,
"projects": [
{ "id": "nerthus.core", "title": "Nerthus.Core",
"path": "margonem/nerthus/it/nerthus.core", "role": "usługa",
"ref": "main", "error": null,
"pipeline": { "project": "nerthus.core", "id": 4242, "iid": 7, "ref": "main",
"sha": "fc368e8…", "status": "success", "source": "push",
"createdAt": "2026-08-19T10:00:00Z", "updatedAt": "2026-08-19T10:04:00Z",
"duration": 240, "webUrl": "https://gitlab.com/…/pipelines/4242",
"author": "stefan.nerthus" } },
{ "id": "nerthus.docs", "title": "Nerthus.Docs", "ref": "main",
"pipeline": null, "error": null }
],
"recent": []
}
One run and its jobs — GET /gitlab/pipelines/{id}¶
?project= is required and checked against the allowlist. A pipeline id on its own would let
a caller name any project's run, which is the allowlist with a hole in it.
Each job carries id, name, stage, status, duration, webUrl, failure, and two
booleans worth having separately: manual (the job is declared when: manual) and awaiting
(it is manual and still waiting for a press, which is the only state /gitlab/jobs/{id}/play
can act on).
Without project:
Naming a project the topology does not declare — the refusal that keeps this from being an SSRF
primitive, and note that it is not a 404:
A job's output — GET /gitlab/jobs/{id}/trace¶
A trace is text written by CI jobs, on runners this family administers but does not audit line by line. It can carry paths, hostnames, environment names and — the case worth designing against — a value somebody echoed by accident. It is also the single most useful thing on the screen when a pipeline fails.
So it is scrubbed before it leaves the host, and the response says what was done to it:
- ANSI escapes are stripped daemon-side, before redaction rather than after: a runner that colours part of a line puts an escape inside the very string being matched, and a matcher reading the coloured form takes the first half of a token and leaves the second half on screen.
- Credential shapes are redacted —
glpat-,glcbt-,gldt-,AGE-SECRET-KEY-,age1, private-key headers, bearer-looking strings, long key-shaped hex runs — andredactedcarries the count. A commit sha is deliberately left alone. - The daemon's own credential is redacted by identity, unconditionally and whether or not it matches any pattern. Pattern matching is for other people's secrets; this one we know exactly.
- The whole trace is served (H72-B.4). Two bounds still stand above that and both are visible:
?tail=Nis the caller asking for less, andintegrations.gitlab.trace_max_bytes(5 MB by default) is the host's ceiling — a response the daemon cannot buffer is not an answer. When either bites,truncatedistrueandofBytescarries the real size, so the panel can say ostatnie N wierszy rather than implying the job said less than it did. - A running job's trace is partial by nature, and
completeisfalsefor one.
Nothing is written to disk. The trace lives in the response cache's seconds and nowhere else; the archive of what CI printed belongs to GitLab.
A principal who may read status may not read a trace:
With gitlab.trace, 200:
{
"readAt": "2026-08-19T20:14:03.1180000Z",
"project": "nerthus.core",
"job": 9931,
"status": "failed",
"complete": true,
"truncated": false,
"tookLast": null,
"ofBytes": 18422,
"redacted": 1,
"text": "$ pwsh -File ./ci/Invoke-NerthusTests.ps1\nPRIVATE-TOKEN=[zredagowano]\n…"
}
The acts — retry and play¶
POST /v1/api/gitlab/pipelines/{id}/retry?project=<id> and
POST /v1/api/gitlab/jobs/{id}/play?project=<id>. Both cost gitlab.act, both are refused on a
read-only host under the ReadOnly id, and both name their project through the allowlist.
Idempotence is stated rather than assumed. GitLab's retry creates a new pipeline, so pressing twice makes two; the response carries the id that was created, which is not the id that was pressed. An act that cannot say what it did is an act nobody can check.
GitLab's own refusal is passed through. A 403 from upstream arrives as GitLabRefused with
upstreamStatus: 403 — the credential may not act on that project — and is a different answer
from this daemon's Forbidden, where the caller may not.
After a successful act the daemon drops the cached cascade for that project, so the panel's next poll is not answered from before the press.
Without the capability:
A successful retry answers 200:
{
"act": "retry",
"project": "nerthus.core",
"target": 4242,
"pipeline": 9001,
"job": null,
"status": "created",
"webUrl": "https://gitlab.com/…/pipelines/9001",
"actedAt": "2026-08-19T20:15:44.0210000Z"
}
and leaves a journal entry readable through GET /events/journal:
{ "seq": 118, "type": "gitlab:act", "town": "torneg",
"data": { "act": "retry", "project": "nerthus.core", "target": 4242,
"pipeline": 9001, "job": null, "status": "created",
"principal": "stefan.nerthus" } }
Freshness, and the budget¶
The panel polls; the daemon caches and coalesces. There is no webhook receiver and no background loop — a receiver is a public endpoint with a shared secret, a replay question and a delivery-failure story, and none of that is worth building before anybody has watched a pipeline in this panel (H72-B.5).
The arithmetic behind the window, which is the one number here not chosen by feel: a cascade read costs one call per repository plus one project record each, and the project record is cached for an hour because a default branch does not move on the minute. At the 20-second default one daemon spends at most 30 calls a minute however many tabs are open, and four fleet hosts spend 120. gitlab.com allows 2000 authenticated requests a minute, so the family sits near 6% of the budget with every screen open and a watched deploy is never more than 20 seconds stale.
Knobs, under integrations.gitlab in .nerthus/config.json — all optional, none seeded:
| Key | Default | What it changes |
|---|---|---|
api_base |
https://gitlab.com/api/v4 |
the upstream root |
cache_seconds |
20 |
how long a pipeline or trace answer is reused |
project_cache_seconds |
3600 |
how long a project's default branch is reused |
timeout_ms |
15000 |
per-request deadline |
trace_max_bytes |
5242880 |
the host ceiling on a served trace |
recent_limit_max |
100 |
the cap on ?limit= |
Rotation and expiry¶
The sejf/ entry carries rotate_by, and that date is the watch. The operator rotates; the new
value is written to .nerthus/local/gitlab.json (or the env var) and the host is restarted, since
the credential is read once at boot.
On the day it lapses the panel reads the proxy has no credential — GitLabNoCredential, both
for an absent token and for one gitlab.com rejects — and never GitLab is down. Status, traces and
both acts stop together; every other route on the host is unaffected.
See also¶
- Fleet mesh — the other half of the operator's screen, and the one that is live
- Admin & lifecycle —
GET /events/journal, where an act lands - Configuration — where
.nerthus/local/sits and what belongs in it
The vault reads¶
Two routes let a signed-in reader see sejf/ without a GitLab PAT of their own. Before them,
the dashboard's vault view called gitlab.com directly with the reader's personal token, held in
module scope for one tab.
| Method | Path | Cmdlet | Cap | Write |
|---|---|---|---|---|
| GET | /sejf/inventory |
— | sejf.read |
— |
| GET | /sejf/blob |
— | sejf.read |
— |
/sejf/inventory answers sejf/inventory.yaml as text. /sejf/blob?path=sejf/… answers one file,
and ?ref= picks a branch on either. Both return
{ path, ref, project, size, content }.
What this does and does not replace. Fetching the tree is delivery — which bytes leave GitLab
— and a capability decides it. Decrypting a value is readability, and no login can produce an age
identity. The capability catalogue already draws that line for the keyring capabilities: they
"decide which BYTES the daemon hands over and never who can read them". So a reader signs in,
sees the vault with no PAT, and still supplies an age identity to read a value. A daemon that
held one would make a single host's compromise the whole vault, and it is refused for the same
reason /keys/claims stores only ciphertext.
The path allowlist. Both routes serve sejf/ and nothing else. That is the safety property
rather than a tidiness rule: nerthus.core.infra.common also holds ci/ files that are apply
targets, fleet.settings.yml and schema/, so a blob route taking a caller-supplied path without
it is a general reader for the infrastructure repository. Traversal is refused on the decoded form
as well as the raw one — %252e%252e and .. are the same request — and a backslash is refused
outright rather than normalised, so a path cannot mean two things on two hosts. A refused path is
400 SejfPathRefused, decided before the credential is consulted, and a /sejf/blob call with
no path at all is 400 SejfPathRequired — a caller error rather than a refusal, and named apart
so a client can tell "you asked for the wrong file" from "you asked for no file".
Why its own capability. The /gitlab/* reads above cost entity.read, which every role bundle
holds. The inventory is cleartext by design and holds no value — names, holders, dates — but that
is a map of the estate's credentials, so it costs sejf.read, which sits in no role bundle.
The Rada grants it per person with a - @dostęp: sejf.read line in nerthus.contributors.md.
Note that sejf.read is not unreachable before they do: admin.all satisfies every required
capability before the exact string is ever compared, so an admin token reaches these routes from
the moment they exist.