@sigrun/sigrun 0.2.0 → 0.3.0
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/CHANGELOG.md +145 -7
- package/README.md +333 -13
- package/dist/blocks/build-stamp.json +13 -0
- package/dist/blocks/builders.d.ts +57 -0
- package/dist/blocks/images.d.ts +26 -0
- package/dist/blocks/index.d.ts +20 -0
- package/dist/blocks/limits.d.ts +25 -0
- package/dist/blocks/placements.d.ts +26 -0
- package/dist/blocks/schema.d.ts +38 -0
- package/dist/blocks/validate.d.ts +73 -0
- package/dist/cli.js +117 -19
- package/dist/config.js +21 -2
- package/dist/extension.d.ts +48 -8
- package/dist/harness/ambient.js +2 -0
- package/dist/harness/blocks/state.js +1 -0
- package/dist/harness/boot.js +92 -6
- package/dist/harness/builtins.js +1 -1
- package/dist/harness/context.js +25 -0
- package/dist/harness/event-bus.js +25 -14
- package/dist/harness/identity/accounts.js +3 -2
- package/dist/harness/identity/index.js +2 -1
- package/dist/harness/identity/invites.js +26 -9
- package/dist/harness/identity/link-sweep.js +47 -0
- package/dist/harness/identity/typed-username.js +15 -0
- package/dist/harness/identity/users.js +10 -7
- package/dist/harness/interactions.js +2 -0
- package/dist/harness/loader.js +57 -1
- package/dist/harness/migration-snapshot.js +97 -0
- package/dist/harness/reports.js +100 -0
- package/dist/harness/seam-host.js +23 -1
- package/dist/harness/sections/context-files.js +1 -1
- package/dist/harness/settings.js +52 -4
- package/dist/jobs/docs.js +3 -0
- package/dist/jobs/fire.js +2 -0
- package/dist/jobs/parked.js +38 -0
- package/dist/jobs/task.js +37 -8
- package/dist/sandbox/availability.js +69 -20
- package/dist/sandbox/launch.js +11 -0
- package/dist/sandbox/policy.js +24 -3
- package/dist/sandbox/worker.js +4 -24
- package/dist/serve.js +162 -40
- package/dist/server/auth/ip.js +9 -4
- package/dist/server/auth/own-host.js +115 -0
- package/dist/server/auth/pipeline.js +93 -28
- package/dist/server/auth/proxy-diagnostic.js +60 -0
- package/dist/server/auth/proxy-identity.js +134 -0
- package/dist/server/auth/proxy-only.js +28 -0
- package/dist/server/images/cache.js +13 -5
- package/dist/server/index.js +10 -2
- package/dist/server/login/resolve.js +88 -26
- package/dist/server/notifications.js +21 -4
- package/dist/server/routes/admin.js +97 -0
- package/dist/server/routes/images.js +16 -9
- package/dist/server/routes/login.js +8 -0
- package/dist/server/routes/me.js +1 -1
- package/dist/server/routes/meta.js +4 -0
- package/dist/server/routes/metrics.js +18 -2
- package/dist/server/routes/onboarding.js +97 -37
- package/dist/server/session-meta.js +1 -0
- package/dist/server/shutdown.js +93 -16
- package/dist/settings/registry.js +6 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +12 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +14 -0
- package/node_modules/@sigrun/shared/dist/usernames.d.ts +8 -0
- package/node_modules/@sigrun/shared/dist/usernames.js +39 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +33 -4
- package/node_modules/@sigrun/shared/package.json +5 -1
- package/package.json +16 -15
- package/webui/dist/assets/{index-CxB969qp.js → index-CEOzj1C4.js} +11 -11
- package/webui/dist/index.html +1 -1
- package/CONTEXT.md +0 -163
- package/dist/server/login/usernames.js +0 -15
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The C5 own-host rule (proxy-trust Task 4b): a peer that IS this host —
|
|
3
|
+
* loopback (127.0.0.0/8, ::1) or any current interface address — can
|
|
4
|
+
* reach the server without traversing the forward-auth proxy, because
|
|
5
|
+
* every local process shares those source addresses. Trusting such a
|
|
6
|
+
* peer through a CIDR RANGE would let any local process impersonate
|
|
7
|
+
* users, so an own-host peer vouches only when:
|
|
8
|
+
*
|
|
9
|
+
* 1. the shared secret is configured (`secret` + `secretHeader`) — the
|
|
10
|
+
* existing secret check then decides; or
|
|
11
|
+
* 2. that exact address is listed explicitly in `trustedProxies` (a
|
|
12
|
+
* bare address, or a /32 (IPv4) / /128 (IPv6) entry) — an explicit
|
|
13
|
+
* operator decision, which the boot warning surfaces.
|
|
14
|
+
*
|
|
15
|
+
* Otherwise both identity-trust paths (the request pipeline's proxy
|
|
16
|
+
* method and onboarding's `resolveProxyIdentity`) reject exactly the
|
|
17
|
+
* way an untrusted peer is rejected, with the OWN_HOST diagnostic.
|
|
18
|
+
*/
|
|
19
|
+
import { networkInterfaces } from "node:os";
|
|
20
|
+
import { isIP } from "node:net";
|
|
21
|
+
import { normalizePeerAddress } from "./ip.js";
|
|
22
|
+
/** Interface addresses refresh at most this often (milliseconds). */
|
|
23
|
+
const CACHE_TTL_MS = 30_000;
|
|
24
|
+
let testAddresses;
|
|
25
|
+
let cached;
|
|
26
|
+
/** The seam override, or the real interface list (cached ≤ 30 s). */
|
|
27
|
+
function ownHostAddresses() {
|
|
28
|
+
if (testAddresses !== undefined)
|
|
29
|
+
return testAddresses;
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
if (cached === undefined || now - cached.readAt >= CACHE_TTL_MS) {
|
|
32
|
+
const addresses = [];
|
|
33
|
+
for (const entries of Object.values(networkInterfaces())) {
|
|
34
|
+
for (const entry of entries ?? [])
|
|
35
|
+
addresses.push(entry.address);
|
|
36
|
+
}
|
|
37
|
+
cached = { addresses, readAt: now };
|
|
38
|
+
}
|
|
39
|
+
return cached.addresses;
|
|
40
|
+
}
|
|
41
|
+
function isLoopback(address) {
|
|
42
|
+
if (address === "::1")
|
|
43
|
+
return true;
|
|
44
|
+
if (isIP(address) !== 4)
|
|
45
|
+
return false;
|
|
46
|
+
return address.split(".")[0] === "127";
|
|
47
|
+
}
|
|
48
|
+
/** Loopback (127.0.0.0/8, ::1) or any current interface address; `::ffff:` unwrapped. */
|
|
49
|
+
export function isOwnHostAddress(address) {
|
|
50
|
+
if (address === undefined)
|
|
51
|
+
return false;
|
|
52
|
+
const candidate = normalizePeerAddress(address);
|
|
53
|
+
if (isLoopback(candidate))
|
|
54
|
+
return true;
|
|
55
|
+
return ownHostAddresses().some((entry) => normalizePeerAddress(entry) === candidate);
|
|
56
|
+
}
|
|
57
|
+
/** True when `address` is listed exactly in `cidrs`: a bare address, or a /32 (IPv4) or /128 (IPv6) entry for it. */
|
|
58
|
+
export function listedExactly(address, cidrs) {
|
|
59
|
+
const candidate = normalizePeerAddress(address);
|
|
60
|
+
for (const raw of cidrs) {
|
|
61
|
+
const entry = raw.trim();
|
|
62
|
+
if (entry === "")
|
|
63
|
+
continue;
|
|
64
|
+
const slash = entry.indexOf("/");
|
|
65
|
+
if (slash === -1) {
|
|
66
|
+
if (normalizePeerAddress(entry) === candidate)
|
|
67
|
+
return true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const base = normalizePeerAddress(entry.slice(0, slash));
|
|
71
|
+
if (base !== candidate)
|
|
72
|
+
continue;
|
|
73
|
+
const prefix = Number(entry.slice(slash + 1));
|
|
74
|
+
const family = isIP(candidate);
|
|
75
|
+
if (family === 4 && prefix === 32)
|
|
76
|
+
return true;
|
|
77
|
+
if (family === 6 && prefix === 128)
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
/** The C5 rule: false only for an own-host peer reached through a range with no shared secret configured. */
|
|
83
|
+
export function ownHostPeerPermitted(address, trustedProxies, secretConfigured) {
|
|
84
|
+
if (!isOwnHostAddress(address))
|
|
85
|
+
return true;
|
|
86
|
+
if (secretConfigured)
|
|
87
|
+
return true;
|
|
88
|
+
return listedExactly(address, trustedProxies);
|
|
89
|
+
}
|
|
90
|
+
/** Test seam: replace the interface addresses (loopback stays implicit); `undefined` restores os.networkInterfaces(). */
|
|
91
|
+
export function setOwnHostAddressesForTests(addresses) {
|
|
92
|
+
testAddresses = addresses;
|
|
93
|
+
cached = undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Explicitly listed own-host entries (verbatim spellings), for the boot warning. */
|
|
96
|
+
export function listedOwnHostEntries(trustedProxies) {
|
|
97
|
+
return trustedProxies.filter((entry) => {
|
|
98
|
+
const trimmed = entry.trim();
|
|
99
|
+
if (trimmed === "")
|
|
100
|
+
return false;
|
|
101
|
+
const slash = trimmed.indexOf("/");
|
|
102
|
+
const base = slash === -1 ? trimmed : trimmed.slice(0, slash);
|
|
103
|
+
if (slash !== -1) {
|
|
104
|
+
const prefix = Number(trimmed.slice(slash + 1));
|
|
105
|
+
const family = isIP(normalizePeerAddress(base));
|
|
106
|
+
if (family === 4 && prefix !== 32)
|
|
107
|
+
return false;
|
|
108
|
+
if (family === 6 && prefix !== 128)
|
|
109
|
+
return false;
|
|
110
|
+
if (family === 0)
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
return isOwnHostAddress(base);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
@@ -34,16 +34,28 @@
|
|
|
34
34
|
* request before handlers run) + a non-empty user header; a
|
|
35
35
|
* directory miss auto-provisions (`source:"proxy"`, admin:false)
|
|
36
36
|
* unless `autoProvision:false` → 403 `user_not_provisioned`.
|
|
37
|
+
* Every proxy rejection (401/403) emits ONE sanitized `warn` naming
|
|
38
|
+
* the socket peer + a short reason (release-readiness Task 1, spec
|
|
39
|
+
* §3 — never tokens, cookies, identity-header VALUES, query
|
|
40
|
+
* strings, or the full URL; a request presenting a Bearer/cookie
|
|
41
|
+
* credential is a token attempt and stays silent).
|
|
37
42
|
* `token`: Bearer or the `sigrun_session` cookie → `tokens.verify`;
|
|
38
|
-
* the token's user must exist in the directory, else 401
|
|
43
|
+
* the token's user must exist in the directory, else 401 (no log —
|
|
44
|
+
* token failures are not proxy diagnostics).
|
|
39
45
|
* 6. **Empty list** (the naive workstation path): the trimmed
|
|
40
|
-
* `X-Sigrun-User` header
|
|
46
|
+
* `X-Sigrun-User` header DERIVED to its canonical key (`admin: true`,
|
|
47
|
+
* method `"none"`). An unusable header (no derivable key) is a loud
|
|
48
|
+
* 400 `invalid_username`; an absent header behaves as before.
|
|
41
49
|
*/
|
|
42
50
|
import { timingSafeEqual } from "node:crypto";
|
|
43
51
|
import { ipAllowed } from "./ip.js";
|
|
44
52
|
import { isLoopbackHost } from "./loopback.js";
|
|
53
|
+
import { listedOwnHostEntries, ownHostPeerPermitted } from "./own-host.js";
|
|
54
|
+
import { ProxyRejectReason, warnProxyRejected } from "./proxy-diagnostic.js";
|
|
55
|
+
import { resolveProxyUser } from "./proxy-identity.js";
|
|
45
56
|
import { AdmissionRefusedError } from "../../harness/identity/users.js";
|
|
46
57
|
import { effectiveAdmin } from "../../harness/identity/groups.js";
|
|
58
|
+
import { deriveUsername } from "@sigrun/shared/usernames";
|
|
47
59
|
/** One header read: Fastify headers may be string | string[]; first wins. */
|
|
48
60
|
function headerOf(request, name) {
|
|
49
61
|
const value = request.headers[name.toLowerCase()];
|
|
@@ -191,7 +203,34 @@ function isPublicPath(request, ctx) {
|
|
|
191
203
|
*/
|
|
192
204
|
async function resolveProxy(request, ctx) {
|
|
193
205
|
const { proxy, adminGroups } = ctx.config.auth;
|
|
206
|
+
// A request presenting a Bearer/cookie CREDENTIAL is attempting token
|
|
207
|
+
// auth (or riding the stage-3 service token), not claiming a proxy
|
|
208
|
+
// identity: when the token method is ALSO configured, suppress the
|
|
209
|
+
// rejection diagnostic — a denied credential is the token method's
|
|
210
|
+
// failure, which never logs (spec §3). The pipeline visits the proxy
|
|
211
|
+
// method FIRST for such a request (a trusted peer presenting a session
|
|
212
|
+
// without proxy headers is a legitimate client shape behind
|
|
213
|
+
// forward-auth), and a "missing secret" warn would fire even when the
|
|
214
|
+
// token then authenticates. In a PROXY-ONLY deployment the suppression
|
|
215
|
+
// does not apply: a rejected credential-bearing request there IS a
|
|
216
|
+
// proxy-method 401 and must log (spec §3 exempts token-auth failures,
|
|
217
|
+
// and no token method exists).
|
|
218
|
+
const attemptingToken = (bearerOf(request) !== undefined || sessionCookieOf(request) !== undefined) &&
|
|
219
|
+
ctx.methods.includes("token");
|
|
194
220
|
if (!ipAllowed(request.socket.remoteAddress, proxy.trustedProxies)) {
|
|
221
|
+
if (!attemptingToken) {
|
|
222
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED);
|
|
223
|
+
}
|
|
224
|
+
return { status: 401 };
|
|
225
|
+
}
|
|
226
|
+
// C5: an own-host peer (loopback or a local interface address) can
|
|
227
|
+
// originate from ANY local process, so trust through a range alone
|
|
228
|
+
// never vouches — the shared secret must be configured (its check
|
|
229
|
+
// then decides), or the exact address must be listed explicitly.
|
|
230
|
+
if (!ownHostPeerPermitted(request.socket.remoteAddress, proxy.trustedProxies, proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
231
|
+
if (!attemptingToken) {
|
|
232
|
+
warnProxyRejected(request, ProxyRejectReason.OWN_HOST);
|
|
233
|
+
}
|
|
195
234
|
return { status: 401 };
|
|
196
235
|
}
|
|
197
236
|
if (proxy.secret !== undefined && proxy.secretHeader !== undefined) {
|
|
@@ -199,41 +238,46 @@ async function resolveProxy(request, ctx) {
|
|
|
199
238
|
const presented = headerOf(request, name);
|
|
200
239
|
delete request.headers[name];
|
|
201
240
|
if (presented === undefined || !timingSafeStringEqual(presented, proxy.secret)) {
|
|
241
|
+
if (!attemptingToken) {
|
|
242
|
+
warnProxyRejected(request, ProxyRejectReason.secretReason(proxy.secretHeader));
|
|
243
|
+
}
|
|
202
244
|
return { status: 401 };
|
|
203
245
|
}
|
|
204
246
|
}
|
|
205
247
|
const user = headerOf(request, proxy.userHeader);
|
|
206
|
-
if (user === undefined)
|
|
248
|
+
if (user === undefined) {
|
|
249
|
+
if (!attemptingToken) {
|
|
250
|
+
warnProxyRejected(request, ProxyRejectReason.missingHeaderReason(proxy.userHeader));
|
|
251
|
+
}
|
|
207
252
|
return { status: 401 };
|
|
253
|
+
}
|
|
208
254
|
const groups = headerOf(request, proxy.groupsHeader)
|
|
209
255
|
?.split(",")
|
|
210
256
|
.map((group) => group.trim())
|
|
211
257
|
.filter((group) => group !== "") ?? [];
|
|
212
|
-
const { users } = ctx.opened.identity;
|
|
213
|
-
// The invalid-username case (a proxy header that can't be a directory
|
|
214
|
-
// user) is a loud 403, not a 500: the proxy vouched for an identity
|
|
215
|
-
// this server can never represent.
|
|
216
|
-
if (!/^[a-z0-9][a-z0-9._-]*$/.test(user)) {
|
|
217
|
-
return { status: 403, error: "invalid_username" };
|
|
218
|
-
}
|
|
219
|
-
const existing = users.get(user);
|
|
220
|
-
if (existing === undefined && !proxy.autoProvision) {
|
|
221
|
-
return { status: 403, error: "user_not_provisioned" };
|
|
222
|
-
}
|
|
258
|
+
const { users, accounts } = ctx.opened.identity;
|
|
223
259
|
let record;
|
|
224
260
|
try {
|
|
225
|
-
|
|
226
|
-
(
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
261
|
+
const resolved = await resolveProxyUser({ users, accounts }, user, {
|
|
262
|
+
...(headerOf(request, proxy.nameHeader) === undefined
|
|
263
|
+
? {}
|
|
264
|
+
: { name: headerOf(request, proxy.nameHeader) }),
|
|
265
|
+
...(headerOf(request, proxy.emailHeader) === undefined
|
|
266
|
+
? {}
|
|
267
|
+
: { email: headerOf(request, proxy.emailHeader) }),
|
|
268
|
+
}, { provision: proxy.autoProvision });
|
|
269
|
+
// The invalid-username case is a loud 403, not a 500: the proxy
|
|
270
|
+
// vouched for an identity this server can never represent. The warn
|
|
271
|
+
// never carries the claimed value (spec §3).
|
|
272
|
+
if (resolved.kind === "invalid") {
|
|
273
|
+
warnProxyRejected(request, ProxyRejectReason.UNUSABLE);
|
|
274
|
+
return { status: 403, error: "invalid_username" };
|
|
275
|
+
}
|
|
276
|
+
if (resolved.kind === "not_provisioned") {
|
|
277
|
+
warnProxyRejected(request, ProxyRejectReason.UNTRUSTED_IDENTITY_SOURCE);
|
|
278
|
+
return { status: 403, error: "user_not_provisioned" };
|
|
279
|
+
}
|
|
280
|
+
record = resolved.record;
|
|
237
281
|
}
|
|
238
282
|
catch (error) {
|
|
239
283
|
if (error instanceof AdmissionRefusedError) {
|
|
@@ -245,7 +289,7 @@ async function resolveProxy(request, ctx) {
|
|
|
245
289
|
// setGroups avoids the write when the values are unchanged. Effective
|
|
246
290
|
// admin is computed over the UPDATED record, so a group exit revokes
|
|
247
291
|
// the request's grant immediately and host-side checks agree.
|
|
248
|
-
record = await users.setGroups(user, "proxy", groups);
|
|
292
|
+
record = await users.setGroups(record.user, "proxy", groups);
|
|
249
293
|
return {
|
|
250
294
|
user: record.user,
|
|
251
295
|
...(record.name === undefined ? {} : { name: record.name }),
|
|
@@ -283,6 +327,18 @@ export function resolveToken(request, ctx) {
|
|
|
283
327
|
}
|
|
284
328
|
/** The ONE request hook. Stages 1→5 in brief order; a stage replies or falls through. */
|
|
285
329
|
export function registerAuthPipeline(app, ctx) {
|
|
330
|
+
// C5 boot warning: an explicitly listed own-host trust entry WITHOUT
|
|
331
|
+
// a shared secret is the one shape where a local process can still
|
|
332
|
+
// impersonate users by connecting directly. Surface it once per
|
|
333
|
+
// entry, at registration (a boot-time config warning, not a request
|
|
334
|
+
// diagnostic — the throttled warnProxyRejected does not apply).
|
|
335
|
+
const { proxy } = ctx.config.auth;
|
|
336
|
+
if (ctx.methods.includes("proxy") &&
|
|
337
|
+
!(proxy.secret !== undefined && proxy.secretHeader !== undefined)) {
|
|
338
|
+
for (const entry of listedOwnHostEntries(proxy.trustedProxies)) {
|
|
339
|
+
console.warn(`sigrun: trusting local address ${entry} without a shared secret lets any local process impersonate users; set server.auth.proxy.secretHeader`);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
286
342
|
app.addHook("onRequest", async (request, reply) => {
|
|
287
343
|
// Stage 1: Host + Origin.
|
|
288
344
|
const hostStatus = checkHost(request, ctx);
|
|
@@ -329,8 +385,17 @@ export function registerAuthPipeline(app, ctx) {
|
|
|
329
385
|
// Stage 5/6: the configured methods, in order; the empty list is
|
|
330
386
|
// the naive loopback path.
|
|
331
387
|
if (ctx.methods.length === 0) {
|
|
388
|
+
const header = headerOf(request, "x-sigrun-user");
|
|
389
|
+
// The header is an unchecked (operator-typed) source: derive the
|
|
390
|
+
// canonical key on receipt, the same rule the CLI applies before
|
|
391
|
+
// sending. An unusable value is a loud 400, never a verbatim
|
|
392
|
+
// identity; an absent header keeps today's ownerless behavior.
|
|
393
|
+
const user = header === undefined ? undefined : deriveUsername(header);
|
|
394
|
+
if (header !== undefined && user === undefined) {
|
|
395
|
+
return await reply.code(400).send({ error: "invalid_username" });
|
|
396
|
+
}
|
|
332
397
|
request.principal = {
|
|
333
|
-
user
|
|
398
|
+
user,
|
|
334
399
|
admin: true,
|
|
335
400
|
service: false,
|
|
336
401
|
method: "none",
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared proxy-auth rejection diagnostic (proxy-trust C1), extracted
|
|
3
|
+
* from the request pipeline so the onboarding routes can report WHY they
|
|
4
|
+
* rejected a proxy identity with the same line and redaction contract.
|
|
5
|
+
*
|
|
6
|
+
* ONE `warn` per rejected request, naming the socket peer, the request
|
|
7
|
+
* method, a short reason, and — only when Fastify already resolved it —
|
|
8
|
+
* the ROUTE TEMPLATE (`routeOptions.url`, never the raw URL). The line
|
|
9
|
+
* must never carry API tokens, cookies, identity-header VALUES, query
|
|
10
|
+
* strings, or the full request URL; a header NAME appears only where the
|
|
11
|
+
* reason itself is "that configured header was absent/wrong" (its value
|
|
12
|
+
* never does). This is diagnostic logging, not evidence that the
|
|
13
|
+
* configured allowlist is correct.
|
|
14
|
+
*
|
|
15
|
+
* The throttle is per reason: one line per reason per 60 s, with a
|
|
16
|
+
* `(N suppressed)` suffix on the next emitted line for that reason —
|
|
17
|
+
* a flood of identical rejections cannot drown the log, and a DIFFERENT
|
|
18
|
+
* reason always emits immediately.
|
|
19
|
+
*/
|
|
20
|
+
/** One line per reason per this window; suppressed lines count up. */
|
|
21
|
+
const THROTTLE_MS = 60_000;
|
|
22
|
+
/** The reason vocabulary (byte-identical to the pipeline's originals). */
|
|
23
|
+
export const ProxyRejectReason = {
|
|
24
|
+
UNTRUSTED: "untrusted proxy",
|
|
25
|
+
UNUSABLE: "unusable identity claim",
|
|
26
|
+
UNTRUSTED_IDENTITY_SOURCE: "untrusted identity source",
|
|
27
|
+
OWN_HOST: "own-host peer without shared secret",
|
|
28
|
+
secretReason: (header) => `missing or wrong shared secret (${header} header)`,
|
|
29
|
+
missingHeaderReason: (header) => `missing identity header (${header})`,
|
|
30
|
+
};
|
|
31
|
+
const throttle = new Map();
|
|
32
|
+
let clock = () => Date.now();
|
|
33
|
+
/** Test seam: inject the clock the throttle reads. */
|
|
34
|
+
export function setProxyDiagnosticClock(now) {
|
|
35
|
+
clock = now;
|
|
36
|
+
}
|
|
37
|
+
/** Test seam: forget all throttle state (and restore the real clock). */
|
|
38
|
+
export function resetProxyDiagnostic() {
|
|
39
|
+
throttle.clear();
|
|
40
|
+
clock = () => Date.now();
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Emit one sanitized rejection line, throttled per reason. The line
|
|
44
|
+
* carries no values by construction: it is built from the method, the
|
|
45
|
+
* socket peer, the caller-supplied reason (a fixed string, or one naming
|
|
46
|
+
* a configured header), and the route template.
|
|
47
|
+
*/
|
|
48
|
+
export function warnProxyRejected(request, reason) {
|
|
49
|
+
const now = clock();
|
|
50
|
+
const entry = throttle.get(reason);
|
|
51
|
+
if (entry !== undefined && now - entry.lastEmit < THROTTLE_MS) {
|
|
52
|
+
entry.suppressed += 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const suppressed = entry?.suppressed ?? 0;
|
|
56
|
+
throttle.set(reason, { lastEmit: now, suppressed: 0 });
|
|
57
|
+
const peer = request.socket.remoteAddress ?? "unknown";
|
|
58
|
+
const route = request.routeOptions.url;
|
|
59
|
+
console.warn(`sigrun: proxy auth rejected ${request.method} request from ${peer} (${reason})${route === undefined ? "" : ` on ${route}`}${suppressed > 0 ? ` (${suppressed} suppressed)` : ""}`);
|
|
60
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Proxy identity resolution (username-derivation D3): a trusted proxy
|
|
3
|
+
* header names an external subject, not necessarily a directory key.
|
|
4
|
+
* Folded subjects are linked to users; case variants of an existing key
|
|
5
|
+
* adopt it, and every other new identity gets a derived, suffixed key.
|
|
6
|
+
*
|
|
7
|
+
* The request pipeline owns trust and diagnostics. The claim route owns
|
|
8
|
+
* its lock; only the pipeline entry below acquires `withClaimLock`.
|
|
9
|
+
*/
|
|
10
|
+
import { deriveUsername, foldSubject, isUsernameKey, withSuffix, } from "@sigrun/shared/usernames";
|
|
11
|
+
import { AccountLinkConflictError, } from "../../harness/identity/accounts.js";
|
|
12
|
+
import { withClaimLock } from "./claim-lock.js";
|
|
13
|
+
function identityParts(header) {
|
|
14
|
+
const subject = foldSubject(header);
|
|
15
|
+
const base = deriveUsername(header);
|
|
16
|
+
if (subject.length > 512 || base === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
return { subject, base };
|
|
19
|
+
}
|
|
20
|
+
function linkedUser(deps, subject) {
|
|
21
|
+
const link = deps.accounts.lookup("proxy", subject);
|
|
22
|
+
return link === undefined ? undefined : deps.users.get(link.user);
|
|
23
|
+
}
|
|
24
|
+
function isAddConflict(error) {
|
|
25
|
+
return error instanceof Error && /^user ".*" already exists$/.test(error.message);
|
|
26
|
+
}
|
|
27
|
+
/** Lock-free resolution body; conflicts become a signal for the pipeline's retry. */
|
|
28
|
+
async function provisionAttempt(deps, header, profile, opts) {
|
|
29
|
+
const parts = identityParts(header);
|
|
30
|
+
if (parts === undefined)
|
|
31
|
+
return { kind: "invalid" };
|
|
32
|
+
const linked = linkedUser(deps, parts.subject);
|
|
33
|
+
if (linked !== undefined)
|
|
34
|
+
return { kind: "user", record: linked };
|
|
35
|
+
if (isUsernameKey(parts.subject)) {
|
|
36
|
+
const existing = deps.users.get(parts.subject);
|
|
37
|
+
if (existing !== undefined) {
|
|
38
|
+
try {
|
|
39
|
+
await deps.accounts.link({
|
|
40
|
+
provider: "proxy",
|
|
41
|
+
subject: parts.subject,
|
|
42
|
+
user: existing.user,
|
|
43
|
+
via: "proxy",
|
|
44
|
+
});
|
|
45
|
+
return { kind: "user", record: existing };
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof AccountLinkConflictError)
|
|
49
|
+
return { kind: "retry", error };
|
|
50
|
+
throw error;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (!opts.provision)
|
|
55
|
+
return { kind: "not_provisioned" };
|
|
56
|
+
const key = withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined);
|
|
57
|
+
try {
|
|
58
|
+
const record = await deps.users.add({
|
|
59
|
+
user: key,
|
|
60
|
+
...(profile.name === undefined ? {} : { name: profile.name }),
|
|
61
|
+
...(profile.email === undefined ? {} : { email: profile.email }),
|
|
62
|
+
admin: opts.admin ?? false,
|
|
63
|
+
source: "proxy",
|
|
64
|
+
});
|
|
65
|
+
try {
|
|
66
|
+
await deps.accounts.link({
|
|
67
|
+
provider: "proxy",
|
|
68
|
+
subject: parts.subject,
|
|
69
|
+
user: record.user,
|
|
70
|
+
via: "proxy",
|
|
71
|
+
});
|
|
72
|
+
return { kind: "user", record };
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
if (error instanceof AccountLinkConflictError) {
|
|
76
|
+
// Never leave the user created by this attempt attached to no
|
|
77
|
+
// identity when the link itself was refused.
|
|
78
|
+
await deps.users.remove(record.user);
|
|
79
|
+
return { kind: "retry", error };
|
|
80
|
+
}
|
|
81
|
+
throw error;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
if (isAddConflict(error))
|
|
86
|
+
return { kind: "retry", error: error };
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/** One lookup/lock pass. A retry is observed only AFTER the lock returns. */
|
|
91
|
+
async function resolveOnce(deps, header, profile, opts) {
|
|
92
|
+
const parts = identityParts(header);
|
|
93
|
+
if (parts === undefined)
|
|
94
|
+
return { kind: "invalid" };
|
|
95
|
+
const linked = linkedUser(deps, parts.subject);
|
|
96
|
+
if (linked !== undefined)
|
|
97
|
+
return { kind: "user", record: linked };
|
|
98
|
+
return await withClaimLock(() => provisionAttempt(deps, header, profile, opts));
|
|
99
|
+
}
|
|
100
|
+
/** Pipeline entry: lookup outside the lock; on a miss, provision under it. */
|
|
101
|
+
export async function resolveProxyUser(deps, header, profile, opts) {
|
|
102
|
+
const first = await resolveOnce(deps, header, profile, opts);
|
|
103
|
+
if (first.kind !== "retry")
|
|
104
|
+
return first;
|
|
105
|
+
// A second-process conflict retries resolution once, after the claim
|
|
106
|
+
// section has returned. A second conflict is a real failure, not a loop.
|
|
107
|
+
const second = await resolveOnce(deps, header, profile, opts);
|
|
108
|
+
if (second.kind === "retry")
|
|
109
|
+
throw second.error;
|
|
110
|
+
return second;
|
|
111
|
+
}
|
|
112
|
+
/** Lock-free body. Callers MUST already hold `withClaimLock`. */
|
|
113
|
+
export async function provisionProxyUser(deps, header, profile, opts) {
|
|
114
|
+
const result = await provisionAttempt(deps, header, profile, opts);
|
|
115
|
+
if (result.kind === "retry")
|
|
116
|
+
throw result.error;
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
/** Read-only key computation for an already-vouched header. */
|
|
120
|
+
export function previewProxyUser(deps, header) {
|
|
121
|
+
const parts = identityParts(header);
|
|
122
|
+
if (parts === undefined)
|
|
123
|
+
return undefined;
|
|
124
|
+
const linked = linkedUser(deps, parts.subject);
|
|
125
|
+
if (linked !== undefined)
|
|
126
|
+
return { user: linked.user, subject: header };
|
|
127
|
+
if (isUsernameKey(parts.subject) && deps.users.get(parts.subject) !== undefined) {
|
|
128
|
+
return { user: parts.subject, subject: header };
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
user: withSuffix(parts.base, (candidate) => deps.users.get(candidate) !== undefined),
|
|
132
|
+
subject: header,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The C2 vouch rule for account-creating public paths (proxy-trust
|
|
3
|
+
* onboarding, spec Task 4): a proxy-only server bound beyond loopback
|
|
4
|
+
* admits accounts ONLY through a trusted proxy vouch. On such a boot
|
|
5
|
+
* every unvouched account-creating public path — the onboarding claim
|
|
6
|
+
* (setup-code and invite branches alike) and the login code-resume —
|
|
7
|
+
* refuses with 403 `proxy_identity_required`, and boot never mints or
|
|
8
|
+
* logs a setup code (it would be an unvouched account-creation path).
|
|
9
|
+
*
|
|
10
|
+
* The rule is deliberately about the CONFIGURED posture, not the
|
|
11
|
+
* request: the login code-resume has no proxy identity at all, so under
|
|
12
|
+
* `requiresProxyVouch` it always refuses. Mixed `[proxy, token]`
|
|
13
|
+
* deployments keep the setup-code/invite paths (token callers exist),
|
|
14
|
+
* and loopback binds keep them too (loopback is its own trust).
|
|
15
|
+
*/
|
|
16
|
+
import { isLoopbackHost } from "./loopback.js";
|
|
17
|
+
/** Whether the configured auth methods are exactly proxy-only. */
|
|
18
|
+
export function isProxyOnly(methods) {
|
|
19
|
+
return methods.includes("proxy") && !methods.includes("token");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Whether this boot requires a proxy vouch for account creation:
|
|
23
|
+
* proxy-only AND bound beyond loopback. The loopback test is the shared
|
|
24
|
+
* one (`127.0.0.0/8`, `::1`, `localhost`; `::ffff:127.*` unwrapped).
|
|
25
|
+
*/
|
|
26
|
+
export function requiresProxyVouch(methods, listenHost) {
|
|
27
|
+
return isProxyOnly(methods) && !isLoopbackHost(listenHost);
|
|
28
|
+
}
|
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
* only).
|
|
9
9
|
*
|
|
10
10
|
* Layout: each entry is one file `<sha256(u)>.<variant>` beside ONE
|
|
11
|
-
* `index.json` (the metadata table: type, size, added-at,
|
|
12
|
-
* pin). The index is written atomically on every mutation, so a re-open
|
|
11
|
+
* `index.json` (the metadata table: type, size, added-at, clamped
|
|
12
|
+
* max-age, last-hit, pin). The index is written atomically on every mutation, so a re-open
|
|
13
13
|
* (the same cache dir, a new process) serves the prior entries.
|
|
14
14
|
*
|
|
15
15
|
* Eviction never deletes a file currently being STREAMED or WRITTEN
|
|
@@ -74,7 +74,8 @@ export class ImageCache {
|
|
|
74
74
|
return;
|
|
75
75
|
}
|
|
76
76
|
for (const [key, entry] of Object.entries(parsed.entries)) {
|
|
77
|
-
|
|
77
|
+
// Pre-max-age indexes used the original default: one hour.
|
|
78
|
+
this.entries.set(key, { ...entry, maxAge: entry.maxAge ?? 3600, pins: 0 });
|
|
78
79
|
}
|
|
79
80
|
}
|
|
80
81
|
/** One hit, or `undefined` on a miss. `now` stamps the LRU order. */
|
|
@@ -99,12 +100,17 @@ export class ImageCache {
|
|
|
99
100
|
entry.pins += 1;
|
|
100
101
|
await this.persist();
|
|
101
102
|
const stream = createReadStream(file);
|
|
103
|
+
let unpinned = false;
|
|
102
104
|
const unpin = () => {
|
|
105
|
+
// Node emits close after error: one hit releases one pin.
|
|
106
|
+
if (unpinned)
|
|
107
|
+
return;
|
|
108
|
+
unpinned = true;
|
|
103
109
|
entry.pins -= 1;
|
|
104
110
|
};
|
|
105
111
|
stream.once("close", unpin);
|
|
106
112
|
stream.once("error", unpin);
|
|
107
|
-
return { type: entry.type, stream };
|
|
113
|
+
return { type: entry.type, addedAt: entry.addedAt, maxAge: entry.maxAge, stream };
|
|
108
114
|
}
|
|
109
115
|
/**
|
|
110
116
|
* Store one fetched image. The write is staged and renamed (atomic:
|
|
@@ -112,7 +118,7 @@ export class ImageCache {
|
|
|
112
118
|
* is pinned for the write's duration — the eviction pass a same-key
|
|
113
119
|
* overwrite triggers can never remove the file mid-rename.
|
|
114
120
|
*/
|
|
115
|
-
async put(u, variant, type, body, now) {
|
|
121
|
+
async put(u, variant, type, body, now, maxAge = 3600) {
|
|
116
122
|
await this.open();
|
|
117
123
|
if (this.closed)
|
|
118
124
|
return;
|
|
@@ -122,6 +128,7 @@ export class ImageCache {
|
|
|
122
128
|
type,
|
|
123
129
|
size: body.byteLength,
|
|
124
130
|
addedAt: at,
|
|
131
|
+
maxAge,
|
|
125
132
|
lastHit: at,
|
|
126
133
|
pins: 1,
|
|
127
134
|
};
|
|
@@ -180,6 +187,7 @@ export class ImageCache {
|
|
|
180
187
|
type: entry.type,
|
|
181
188
|
size: entry.size,
|
|
182
189
|
addedAt: entry.addedAt,
|
|
190
|
+
maxAge: entry.maxAge,
|
|
183
191
|
lastHit: entry.lastHit,
|
|
184
192
|
},
|
|
185
193
|
])),
|
package/dist/server/index.js
CHANGED
|
@@ -93,8 +93,9 @@ import { resolveWebuiBundle, serveWebuiBundle, } from "./static.js";
|
|
|
93
93
|
* then the configured methods — the resolved principal stamps
|
|
94
94
|
* `SessionMeta.owner` at create/fork/import.
|
|
95
95
|
* @param options.listenHost the host the server will bind (fallback
|
|
96
|
-
* `"0.0.0.0"` when omitted; `serve()` passes
|
|
97
|
-
*
|
|
96
|
+
* `"0.0.0.0"` when omitted; `serve()` passes its ONE resolved host —
|
|
97
|
+
* `options.host` → `SIGRUN_LISTEN_HOST` → the dual-stack `::`, serving
|
|
98
|
+
* spec §1); the pipeline's Host check and the default method list
|
|
98
99
|
* (`effectiveMethods`) both derive from it.
|
|
99
100
|
* @param contributedBundles the extension-contributed webui-bundle registry
|
|
100
101
|
* (build-and-webui Task 6 threads it here from boot; the serve-time
|
|
@@ -299,6 +300,13 @@ export async function createServer(opened, config, contributedBundles = [], opti
|
|
|
299
300
|
commandCatalog,
|
|
300
301
|
listenHost,
|
|
301
302
|
methods,
|
|
303
|
+
// The listener record (release-readiness Task 1, serving spec §2):
|
|
304
|
+
// host + port are the server construction's inputs; `families`
|
|
305
|
+
// starts EMPTY (never listened) and `serve()` fills it in place
|
|
306
|
+
// with the measured families after `app.listen` resolves — the
|
|
307
|
+
// meta route reads whichever state exists at request time, never
|
|
308
|
+
// probing itself. A hand-built test server serves intent only.
|
|
309
|
+
listener: { host: listenHost, port: config.port, families: [] },
|
|
302
310
|
env,
|
|
303
311
|
// U4 §13.2: the harness's settings-changed fan-out (hub publish +
|
|
304
312
|
// extension onSettingsChanged + the scheduler re-arm).
|