@celilo/e2e 0.7.14 → 0.7.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/dns/knot-namecheap.conf +2 -2
- package/config/dns/net.zone +2 -2
- package/config/dns/{lunacycle.net.zone → templates/example.net.zone} +3 -3
- package/config/proxy/startup.sh +14 -0
- package/config/resolver/unbound.conf +2 -2
- package/config/routing/fw-ext-routes.sh +13 -0
- package/config/routing/observer-setup.sh +60 -0
- package/config/routing/resolver-internal-routes.sh +2 -2
- package/docker/Dockerfile.observer +26 -0
- package/package.json +2 -2
- package/registry-server/src/auth.test.ts +56 -30
- package/registry-server/src/auth.ts +88 -19
- package/registry-server/src/scoped-token-store.test.ts +93 -0
- package/registry-server/src/scoped-token-store.ts +110 -0
- package/registry-server/src/server.test.ts +92 -10
- package/registry-server/src/server.ts +104 -31
- package/scripts/pack-celilo-packages.ts +114 -22
- package/src/container-manager.ts +4 -4
- package/src/docker-compose-generator.ts +19 -4
- package/src/index.ts +28 -1
- package/src/network-builder.ts +18 -1
- package/src/observer.test.ts +155 -0
- package/src/observer.ts +270 -0
- package/src/shared-infra.ts +18 -1
- package/src/types.ts +58 -0
- package/src/vantage-helpers.ts +78 -0
- package/src/vantage.test.ts +200 -0
- package/src/vantage.ts +266 -0
- package/src/zone-classifier.test.ts +82 -0
- package/src/zone-classifier.ts +92 -0
- package/config/dns/iamtheinternet.org.zone +0 -16
- package/config/dns/templates/lunacycle.net.zone +0 -16
|
@@ -10,8 +10,8 @@ zone:
|
|
|
10
10
|
file: "/config/celilo.computer.zone"
|
|
11
11
|
storage: "/var/lib/knot"
|
|
12
12
|
|
|
13
|
-
- domain: "
|
|
14
|
-
file: "/config/
|
|
13
|
+
- domain: "example.net"
|
|
14
|
+
file: "/config/example.net.zone"
|
|
15
15
|
storage: "/var/lib/knot"
|
|
16
16
|
|
|
17
17
|
- domain: "park-your-domain.com"
|
package/config/dns/net.zone
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
$ORIGIN
|
|
1
|
+
$ORIGIN example.net.
|
|
2
2
|
$TTL 300
|
|
3
3
|
|
|
4
|
-
@ IN SOA ns1.
|
|
4
|
+
@ IN SOA ns1.example.net. admin.example.net. (
|
|
5
5
|
2024020108 300 60 604800 300
|
|
6
6
|
)
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@ $TTL 300
|
|
|
10
10
|
; private IPs are unreachable from outside. All public-facing names
|
|
11
11
|
; resolve to the firewall's external IP (100.100.0.100), which DNATs
|
|
12
12
|
; inbound to the right internal host.
|
|
13
|
-
@ IN NS ns1.
|
|
13
|
+
@ IN NS ns1.example.net.
|
|
14
14
|
ns1 IN A 100.64.0.55
|
|
15
15
|
@ IN A 100.100.0.100
|
|
16
16
|
www IN A 100.100.0.100
|
package/config/proxy/startup.sh
CHANGED
|
@@ -7,5 +7,19 @@ ip route add default via 172.30.0.1
|
|
|
7
7
|
|
|
8
8
|
rm -f /run/squid.pid
|
|
9
9
|
|
|
10
|
+
# Initialize the cache swap directories before starting Squid. The image's
|
|
11
|
+
# build-time `squid -z` can leave an INCOMPLETE L1/L2 set (observed: 00–0C
|
|
12
|
+
# present, 0D–0F missing); on a fresh container Squid then FATALs with "Failed
|
|
13
|
+
# to verify one of the swap directories" and exits. With Squid dead, the
|
|
14
|
+
# transparent-proxy REDIRECT (:80→3128, :443→3129) forwards every apt fetch to
|
|
15
|
+
# a closed port → "connection refused" — which surfaced as flaky, non-warming
|
|
16
|
+
# `apt install` failures during module deploys (e.g. knot). `squid -z` is
|
|
17
|
+
# idempotent (creates only missing dirs). `--foreground` is REQUIRED: a bare
|
|
18
|
+
# `squid -z` daemonizes and lingers (holding /run/squid.pid), so the following
|
|
19
|
+
# `squid -N` would FATAL "Squid is already running"; with --foreground, -z runs
|
|
20
|
+
# synchronously, removes the pid file, and exits clean.
|
|
21
|
+
squid --foreground -z -f /etc/squid/squid.conf
|
|
22
|
+
rm -f /run/squid.pid
|
|
23
|
+
|
|
10
24
|
echo "forward-proxy ready on ports 3128 (HTTP) / 3129 (HTTPS)"
|
|
11
25
|
exec squid -N -f /etc/squid/squid.conf
|
|
@@ -34,11 +34,11 @@ stub-zone:
|
|
|
34
34
|
stub-addr: 100.64.0.55
|
|
35
35
|
|
|
36
36
|
stub-zone:
|
|
37
|
-
name: "
|
|
37
|
+
name: "example.net."
|
|
38
38
|
stub-addr: 100.64.0.55
|
|
39
39
|
|
|
40
40
|
# celilo.computer hosts the website + install.sh (served by the website-sim).
|
|
41
|
-
# Without this stub it falls through to the real internet (
|
|
41
|
+
# Without this stub it falls through to the real internet (203.0.113.11), so the
|
|
42
42
|
# website-sim's ACME challenge can't be validated in-topology and the bake's
|
|
43
43
|
# `curl https://celilo.computer/install.sh` never sees a cert. Covers
|
|
44
44
|
# www.celilo.computer too. (ISS-0063)
|
|
@@ -19,6 +19,19 @@ iptables -P FORWARD ACCEPT
|
|
|
19
19
|
|
|
20
20
|
# Start Squid transparent proxy (runs on fw-ext itself)
|
|
21
21
|
rm -f /run/squid.pid
|
|
22
|
+
# Complete the cache swap directories before starting. The image's build-time
|
|
23
|
+
# `squid -z` (Dockerfile.router) is killed after a fixed `sleep 1`, which can
|
|
24
|
+
# interrupt it and leave an INCOMPLETE L1/L2 set (observed: 00–0C present,
|
|
25
|
+
# 0D–0F missing). `squid -N` then FATALs ("Failed to verify one of the swap
|
|
26
|
+
# directories") and never binds — so the transparent-proxy REDIRECT below sends
|
|
27
|
+
# every apt fetch to a closed port and target deploys fail with intermittent,
|
|
28
|
+
# non-warming "connection refused" (e.g. knot's `apt install`). `squid -z` is
|
|
29
|
+
# idempotent, so running it here guarantees a complete tree. `--foreground` is
|
|
30
|
+
# REQUIRED: a bare `squid -z` daemonizes and lingers (holding /run/squid.pid),
|
|
31
|
+
# so the following `squid -N` would FATAL "Squid is already running"; with
|
|
32
|
+
# --foreground, -z runs synchronously, removes the pid file, and exits clean.
|
|
33
|
+
squid --foreground -z -f /etc/squid/squid.conf
|
|
34
|
+
rm -f /run/squid.pid
|
|
22
35
|
squid -N -f /etc/squid/squid.conf &
|
|
23
36
|
|
|
24
37
|
# Wait for Squid to be listening on both 3128 (HTTP) and 3129 (HTTPS)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# Observer vantage setup (ISS-0117).
|
|
3
|
+
#
|
|
4
|
+
# Installs the routing profile that makes this container a FAITHFUL vantage — the
|
|
5
|
+
# load-bearing part of the framework. A vantage's correctness is its routing table:
|
|
6
|
+
# an internalDevice must reach a fronted service only via the firewall natIp, never a
|
|
7
|
+
# segmented-zone container IP directly. Driven by env from the compose generator:
|
|
8
|
+
# OBSERVER_GATEWAY - default route (the home router for internalDevice, the zone
|
|
9
|
+
# firewall for dmz/app/secure, fw-ext for publicInternet)
|
|
10
|
+
# OBSERVER_INTERZONE - "1" to add the management-style explicit inter-zone routes
|
|
11
|
+
# (the all-VLAN trap). "0" for every faithful consumer vantage.
|
|
12
|
+
# OBSERVER_RESOLVERS - space-separated nameservers for /etc/resolv.conf, in order
|
|
13
|
+
set -u
|
|
14
|
+
|
|
15
|
+
GATEWAY="${OBSERVER_GATEWAY:?OBSERVER_GATEWAY is required}"
|
|
16
|
+
INTERZONE="${OBSERVER_INTERZONE:-0}"
|
|
17
|
+
RESOLVERS="${OBSERVER_RESOLVERS:-100.100.0.1}"
|
|
18
|
+
|
|
19
|
+
echo "Observer starting: gateway=${GATEWAY} interzone=${INTERZONE} resolvers='${RESOLVERS}'"
|
|
20
|
+
|
|
21
|
+
# --- Default route ---
|
|
22
|
+
# Docker seeds a default via the network's own gateway; replace it with the gateway a
|
|
23
|
+
# real device at this location would use.
|
|
24
|
+
ip route del default 2>/dev/null || true
|
|
25
|
+
ip route add default via "${GATEWAY}"
|
|
26
|
+
|
|
27
|
+
# --- Inter-zone routes (the management trap; only when explicitly requested) ---
|
|
28
|
+
# A real consumer vantage NEVER has these — that is the whole point. Present only so a
|
|
29
|
+
# `management`-style all-VLAN box can be modelled deliberately.
|
|
30
|
+
if [ "${INTERZONE}" = "1" ]; then
|
|
31
|
+
ip route add 10.0.10.0/24 via 192.168.0.254 2>/dev/null || true # dmz via fw-main
|
|
32
|
+
ip route add 10.0.20.0/24 via 192.168.0.254 2>/dev/null || true # app via fw-main
|
|
33
|
+
ip route add 10.0.30.0/24 via 192.168.0.254 2>/dev/null || true # secure via fw-main
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# --- Resolver ---
|
|
37
|
+
: > /etc/resolv.conf
|
|
38
|
+
for ns in ${RESOLVERS}; do
|
|
39
|
+
echo "nameserver ${ns}" >> /etc/resolv.conf
|
|
40
|
+
done
|
|
41
|
+
|
|
42
|
+
# --- Pebble runtime ACME root CA ---
|
|
43
|
+
# Pebble mints a fresh root each startup, so the build-time CA can't verify certs Caddy
|
|
44
|
+
# obtained at runtime. Fetch the live root so curl/openssl can validate real chains —
|
|
45
|
+
# the no-self-signed / CA-provenance assertions depend on a correct trust store.
|
|
46
|
+
echo "Fetching Pebble ACME root CA..."
|
|
47
|
+
for _ in $(seq 1 30); do
|
|
48
|
+
if curl -sk https://100.64.0.100:15000/roots/0 -o /usr/local/share/ca-certificates/pebble-acme-root.crt 2>/dev/null; then
|
|
49
|
+
if [ -s /usr/local/share/ca-certificates/pebble-acme-root.crt ]; then
|
|
50
|
+
update-ca-certificates 2>/dev/null
|
|
51
|
+
echo "Pebble ACME root CA installed"
|
|
52
|
+
break
|
|
53
|
+
fi
|
|
54
|
+
fi
|
|
55
|
+
sleep 2
|
|
56
|
+
done
|
|
57
|
+
|
|
58
|
+
echo "Observer ready: $(hostname)"
|
|
59
|
+
ip route
|
|
60
|
+
sleep infinity
|
|
@@ -9,9 +9,9 @@ CADDY_IP="${CADDY_IP:-10.0.10.10}"
|
|
|
9
9
|
# DOMAINS: comma-separated list of e2e test domains that should be
|
|
10
10
|
# split-horizon redirected to caddy. Default covers the canonical
|
|
11
11
|
# (iamtheinternet.org) plus the cross-domain test's second domain
|
|
12
|
-
# (
|
|
12
|
+
# (example.net). Tests can override via DEFAULT_GATEWAY/DOMAINS env
|
|
13
13
|
# vars in docker-compose.
|
|
14
|
-
DOMAINS="${DOMAINS:-iamtheinternet.org,
|
|
14
|
+
DOMAINS="${DOMAINS:-iamtheinternet.org,example.net}"
|
|
15
15
|
|
|
16
16
|
# Routing — reach comcast-resolver (100.100.0.1) via fw-main or fw-isp.
|
|
17
17
|
# DEFAULT_GATEWAY is set by docker-compose from the topology config.
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# Observer — a passive vantage-point spy (ISS-0117).
|
|
2
|
+
#
|
|
3
|
+
# Carries the full probe toolbox (dig, curl, openssl, nc, ping, ip) so a single image
|
|
4
|
+
# can be injected at any network location and assert resolve/reach/TLS from that seat.
|
|
5
|
+
# It runs NO services and deploys NO modules — it never perturbs the system under test.
|
|
6
|
+
# Its faithfulness as a vantage comes from the routing profile installed at startup
|
|
7
|
+
# (see config/routing/observer-setup.sh), driven by the OBSERVER_* env the compose
|
|
8
|
+
# generator sets per vantage.
|
|
9
|
+
FROM ubuntu:22.04
|
|
10
|
+
|
|
11
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
12
|
+
|
|
13
|
+
RUN apt-get update && apt-get install -y \
|
|
14
|
+
dnsutils \
|
|
15
|
+
curl \
|
|
16
|
+
openssl \
|
|
17
|
+
netcat-openbsd \
|
|
18
|
+
iputils-ping \
|
|
19
|
+
iproute2 \
|
|
20
|
+
ca-certificates \
|
|
21
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
22
|
+
|
|
23
|
+
COPY config/routing/observer-setup.sh /startup.sh
|
|
24
|
+
RUN chmod +x /startup.sh
|
|
25
|
+
|
|
26
|
+
CMD ["/startup.sh"]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@celilo/e2e",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.16",
|
|
4
4
|
"description": "E2E test infrastructure for Celilo-deployed applications. Provides a simulated internet with DNS hierarchy, ACME server, firewalls, and target machines in Docker.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
],
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@celilo/cli-display": "^0.1.9",
|
|
40
|
-
"@celilo/event-bus": "^0.1.
|
|
40
|
+
"@celilo/event-bus": "^0.1.7",
|
|
41
41
|
"yaml": "^2.8.0",
|
|
42
42
|
"zod": "^3.24.1"
|
|
43
43
|
},
|
|
@@ -1,50 +1,76 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import { TokenAuth } from './auth';
|
|
2
|
+
import { ADMIN_SCOPE, TokenAuth, hashToken } from './auth';
|
|
3
3
|
|
|
4
|
-
describe('TokenAuth', () => {
|
|
5
|
-
test('empty
|
|
4
|
+
describe('TokenAuth (package-scoped — ISS-0140)', () => {
|
|
5
|
+
test('empty specs → no tokens; nothing authorizes', () => {
|
|
6
6
|
const auth = new TokenAuth([]);
|
|
7
7
|
expect(auth.hasTokens()).toBe(false);
|
|
8
|
-
expect(auth.
|
|
8
|
+
expect(auth.authorize('anything', 'caddy')).toBe(false);
|
|
9
|
+
expect(auth.isAdmin('anything')).toBe(false);
|
|
9
10
|
});
|
|
10
11
|
|
|
11
|
-
test('
|
|
12
|
-
const auth = new TokenAuth(['
|
|
13
|
-
expect(auth.
|
|
14
|
-
expect(auth.
|
|
12
|
+
test('admin-scoped token publishes ANY package', () => {
|
|
13
|
+
const auth = new TokenAuth([{ token: 'admin-tok', scope: ADMIN_SCOPE }]);
|
|
14
|
+
expect(auth.authorize('admin-tok', 'caddy')).toBe(true);
|
|
15
|
+
expect(auth.authorize('admin-tok', 'lunacycle')).toBe(true);
|
|
16
|
+
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
test('package-scoped token publishes ONLY its package', () => {
|
|
20
|
+
const auth = new TokenAuth([{ token: 'luna-tok', scope: 'lunacycle' }]);
|
|
21
|
+
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
22
|
+
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
23
|
+
// A scoped token is NOT an admin token — it cannot mint.
|
|
24
|
+
expect(auth.isAdmin('luna-tok')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('unknown token never authorizes', () => {
|
|
28
|
+
const auth = new TokenAuth([{ token: 'known', scope: 'lunacycle' }]);
|
|
29
|
+
expect(auth.authorize('unknown', 'lunacycle')).toBe(false);
|
|
15
30
|
});
|
|
16
31
|
|
|
17
32
|
test('tokens are stored hashed, not in cleartext', () => {
|
|
18
|
-
const auth = new TokenAuth(['secret-token']);
|
|
19
|
-
|
|
20
|
-
// 'secret-token' would appear in the Set. Only hashes should be present.
|
|
21
|
-
const rawDump = JSON.stringify(auth);
|
|
22
|
-
expect(rawDump).not.toContain('secret-token');
|
|
33
|
+
const auth = new TokenAuth([{ token: 'secret-token', scope: ADMIN_SCOPE }]);
|
|
34
|
+
expect(JSON.stringify(auth)).not.toContain('secret-token');
|
|
23
35
|
});
|
|
24
36
|
|
|
25
|
-
test('
|
|
26
|
-
const auth = new TokenAuth(['clean']);
|
|
27
|
-
expect(auth.
|
|
37
|
+
test('tolerates a Bearer prefix and surrounding whitespace', () => {
|
|
38
|
+
const auth = new TokenAuth([{ token: 'clean', scope: ADMIN_SCOPE }]);
|
|
39
|
+
expect(auth.authorize(' clean ', 'caddy')).toBe(true);
|
|
40
|
+
expect(auth.authorize('Bearer clean', 'caddy')).toBe(true);
|
|
28
41
|
});
|
|
29
42
|
|
|
30
|
-
test('
|
|
31
|
-
const auth = new TokenAuth(['secret']);
|
|
32
|
-
expect(auth.
|
|
43
|
+
test('empty header rejected', () => {
|
|
44
|
+
const auth = new TokenAuth([{ token: 'secret', scope: ADMIN_SCOPE }]);
|
|
45
|
+
expect(auth.authorize('', 'caddy')).toBe(false);
|
|
46
|
+
expect(auth.scopeOf('')).toBe(null);
|
|
33
47
|
});
|
|
34
48
|
|
|
35
|
-
test('
|
|
36
|
-
const auth = new TokenAuth([
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
expect(auth.
|
|
40
|
-
|
|
49
|
+
test('addHashed / removeHashed manage minted tokens at runtime', () => {
|
|
50
|
+
const auth = new TokenAuth([]);
|
|
51
|
+
const hash = hashToken('minted-tok');
|
|
52
|
+
auth.addHashed(hash, 'lunacycle');
|
|
53
|
+
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(true);
|
|
54
|
+
auth.removeHashed(hash);
|
|
55
|
+
expect(auth.authorize('minted-tok', 'lunacycle')).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe('TokenAuth.fromEnv (PUBLISH_TOKENS format)', () => {
|
|
60
|
+
test('bare line → admin scope; "token pkg" line → scoped', () => {
|
|
61
|
+
process.env.PUBLISH_TOKENS = 'admin-tok\nluna-tok lunacycle\n';
|
|
62
|
+
const auth = TokenAuth.fromEnv();
|
|
63
|
+
expect(auth.isAdmin('admin-tok')).toBe(true);
|
|
64
|
+
expect(auth.authorize('luna-tok', 'lunacycle')).toBe(true);
|
|
65
|
+
expect(auth.authorize('luna-tok', 'caddy')).toBe(false);
|
|
66
|
+
process.env.PUBLISH_TOKENS = '';
|
|
41
67
|
});
|
|
42
68
|
|
|
43
|
-
test('
|
|
44
|
-
|
|
69
|
+
test('blank lines ignored', () => {
|
|
70
|
+
process.env.PUBLISH_TOKENS = '\n \nt1\n';
|
|
71
|
+
const auth = TokenAuth.fromEnv();
|
|
45
72
|
expect(auth.hasTokens()).toBe(true);
|
|
46
|
-
expect(auth.
|
|
47
|
-
|
|
48
|
-
expect(auth.verify('')).toBe(false);
|
|
73
|
+
expect(auth.isAdmin('t1')).toBe(true);
|
|
74
|
+
process.env.PUBLISH_TOKENS = '';
|
|
49
75
|
});
|
|
50
76
|
});
|
|
@@ -1,36 +1,105 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* a
|
|
4
|
+
* Package-scoped token auth (build-bus Phase 3, ISS-0140).
|
|
5
|
+
*
|
|
6
|
+
* Each token carries a SCOPE — either a single package name (the token may
|
|
7
|
+
* publish only that module) or the admin scope `*` (publish anything + mint
|
|
8
|
+
* scoped tokens). Tokens are stored SHA-256 hashed; cleartext never lives in
|
|
9
|
+
* the auth set once the server starts.
|
|
10
|
+
*
|
|
11
|
+
* PUBLISH_TOKENS env (newline-separated), per line:
|
|
12
|
+
* <token> → admin scope `*` (publish any package, mint tokens)
|
|
13
|
+
* <token> <package> → scoped to that one package
|
|
14
|
+
*
|
|
15
|
+
* A bare `<token>` line is the admin/bootstrap token — this preserves the
|
|
16
|
+
* pre-scoping behavior (any configured token publishes anything) AND is the
|
|
17
|
+
* privilege that the `registry_publish` capability uses to mint per-repo
|
|
18
|
+
* scoped tokens. Minted scoped tokens are added at runtime via {@link addHashed}
|
|
19
|
+
* (loaded from the persisted store) and removed via {@link removeHashed}.
|
|
7
20
|
*/
|
|
21
|
+
|
|
22
|
+
/** Admin scope: publish any package + mint scoped tokens. */
|
|
23
|
+
export const ADMIN_SCOPE = '*';
|
|
24
|
+
|
|
25
|
+
export interface TokenSpec {
|
|
26
|
+
token: string;
|
|
27
|
+
/** Package name, or {@link ADMIN_SCOPE}. */
|
|
28
|
+
scope: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** SHA-256 of a token (trimmed). The persisted scoped-token store hashes too. */
|
|
32
|
+
export function hashToken(token: string): string {
|
|
33
|
+
return createHash('sha256').update(token.trim()).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Strip an optional `Bearer ` prefix and surrounding whitespace from a header. */
|
|
37
|
+
function bareToken(header: string): string {
|
|
38
|
+
return header.replace(/^Bearer\s+/i, '').trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
8
41
|
export class TokenAuth {
|
|
9
|
-
|
|
42
|
+
/** hash → scope. */
|
|
43
|
+
private readonly scopeByHash = new Map<string, string>();
|
|
10
44
|
|
|
11
|
-
constructor(
|
|
12
|
-
|
|
13
|
-
rawTokens
|
|
14
|
-
.map((t) => t.trim())
|
|
15
|
-
.filter(Boolean)
|
|
16
|
-
.map((t) => createHash('sha256').update(t).digest('hex')),
|
|
17
|
-
);
|
|
45
|
+
constructor(specs: TokenSpec[]) {
|
|
46
|
+
for (const s of specs) this.addRaw(s.token, s.scope);
|
|
18
47
|
}
|
|
19
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Parse the `PUBLISH_TOKENS` env format into the scoped auth set. Each line is
|
|
51
|
+
* `<token>` (admin scope) or `<token> <package>` (scoped to one package).
|
|
52
|
+
*/
|
|
20
53
|
static fromEnv(): TokenAuth {
|
|
21
54
|
const raw = process.env.PUBLISH_TOKENS ?? '';
|
|
22
|
-
const
|
|
23
|
-
|
|
55
|
+
const specs: TokenSpec[] = [];
|
|
56
|
+
for (const line of raw.split('\n')) {
|
|
57
|
+
const trimmed = line.trim();
|
|
58
|
+
if (!trimmed) continue;
|
|
59
|
+
const [token, scope] = trimmed.split(/\s+/, 2);
|
|
60
|
+
specs.push({ token, scope: scope || ADMIN_SCOPE });
|
|
61
|
+
}
|
|
62
|
+
return new TokenAuth(specs);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Add a cleartext token (hashes it). Empty tokens are ignored. */
|
|
66
|
+
addRaw(rawToken: string, scope: string): void {
|
|
67
|
+
const t = rawToken.trim();
|
|
68
|
+
if (!t) return;
|
|
69
|
+
this.scopeByHash.set(hashToken(t), scope.trim() || ADMIN_SCOPE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Add an already-hashed token (used when loading the persisted minted-token store). */
|
|
73
|
+
addHashed(hash: string, scope: string): void {
|
|
74
|
+
this.scopeByHash.set(hash, scope);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Remove a token by its hash (token revocation). */
|
|
78
|
+
removeHashed(hash: string): void {
|
|
79
|
+
this.scopeByHash.delete(hash);
|
|
24
80
|
}
|
|
25
81
|
|
|
26
|
-
/** If no tokens are configured, the server treats every
|
|
82
|
+
/** If no tokens are configured, the server treats every write as unauthorized. */
|
|
27
83
|
hasTokens(): boolean {
|
|
28
|
-
return this.
|
|
84
|
+
return this.scopeByHash.size > 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The scope of a token (from an Authorization header value), or null if unknown. */
|
|
88
|
+
scopeOf(header: string): string | null {
|
|
89
|
+
const t = bareToken(header);
|
|
90
|
+
if (!t) return null;
|
|
91
|
+
return this.scopeByHash.get(hashToken(t)) ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** True when the token may publish/yank the given package (admin or exact scope). */
|
|
95
|
+
authorize(header: string, pkg: string): boolean {
|
|
96
|
+
const scope = this.scopeOf(header);
|
|
97
|
+
if (scope === null) return false;
|
|
98
|
+
return scope === ADMIN_SCOPE || scope === pkg;
|
|
29
99
|
}
|
|
30
100
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return this.hashedTokens.has(hashed);
|
|
101
|
+
/** True when the token is an admin token (scope `*`) — required to mint scoped tokens. */
|
|
102
|
+
isAdmin(header: string): boolean {
|
|
103
|
+
return this.scopeOf(header) === ADMIN_SCOPE;
|
|
35
104
|
}
|
|
36
105
|
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import { hashToken } from './auth';
|
|
3
|
+
import {
|
|
4
|
+
type ScopedTokenEntry,
|
|
5
|
+
type ScopedTokenPersistence,
|
|
6
|
+
ScopedTokenStore,
|
|
7
|
+
} from './scoped-token-store';
|
|
8
|
+
|
|
9
|
+
/** In-memory persistence so the store is testable without the filesystem. */
|
|
10
|
+
function memoryPersistence(seed: ScopedTokenEntry[] = []): ScopedTokenPersistence & {
|
|
11
|
+
saved: ScopedTokenEntry[];
|
|
12
|
+
} {
|
|
13
|
+
const state = { saved: [...seed] };
|
|
14
|
+
return {
|
|
15
|
+
saved: state.saved,
|
|
16
|
+
load() {
|
|
17
|
+
return [...state.saved];
|
|
18
|
+
},
|
|
19
|
+
save(entries) {
|
|
20
|
+
state.saved.length = 0;
|
|
21
|
+
state.saved.push(...entries);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Deterministic token generator (avoids randomness in assertions). */
|
|
27
|
+
function seqGen() {
|
|
28
|
+
let n = 0;
|
|
29
|
+
return () => `tok-${++n}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe('ScopedTokenStore.mint (ISS-0140)', () => {
|
|
33
|
+
test('mints a scoped token and persists only the hash', () => {
|
|
34
|
+
const p = memoryPersistence();
|
|
35
|
+
const store = new ScopedTokenStore(p, seqGen(), () => '2026-06-20T00:00:00Z');
|
|
36
|
+
const { token, entry, revokedHashes } = store.mint('celilo/lunacycle', 'lunacycle');
|
|
37
|
+
|
|
38
|
+
expect(token).toBe('tok-1');
|
|
39
|
+
expect(entry.scope).toBe('lunacycle');
|
|
40
|
+
expect(entry.repo).toBe('celilo/lunacycle');
|
|
41
|
+
expect(entry.hash).toBe(hashToken('tok-1'));
|
|
42
|
+
expect(revokedHashes).toEqual([]);
|
|
43
|
+
// Persisted form holds the hash, never the cleartext token.
|
|
44
|
+
expect(JSON.stringify(p.saved)).not.toContain('tok-1');
|
|
45
|
+
expect(p.saved).toHaveLength(1);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('re-minting for the same repo rotates the token (reconcile)', () => {
|
|
49
|
+
const p = memoryPersistence();
|
|
50
|
+
const store = new ScopedTokenStore(p, seqGen(), () => 't');
|
|
51
|
+
const first = store.mint('celilo/lunacycle', 'lunacycle');
|
|
52
|
+
const second = store.mint('celilo/lunacycle', 'lunacycle');
|
|
53
|
+
|
|
54
|
+
expect(second.token).toBe('tok-2');
|
|
55
|
+
expect(second.revokedHashes).toEqual([first.entry.hash]);
|
|
56
|
+
// Still exactly one active token for the repo.
|
|
57
|
+
expect(store.list().filter((e) => e.repo === 'celilo/lunacycle')).toHaveLength(1);
|
|
58
|
+
expect(store.list()).toHaveLength(1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('different repos coexist independently', () => {
|
|
62
|
+
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
63
|
+
store.mint('celilo/lunacycle', 'lunacycle');
|
|
64
|
+
store.mint('celilo/caddy', 'caddy');
|
|
65
|
+
expect(store.list()).toHaveLength(2);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe('ScopedTokenStore.revoke', () => {
|
|
70
|
+
test('removes all tokens for a repo and returns their hashes', () => {
|
|
71
|
+
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
72
|
+
const { entry } = store.mint('celilo/lunacycle', 'lunacycle');
|
|
73
|
+
const removed = store.revoke('celilo/lunacycle');
|
|
74
|
+
expect(removed).toEqual([entry.hash]);
|
|
75
|
+
expect(store.list()).toHaveLength(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('revoking an unknown repo is a no-op', () => {
|
|
79
|
+
const store = new ScopedTokenStore(memoryPersistence(), seqGen(), () => 't');
|
|
80
|
+
expect(store.revoke('celilo/nope')).toEqual([]);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('ScopedTokenStore startup load', () => {
|
|
85
|
+
test('loads persisted entries on construction', () => {
|
|
86
|
+
const seed: ScopedTokenEntry[] = [
|
|
87
|
+
{ hash: hashToken('x'), scope: 'caddy', repo: 'celilo/caddy', mintedAt: 't' },
|
|
88
|
+
];
|
|
89
|
+
const store = new ScopedTokenStore(memoryPersistence(seed), seqGen(), () => 't');
|
|
90
|
+
expect(store.list()).toHaveLength(1);
|
|
91
|
+
expect(store.list()[0]?.repo).toBe('celilo/caddy');
|
|
92
|
+
});
|
|
93
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname } from 'node:path';
|
|
4
|
+
import { hashToken } from './auth';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Persisted store of minted, package-scoped publish tokens (build-bus Phase 3,
|
|
8
|
+
* ISS-0140). The `registry_publish` capability mints one token per registered
|
|
9
|
+
* repo, scoped to the single package that repo publishes — so a compromised
|
|
10
|
+
* runner can ship only its own module, never another.
|
|
11
|
+
*
|
|
12
|
+
* Only the token HASH is persisted (never cleartext) — the raw token is returned
|
|
13
|
+
* exactly once at mint time, for the caller to set as the repo's Forgejo Actions
|
|
14
|
+
* secret. At startup the server loads this store into its {@link TokenAuth} via
|
|
15
|
+
* `addHashed`; mint/revoke update both the in-memory auth and this file, so a
|
|
16
|
+
* minted token works without a server restart.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface ScopedTokenEntry {
|
|
20
|
+
/** SHA-256 of the raw token. */
|
|
21
|
+
hash: string;
|
|
22
|
+
/** Package this token may publish. */
|
|
23
|
+
scope: string;
|
|
24
|
+
/** Repo path on the source forge this token was minted for (the reconcile key). */
|
|
25
|
+
repo: string;
|
|
26
|
+
/** ISO timestamp of mint. */
|
|
27
|
+
mintedAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface MintResult {
|
|
31
|
+
/** The raw token — returned ONCE; never persisted in cleartext. */
|
|
32
|
+
token: string;
|
|
33
|
+
entry: ScopedTokenEntry;
|
|
34
|
+
/** Hashes of tokens this mint superseded for the same repo (revoke them in auth). */
|
|
35
|
+
revokedHashes: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** I/O seam (Rule 2.3) so the store is unit-testable without the filesystem. */
|
|
39
|
+
export interface ScopedTokenPersistence {
|
|
40
|
+
load(): ScopedTokenEntry[];
|
|
41
|
+
save(entries: ScopedTokenEntry[]): void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** File-backed persistence: a JSON array at `filePath`, tolerant of a missing file. */
|
|
45
|
+
export function fileScopedTokenPersistence(filePath: string): ScopedTokenPersistence {
|
|
46
|
+
return {
|
|
47
|
+
load() {
|
|
48
|
+
if (!existsSync(filePath)) return [];
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(readFileSync(filePath, 'utf-8'));
|
|
51
|
+
return Array.isArray(parsed) ? (parsed as ScopedTokenEntry[]) : [];
|
|
52
|
+
} catch {
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
save(entries) {
|
|
57
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
58
|
+
writeFileSync(filePath, JSON.stringify(entries, null, 2));
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function defaultGenToken(): string {
|
|
64
|
+
return `cpt_${randomBytes(32).toString('base64url')}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export class ScopedTokenStore {
|
|
68
|
+
private entries: ScopedTokenEntry[];
|
|
69
|
+
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly persistence: ScopedTokenPersistence,
|
|
72
|
+
private readonly genToken: () => string = defaultGenToken,
|
|
73
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
74
|
+
) {
|
|
75
|
+
this.entries = persistence.load();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
list(): ScopedTokenEntry[] {
|
|
79
|
+
return [...this.entries];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Mint a fresh scoped token for `repo`, reconciling: any existing token for the
|
|
84
|
+
* same repo is superseded (its hash returned in `revokedHashes`). Idempotent at
|
|
85
|
+
* the repo level — re-running rotates the token cleanly.
|
|
86
|
+
*/
|
|
87
|
+
mint(repo: string, scope: string): MintResult {
|
|
88
|
+
const revokedHashes = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
|
|
89
|
+
const token = this.genToken();
|
|
90
|
+
const entry: ScopedTokenEntry = {
|
|
91
|
+
hash: hashToken(token),
|
|
92
|
+
scope,
|
|
93
|
+
repo,
|
|
94
|
+
mintedAt: this.now(),
|
|
95
|
+
};
|
|
96
|
+
this.entries = [...this.entries.filter((e) => e.repo !== repo), entry];
|
|
97
|
+
this.persistence.save(this.entries);
|
|
98
|
+
return { token, entry, revokedHashes };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Revoke all tokens for `repo`. Returns the removed hashes. */
|
|
102
|
+
revoke(repo: string): string[] {
|
|
103
|
+
const removed = this.entries.filter((e) => e.repo === repo).map((e) => e.hash);
|
|
104
|
+
if (removed.length > 0) {
|
|
105
|
+
this.entries = this.entries.filter((e) => e.repo !== repo);
|
|
106
|
+
this.persistence.save(this.entries);
|
|
107
|
+
}
|
|
108
|
+
return removed;
|
|
109
|
+
}
|
|
110
|
+
}
|