@vzn/vx-dte-server 0.0.0 → 0.0.649
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/LICENSE +21 -0
- package/PROTOCOL.md +212 -0
- package/README.md +164 -1
- package/index.ts +4 -0
- package/package.json +59 -4
- package/src/bun.ts +107 -0
- package/src/index.ts +21 -0
- package/src/oidc.ts +307 -0
- package/src/proof.ts +82 -0
- package/src/relay.ts +785 -0
- package/src/serve.ts +75 -0
- package/src/worker.ts +250 -0
- package/wrangler.jsonc +18 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 VZN
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/PROTOCOL.md
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Switchboard protocol, v1
|
|
2
|
+
|
|
3
|
+
Switchboard connects one **host** to any number of **peers** through a
|
|
4
|
+
relay, when none of them takes an inbound connection (CI runners, laptops
|
|
5
|
+
behind NAT). Each participant dials out: one websocket, which joins as it
|
|
6
|
+
opens and carries the rest. The shape is a star: the host talks to every
|
|
7
|
+
peer, a peer talks only to the host, and the relay passes each message on
|
|
8
|
+
without reading it. What host and peers say to each other is their own
|
|
9
|
+
protocol, versioned on its own.
|
|
10
|
+
|
|
11
|
+
`@vzn/vx-dte-server` is one relay that speaks it (on Bun and on Cloudflare
|
|
12
|
+
Workers); any server that does can stand in for it.
|
|
13
|
+
|
|
14
|
+
## Versions
|
|
15
|
+
|
|
16
|
+
A client names the versions it speaks in the websocket's subprotocol
|
|
17
|
+
header, on the request that joins, so negotiation costs no round trip:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
Sec-WebSocket-Protocol: switchboard.v2, switchboard.v1
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The relay answers with the highest version both speak. If none is, it
|
|
24
|
+
answers with the first one offered (a client offered nothing it can read
|
|
25
|
+
otherwise) and closes the socket `4406`, reason `supported:
|
|
26
|
+
switchboard.v<lowest>-v<highest>`. A request offering none is refused the
|
|
27
|
+
same way. This document is `switchboard.v1`.
|
|
28
|
+
|
|
29
|
+
The path names the API version apart from the protocol's: every route is
|
|
30
|
+
under `/v1/`. A change to the routes ships under `/v2/`, beside `/v1/` for
|
|
31
|
+
as long as the relay keeps it, while the messages keep their subprotocol.
|
|
32
|
+
|
|
33
|
+
## Sessions
|
|
34
|
+
|
|
35
|
+
One request is HTTP, a websocket upgrade of `GET
|
|
36
|
+
<relay>/v1/sessions/<session>/socket`. `<session>` is the session id, a random
|
|
37
|
+
(v4) UUID, so no one can guess it; another answers `400`, another path
|
|
38
|
+
`404`, another method `405` (`Allow: GET`), a request that is no upgrade `426`. It carries
|
|
39
|
+
`Authorization: Bearer <token>` (§ Tokens).
|
|
40
|
+
|
|
41
|
+
| Query | Does |
|
|
42
|
+
| ----------- | ---------------------------------------------------------------------------- |
|
|
43
|
+
| `?join=<j>` | joins: `<j>` is `{ "role": "host" \| "peer", "info"?: any, "key"?: string }` |
|
|
44
|
+
| `?id=<id>` | a participant's socket dialed again (§ Sockets) |
|
|
45
|
+
|
|
46
|
+
- A join's socket says first `{ "joined": { "id", "heartbeatMs" } }`. The
|
|
47
|
+
`id` is a secret the relay picked: whoever holds it, with a token of this
|
|
48
|
+
session, acts as that participant.
|
|
49
|
+
- The host's join opens the session; a session has one host, and a second
|
|
50
|
+
host's join is refused `409`. A peer's join of a session with no host is
|
|
51
|
+
refused `410`.
|
|
52
|
+
- A session lives while its host does. The host's `leave` is its final
|
|
53
|
+
call: it closes the session at once, and the relay keeps nothing of it.
|
|
54
|
+
A host whose socket dropped without it has the grace (§ Liveness) to
|
|
55
|
+
dial again; the session waits for it. When the grace passes, the relay
|
|
56
|
+
forgets the session and closes each peer's socket with no code: the
|
|
57
|
+
host may still join it again, and a peer's redial, closed `4404`, joins
|
|
58
|
+
again too.
|
|
59
|
+
|
|
60
|
+
## The host's key
|
|
61
|
+
|
|
62
|
+
The host's join names `key`, a P-256 public key (raw, base64url), which
|
|
63
|
+
the relay keeps for the session; one without it is refused `400`. Each of
|
|
64
|
+
the host's socket requests, its join too, carries `relay-proof: <ms>.<nonce>.<signature>`, an
|
|
65
|
+
ECDSA SHA-256 signature by that key of
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
switchboard-proof\0GET\0<path and query>\0<ms>\0<nonce>\0
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`<ms>` is when it was made, within two minutes of the relay's clock, and
|
|
72
|
+
`<nonce>` 16 to 64 base64url characters the relay takes once; else `403`.
|
|
73
|
+
So only the side holding the private key acts as the host, and a leaked
|
|
74
|
+
host `id` acts as it nowhere. A frame carries no proof: its socket's
|
|
75
|
+
upgrade did.
|
|
76
|
+
|
|
77
|
+
## Frames
|
|
78
|
+
|
|
79
|
+
Frames are JSON text, each at most 1 MiB of UTF-8 (bytes, not characters).
|
|
80
|
+
|
|
81
|
+
The relay pushes:
|
|
82
|
+
|
|
83
|
+
- `{ "messages": [{ "from", "body" }] }` as messages arrive. `from` is
|
|
84
|
+
`"host"`, a peer's `id`, or `"relay"` (§ Notices);
|
|
85
|
+
- `{ "close": <code>, "error"? }` just before it closes the socket, `0`
|
|
86
|
+
for no code. A client acts on this frame: a host may never end TCP after
|
|
87
|
+
its close.
|
|
88
|
+
|
|
89
|
+
A participant sends:
|
|
90
|
+
|
|
91
|
+
| Frame | Does |
|
|
92
|
+
| -------------------------------------- | ---------------------------------------------------------- |
|
|
93
|
+
| `{ "n", "sends": [{ "to", "body" }] }` | passes each `body` to `to` |
|
|
94
|
+
| `{ "n", "leave": true }` | removes the participant; its answer is the socket's close |
|
|
95
|
+
| `{ "n", "remove": "<peer id>" }` | the host only (`403` else): takes the peer off the session |
|
|
96
|
+
|
|
97
|
+
- The host sends to any peer `id`; a peer sends only to `"host"`. An
|
|
98
|
+
unknown or gone `to` is `404`.
|
|
99
|
+
- A frame with a number `n` is answered `{ "n" }` when all went, else
|
|
100
|
+
`{ "n", "results" }` with `null` or `{ "status", "error" }` for each
|
|
101
|
+
send. A frame of sends with no `n` is answered only if a send in it
|
|
102
|
+
fails, `n` then `null`.
|
|
103
|
+
- A frame that fails is answered `{ "n", "status", "error" }`; one that is
|
|
104
|
+
no JSON object, or none of the above, `400`; one over 1 MiB `413`,
|
|
105
|
+
answered by its `n` when the frame starts `{"n":`.
|
|
106
|
+
- A removed peer's socket is closed `4404` after what waited for it; the
|
|
107
|
+
host is not told it left.
|
|
108
|
+
|
|
109
|
+
## Delivery
|
|
110
|
+
|
|
111
|
+
The relay holds no participant's message. One it cannot push now (no
|
|
112
|
+
socket, or a push that failed) is dropped. Delivery is the clients': a
|
|
113
|
+
sender numbers each send, the receiver acknowledges it end to end, and
|
|
114
|
+
the sender sends again what is not acknowledged, at once on a new socket.
|
|
115
|
+
How they number and acknowledge is inside `body`, their own protocol;
|
|
116
|
+
`@vzn/vx-dte` puts `{ "n" }` and `{ "acked" }` beside its payload.
|
|
117
|
+
|
|
118
|
+
Only the relay's notices wait, in memory, for the participant's next
|
|
119
|
+
socket.
|
|
120
|
+
|
|
121
|
+
## Notices
|
|
122
|
+
|
|
123
|
+
The relay itself sends, `"from": "relay"`:
|
|
124
|
+
|
|
125
|
+
- to the host, `{ "type": "joined", "id", "info" }` for each peer, its
|
|
126
|
+
`info` as its join said, then forgotten;
|
|
127
|
+
- to the host, `{ "type": "left", "id" }` when a peer leaves, or its
|
|
128
|
+
grace passes with no socket;
|
|
129
|
+
- to every peer, `{ "type": "closed" }` when the host leaves: the
|
|
130
|
+
session is over. A peer on a socket is told and let go; one with no
|
|
131
|
+
socket gets `4404` when it opens one.
|
|
132
|
+
|
|
133
|
+
## Sockets
|
|
134
|
+
|
|
135
|
+
The socket a frame comes down is its participant's from then on. A
|
|
136
|
+
client that gave up on a socket whose close never reached the relay dials
|
|
137
|
+
again by its `id`: the relay closes the earlier one and pushes what
|
|
138
|
+
waited. The relay closes `4404` the socket of a participant that left, was
|
|
139
|
+
removed or lost, or that it never knew (a failed upgrade tells a client
|
|
140
|
+
nothing it can read, so the request is let in and closed). It closes a
|
|
141
|
+
socket a push failed on with no code.
|
|
142
|
+
|
|
143
|
+
## Liveness
|
|
144
|
+
|
|
145
|
+
Only `leave` ends a participant at once. One whose socket closed
|
|
146
|
+
without it, the host too, may dial again by its `id` for a grace, 10 s
|
|
147
|
+
on `@vzn/vx-dte-server`, and is gone when the grace passes with no socket:
|
|
148
|
+
a peer as if it left, the host closing its session. The relay keeps no
|
|
149
|
+
timer and counts no heartbeat: it finds a grace passed as the session is
|
|
150
|
+
next used. A join whose socket never opened (its client dropped during
|
|
151
|
+
the upgrade) has the same grace from the join. A client whose socket drops dials again, and says its `leave`
|
|
152
|
+
again on a new socket if the first dropped under it.
|
|
153
|
+
`heartbeatMs` is how often participants should hear from each other: a
|
|
154
|
+
peer quiet on a socket that never closed is its peers' to lose. The
|
|
155
|
+
clients probe a quiet peer with an empty numbered send, which it
|
|
156
|
+
acknowledges at once, and lose one quiet for two `heartbeatMs`. A
|
|
157
|
+
numbered frame the relay leaves unanswered a heartbeat is a dead socket.
|
|
158
|
+
|
|
159
|
+
## Close codes
|
|
160
|
+
|
|
161
|
+
A refused request is let in and its socket closed with `4000 + status`,
|
|
162
|
+
said first in a `{ "close", "error" }` frame:
|
|
163
|
+
|
|
164
|
+
| Code | Why |
|
|
165
|
+
| ------ | -------------------------------------------------------------------------- |
|
|
166
|
+
| `4400` | a malformed request or join |
|
|
167
|
+
| `4401` | no token, or an invalid one |
|
|
168
|
+
| `4403` | a token the relay does not accept, or a host request not signed by its key |
|
|
169
|
+
| `4404` | an unknown, left or removed participant (and every socket it let go) |
|
|
170
|
+
| `4406` | no version both speak; the reason names the relay's range |
|
|
171
|
+
| `4409` | a second host |
|
|
172
|
+
| `4410` | a closed session |
|
|
173
|
+
| `4413` | a join over 1 MiB |
|
|
174
|
+
| `4502` | the relay could not read the token issuer's keys; retry |
|
|
175
|
+
| `4503` | the relay's host refused the session (a plan's limit) |
|
|
176
|
+
|
|
177
|
+
A request that is no upgrade is answered its status and `{ "error" }`.
|
|
178
|
+
|
|
179
|
+
## Tokens
|
|
180
|
+
|
|
181
|
+
The relay checks the bearer token. `@vzn/vx-dte-server` takes CI OIDC
|
|
182
|
+
tokens: their signature by the issuer's published keys
|
|
183
|
+
(`<iss>/.well-known/openid-configuration`, then `jwks_uri`; RS256), `exp`
|
|
184
|
+
and `nbf`, an issuer it trusts, an `aud` naming its audience, and a
|
|
185
|
+
repository and workflow it allows (`403` else). Keys it cannot read
|
|
186
|
+
are the issuer's outage, not the token's fault: `502`, which a client
|
|
187
|
+
retries. Keys are read again once ten minutes old, so a key the issuer
|
|
188
|
+
withdraws stops admitting tokens, and for a key the relay does not hold
|
|
189
|
+
at most once a minute. A session is its id
|
|
190
|
+
within its token's issuer and repository claims (`repository_id` on
|
|
191
|
+
GitHub Actions, `project_id` on GitLab), so a tenant that learns another's
|
|
192
|
+
session id cannot open or join it.
|
|
193
|
+
|
|
194
|
+
## What the relay knows
|
|
195
|
+
|
|
196
|
+
A relay stores nothing. It writes no message, participant or session to
|
|
197
|
+
any file, database, key-value store or alarm: what it knows lives in
|
|
198
|
+
memory and in its open sockets, and a restart forgets it. Its host and
|
|
199
|
+
peers carry a session across one by joining it again. The one thing a
|
|
200
|
+
relay may cache is an issuer's published public keys (§ Tokens).
|
|
201
|
+
|
|
202
|
+
It holds only what routing needs, and no longer:
|
|
203
|
+
|
|
204
|
+
- A session is a SHA-256 of its id and its tokens' issuer and claims: no
|
|
205
|
+
name says whose it is. A token is checked, never kept.
|
|
206
|
+
- A peer's `info` is held until the host is told it joined.
|
|
207
|
+
- A message is pushed or dropped, never held; a notice waits in memory.
|
|
208
|
+
- A closed session is gone at once; while open, each participant's role
|
|
209
|
+
is kept, and when a socket dropped. A relay restart keeps a session whose
|
|
210
|
+
host's socket, or a peer's naming that host, it kept; one whose host's
|
|
211
|
+
socket it did not keep waits the grace from the restart.
|
|
212
|
+
- It logs nothing. It still sees each message's size, time and recipient.
|
package/README.md
CHANGED
|
@@ -1,3 +1,166 @@
|
|
|
1
1
|
# @vzn/vx-dte-server
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Website: <https://vznjs.github.io/vx/> · source: <https://github.com/vznjs/vx>
|
|
4
|
+
|
|
5
|
+
The reference relay for `@vzn/vx-dte`, which spreads one `vx run` over CI
|
|
6
|
+
workers. CI runners take no inbound connection, so the run's orchestrator
|
|
7
|
+
and its workers all dial out to a relay, which passes their messages on.
|
|
8
|
+
It admits only CI OIDC tokens of the repositories it names, and binds
|
|
9
|
+
each run to the repository that started it.
|
|
10
|
+
|
|
11
|
+
The relay is one package whose one runtime dependency is `jose`, which checks
|
|
12
|
+
the CI tokens; not even vx itself. It speaks the
|
|
13
|
+
[Switchboard protocol](./PROTOCOL.md) (`switchboard.v1`): one host, the
|
|
14
|
+
run's orchestrator, and its peers, the workers, behind one relay. Any
|
|
15
|
+
server that speaks it can take its place, and any tool can use it.
|
|
16
|
+
|
|
17
|
+
## Run it
|
|
18
|
+
|
|
19
|
+
### On Cloudflare Workers
|
|
20
|
+
|
|
21
|
+
[](https://deploy.workers.cloudflare.com/?url=https://github.com/vznjs/vx/tree/main/packages/vx-dte-server)
|
|
22
|
+
|
|
23
|
+
The button copies this directory into a new repository of yours and
|
|
24
|
+
deploys it. It asks for one var, `ALLOW`: who may take part, space
|
|
25
|
+
separated, each `issuer:owner/repo:workflow`. It is not a secret: keep it
|
|
26
|
+
in your copy's `wrangler.jsonc`, since each deploy sets vars from that file.
|
|
27
|
+
`https://token.actions.githubusercontent.com:vznjs/vx:ci.yml` admits the
|
|
28
|
+
jobs of `.github/workflows/ci.yml` in `vznjs/vx`;
|
|
29
|
+
`https://gitlab.com:group/project:.gitlab-ci.yml` a GitLab project's.
|
|
30
|
+
Repeat an entry for each repository and workflow, the orchestrator's and
|
|
31
|
+
the workers'. Every other token is refused. The relay's URL is the Cloudflare Worker's URL, such
|
|
32
|
+
as `https://vx-dte-server.<you>.workers.dev`. Name it as `relay` in
|
|
33
|
+
`dte()`.
|
|
34
|
+
|
|
35
|
+
Or deploy from this directory with Cloudflare's
|
|
36
|
+
[`wrangler`](https://developers.cloudflare.com/workers/wrangler/):
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
bun install
|
|
40
|
+
bunx wrangler login
|
|
41
|
+
bunx wrangler deploy
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Set `ALLOW` in `wrangler.jsonc`'s `vars` first.
|
|
45
|
+
|
|
46
|
+
`src/worker.ts` is the same relay as the one on Bun. Each run is one
|
|
47
|
+
Durable Object, so its sockets meet in one place. Participants hold
|
|
48
|
+
websockets, which the object hibernates between messages: the run is kept
|
|
49
|
+
in their attachments, and the object writes no storage. It sets no alarm and counts no heartbeat: a
|
|
50
|
+
socket's close loses its participant, and a peer quiet on a socket that
|
|
51
|
+
never closed is its peers' to lose. Issuers' keys
|
|
52
|
+
are read once per isolate and held ten minutes in the data center's
|
|
53
|
+
cache, so a run's object does not fetch them again; a token naming a key
|
|
54
|
+
they lack is checked against a fresh read.
|
|
55
|
+
It fits Workers' free plan: each participant joins on the socket it
|
|
56
|
+
opens, one request, and its websocket messages count 20 to one Durable
|
|
57
|
+
Object request, against 100 000 a day; it writes no row.
|
|
58
|
+
`wrangler.jsonc` sets the rest; change a value there, in the repository
|
|
59
|
+
the button made, or in the Worker's settings in the Cloudflare dashboard:
|
|
60
|
+
|
|
61
|
+
- `AUDIENCE`, unset, is the URL the relay is reached at. Set it only
|
|
62
|
+
when tokens name another one. Blanks around it are dropped, and a
|
|
63
|
+
blank one is refused, as on Bun.
|
|
64
|
+
|
|
65
|
+
### On Bun
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml AUDIENCE=https://relay.example.com \
|
|
69
|
+
bunx @vzn/vx-dte-server --port 8080
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Or as a container:
|
|
73
|
+
|
|
74
|
+
```sh
|
|
75
|
+
docker run -p 8080:8080 -e ALLOW=https://gitlab.com:group/app:.gitlab-ci.yml \
|
|
76
|
+
-e AUDIENCE=https://relay.example.com ghcr.io/vznjs/vx-dte-server
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
All vx servers at once, this relay with its state on a volume included:
|
|
80
|
+
[`private/vx-servers/compose.yaml`](../../private/vx-servers/compose.yaml)
|
|
81
|
+
(`docker compose up`).
|
|
82
|
+
|
|
83
|
+
- `ALLOW` is the same list as on Workers (above), read from the
|
|
84
|
+
environment as every vx server reads it. GitLab names a project by
|
|
85
|
+
its full path, `group/subgroup/project`.
|
|
86
|
+
- `AUDIENCE` is the `aud` every token must name: the URL the CI job
|
|
87
|
+
asks its ID token for.
|
|
88
|
+
- `--port` (default 8080) and `--host` (default `0.0.0.0`) set where it
|
|
89
|
+
listens.
|
|
90
|
+
- `--no-auth` admits every request. Use it only to try `@vzn/vx-dte`
|
|
91
|
+
locally: it listens on loopback only.
|
|
92
|
+
|
|
93
|
+
CI builds this image and carries one run through it (`test.live`).
|
|
94
|
+
|
|
95
|
+
Runs live in memory. A restarted relay forgets the runs it was carrying,
|
|
96
|
+
and vx-dte joins each again.
|
|
97
|
+
Participants hold websockets here too, so a message reaches its receiver
|
|
98
|
+
the moment it is sent.
|
|
99
|
+
|
|
100
|
+
Each participant is one socket and one file descriptor. Bun raises the
|
|
101
|
+
open-file limit to the container's hard limit (Docker's is 524288 or
|
|
102
|
+
more), so it never needs a `--ulimit`. Measured on 4 cores (2026-10-24):
|
|
103
|
+
10,000 sockets over 100 runs take 41 MB over an idle relay (about 4 KB
|
|
104
|
+
each) and 6% of a core idle; with each worker sending one message a
|
|
105
|
+
second (10,000 a second) 43% of a core, every message delivered.
|
|
106
|
+
|
|
107
|
+
## Environment
|
|
108
|
+
|
|
109
|
+
- `RELAY`: on Workers, the `RelayRun` Durable Object binding
|
|
110
|
+
(`wrangler.jsonc`); the relay refuses every request without it.
|
|
111
|
+
- `NO_COLOR`, then `FORCE_COLOR`, then a terminal whose `TERM` is not
|
|
112
|
+
dumb: whether the Bun relay's status lines print in colour.
|
|
113
|
+
|
|
114
|
+
## API
|
|
115
|
+
|
|
116
|
+
The same relay as a `fetch` handler, to mount in a server of your own
|
|
117
|
+
(`src/index.ts`):
|
|
118
|
+
|
|
119
|
+
- `createRelay(options?: RelayOptions): Relay`. `RelayOptions` is
|
|
120
|
+
`{ verify?, heartbeatMs?, graceMs? }`: `verify` checks a request's bearer token
|
|
121
|
+
(none admits every request) and gets the request too, `heartbeatMs` (default 2000) is how often
|
|
122
|
+
participants hear from each other, said in each join's answer, and
|
|
123
|
+
`graceMs` (default 10 000) how long a participant whose socket dropped
|
|
124
|
+
without `leave` may dial again before it is gone (PROTOCOL.md § Liveness). A `Relay` is `{ fetch(req) }`,
|
|
125
|
+
plus the hooks a host of websockets drives (`src/worker.ts` is their
|
|
126
|
+
user): the options `sockets`, the `[Kept, RelaySocket]` pairs a host
|
|
127
|
+
kept across a restart, from which the sessions are rebuilt (PROTOCOL.md
|
|
128
|
+
§ What the relay knows), and `upgrade`, which answers a `socket`
|
|
129
|
+
request; and the methods `attach` and `detach` (its socket closed: the
|
|
130
|
+
participant is gone unless it dials again within `graceMs`), which hand the relay a participant's
|
|
131
|
+
`RelaySocket` (`{ send, close }`), and `frame`, which returns the
|
|
132
|
+
answer to send down the socket, or `undefined` for none. `attach`
|
|
133
|
+
returns the `Kept` its host keeps with the socket (a Durable Object's
|
|
134
|
+
attachment: session, id, role, the host's key, and a peer's host's id), or
|
|
135
|
+
`false`. A host closes a socket `attach` refuses
|
|
136
|
+
with `closeSocket(socket, GONE)` (`GONE` is 4404), which says the close in a frame first
|
|
137
|
+
(PROTOCOL.md § Sockets).
|
|
138
|
+
- `serveRelay(options: ServeOptions): RelayServer` serves the relay on
|
|
139
|
+
`Bun.serve`, websockets included. `ServeOptions` is
|
|
140
|
+
`{ hostname, port, verify?, heartbeatMs? }`, and a `RelayServer` is
|
|
141
|
+
`{ url, stop() }`.
|
|
142
|
+
- `createProver(): Promise<Prover>` makes the host's key for one
|
|
143
|
+
session (PROTOCOL.md § The host's key). A `Prover` is
|
|
144
|
+
`{ key, proof(method, path, body) }`: `key` is what its `join` names,
|
|
145
|
+
and `proof` is the value of the `PROOF_HEADER` (`relay-proof`) header of
|
|
146
|
+
each request as the host.
|
|
147
|
+
- `createVerifier(options: OidcOptions)` returns that `verify`.
|
|
148
|
+
`OidcOptions` is
|
|
149
|
+
`{ allow, audience, runClaims?, fetchJson?, now? }`: who may take part,
|
|
150
|
+
`Allowed`s `{ issuer, repository?, workflow? }` (an unset part admits
|
|
151
|
+
any; a token's `repositoryClaimOf(iss)` and `workflowClaimOf(iss)` up to
|
|
152
|
+
its `@` are matched without case), the `aud` a token must name, run claims per issuer (default
|
|
153
|
+
`runClaimsOf(iss)`), and a key reader and clock for tests. It
|
|
154
|
+
resolves a `VerifiedToken`, `{ iss, run }`, or rejects with a
|
|
155
|
+
`TokenError` carrying the `status` (401 or 403) the relay answers.
|
|
156
|
+
- `parseAllow(list)` reads `ALLOW`'s entries into `Allowed`s, each
|
|
157
|
+
`issuer:owner/repo:workflow` read from the right (an issuer holds `:`s),
|
|
158
|
+
or throws naming the entry it cannot read. An issuer is `https://`, or
|
|
159
|
+
plain `http://` only to `127.0.0.1`, as the cache and history servers read it.
|
|
160
|
+
- `runClaimsOf(iss)` names the claims that bind a run: `repository_id`
|
|
161
|
+
for GitHub Actions, `project_id` otherwise.
|
|
162
|
+
- `repositoryClaimOf(iss)` names the claim that names a token's
|
|
163
|
+
repository: `repository` for GitHub Actions, `project_path` otherwise.
|
|
164
|
+
- `workflowClaimOf(iss)` names the claim that names the workflow a
|
|
165
|
+
token's job is defined in: `job_workflow_ref` for GitHub Actions,
|
|
166
|
+
`ci_config_ref_uri` otherwise.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,8 +1,63 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-dte-server",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder; real releases are published by CI.",
|
|
3
|
+
"version": "0.0.649",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
"
|
|
5
|
+
"description": "The reference relay for @vzn/vx-dte: carries messages between a vx run's orchestrator and its CI agents, admitting only CI OIDC tokens of the same repository.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vx",
|
|
8
|
+
"monorepo",
|
|
9
|
+
"distributed",
|
|
10
|
+
"ci",
|
|
11
|
+
"relay"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./src/index.ts",
|
|
15
|
+
"types": "./src/index.ts",
|
|
16
|
+
"bin": {
|
|
17
|
+
"vx-dte-server": "./src/bun.ts"
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"types": "./src/index.ts",
|
|
22
|
+
"import": "./src/index.ts"
|
|
23
|
+
},
|
|
24
|
+
"./worker": {
|
|
25
|
+
"types": "./src/worker.ts",
|
|
26
|
+
"import": "./src/worker.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"index.ts",
|
|
31
|
+
"src",
|
|
32
|
+
"PROTOCOL.md",
|
|
33
|
+
"wrangler.jsonc",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"engines": {
|
|
38
|
+
"bun": ">=1.4.4"
|
|
39
|
+
},
|
|
40
|
+
"//": [
|
|
41
|
+
"wrangler: deploys src/worker.ts by wrangler.jsonc, which the Deploy to Cloudflare button reads (it parses no other config).",
|
|
42
|
+
"@vzn/vx: vx.config.ts's core, the workspace's own here; `*`, not workspace:, which npm refuses when the button installs this directory alone.",
|
|
43
|
+
"miniflare: tests/worker-sockets.test.ts runs src/worker.ts in workerd, the runtime Workers deploy to.",
|
|
44
|
+
"jose: checks the CI OIDC tokens (signature, algorithm, audience, times) with a widely used, dependency-free JWT library instead of hand-rolled WebCrypto; it runs in Bun and workerd alike."
|
|
45
|
+
],
|
|
46
|
+
"cloudflare": {
|
|
47
|
+
"bindings": {
|
|
48
|
+
"ALLOW": {
|
|
49
|
+
"description": "Who may take part, space separated, each `issuer:owner/repo:workflow`: `https://token.actions.githubusercontent.com:vznjs/vx:ci.yml` admits jobs of `.github/workflows/ci.yml` in vznjs/vx (GitLab: `https://gitlab.com:group/project:.gitlab-ci.yml`). List the orchestrator's workflow and the agents'."
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"jose": "6.2.13"
|
|
55
|
+
},
|
|
56
|
+
"repository": {
|
|
57
|
+
"type": "git",
|
|
58
|
+
"url": "git+https://github.com/vznjs/vx.git",
|
|
59
|
+
"directory": "packages/vx-dte-server"
|
|
60
|
+
},
|
|
61
|
+
"homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-dte-server#readme",
|
|
62
|
+
"bugs": "https://github.com/vznjs/vx/issues"
|
|
8
63
|
}
|
package/src/bun.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
#!/usr/bin/env -S bun --no-env-file --no-install
|
|
2
|
+
// `bunx @vzn/vx-dte-server`: the relay on Bun, runs in memory (PROTOCOL.md).
|
|
3
|
+
|
|
4
|
+
import { parseArgs } from 'node:util'
|
|
5
|
+
import { createVerifier, parseAllow } from './oidc.js'
|
|
6
|
+
import { serveRelay } from './serve.js'
|
|
7
|
+
|
|
8
|
+
const USAGE = `usage: vx-dte-server [--port 8080] [--host 0.0.0.0]
|
|
9
|
+
vx-dte-server --no-auth [--port 8080] (loopback only)
|
|
10
|
+
ALLOW who may take part, space separated, each issuer:owner/repo:workflow
|
|
11
|
+
AUDIENCE the aud every CI token names: the URL CI reaches the relay at`
|
|
12
|
+
|
|
13
|
+
const LOOPBACK = new Set(['127.0.0.1', '::1', 'localhost'])
|
|
14
|
+
|
|
15
|
+
function main(argv: string[]): void {
|
|
16
|
+
const { values } = parseArgs({
|
|
17
|
+
args: argv,
|
|
18
|
+
options: {
|
|
19
|
+
port: { type: 'string', default: '8080' },
|
|
20
|
+
host: { type: 'string' },
|
|
21
|
+
'no-auth': { type: 'boolean', default: false },
|
|
22
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
if (values.help) {
|
|
26
|
+
console.log(USAGE)
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
const noAuth = values['no-auth']
|
|
30
|
+
const host = values.host ?? (noAuth ? '127.0.0.1' : '0.0.0.0')
|
|
31
|
+
// Bun.serve reads '' as localhost: unreachable from another host or container.
|
|
32
|
+
if (host.trim() === '') fail(`--host: needs an address, got '${host}'`)
|
|
33
|
+
if (noAuth && !LOOPBACK.has(host)) fail('--no-auth listens on loopback only')
|
|
34
|
+
// Read from the environment, as the cache and history servers and the
|
|
35
|
+
// relay's Worker read them: one way to configure every vx server.
|
|
36
|
+
const env = Bun.env
|
|
37
|
+
const audience = env.AUDIENCE?.trim() ?? ''
|
|
38
|
+
// The bare usage said nothing of which was missing.
|
|
39
|
+
if (!noAuth) {
|
|
40
|
+
const missing = [
|
|
41
|
+
// An empty one is missing too: it started a relay that refused every token.
|
|
42
|
+
...((env.ALLOW ?? '').trim() === '' ? ['ALLOW'] : []),
|
|
43
|
+
...(audience === '' ? ['AUDIENCE'] : []),
|
|
44
|
+
]
|
|
45
|
+
if (missing.length > 0) fail(`missing ${missing.join(' and ')} (or --no-auth)\n${USAGE}`)
|
|
46
|
+
}
|
|
47
|
+
let allow: ReturnType<typeof parseAllow> = []
|
|
48
|
+
try {
|
|
49
|
+
allow = parseAllow(env.ALLOW ?? '')
|
|
50
|
+
} catch (err) {
|
|
51
|
+
fail(`ALLOW: ${(err as Error).message}`)
|
|
52
|
+
}
|
|
53
|
+
// Digits only, as the cache and history servers read it: Number('') is
|
|
54
|
+
// 0, a random port for `--port "$PORT"` with PORT unset, and Number read
|
|
55
|
+
// 0x50 as 80 and 1e1 as 10.
|
|
56
|
+
const port = /^\d+$/.test(values.port) ? Number(values.port) : Number.NaN
|
|
57
|
+
if (!Number.isInteger(port) || port < 0 || port > 65_535)
|
|
58
|
+
fail(`--port: not a port: ${values.port}`)
|
|
59
|
+
const server = serveRelay({
|
|
60
|
+
hostname: host,
|
|
61
|
+
port,
|
|
62
|
+
verify: noAuth ? undefined : createVerifier({ allow, audience }),
|
|
63
|
+
})
|
|
64
|
+
process.stdout.write(statusLine('ok', `listening on ${server.url}`))
|
|
65
|
+
const stop = (): void => {
|
|
66
|
+
void server.stop().then(() => process.exit(0))
|
|
67
|
+
}
|
|
68
|
+
process.on('SIGINT', stop)
|
|
69
|
+
process.on('SIGTERM', stop)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function fail(message: string): never {
|
|
73
|
+
process.stderr.write(statusLine('err', message))
|
|
74
|
+
process.exit(2)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
main(process.argv.slice(2))
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// parseArgs names the flag it refused, not the ones there are.
|
|
81
|
+
const usage = (err as { code?: string }).code?.startsWith('ERR_PARSE_ARGS') ? `\n${USAGE}` : ''
|
|
82
|
+
fail(`${err instanceof Error ? err.message : String(err)}${usage}`)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `✓ vx-dte-server listening on …` / `✗ vx-dte-server <why>`, painted as
|
|
87
|
+
* vx's status lines are (NO_COLOR, then FORCE_COLOR, then a terminal). The
|
|
88
|
+
* relay imports only jose, so this is @vzn/vx-devtools' `statusLine` kept
|
|
89
|
+
* here; usage lines under a refusal print dim.
|
|
90
|
+
*/
|
|
91
|
+
function statusLine(tone: 'ok' | 'err', text: string): string {
|
|
92
|
+
const stream = tone === 'ok' ? process.stdout : process.stderr
|
|
93
|
+
const env = process.env
|
|
94
|
+
const force = env['FORCE_COLOR']
|
|
95
|
+
const on = env['NO_COLOR']
|
|
96
|
+
? false
|
|
97
|
+
: force !== undefined
|
|
98
|
+
? force !== '0' && force !== 'false'
|
|
99
|
+
: stream.isTTY === true && env['TERM'] !== 'dumb'
|
|
100
|
+
const [head = '', ...rest] = text.split('\n')
|
|
101
|
+
const symbol = tone === 'ok' ? '✓' : '✗'
|
|
102
|
+
if (!on) return `${[`${symbol} vx-dte-server ${head}`, ...rest].join('\n')}\n`
|
|
103
|
+
const hue = tone === 'ok' ? '34;197;94' : '239;68;68'
|
|
104
|
+
const url = head.replace(/(https?:\/\/\S+)/g, '\x1b[38;2;6;182;212m$1\x1b[0m')
|
|
105
|
+
const first = `\x1b[38;2;${hue}m${symbol}\x1b[0m \x1b[1mvx-dte-server\x1b[0m ${url}`
|
|
106
|
+
return `${[first, ...rest.map((l) => (l === '' ? l : `\x1b[2m${l}\x1b[0m`))].join('\n')}\n`
|
|
107
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
closeSocket,
|
|
3
|
+
createRelay,
|
|
4
|
+
GONE,
|
|
5
|
+
type Kept,
|
|
6
|
+
type Relay,
|
|
7
|
+
type RelayOptions,
|
|
8
|
+
} from './relay.js'
|
|
9
|
+
export { serveRelay, type RelayServer, type ServeOptions } from './serve.js'
|
|
10
|
+
export {
|
|
11
|
+
createVerifier,
|
|
12
|
+
parseAllow,
|
|
13
|
+
repositoryClaimOf,
|
|
14
|
+
runClaimsOf,
|
|
15
|
+
workflowClaimOf,
|
|
16
|
+
TokenError,
|
|
17
|
+
type Allowed,
|
|
18
|
+
type OidcOptions,
|
|
19
|
+
type VerifiedToken,
|
|
20
|
+
} from './oidc.js'
|
|
21
|
+
export { createProver, PROOF_HEADER, type Prover } from './proof.js'
|