Skip to content

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.

GET /v1/api/gitlab/config
Authorization: Bearer <token>
await fetch("https://torneg.nerthus.pl/v1/api/gitlab/config", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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.
  • status is GitLab's own word, unfolded. The panel maps GitLab's vocabulary onto the seven states it draws and answers null for 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 error on 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:

GET /v1/api/gitlab/pipelines?limit=5
Authorization: Bearer <token>
await fetch("https://torneg.nerthus.pl/v1/api/gitlab/pipelines?limit=5", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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:

GET /v1/api/gitlab/pipelines/4242
Authorization: Bearer <token>
await fetch("https://torneg.nerthus.pl/v1/api/gitlab/pipelines/4242", {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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:

GET /v1/api/gitlab/pipelines/4242?project=margonem/nerthus/it/nerthus.core.infra.prod
Authorization: Bearer <token>
const q = new URLSearchParams({ project: "margonem/nerthus/it/nerthus.core.infra.prod" });
await fetch(`https://torneg.nerthus.pl/v1/api/gitlab/pipelines/4242?${q}`, {
  headers: { "Authorization": `Bearer ${token}` },
}).then((r) => r.json());

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 redactedglpat-, glcbt-, gldt-, AGE-SECRET-KEY-, age1, private-key headers, bearer-looking strings, long key-shaped hex runs — and redacted carries 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=N is the caller asking for less, and integrations.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, truncated is true and ofBytes carries 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 complete is false for 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:

GET /v1/api/gitlab/jobs/9931/trace?project=nerthus.core
Authorization: Bearer <token>
await fetch("https://torneg.nerthus.pl/v1/api/gitlab/jobs/9931/trace?project=nerthus.core", {
  headers: { "Authorization": `Bearer ${scopedToken}` },
}).then((r) => r.json());

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:

POST /v1/api/gitlab/pipelines/4242/retry?project=nerthus.core
Authorization: Bearer <token>
Content-Type: application/json

{}
await fetch("https://torneg.nerthus.pl/v1/api/gitlab/pipelines/4242/retry?project=nerthus.core", {
  method: "POST",
  headers: { "Authorization": `Bearer ${scopedToken}`, "Content-Type": "application/json" },
  body: "{}",
}).then((r) => r.json());

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 credentialGitLabNoCredential, 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 & lifecycleGET /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.