Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,10 @@ env:
# fork image, so unrelated image changes must not trigger this publish.
GATEWAY_IMAGE_CLOSURE_PATHS: |
tools/gateway-image/**
go/cmd/compass-gen-cert/**
go/internal/certgen/**
go/go.mod
go/go.sum
.github/workflows/release.yml
# Same toolchain contract as the devenv shell: moon runs the nix tools on
# PATH, never a ~/.proto shim, even if a runner image ever ships one.
Expand Down Expand Up @@ -1803,7 +1807,8 @@ jobs:

publish-gateway-image:
name: publish-gateway-image
runs-on: ubuntu-latest
# passt on noble predates --map-host-loopback, which needs 2024-08-21 or later.
runs-on: ubuntu-26.04
permissions:
contents: read
packages: write
Expand Down Expand Up @@ -1891,6 +1896,18 @@ jobs:
nix build --no-link "$store"
echo "$store/bin" >>"$GITHUB_PATH"

- name: Put the pinned go toolchain on PATH
if: steps.gate.outputs.should_publish == 'true'
run: |
set -euo pipefail
store=$(nix eval --json -f tools/toolchain/gate-tools.nix langs.go | jq -r '.store')
if [ -z "$store" ] || [ "$store" = null ]; then
echo "::error::gate-tools.nix langs.go produced no store path" >&2
exit 1
fi
nix build --no-link "$store"
echo "$store/bin" >>"$GITHUB_PATH"

- name: Put the pinned skopeo on PATH
if: steps.gate.outputs.should_publish == 'true'
run: |
Expand Down
7 changes: 4 additions & 3 deletions tools/gateway-image/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ bun tools/gateway-image/smoke.ts

Both commands accept `--out <dir>`; the default is `tools/gateway-image/out`.
The build needs a reachable rootless BuildKit daemon and `BUILDKIT_HOST`. The
smoke needs `skopeo` and `podman`.
smoke needs `skopeo`, `podman`, Go, and Bun. It starts a local TLS fake Compass
Server and checks the gateway's health and agent-token handling.

`moon run gateway-image:ci` runs the lane's typecheck and tests. The build and
smoke tasks are not part of CI.
Expand All @@ -31,8 +32,8 @@ It refuses to overwrite an existing tag with different content.
## Credentials

Build and smoke need no registry credentials: the fork is public, and the smoke
mints a throwaway gateway token per run and deletes it on exit. Publish needs
GHCR write access through `REGISTRY_AUTH_FILE`.
uses throwaway service and agent tokens per run, then removes the mounted files.
Publish needs GHCR write access through `REGISTRY_AUTH_FILE`.

## Rotate

Expand Down
80 changes: 72 additions & 8 deletions tools/gateway-image/core.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
digestRef,
FORK_BRANCH,
FORK_REPO,
fakeCompassDoorResponse,
gatewayBuildArgs,
healthzOk,
imageConfigEntries,
Expand Down Expand Up @@ -234,14 +235,17 @@ describe("secretConfigViolations", () => {
const scan = (env: string[], labels: Record<string, string> = {}) =>
secretConfigViolations({ env, labels });

test("passes the allowlisted names the gateway config ships", () => {
test("passes the benign names the gateway config ships", () => {
expect(scan(["GPG_KEY=A035C8C1", "PATH=/bin"])).toEqual([]);
});

test("flags the removed gateway token file name", () => {
expect(
scan([
"GPG_KEY=A035C8C1",
"COMPASS_GATEWAY_TOKEN_FILE=/run/token",
"PATH=/bin",
"COMPASS_GATEWAY_TOKEN_FILE_2=/x",
]),
).toEqual([]);
).toEqual(["COMPASS_GATEWAY_TOKEN_FILE", "COMPASS_GATEWAY_TOKEN_FILE_2"]);
});

test("still flags a real secret name", () => {
Expand All @@ -250,7 +254,7 @@ describe("secretConfigViolations", () => {
]);
});

test("allows only an exact allowlist match", () => {
test("does not allowlist credential-shaped names", () => {
expect(scan(["GPG_KEY_X=1", "COMPASS_GATEWAY_TOKEN_FILE_2=/x"])).toEqual([
"GPG_KEY_X",
"COMPASS_GATEWAY_TOKEN_FILE_2",
Expand All @@ -265,7 +269,7 @@ describe("secretConfigViolations", () => {
test("applies the exact-match allowlist to label keys and values", () => {
expect(
scan([], { GPG_KEY: "x", note: "COMPASS_GATEWAY_TOKEN_FILE" }),
).toEqual([]);
).toEqual(["note"]);
expect(scan([], { GPG_KEY_X: "x" })).toEqual(["GPG_KEY_X"]);
});

Expand All @@ -278,7 +282,7 @@ describe("secretConfigViolations", () => {
).toEqual(["org.example.registry-token", "org.example.api.key"]);
});

test("passes every env name of the built gateway image", () => {
test("flags the removed token env if it is put back on the image", () => {
const env = [
"LANG=C.UTF-8",
"GPG_KEY=7169605F62C751356D054A26A821E680E5FA6305",
Expand All @@ -295,11 +299,13 @@ describe("secretConfigViolations", () => {
"PATH=/opt/bun/bin:/usr/local/cargo/bin:/usr/local/bin:/usr/bin:/bin",
"PI_ROOT=/pi",
"HOME=/tmp",
"COMPASS_GATEWAY_TOKEN_FILE=/run/compass/gateway.token",
"COMPASS_GATEWAY_BIND=0.0.0.0:4000",
"COMPASS_GATEWAY_DRAIN_MS=20000",
];
expect(scan(env, { "io.buildah.version": "1.43.2" })).toEqual([]);
expect(
scan([...env, "COMPASS_GATEWAY_TOKEN_FILE=/run/compass/gateway.token"]),
).toEqual(["COMPASS_GATEWAY_TOKEN_FILE"]);
});

test("flags a label whose value names a secret", () => {
Expand Down Expand Up @@ -369,3 +375,61 @@ describe("smoke predicates", () => {
expect(modelsListOk(200, '{"error":"unauthorized"}')).toBe(false);
});
});

describe("fake Compass door", () => {
const options = {
serviceToken: "service-token",
agentToken: "good-agent-token",
};
const makeRequest = (token: string, serviceToken = options.serviceToken) =>
new Request(
"https://host.containers.internal/compass.v1.GatewayAuth/VerifyAgentToken",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"Connect-Protocol-Version": "1",
Authorization: `Bearer ${serviceToken}`,
},
body: JSON.stringify({ token }),
},
);

test("returns the verified agent and owner", async () => {
const response = await fakeCompassDoorResponse(
makeRequest(options.agentToken),
options,
);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
agentAccountId: "agent-smoke",
ownerUserId: "owner-smoke",
});
});

test("rejects empty and incorrect agent tokens with the verifier contract", async () => {
for (const token of ["", "wrong-agent-token"]) {
const response = await fakeCompassDoorResponse(
makeRequest(token),
options,
);
expect(response.status).toBe(401);
expect(await response.json()).toEqual({
code: "unauthenticated",
message: "invalid agent token",
});
}
});

test("rejects an invalid service bearer", async () => {
const response = await fakeCompassDoorResponse(
makeRequest(options.agentToken, "wrong-service-token"),
options,
);
expect(response.status).toBe(401);
expect(await response.json()).toEqual({
code: "unauthenticated",
message: "unauthenticated",
});
});
});
75 changes: 68 additions & 7 deletions tools/gateway-image/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,12 +278,8 @@ export function blobPath(layoutDir: string, digest: string): string {
return join(layoutDir, "blobs", "sha256", digest.slice("sha256:".length));
}

/** Benign names the gateway config ships: a public-key fingerprint the
* `python` base sets, and a path. Exempt on an exact match only. */
export const SECRET_NAME_ALLOWLIST: readonly string[] = [
"GPG_KEY",
"COMPASS_GATEWAY_TOKEN_FILE",
];
/** Benign name shipped by the `python` base. Exempt on an exact match only. */
export const SECRET_NAME_ALLOWLIST: readonly string[] = ["GPG_KEY"];

/** Credential-shaped name segments. The boundary spans `_`, `.` and `-` so it
* also reads label keys (`org.example.registry-token`), and keeps `PAT` from
Expand Down Expand Up @@ -368,7 +364,72 @@ export function tagDisposition(
};
}

/** The gateway's `/healthz`: 200 with `{"ok": true}`. */
const VERIFY_AGENT_TOKEN_PATH = "/compass.v1.GatewayAuth/VerifyAgentToken";

export interface FakeCompassDoorOptions {
readonly serviceToken: string;
readonly agentToken: string;
}

/** The fake network door used by the gateway-image smoke. */
export async function fakeCompassDoorResponse(
request: Request,
options: FakeCompassDoorOptions,
): Promise<Response> {
if (new URL(request.url).pathname !== VERIFY_AGENT_TOKEN_PATH) {
return new Response(null, { status: 404 });
}
if (request.method !== "POST") return new Response(null, { status: 405 });
if (
request.headers.get("connect-protocol-version") !== "1" ||
request.headers.get("content-type")?.split(";")[0]?.trim() !==
"application/json"
) {
return new Response(null, { status: 415 });
}
if (
request.headers.get("authorization") !== `Bearer ${options.serviceToken}`
) {
return Response.json(
{ code: "unauthenticated", message: "unauthenticated" },
{ status: 401 },
);
}

let body: unknown;
try {
body = await request.json();
} catch {
return Response.json(
{ code: "invalid_argument", message: "invalid JSON" },
{ status: 400 },
);
}
const token =
typeof body === "object" &&
body !== null &&
"token" in body &&
typeof body.token === "string"
? body.token
: undefined;
if (token === undefined) {
return Response.json(
{ code: "invalid_argument", message: "token is required" },
{ status: 400 },
);
}
if (token !== options.agentToken) {
return Response.json(
{ code: "unauthenticated", message: "invalid agent token" },
{ status: 401 },
);
}
return Response.json({
agentAccountId: "agent-smoke",
ownerUserId: "owner-smoke",
});
}

export function healthzOk(status: number, body: string): boolean {
return status === 200 && jsonField(body, "ok") === true;
}
Expand Down
Loading
Loading