hammerkit 1.27.0-next.14 → 1.27.0-next.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/dist/docker/item-network.js +13 -5
- package/dist/executer/docker-service.js +30 -8
- package/dist/harness/mcp.js +10 -0
- package/dist/harness/opencode.js +22 -0
- package/dist/harness/pi.js +14 -0
- package/dist/harness/sandbox-client.js +55 -1
- package/dist/harness/service-tools.js +141 -0
- package/dist/kubernetes/ensure-kubernetes-service-exists.js +2 -0
- package/dist/planner/utils/parse-work-port.js +4 -1
- package/dist/remote/remote-work-commands.js +98 -12
- package/dist/schema/work-scope-parser.js +6 -0
- package/dist/server/accounts-api.js +2 -1
- package/dist/server/approvals-api.js +1 -1
- package/dist/server/endpoints.js +612 -0
- package/dist/server/executor.js +14 -1
- package/dist/server/kubernetes-backend.js +200 -33
- package/dist/server/policy.js +24 -2
- package/dist/server/repo-access.js +5 -0
- package/dist/server/run-plan.js +7 -3
- package/dist/server/run-request.js +44 -2
- package/dist/server/run-worker.js +130 -9
- package/dist/server/runs.js +319 -24
- package/dist/server/server-config.js +160 -9
- package/dist/server/server.js +210 -14
- package/dist/server/service-runs.js +82 -0
- package/package.json +1 -1
|
@@ -6,7 +6,7 @@ const policy_1 = require("./policy");
|
|
|
6
6
|
/**
|
|
7
7
|
* Who the caller is and what they may do:
|
|
8
8
|
*
|
|
9
|
-
* GET /v1/me identity, accounts with their level, repositories, the builds level
|
|
9
|
+
* GET /v1/me identity, accounts with their level, repositories, the builds level the sandbox and endpoint grants
|
|
10
10
|
* GET /v1/accounts the accounts the caller has a level on, with what the operator says about them
|
|
11
11
|
*/
|
|
12
12
|
async function handleAccounts(config, log, principal, req, res, url) {
|
|
@@ -32,6 +32,7 @@ async function handleAccounts(config, log, principal, req, res, url) {
|
|
|
32
32
|
repos: grant.repos,
|
|
33
33
|
builds: grant.builds,
|
|
34
34
|
sandboxes: grant.sandboxes,
|
|
35
|
+
endpoints: grant.endpoints,
|
|
35
36
|
});
|
|
36
37
|
return true;
|
|
37
38
|
}
|
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ownPath = exports.EndpointGateway = exports.LOGIN_HOST = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
5
|
+
const promises_1 = require("fs/promises");
|
|
6
|
+
const http_1 = require("http");
|
|
7
|
+
const net_1 = require("net");
|
|
8
|
+
const path_1 = require("path");
|
|
9
|
+
const oauth_1 = require("./oauth");
|
|
10
|
+
const policy_1 = require("./policy");
|
|
11
|
+
const service_runs_1 = require("./service-runs");
|
|
12
|
+
const units_1 = require("../utils/units");
|
|
13
|
+
// The cookie of a sign-in at one endpoint host. `__Host-` keeps a page on another host of the
|
|
14
|
+
// domain from setting or overwriting it; it needs https, so plain http (on the loopback
|
|
15
|
+
// interface, for testing) goes without the prefix.
|
|
16
|
+
const SESSION_COOKIE = '__Host-hammerkit-session';
|
|
17
|
+
const PLAIN_SESSION_COOKIE = 'hammerkit-session';
|
|
18
|
+
// the host of the domain that signs people in, the one redirect URI registered at the issuer
|
|
19
|
+
exports.LOGIN_HOST = 'login';
|
|
20
|
+
// the paths of an endpoint host that are the server's, never the app's
|
|
21
|
+
const OWN_PATH = '/.hammerkit/';
|
|
22
|
+
// how long a sign-in may take at the issuer, and how long its one-time code holds
|
|
23
|
+
const LOGIN_TIMEOUT_MS = 10 * 60 * 1000;
|
|
24
|
+
const CODE_TIMEOUT_MS = 60 * 1000;
|
|
25
|
+
const MAX_PENDING_LOGINS = 1000;
|
|
26
|
+
const DISCOVERY_TTL_MS = 10 * 60 * 1000;
|
|
27
|
+
const FETCH_TIMEOUT_MS = 10000;
|
|
28
|
+
/**
|
|
29
|
+
* Publishes the endpoints of service runs: every host `<label>.<domain>` is answered here
|
|
30
|
+
* and nowhere else. A caller is known by a session cookie of that host, which the sign-in
|
|
31
|
+
* at `login.<domain>` gives a person, or by a token in `Hammerkit-Authorization`. Who may
|
|
32
|
+
* open it (the run's owner, or an `open` level for its repository) is decided with the
|
|
33
|
+
* entitlements of the moment on every request, then the request goes to the service's port.
|
|
34
|
+
*/
|
|
35
|
+
class EndpointGateway {
|
|
36
|
+
constructor(config, registry, options) {
|
|
37
|
+
this.config = config;
|
|
38
|
+
this.registry = registry;
|
|
39
|
+
this.options = options;
|
|
40
|
+
this.pending = new Map();
|
|
41
|
+
this.codes = new Map();
|
|
42
|
+
this.base = { scheme: 'https', port: '' };
|
|
43
|
+
this.fetcher = options.fetch ?? fetch;
|
|
44
|
+
this.now = options.now ?? Date.now;
|
|
45
|
+
const login = config.endpoints?.login;
|
|
46
|
+
const issuer = login ? config.issuers.find((candidate) => candidate.issuer === login.issuer) : undefined;
|
|
47
|
+
if (login && issuer) {
|
|
48
|
+
// an ID token is meant for the client that signed in, not for the API
|
|
49
|
+
this.loginVerifier = new oauth_1.TokenVerifier([{ ...issuer, audience: login.clientId }], {
|
|
50
|
+
...(options.fetch ? { fetch: options.fetch } : {}),
|
|
51
|
+
...(options.now ? { now: options.now } : {}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async load() {
|
|
56
|
+
try {
|
|
57
|
+
const key = await (0, promises_1.readFile)(this.options.keyFile);
|
|
58
|
+
if (key.length >= 32) {
|
|
59
|
+
this.key = key;
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// made below
|
|
65
|
+
}
|
|
66
|
+
this.key = (0, crypto_1.randomBytes)(32);
|
|
67
|
+
await (0, promises_1.mkdir)((0, path_1.dirname)(this.options.keyFile), { recursive: true, mode: 0o700 });
|
|
68
|
+
await (0, promises_1.writeFile)(this.options.keyFile, this.key, { mode: 0o600 });
|
|
69
|
+
}
|
|
70
|
+
// Where the server listens, for the addresses of endpoints: https unless it serves plain
|
|
71
|
+
// http on the loopback interface; no port behind a proxy or on the default one.
|
|
72
|
+
listening(port, secure) {
|
|
73
|
+
const scheme = secure || this.config.behindTlsProxy ? 'https' : 'http';
|
|
74
|
+
const standard = this.config.behindTlsProxy || (scheme === 'https' ? port === 443 : port === 80);
|
|
75
|
+
this.base = { scheme, port: standard ? '' : `:${port}` };
|
|
76
|
+
}
|
|
77
|
+
url(host, path = '/') {
|
|
78
|
+
return `${this.base.scheme}://${host}.${this.config.endpoints?.domain}${this.base.port}${path}`;
|
|
79
|
+
}
|
|
80
|
+
// The label of an endpoint host the request is for, or null when it is for the API.
|
|
81
|
+
hostOf(req) {
|
|
82
|
+
const domain = this.config.endpoints?.domain;
|
|
83
|
+
const host = (req.headers.host ?? '').toLowerCase().replace(/:\d+$/, '');
|
|
84
|
+
if (!domain || !host.endsWith(`.${domain}`)) {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return host.slice(0, -domain.length - 1);
|
|
88
|
+
}
|
|
89
|
+
async handle(label, req, res) {
|
|
90
|
+
const url = new URL(req.url ?? '/', 'http://endpoint');
|
|
91
|
+
if (label === exports.LOGIN_HOST) {
|
|
92
|
+
return this.handleLogin(req, res, url);
|
|
93
|
+
}
|
|
94
|
+
const found = /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
|
|
95
|
+
if (url.pathname === `${OWN_PATH}session`) {
|
|
96
|
+
return this.takeCode(label, req, res, url);
|
|
97
|
+
}
|
|
98
|
+
if (url.pathname === `${OWN_PATH}logout`) {
|
|
99
|
+
res.writeHead(303, { location: '/', 'set-cookie': this.clearCookie(), 'cache-control': 'no-store' });
|
|
100
|
+
res.end();
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (!found) {
|
|
104
|
+
return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
|
|
105
|
+
}
|
|
106
|
+
const principal = await this.caller(label, req);
|
|
107
|
+
if (!principal) {
|
|
108
|
+
if (this.loginVerifier && req.method === 'GET' && acceptsHtml(req.headers)) {
|
|
109
|
+
const start = new URL(this.url(exports.LOGIN_HOST, '/start'));
|
|
110
|
+
start.searchParams.set('host', label);
|
|
111
|
+
start.searchParams.set('path', url.pathname + url.search);
|
|
112
|
+
res.writeHead(302, { location: start.toString(), 'cache-control': 'no-store' });
|
|
113
|
+
res.end();
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
res.writeHead(401, {
|
|
117
|
+
'content-type': 'text/plain; charset=utf-8',
|
|
118
|
+
'www-authenticate': 'Bearer realm="hammerkit"',
|
|
119
|
+
'cache-control': 'no-store',
|
|
120
|
+
});
|
|
121
|
+
res.end('sign in: send a token in Hammerkit-Authorization: Bearer <token>\n');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
if (!this.mayOpen(principal, found.run)) {
|
|
125
|
+
this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
|
|
126
|
+
return page(res, 403, 'Not allowed', `<p>You are signed in as <code>${escape(principal.sub)}</code> of <code>${escape(principal.iss)}</code>, which may not open this endpoint.</p><p><a href="${OWN_PATH}logout">Sign out</a></p>`);
|
|
127
|
+
}
|
|
128
|
+
if (url.pathname === `${OWN_PATH}status` || url.pathname === `${OWN_PATH}extend`) {
|
|
129
|
+
return this.status(label, found.run, found.endpoint, principal, req, res, url);
|
|
130
|
+
}
|
|
131
|
+
if (url.pathname.startsWith(OWN_PATH)) {
|
|
132
|
+
return page(res, 404, 'Not found', '<p>There is no such page.</p>');
|
|
133
|
+
}
|
|
134
|
+
// a request keeps the services up, or wakes them
|
|
135
|
+
const refused = this.registry.visit(found.run.id, this.options.mayWake);
|
|
136
|
+
if (!found.target) {
|
|
137
|
+
return this.notUp(res, this.registry.endpoint(label)?.run ?? found.run, refused);
|
|
138
|
+
}
|
|
139
|
+
this.forward(found.target, principal, req, res);
|
|
140
|
+
}
|
|
141
|
+
// A WebSocket (or any upgrade) to an endpoint, after the same checks as a request.
|
|
142
|
+
async upgrade(label, req, socket, head) {
|
|
143
|
+
const refuse = (status, text) => {
|
|
144
|
+
socket.end(`HTTP/1.1 ${status} ${text}\r\nconnection: close\r\ncontent-length: 0\r\n\r\n`);
|
|
145
|
+
};
|
|
146
|
+
const found = label !== exports.LOGIN_HOST && /^[a-z0-9-]+$/.test(label) ? this.registry.endpoint(label) : undefined;
|
|
147
|
+
if (!found) {
|
|
148
|
+
return refuse(404, 'Not Found');
|
|
149
|
+
}
|
|
150
|
+
const principal = await this.caller(label, req);
|
|
151
|
+
if (!principal) {
|
|
152
|
+
return refuse(401, 'Unauthorized');
|
|
153
|
+
}
|
|
154
|
+
if (!this.mayOpen(principal, found.run)) {
|
|
155
|
+
this.options.log(`deny endpoint ${label} iss=${principal.iss} sub=${principal.sub}`);
|
|
156
|
+
return refuse(403, 'Forbidden');
|
|
157
|
+
}
|
|
158
|
+
if (new URL(req.url ?? '/', 'http://endpoint').pathname.startsWith(OWN_PATH)) {
|
|
159
|
+
return refuse(404, 'Not Found');
|
|
160
|
+
}
|
|
161
|
+
this.registry.visit(found.run.id, this.options.mayWake);
|
|
162
|
+
const target = found.target;
|
|
163
|
+
if (!target) {
|
|
164
|
+
return refuse(503, 'Service Unavailable');
|
|
165
|
+
}
|
|
166
|
+
const upstream = (0, net_1.connect)(target.address.port, target.address.host);
|
|
167
|
+
const headers = this.forwardedHeaders(req, principal);
|
|
168
|
+
upstream.once('connect', () => {
|
|
169
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
170
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
171
|
+
for (const item of Array.isArray(value) ? value : [value]) {
|
|
172
|
+
if (item !== undefined) {
|
|
173
|
+
lines.push(`${name}: ${item}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
|
|
178
|
+
if (head.length > 0) {
|
|
179
|
+
upstream.write(head);
|
|
180
|
+
}
|
|
181
|
+
upstream.pipe(socket);
|
|
182
|
+
socket.pipe(upstream);
|
|
183
|
+
});
|
|
184
|
+
upstream.on('error', () => socket.destroy());
|
|
185
|
+
socket.on('error', () => upstream.destroy());
|
|
186
|
+
socket.on('close', () => upstream.destroy());
|
|
187
|
+
upstream.on('close', () => socket.destroy());
|
|
188
|
+
}
|
|
189
|
+
// The owner of the run, or who has an `open` level for its repository, with the
|
|
190
|
+
// entitlements as they are now.
|
|
191
|
+
mayOpen(principal, run) {
|
|
192
|
+
if (run.owner.iss === principal.iss && run.owner.sub === principal.sub) {
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
return (0, service_runs_1.mayOpenEndpoints)((0, policy_1.getGrant)(this.config.entitlements, principal), run);
|
|
196
|
+
}
|
|
197
|
+
async caller(label, req) {
|
|
198
|
+
const header = req.headers['hammerkit-authorization'];
|
|
199
|
+
const token = typeof header === 'string' ? /^Bearer ([A-Za-z0-9._~+/-]+=*)$/.exec(header)?.[1] : undefined;
|
|
200
|
+
if (token) {
|
|
201
|
+
try {
|
|
202
|
+
return await this.options.verifier.verify(token);
|
|
203
|
+
}
|
|
204
|
+
catch (e) {
|
|
205
|
+
this.options.log(`deny endpoint ${label}: ${e instanceof oauth_1.TokenError ? e.message : 'token could not be checked'}`);
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
// the cookie is sent along when a page of another endpoint asks: the endpoints share
|
|
210
|
+
// one site, and the app of one is not to act as its visitor on another
|
|
211
|
+
if (this.fromElsewhere(label, req)) {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
const session = this.readSession(label, req.headers.cookie);
|
|
215
|
+
return session ? { iss: session.iss, sub: session.sub, groups: session.groups, claims: session.claims } : null;
|
|
216
|
+
}
|
|
217
|
+
// Whether the browser sends the request for another page than the endpoint's own: an
|
|
218
|
+
// upgrade from another origin, or a request a page of another site makes (a frame too),
|
|
219
|
+
// but a link followed to the endpoint. Without Sec-Fetch-Site, by its Origin.
|
|
220
|
+
fromElsewhere(label, req) {
|
|
221
|
+
const origin = req.headers.origin;
|
|
222
|
+
const own = this.url(label, '/').slice(0, -1);
|
|
223
|
+
if (req.headers.upgrade) {
|
|
224
|
+
return origin !== own;
|
|
225
|
+
}
|
|
226
|
+
const site = req.headers['sec-fetch-site'];
|
|
227
|
+
if (site === 'same-site' || site === 'cross-site') {
|
|
228
|
+
// a page of its own the person goes to, not one framed by another site
|
|
229
|
+
const dest = req.headers['sec-fetch-dest'];
|
|
230
|
+
return !(req.headers['sec-fetch-mode'] === 'navigate' &&
|
|
231
|
+
(dest === undefined || dest === 'document') &&
|
|
232
|
+
(req.method === 'GET' || req.method === 'HEAD'));
|
|
233
|
+
}
|
|
234
|
+
return site === undefined && origin !== undefined && origin !== own;
|
|
235
|
+
}
|
|
236
|
+
forward(target, principal, req, res) {
|
|
237
|
+
const upstream = (0, http_1.request)({
|
|
238
|
+
host: target.address.host,
|
|
239
|
+
port: target.address.port,
|
|
240
|
+
method: req.method,
|
|
241
|
+
path: req.url,
|
|
242
|
+
headers: this.forwardedHeaders(req, principal),
|
|
243
|
+
}, (answer) => {
|
|
244
|
+
const headers = { ...answer.headers, 'x-robots-tag': 'noindex' };
|
|
245
|
+
res.writeHead(answer.statusCode ?? 502, answer.statusMessage, headers);
|
|
246
|
+
answer.pipe(res);
|
|
247
|
+
});
|
|
248
|
+
upstream.on('error', (e) => {
|
|
249
|
+
this.options.log(`endpoint ${target.service}:${target.port}: ${e.message}`);
|
|
250
|
+
if (!res.headersSent) {
|
|
251
|
+
page(res, 502, 'Not reachable', `<p>${escape(target.service)} did not answer on port ${target.port}.</p>`);
|
|
252
|
+
}
|
|
253
|
+
else {
|
|
254
|
+
res.destroy();
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
res.on('close', () => upstream.destroy());
|
|
258
|
+
req.pipe(upstream);
|
|
259
|
+
}
|
|
260
|
+
// What reaches the app: the request without the server's cookie and headers, with who asked.
|
|
261
|
+
forwardedHeaders(req, principal) {
|
|
262
|
+
const headers = {};
|
|
263
|
+
for (const [name, value] of Object.entries(req.headers)) {
|
|
264
|
+
if (name.startsWith('hammerkit-') || name.startsWith('x-hammerkit-') || name.startsWith('x-forwarded-')) {
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
headers[name] = value;
|
|
268
|
+
}
|
|
269
|
+
const cookies = (req.headers.cookie ?? '')
|
|
270
|
+
.split(/;\s*/)
|
|
271
|
+
.filter((cookie) => cookie && !cookie.startsWith(`${SESSION_COOKIE}=`) && !cookie.startsWith(`${PLAIN_SESSION_COOKIE}=`));
|
|
272
|
+
if (cookies.length > 0) {
|
|
273
|
+
headers.cookie = cookies.join('; ');
|
|
274
|
+
}
|
|
275
|
+
else {
|
|
276
|
+
delete headers.cookie;
|
|
277
|
+
}
|
|
278
|
+
headers['x-forwarded-proto'] = this.base.scheme;
|
|
279
|
+
headers['x-forwarded-host'] = req.headers.host ?? '';
|
|
280
|
+
headers['x-forwarded-for'] = req.socket.remoteAddress ?? '';
|
|
281
|
+
headers['x-hammerkit-caller'] = `${principal.iss} ${principal.sub}`;
|
|
282
|
+
return headers;
|
|
283
|
+
}
|
|
284
|
+
notUp(res, run, refused) {
|
|
285
|
+
if (run.state === 'queued' || run.state === 'running' || run.state === 'awaiting-approval') {
|
|
286
|
+
res.setHeader('refresh', '5');
|
|
287
|
+
res.setHeader('retry-after', '5');
|
|
288
|
+
return page(res, 503, 'Starting', `<p>The services of this endpoint are ${run.wakes ? 'waking up' : 'starting'} (${escape(run.state)}), this page reloads until they are up.</p>`);
|
|
289
|
+
}
|
|
290
|
+
if (run.state === 'asleep') {
|
|
291
|
+
return page(res, 503, 'Asleep', `<p>The services of this endpoint sleep and can not wake now: ${escape(refused ?? 'try again')}.</p>`);
|
|
292
|
+
}
|
|
293
|
+
const why = run.stoppedBy === 'ttl'
|
|
294
|
+
? 'its time ran out'
|
|
295
|
+
: run.stoppedBy === 'replaced'
|
|
296
|
+
? 'a newer run of the same name replaced it'
|
|
297
|
+
: run.stoppedBy
|
|
298
|
+
? `${escape(run.stoppedBy.sub)} stopped it`
|
|
299
|
+
: escape(run.error ?? run.state);
|
|
300
|
+
return page(res, 410, 'Ended', `<p>This endpoint ended${run.finishedAt ? ` at ${escape(run.finishedAt)}` : ''}: ${why}.</p><p>Ask <code>${escape(run.owner.sub)}</code> to start it again.</p>`);
|
|
301
|
+
}
|
|
302
|
+
async status(label, run, endpoint, principal, req, res, url) {
|
|
303
|
+
const csrf = this.sign(`csrf\0${label}\0${principal.iss}\0${principal.sub}`);
|
|
304
|
+
if (url.pathname === `${OWN_PATH}extend`) {
|
|
305
|
+
if (req.method !== 'POST') {
|
|
306
|
+
return page(res, 405, 'Not allowed', '<p>Extend with the form of the status page.</p>');
|
|
307
|
+
}
|
|
308
|
+
const form = new URLSearchParams(await readText(req, 4096));
|
|
309
|
+
if (!safeEqual(form.get('csrf') ?? '', csrf)) {
|
|
310
|
+
return page(res, 403, 'Not allowed', '<p>The form is out of date, open the status page again.</p>');
|
|
311
|
+
}
|
|
312
|
+
let ttl;
|
|
313
|
+
try {
|
|
314
|
+
ttl = (0, units_1.parseDuration)(form.get('ttl') ?? '');
|
|
315
|
+
if (!(ttl > 0)) {
|
|
316
|
+
throw new Error('no time');
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
catch {
|
|
320
|
+
return page(res, 400, 'Not a duration', '<p>Pick how long it should stay.</p>');
|
|
321
|
+
}
|
|
322
|
+
const result = this.registry.extend(principal, run.id, ttl, () => this.mayOpen(principal, run));
|
|
323
|
+
this.options.log(`extend iss=${principal.iss} sub=${principal.sub} run=${run.id} result=${result.status}`);
|
|
324
|
+
res.writeHead(303, { location: `${OWN_PATH}status`, 'cache-control': 'no-store' });
|
|
325
|
+
res.end();
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const source = run.source.type === 'ref'
|
|
329
|
+
? `${run.source.repo} ${run.source.ref} at ${run.source.commit.slice(0, 12)}`
|
|
330
|
+
: run.source.type === 'sandbox'
|
|
331
|
+
? `sandbox ${run.source.id} of ${run.source.repo} at ${run.source.commit.slice(0, 12)}${run.source.clean === false ? ' with changes' : ''}`
|
|
332
|
+
: 'an upload';
|
|
333
|
+
const options = ['1h', '8h', '24h', '72h']
|
|
334
|
+
.map((ttl) => `<option value="${ttl}"${ttl === '8h' ? ' selected' : ''}>${ttl}</option>`)
|
|
335
|
+
.join('');
|
|
336
|
+
return page(res, 200, run.up?.name ?? 'Endpoint', `<dl>
|
|
337
|
+
<dt>Service</dt><dd>${escape(endpoint.service)}, port ${endpoint.port}</dd>
|
|
338
|
+
<dt>Run</dt><dd><code>${escape(run.id)}</code> of <code>${escape(run.owner.sub)}</code>, ${escape(run.state)}</dd>
|
|
339
|
+
<dt>Source</dt><dd>${escape(source)}</dd>
|
|
340
|
+
<dt>Ends</dt><dd>${escape(run.expiresAt ?? 'when it is up and its time ran out')}</dd>
|
|
341
|
+
${run.up?.sleepTtl
|
|
342
|
+
? `<dt>Sleeps</dt><dd>after ${escape((0, units_1.formatDuration)(run.up.sleepTtl))} without a request${run.sleepsAt ? `, next at ${escape(run.sleepsAt)}` : ''}; a request wakes it</dd>\n`
|
|
343
|
+
: ''}<dt>Signed in</dt><dd><code>${escape(principal.sub)}</code></dd>
|
|
344
|
+
</dl>
|
|
345
|
+
${run.state === 'up'
|
|
346
|
+
? `<form method="post" action="${OWN_PATH}extend"><input type="hidden" name="csrf" value="${csrf}"><label>Keep it for <select name="ttl">${options}</select> from now</label> <button>Extend</button></form>`
|
|
347
|
+
: ''}
|
|
348
|
+
<p><a href="/">Open the app</a> · <a href="${OWN_PATH}logout">Sign out</a></p>`);
|
|
349
|
+
}
|
|
350
|
+
// ---- sign-in, on login.<domain> ----
|
|
351
|
+
async handleLogin(req, res, url) {
|
|
352
|
+
const login = this.config.endpoints?.login;
|
|
353
|
+
if (!login || !this.loginVerifier || req.method !== 'GET') {
|
|
354
|
+
return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
|
|
355
|
+
}
|
|
356
|
+
if (url.pathname === '/start') {
|
|
357
|
+
const host = url.searchParams.get('host') ?? '';
|
|
358
|
+
const path = url.searchParams.get('path') ?? '/';
|
|
359
|
+
if (!/^[a-z0-9-]+$/.test(host) || host === exports.LOGIN_HOST || !this.registry.endpoint(host)) {
|
|
360
|
+
return page(res, 404, 'Not found', '<p>There is no endpoint at this address.</p>');
|
|
361
|
+
}
|
|
362
|
+
const endpoints = await this.endpointsOf(login.issuer);
|
|
363
|
+
this.sweep();
|
|
364
|
+
if (this.pending.size >= MAX_PENDING_LOGINS) {
|
|
365
|
+
return page(res, 503, 'Busy', '<p>Too many sign-ins at once, try again in a minute.</p>');
|
|
366
|
+
}
|
|
367
|
+
const state = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
368
|
+
const verifier = (0, crypto_1.randomBytes)(32).toString('base64url');
|
|
369
|
+
const nonce = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
370
|
+
this.pending.set(state, {
|
|
371
|
+
host,
|
|
372
|
+
path: ownPath(path),
|
|
373
|
+
verifier,
|
|
374
|
+
nonce,
|
|
375
|
+
expires: this.now() + LOGIN_TIMEOUT_MS,
|
|
376
|
+
});
|
|
377
|
+
const authorize = new URL(endpoints.authorize);
|
|
378
|
+
authorize.searchParams.set('response_type', 'code');
|
|
379
|
+
authorize.searchParams.set('client_id', login.clientId);
|
|
380
|
+
authorize.searchParams.set('redirect_uri', this.url(exports.LOGIN_HOST, '/callback'));
|
|
381
|
+
authorize.searchParams.set('scope', login.scope);
|
|
382
|
+
authorize.searchParams.set('state', state);
|
|
383
|
+
authorize.searchParams.set('nonce', nonce);
|
|
384
|
+
authorize.searchParams.set('code_challenge', (0, crypto_1.createHash)('sha256').update(verifier).digest('base64url'));
|
|
385
|
+
authorize.searchParams.set('code_challenge_method', 'S256');
|
|
386
|
+
res.writeHead(302, { location: authorize.toString(), 'cache-control': 'no-store' });
|
|
387
|
+
res.end();
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (url.pathname === '/callback') {
|
|
391
|
+
const state = url.searchParams.get('state') ?? '';
|
|
392
|
+
const pending = this.pending.get(state);
|
|
393
|
+
this.pending.delete(state);
|
|
394
|
+
if (!pending || pending.expires < this.now()) {
|
|
395
|
+
return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
|
|
396
|
+
}
|
|
397
|
+
const code = url.searchParams.get('code');
|
|
398
|
+
if (!code) {
|
|
399
|
+
const error = url.searchParams.get('error') ?? 'no code';
|
|
400
|
+
return page(res, 400, 'Not signed in', `<p>The sign-in did not succeed: ${escape(error.slice(0, 200))}.</p>`);
|
|
401
|
+
}
|
|
402
|
+
let principal;
|
|
403
|
+
try {
|
|
404
|
+
principal = await this.exchange(code, pending);
|
|
405
|
+
}
|
|
406
|
+
catch (e) {
|
|
407
|
+
this.options.log(`endpoint sign-in failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
408
|
+
return page(res, 400, 'Not signed in', '<p>The sign-in could not be checked, open the endpoint again.</p>');
|
|
409
|
+
}
|
|
410
|
+
const found = this.registry.endpoint(pending.host);
|
|
411
|
+
if (!found || !this.mayOpen(principal, found.run)) {
|
|
412
|
+
this.options.log(`deny endpoint ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
|
|
413
|
+
return page(res, 403, 'Not allowed', `<p>You signed in as <code>${escape(principal.sub)}</code>, which may not open this endpoint.</p>`);
|
|
414
|
+
}
|
|
415
|
+
const issued = (0, crypto_1.randomBytes)(24).toString('base64url');
|
|
416
|
+
this.codes.set(issued, {
|
|
417
|
+
host: pending.host,
|
|
418
|
+
path: pending.path,
|
|
419
|
+
principal,
|
|
420
|
+
expires: this.now() + CODE_TIMEOUT_MS,
|
|
421
|
+
});
|
|
422
|
+
const target = new URL(this.url(pending.host, `${OWN_PATH}session`));
|
|
423
|
+
target.searchParams.set('code', issued);
|
|
424
|
+
this.options.log(`endpoint sign-in ${pending.host} iss=${principal.iss} sub=${principal.sub}`);
|
|
425
|
+
res.writeHead(302, { location: target.toString(), 'cache-control': 'no-store' });
|
|
426
|
+
res.end();
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
return page(res, 404, 'Not found', '<p>There is nothing here.</p>');
|
|
430
|
+
}
|
|
431
|
+
// The code of the issuer for its tokens, and the ID token for who signed in.
|
|
432
|
+
async exchange(code, pending) {
|
|
433
|
+
const login = this.config.endpoints?.login;
|
|
434
|
+
if (!login || !this.loginVerifier) {
|
|
435
|
+
throw new Error('no sign-in configured');
|
|
436
|
+
}
|
|
437
|
+
const endpoints = await this.endpointsOf(login.issuer);
|
|
438
|
+
const body = new URLSearchParams({
|
|
439
|
+
grant_type: 'authorization_code',
|
|
440
|
+
code,
|
|
441
|
+
redirect_uri: this.url(exports.LOGIN_HOST, '/callback'),
|
|
442
|
+
client_id: login.clientId,
|
|
443
|
+
code_verifier: pending.verifier,
|
|
444
|
+
});
|
|
445
|
+
if (this.options.clientSecret) {
|
|
446
|
+
body.set('client_secret', await this.options.clientSecret());
|
|
447
|
+
}
|
|
448
|
+
const response = await this.fetcher(endpoints.token, {
|
|
449
|
+
method: 'POST',
|
|
450
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
451
|
+
body: body.toString(),
|
|
452
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
453
|
+
});
|
|
454
|
+
const answer = (await response.json().catch(() => ({})));
|
|
455
|
+
if (!response.ok || typeof answer.id_token !== 'string') {
|
|
456
|
+
throw new Error(`the token endpoint answered ${response.status}${answer.error ? ` ${String(answer.error)}` : ''}`);
|
|
457
|
+
}
|
|
458
|
+
const principal = await this.loginVerifier.verify(answer.id_token);
|
|
459
|
+
if (principal.claims.nonce !== pending.nonce) {
|
|
460
|
+
throw new oauth_1.TokenError('the ID token is not of this sign-in (nonce)');
|
|
461
|
+
}
|
|
462
|
+
return principal;
|
|
463
|
+
}
|
|
464
|
+
async endpointsOf(issuer) {
|
|
465
|
+
if (this.discovery && this.now() - this.discovery.at < DISCOVERY_TTL_MS) {
|
|
466
|
+
return this.discovery;
|
|
467
|
+
}
|
|
468
|
+
const response = await this.fetcher(`${issuer.replace(/\/+$/, '')}/.well-known/openid-configuration`, {
|
|
469
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
470
|
+
});
|
|
471
|
+
const metadata = (await response.json());
|
|
472
|
+
if (typeof metadata.authorization_endpoint !== 'string' || typeof metadata.token_endpoint !== 'string') {
|
|
473
|
+
throw new Error(`${issuer} names no authorization or token endpoint`);
|
|
474
|
+
}
|
|
475
|
+
this.discovery = { at: this.now(), authorize: metadata.authorization_endpoint, token: metadata.token_endpoint };
|
|
476
|
+
return this.discovery;
|
|
477
|
+
}
|
|
478
|
+
// `/.hammerkit/session?code=` on the endpoint host: the one-time code of the sign-in
|
|
479
|
+
// becomes the cookie of this host.
|
|
480
|
+
takeCode(label, req, res, url) {
|
|
481
|
+
const code = url.searchParams.get('code') ?? '';
|
|
482
|
+
const issued = this.codes.get(code);
|
|
483
|
+
this.codes.delete(code);
|
|
484
|
+
if (!issued || issued.host !== label || issued.expires < this.now()) {
|
|
485
|
+
return page(res, 400, 'Sign-in expired', '<p>The sign-in took too long or was used already, open the endpoint again.</p>');
|
|
486
|
+
}
|
|
487
|
+
const found = this.registry.endpoint(label);
|
|
488
|
+
const ends = Date.parse(found?.run.expiresAt ?? '');
|
|
489
|
+
const session = this.now() + (0, units_1.parseDuration)(this.config.endpoints?.session ?? '8h');
|
|
490
|
+
const exp = Number.isFinite(ends) ? Math.min(session, Math.max(ends, this.now() + LOGIN_TIMEOUT_MS)) : session;
|
|
491
|
+
res.writeHead(303, {
|
|
492
|
+
location: issued.path,
|
|
493
|
+
'set-cookie': this.sessionCookie({
|
|
494
|
+
host: label,
|
|
495
|
+
iss: issued.principal.iss,
|
|
496
|
+
sub: issued.principal.sub,
|
|
497
|
+
groups: issued.principal.groups,
|
|
498
|
+
claims: this.matchedClaims(issued.principal),
|
|
499
|
+
exp,
|
|
500
|
+
}),
|
|
501
|
+
'cache-control': 'no-store',
|
|
502
|
+
});
|
|
503
|
+
res.end();
|
|
504
|
+
}
|
|
505
|
+
// Only the claims an entitlement matches on go into the cookie, which stays small.
|
|
506
|
+
matchedClaims(principal) {
|
|
507
|
+
const names = new Set(this.config.entitlements.flatMap((entitlement) => Object.keys(entitlement.claims?.match ?? {})));
|
|
508
|
+
return Object.fromEntries(Object.entries(principal.claims).filter(([name]) => names.has(name)));
|
|
509
|
+
}
|
|
510
|
+
cookieName() {
|
|
511
|
+
return this.base.scheme === 'https' ? SESSION_COOKIE : PLAIN_SESSION_COOKIE;
|
|
512
|
+
}
|
|
513
|
+
sessionCookie(session) {
|
|
514
|
+
const payload = Buffer.from(JSON.stringify(session)).toString('base64url');
|
|
515
|
+
const value = `${payload}.${this.sign(payload)}`;
|
|
516
|
+
const maxAge = Math.max(0, Math.floor((session.exp - this.now()) / 1000));
|
|
517
|
+
return `${this.cookieName()}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${this.base.scheme === 'https' ? '; Secure' : ''}`;
|
|
518
|
+
}
|
|
519
|
+
clearCookie() {
|
|
520
|
+
return `${this.cookieName()}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${this.base.scheme === 'https' ? '; Secure' : ''}`;
|
|
521
|
+
}
|
|
522
|
+
readSession(label, header) {
|
|
523
|
+
const name = this.cookieName();
|
|
524
|
+
const value = (header ?? '')
|
|
525
|
+
.split(/;\s*/)
|
|
526
|
+
.find((cookie) => cookie.startsWith(`${name}=`))
|
|
527
|
+
?.slice(name.length + 1);
|
|
528
|
+
const [payload, signature] = (value ?? '').split('.');
|
|
529
|
+
if (!payload || !signature || !safeEqual(signature, this.sign(payload))) {
|
|
530
|
+
return null;
|
|
531
|
+
}
|
|
532
|
+
try {
|
|
533
|
+
const session = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
|
|
534
|
+
if (session.host !== label || !(session.exp > this.now()) || !session.iss || !session.sub) {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
...session,
|
|
539
|
+
groups: Array.isArray(session.groups) ? session.groups : [],
|
|
540
|
+
claims: session.claims ?? {},
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
sign(value) {
|
|
548
|
+
if (!this.key) {
|
|
549
|
+
throw new Error('the endpoints were not loaded');
|
|
550
|
+
}
|
|
551
|
+
return (0, crypto_1.createHmac)('sha256', this.key).update(value).digest('base64url');
|
|
552
|
+
}
|
|
553
|
+
sweep() {
|
|
554
|
+
const now = this.now();
|
|
555
|
+
for (const [state, login] of this.pending) {
|
|
556
|
+
if (login.expires < now) {
|
|
557
|
+
this.pending.delete(state);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
for (const [code, issued] of this.codes) {
|
|
561
|
+
if (issued.expires < now) {
|
|
562
|
+
this.codes.delete(code);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
exports.EndpointGateway = EndpointGateway;
|
|
568
|
+
function safeEqual(a, b) {
|
|
569
|
+
const left = Buffer.from(a);
|
|
570
|
+
const right = Buffer.from(b);
|
|
571
|
+
return left.length === right.length && (0, crypto_1.timingSafeEqual)(left, right);
|
|
572
|
+
}
|
|
573
|
+
function acceptsHtml(headers) {
|
|
574
|
+
return (headers.accept ?? '').includes('text/html');
|
|
575
|
+
}
|
|
576
|
+
function escape(text) {
|
|
577
|
+
return text.replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
|
|
578
|
+
}
|
|
579
|
+
async function readText(req, limit) {
|
|
580
|
+
let text = '';
|
|
581
|
+
for await (const chunk of req) {
|
|
582
|
+
text += chunk.toString('utf8');
|
|
583
|
+
if (text.length > limit) {
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
return text.slice(0, limit);
|
|
588
|
+
}
|
|
589
|
+
// A page of the server's own on an endpoint host: nothing of it is the app's.
|
|
590
|
+
function page(res, status, title, body) {
|
|
591
|
+
res.writeHead(status, {
|
|
592
|
+
'content-type': 'text/html; charset=utf-8',
|
|
593
|
+
'cache-control': 'no-store',
|
|
594
|
+
'x-robots-tag': 'noindex',
|
|
595
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; form-action 'self'",
|
|
596
|
+
'x-frame-options': 'DENY',
|
|
597
|
+
});
|
|
598
|
+
res.end(`<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${escape(title)}</title><style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:3rem auto;padding:0 1rem;color:#222}code{background:#f2f2f2;padding:0 .2em}dt{font-weight:600}dd{margin:0 0 .5rem}</style></head><body><h1>${escape(title)}</h1>${body}<p><small>hammerkit</small></p></body></html>`);
|
|
599
|
+
}
|
|
600
|
+
// Where on its own host a person goes back to after the sign-in: a path, never an address a
|
|
601
|
+
// browser reads as another host (`//x`, `/\\x`, control characters).
|
|
602
|
+
function ownPath(path) {
|
|
603
|
+
if (!path.startsWith('/') ||
|
|
604
|
+
[...path].some((char) => char === '\\' || char.charCodeAt(0) < 0x20 || char === '\u007f')) {
|
|
605
|
+
return '/';
|
|
606
|
+
}
|
|
607
|
+
const base = 'http://endpoint.invalid';
|
|
608
|
+
const target = new URL(path, base);
|
|
609
|
+
return target.origin === base ? target.pathname + target.search : '/';
|
|
610
|
+
}
|
|
611
|
+
exports.ownPath = ownPath;
|
|
612
|
+
//# sourceMappingURL=endpoints.js.map
|