@camstack/server 1.2.578 → 1.2.580
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/agent/agent-config.js +2 -11
- package/dist/agent/agent-http-auth.js +91 -78
- package/dist/agent/agent-http.js +89 -139
- package/dist/agent/agent-hub-registration.js +31 -58
- package/dist/agent/agent-json-file.js +59 -0
- package/dist/agent/agent-mesh-gate.js +57 -0
- package/dist/agent/agent-node-certificates.js +7 -7
- package/dist/agent/agent-pairing-routes.js +1 -4
- package/dist/agent/agent-pairing-store.js +8 -8
- package/dist/agent/agent-service.js +0 -9
- package/dist/agent/main.js +41 -99
- package/dist/api/core/auth.router.js +11 -0
- package/dist/api/core/cap-providers.js +3 -1
- package/dist/api/core/certificates.router.js +2 -2
- package/dist/api/core/server-name.js +64 -0
- package/dist/api/oauth2/consent-page.js +22 -31
- package/dist/api/oauth2/oauth2-routes.js +5 -0
- package/dist/api/trpc/trpc.context.js +3 -3
- package/dist/api/trpc/trpc.router.js +29 -2
- package/dist/auth/app-confirm-page.js +38 -24
- package/dist/auth/app-confirm-routes.js +8 -1
- package/dist/auth/app-page-shell.js +123 -0
- package/dist/auth/app-page-strings.js +157 -0
- package/dist/auth/app-passkey-page.js +47 -26
- package/dist/auth/app-passkey-routes.js +9 -2
- package/dist/auth/app-refused-route.js +43 -0
- package/dist/auth/session-connections.js +136 -0
- package/dist/auth/sso-finish-app.js +7 -1
- package/dist/boot/boot-config.js +7 -5
- package/dist/core/agent/agent-registry.service.js +0 -10
- package/dist/core/moleculer/hub-mesh-guard.js +30 -101
- package/dist/core/moleculer/moleculer.service.js +8 -32
- package/dist/main.js +33 -2
- package/package.json +9 -9
- package/dist/agent/agent-mesh-enroll.js +0 -317
- package/dist/agent/agent-mesh-join.js +0 -140
- package/dist/api/mesh/enroll.routes.js +0 -34
|
@@ -19,13 +19,15 @@ exports.appPasskeyRefusalPage = appPasskeyRefusalPage;
|
|
|
19
19
|
* specific is ever placed in the script; the only server-rendered value is the
|
|
20
20
|
* (allow-listed, HTML-escaped) cancel link.
|
|
21
21
|
*/
|
|
22
|
-
const
|
|
22
|
+
const app_page_shell_js_1 = require("./app-page-shell.js");
|
|
23
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
23
24
|
const PAGE_SCRIPT = `(function () {
|
|
24
25
|
var q = new URLSearchParams(location.search);
|
|
25
26
|
var base = location.pathname.replace(/\\/+$/, '');
|
|
26
27
|
var btn = document.getElementById('go');
|
|
27
28
|
var msg = document.getElementById('msg');
|
|
28
|
-
var
|
|
29
|
+
var S = function (k) { return document.body.getAttribute('data-s-' + k) || ''; };
|
|
30
|
+
var KEYS = ['code_challenge', 'code_challenge_method', 'state', 'purpose', 'redirect', 'lang'];
|
|
29
31
|
var APP_CALLBACK = /^camstack(\\.dev)?:\\/\\/auth-callback(\\?|$)/;
|
|
30
32
|
function fromB64u(s) {
|
|
31
33
|
s = s.replace(/-/g, '+').replace(/_/g, '/');
|
|
@@ -58,10 +60,10 @@ const PAGE_SCRIPT = `(function () {
|
|
|
58
60
|
btn.disabled = true;
|
|
59
61
|
msg.textContent = '';
|
|
60
62
|
if (!window.PublicKeyCredential || !navigator.credentials) {
|
|
61
|
-
return fail('
|
|
63
|
+
return fail(S('passkey-unsupported'));
|
|
62
64
|
}
|
|
63
65
|
post('/begin', flow()).then(function (begin) {
|
|
64
|
-
if (!begin.ok) throw new Error(
|
|
66
|
+
if (!begin.ok) throw new Error('unavailable');
|
|
65
67
|
var opts = begin.body.optionsJSON;
|
|
66
68
|
var pk = {
|
|
67
69
|
challenge: fromB64u(opts.challenge),
|
|
@@ -98,38 +100,57 @@ const PAGE_SCRIPT = `(function () {
|
|
|
98
100
|
}
|
|
99
101
|
var target = done.body && done.body.location;
|
|
100
102
|
if (typeof target !== 'string' || !APP_CALLBACK.test(target)) {
|
|
101
|
-
throw new Error(
|
|
103
|
+
throw new Error('failed');
|
|
102
104
|
}
|
|
103
105
|
location.assign(target);
|
|
104
106
|
}).catch(function (e) {
|
|
105
|
-
fail(
|
|
107
|
+
fail(
|
|
108
|
+
e && e.name === 'NotAllowedError' ? S('passkey-cancelled')
|
|
109
|
+
: e && e.message === 'unavailable' ? S('passkey-unavailable')
|
|
110
|
+
: S('sign-in-failed')
|
|
111
|
+
);
|
|
106
112
|
});
|
|
107
113
|
}
|
|
108
114
|
btn.addEventListener('click', go);
|
|
109
115
|
})();`;
|
|
110
|
-
const PAGE_STYLE = `body{font-family:system-ui,sans-serif;max-width:26rem;margin:4rem auto;padding:0 1rem;text-align:center}button{font:inherit;padding:.75rem 1.25rem;border-radius:.5rem;border:1px solid #888;cursor:pointer}p#msg{color:#b00020;min-height:1.5em}a{display:inline-block;margin-top:1rem}`;
|
|
111
|
-
const sha256 = (text) => `'sha256-${(0, node_crypto_1.createHash)('sha256').update(text).digest('base64')}'`;
|
|
112
116
|
/**
|
|
113
|
-
* The page's CSP: the
|
|
114
|
-
* only, no framing, no base rewrite, no form posts, no plugins.
|
|
117
|
+
* The page's CSP: the shell's style and the one script by hash, same-origin
|
|
118
|
+
* requests only, no framing, no base rewrite, no form posts, no plugins.
|
|
115
119
|
*/
|
|
116
|
-
exports.APP_PASSKEY_CSP =
|
|
117
|
-
"default-src 'none'",
|
|
118
|
-
`script-src ${sha256(PAGE_SCRIPT)}`,
|
|
119
|
-
`style-src ${sha256(PAGE_STYLE)}`,
|
|
120
|
-
"connect-src 'self'",
|
|
121
|
-
"base-uri 'none'",
|
|
122
|
-
"form-action 'none'",
|
|
123
|
-
"frame-ancestors 'none'",
|
|
124
|
-
].join('; ');
|
|
125
|
-
const escapeHtml = (text) => text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
|
|
120
|
+
exports.APP_PASSKEY_CSP = (0, app_page_shell_js_1.appPageCsp)({ script: PAGE_SCRIPT });
|
|
126
121
|
/** The page; `cancelHref` is the app callback carrying `error=cancelled`. */
|
|
127
|
-
function appPasskeyPage(cancelHref) {
|
|
128
|
-
|
|
122
|
+
function appPasskeyPage(cancelHref, lang) {
|
|
123
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang);
|
|
124
|
+
return (0, app_page_shell_js_1.renderAppPage)({
|
|
125
|
+
lang,
|
|
126
|
+
title: t.passkey.title,
|
|
127
|
+
heading: t.passkey.heading,
|
|
128
|
+
noReferrer: true,
|
|
129
|
+
script: PAGE_SCRIPT,
|
|
130
|
+
scriptStrings: {
|
|
131
|
+
'passkey-unsupported': t.script.passkeyUnsupported,
|
|
132
|
+
'passkey-unavailable': t.script.passkeyUnavailable,
|
|
133
|
+
'passkey-cancelled': t.script.passkeyCancelled,
|
|
134
|
+
'sign-in-failed': t.script.signInFailed,
|
|
135
|
+
},
|
|
136
|
+
body: [
|
|
137
|
+
(0, app_page_shell_js_1.appParagraph)(t.passkey.body),
|
|
138
|
+
(0, app_page_shell_js_1.appButton)(t.passkey.button, 'id="go"'),
|
|
139
|
+
(0, app_page_shell_js_1.appErrorLine)('msg'),
|
|
140
|
+
(0, app_page_shell_js_1.appCancelLink)(cancelHref, t.passkey.cancel),
|
|
141
|
+
`<noscript><p class="error">${(0, app_page_shell_js_1.escapeHtml)(t.passkey.noscript)}</p></noscript>`,
|
|
142
|
+
].join(''),
|
|
143
|
+
});
|
|
129
144
|
}
|
|
130
145
|
/** The page for a request the hub refuses to serve: a sentence, no script, no assets. */
|
|
131
|
-
function appPasskeyRefusalPage(message) {
|
|
132
|
-
|
|
146
|
+
function appPasskeyRefusalPage(message, lang) {
|
|
147
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
|
|
148
|
+
return (0, app_page_shell_js_1.renderAppPage)({
|
|
149
|
+
lang,
|
|
150
|
+
title: t.title,
|
|
151
|
+
heading: t.title,
|
|
152
|
+
body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
|
|
153
|
+
});
|
|
133
154
|
}
|
|
134
|
-
/** CSP of the refusal page:
|
|
135
|
-
exports.APP_PASSKEY_REFUSAL_CSP =
|
|
155
|
+
/** CSP of the refusal page: the shell's style only, nothing runs. */
|
|
156
|
+
exports.APP_PASSKEY_REFUSAL_CSP = (0, app_page_shell_js_1.appPageCsp)();
|
|
@@ -22,6 +22,7 @@ const types_1 = require("@camstack/types");
|
|
|
22
22
|
const zod_1 = require("zod");
|
|
23
23
|
const app_confirm_routes_js_1 = require("./app-confirm-routes.js");
|
|
24
24
|
const app_passkey_page_js_1 = require("./app-passkey-page.js");
|
|
25
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
25
26
|
const auth_rate_limit_js_1 = require("./auth-rate-limit.js");
|
|
26
27
|
const session_mint_js_1 = require("./session-mint.js");
|
|
27
28
|
exports.APP_PASSKEY_PATH = '/auth/app/passkey';
|
|
@@ -32,6 +33,7 @@ const FinishBodySchema = zod_1.z.object({
|
|
|
32
33
|
state: zod_1.z.string().optional(),
|
|
33
34
|
purpose: zod_1.z.string().optional(),
|
|
34
35
|
redirect: zod_1.z.string().optional(),
|
|
36
|
+
lang: zod_1.z.string().optional(),
|
|
35
37
|
});
|
|
36
38
|
function noStore(reply) {
|
|
37
39
|
reply.header('Cache-Control', 'no-store');
|
|
@@ -53,6 +55,10 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
53
55
|
noStore(reply);
|
|
54
56
|
reply.type('text/html; charset=utf-8');
|
|
55
57
|
const parsed = (0, types_1.parseAppFlowRequest)(request.query);
|
|
58
|
+
const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
59
|
+
lang: parsed.ok ? parsed.request.lang : request.query['lang'],
|
|
60
|
+
acceptLanguage: request.headers['accept-language'],
|
|
61
|
+
});
|
|
56
62
|
if (!parsed.ok) {
|
|
57
63
|
deps.logger.warn('Passkey app sign-in refused at the page', {
|
|
58
64
|
meta: { reason: parsed.reason },
|
|
@@ -60,11 +66,11 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
60
66
|
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_REFUSAL_CSP);
|
|
61
67
|
return reply
|
|
62
68
|
.status(400)
|
|
63
|
-
.send((0, app_passkey_page_js_1.appPasskeyRefusalPage)(
|
|
69
|
+
.send((0, app_passkey_page_js_1.appPasskeyRefusalPage)((0, app_page_strings_js_1.appStrings)(lang).refused.reasons[parsed.reason], lang));
|
|
64
70
|
}
|
|
65
71
|
reply.header('Content-Security-Policy', app_passkey_page_js_1.APP_PASSKEY_CSP);
|
|
66
72
|
const cancel = (0, types_1.appFlowLocation)(parsed.request.redirect, { error: 'cancelled' }, parsed.request.state);
|
|
67
|
-
return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel));
|
|
73
|
+
return reply.send((0, app_passkey_page_js_1.appPasskeyPage)(cancel, lang));
|
|
68
74
|
});
|
|
69
75
|
fastify.post(`${exports.APP_PASSKEY_PATH}/begin`, async (request, reply) => {
|
|
70
76
|
noStore(reply);
|
|
@@ -103,6 +109,7 @@ function registerAppPasskeyRoutes(fastify, deps) {
|
|
|
103
109
|
state: body.data.state,
|
|
104
110
|
purpose: body.data.purpose,
|
|
105
111
|
redirect: body.data.redirect,
|
|
112
|
+
lang: body.data.lang,
|
|
106
113
|
});
|
|
107
114
|
if (!parsed.ok)
|
|
108
115
|
return reply.status(400).send({ error: types_1.APP_FLOW_REFUSAL_MESSAGES[parsed.reason] });
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.APP_REFUSED_CSP = void 0;
|
|
4
|
+
exports.appRefusedPage = appRefusedPage;
|
|
5
|
+
exports.registerAppRefusedRoute = registerAppRefusedRoute;
|
|
6
|
+
/**
|
|
7
|
+
* `GET /auth/app/refused?reason=<code>` - the page that says why a native app
|
|
8
|
+
* sign-in could not start (D858).
|
|
9
|
+
*
|
|
10
|
+
* The OIDC start lives in an addon, which cannot reach the hub's theme; it
|
|
11
|
+
* redirects here with a reason CODE (and the app's optional `lang`) and the hub renders the
|
|
12
|
+
* sentence, in the language of `lang`/`Accept-Language`, in the same shell as every other app page. The query is never echoed: an unknown or
|
|
13
|
+
* absent code gets a generic sentence, a known one its fixed words.
|
|
14
|
+
*/
|
|
15
|
+
const types_1 = require("@camstack/types");
|
|
16
|
+
const app_page_shell_js_1 = require("./app-page-shell.js");
|
|
17
|
+
const app_page_strings_js_1 = require("./app-page-strings.js");
|
|
18
|
+
/** The refusal page for `reason` (a known code, or anything: unknown renders the generic sentence). */
|
|
19
|
+
function appRefusedPage(reason, lang) {
|
|
20
|
+
const t = (0, app_page_strings_js_1.appStrings)(lang).refused;
|
|
21
|
+
const message = (0, types_1.isAppStartRefusal)(reason) ? t.reasons[reason] : t.generic;
|
|
22
|
+
return (0, app_page_shell_js_1.renderAppPage)({
|
|
23
|
+
lang,
|
|
24
|
+
title: t.title,
|
|
25
|
+
heading: t.title,
|
|
26
|
+
body: (0, app_page_shell_js_1.appParagraph)(message) + (0, app_page_shell_js_1.appHint)(t.hint),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** CSP of the refusal page: the shell's style only, nothing runs. */
|
|
30
|
+
exports.APP_REFUSED_CSP = (0, app_page_shell_js_1.appPageCsp)();
|
|
31
|
+
function registerAppRefusedRoute(fastify) {
|
|
32
|
+
fastify.get(types_1.APP_REFUSED_PATH, async (request, reply) => {
|
|
33
|
+
reply.header('Cache-Control', 'no-store');
|
|
34
|
+
reply.header('Referrer-Policy', 'no-referrer');
|
|
35
|
+
reply.header('Content-Security-Policy', exports.APP_REFUSED_CSP);
|
|
36
|
+
reply.type('text/html; charset=utf-8');
|
|
37
|
+
const lang = (0, app_page_strings_js_1.resolveAppPageLang)({
|
|
38
|
+
lang: request.query['lang'],
|
|
39
|
+
acceptLanguage: request.headers['accept-language'],
|
|
40
|
+
});
|
|
41
|
+
return reply.status(400).send(appRefusedPage(request.query['reason'], lang));
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* What a login session still HOLDS on the hub — its open WebSockets and its
|
|
4
|
+
* live media sessions — so that revoking the session ends them.
|
|
5
|
+
*
|
|
6
|
+
* A revoked `sid` is refused on the NEXT call (`ctx.sessionLive`), which is not
|
|
7
|
+
* the same as ending anything: the socket stayed open, the client kept polling
|
|
8
|
+
* as an anonymous principal and its stream kept playing until the app was
|
|
9
|
+
* killed (2026-10-16, "Sign out all my sessions"). The login-session table
|
|
10
|
+
* announces every revocation (`onSessionsRevoked`); this registry turns that
|
|
11
|
+
* into closed sockets and closed media, touching ONLY the sessions named.
|
|
12
|
+
*
|
|
13
|
+
* - A socket closes with {@link SESSION_ENDED_CLOSE_CODE} / {@link SESSION_ENDED_CLOSE_REASON}
|
|
14
|
+
* so the client can tell "your session ended" from a network drop.
|
|
15
|
+
* - A media hold is whatever the creating call registered as its way to end
|
|
16
|
+
* the session (the WebRTC `closeSession`); recorded playback rides the same
|
|
17
|
+
* cap, so the same hold covers it.
|
|
18
|
+
*/
|
|
19
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
20
|
+
exports.SessionConnections = exports.SESSION_ENDED_CLOSE_REASON = exports.SESSION_ENDED_CLOSE_CODE = void 0;
|
|
21
|
+
exports.getSessionConnections = getSessionConnections;
|
|
22
|
+
exports.__resetSessionConnectionsForTests = __resetSessionConnectionsForTests;
|
|
23
|
+
/** WebSocket close code a client recognises as "the hub ended this session". */
|
|
24
|
+
exports.SESSION_ENDED_CLOSE_CODE = 4401;
|
|
25
|
+
exports.SESSION_ENDED_CLOSE_REASON = 'session-ended';
|
|
26
|
+
/** Bound on live media holds kept per login session (oldest dropped past it). */
|
|
27
|
+
const MAX_MEDIA_HOLDS_PER_SESSION = 256;
|
|
28
|
+
class SessionConnections {
|
|
29
|
+
sockets = new Set();
|
|
30
|
+
media = new Map();
|
|
31
|
+
logger = null;
|
|
32
|
+
setLogger(logger) {
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
}
|
|
35
|
+
/** Track an authenticated socket; returns the untrack (call on socket close). */
|
|
36
|
+
trackSocket(socket, identity) {
|
|
37
|
+
const tracked = { socket, identity };
|
|
38
|
+
this.sockets.add(tracked);
|
|
39
|
+
return () => {
|
|
40
|
+
this.sockets.delete(tracked);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Track a live media session opened under `sessionId`; returns the untrack. */
|
|
44
|
+
trackMedia(sessionId, hold) {
|
|
45
|
+
const holds = this.media.get(sessionId) ?? new Map();
|
|
46
|
+
holds.set(hold.mediaSessionId, hold);
|
|
47
|
+
if (holds.size > MAX_MEDIA_HOLDS_PER_SESSION) {
|
|
48
|
+
const oldest = holds.keys().next();
|
|
49
|
+
if (!oldest.done)
|
|
50
|
+
holds.delete(oldest.value);
|
|
51
|
+
}
|
|
52
|
+
this.media.set(sessionId, holds);
|
|
53
|
+
return () => {
|
|
54
|
+
const current = this.media.get(sessionId);
|
|
55
|
+
current?.delete(hold.mediaSessionId);
|
|
56
|
+
if (current?.size === 0)
|
|
57
|
+
this.media.delete(sessionId);
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
/** A media session the client closed itself: nothing left to end. */
|
|
61
|
+
forgetMedia(sessionId, mediaSessionId) {
|
|
62
|
+
const current = this.media.get(sessionId);
|
|
63
|
+
current?.delete(mediaSessionId);
|
|
64
|
+
if (current?.size === 0)
|
|
65
|
+
this.media.delete(sessionId);
|
|
66
|
+
}
|
|
67
|
+
/** End what the revoked sessions hold. Sessions not named are never touched. */
|
|
68
|
+
endSessions(input) {
|
|
69
|
+
const named = new Set(input.sessionIds);
|
|
70
|
+
let sockets = 0;
|
|
71
|
+
for (const tracked of [...this.sockets]) {
|
|
72
|
+
const { sessionId, userId } = tracked.identity;
|
|
73
|
+
const hit = sessionId !== undefined
|
|
74
|
+
? named.has(sessionId)
|
|
75
|
+
: input.allOfUser && input.userId !== null && userId === input.userId;
|
|
76
|
+
if (!hit)
|
|
77
|
+
continue;
|
|
78
|
+
this.sockets.delete(tracked);
|
|
79
|
+
try {
|
|
80
|
+
tracked.socket.close(exports.SESSION_ENDED_CLOSE_CODE, exports.SESSION_ENDED_CLOSE_REASON);
|
|
81
|
+
sockets += 1;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
this.logger?.warn('Closing a socket of an ended session failed', {
|
|
85
|
+
meta: { error: error instanceof Error ? error.message : String(error) },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
let mediaSessions = 0;
|
|
90
|
+
for (const sessionId of named) {
|
|
91
|
+
const holds = this.media.get(sessionId);
|
|
92
|
+
if (holds === undefined)
|
|
93
|
+
continue;
|
|
94
|
+
this.media.delete(sessionId);
|
|
95
|
+
for (const hold of holds.values()) {
|
|
96
|
+
mediaSessions += 1;
|
|
97
|
+
void this.closeMedia(hold, input.reason);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (sockets > 0 || mediaSessions > 0) {
|
|
101
|
+
this.logger?.info('Revoked sessions ended their connections', {
|
|
102
|
+
meta: { reason: input.reason, sessions: input.sessionIds.length, sockets, mediaSessions },
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
return { sockets, mediaSessions };
|
|
106
|
+
}
|
|
107
|
+
async closeMedia(hold, reason) {
|
|
108
|
+
try {
|
|
109
|
+
await hold.close();
|
|
110
|
+
this.logger?.info('Media session closed by session revocation', {
|
|
111
|
+
tags: { deviceId: hold.deviceId },
|
|
112
|
+
meta: { mediaSessionId: hold.mediaSessionId, reason },
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
this.logger?.warn('Closing a media session of an ended session failed', {
|
|
117
|
+
tags: { deviceId: hold.deviceId },
|
|
118
|
+
meta: {
|
|
119
|
+
mediaSessionId: hold.mediaSessionId,
|
|
120
|
+
error: error instanceof Error ? error.message : String(error),
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
exports.SessionConnections = SessionConnections;
|
|
127
|
+
let instance = null;
|
|
128
|
+
/** The process-wide registry (the WS factory, the WebRTC wrapper and the revoke listener share it). */
|
|
129
|
+
function getSessionConnections() {
|
|
130
|
+
instance ??= new SessionConnections();
|
|
131
|
+
return instance;
|
|
132
|
+
}
|
|
133
|
+
/** Test-only. */
|
|
134
|
+
function __resetSessionConnectionsForTests() {
|
|
135
|
+
instance = null;
|
|
136
|
+
}
|
|
@@ -82,7 +82,13 @@ async function finishSsoApp(bridge, deps) {
|
|
|
82
82
|
const parked = deps.confirmations.create({
|
|
83
83
|
userId: record.id,
|
|
84
84
|
username: record.username,
|
|
85
|
-
app: {
|
|
85
|
+
app: {
|
|
86
|
+
codeChallenge: app.codeChallenge,
|
|
87
|
+
state: app.state,
|
|
88
|
+
purpose: app.purpose,
|
|
89
|
+
redirect,
|
|
90
|
+
...(app.lang !== undefined ? { lang: app.lang } : {}),
|
|
91
|
+
},
|
|
86
92
|
authMethod: 'sso',
|
|
87
93
|
});
|
|
88
94
|
return { kind: 'confirm', ...parked };
|
package/dist/boot/boot-config.js
CHANGED
|
@@ -56,8 +56,9 @@ const CONFIG_DEFAULTS = {
|
|
|
56
56
|
};
|
|
57
57
|
/**
|
|
58
58
|
* Bootstrap env overrides. No SECRET is among them (D783, D797): the session
|
|
59
|
-
* signing key
|
|
60
|
-
*
|
|
59
|
+
* signing key lives in the keychain, the first-boot admin is fixed
|
|
60
|
+
* (`admin` / `changeme`), and there is no cluster secret at all (D857 — a
|
|
61
|
+
* node's identity is its mesh certificate).
|
|
61
62
|
*/
|
|
62
63
|
const ENV_VAR_MAP = {
|
|
63
64
|
CAMSTACK_PORT: 'server.port',
|
|
@@ -66,7 +67,8 @@ const ENV_VAR_MAP = {
|
|
|
66
67
|
CAMSTACK_TLS_ENABLED: 'tls.enabled',
|
|
67
68
|
};
|
|
68
69
|
/**
|
|
69
|
-
* Env variables that once carried a secret into the hub. Ignored since D797
|
|
70
|
+
* Env variables that once carried a secret into the hub. Ignored since D797
|
|
71
|
+
* (`CAMSTACK_CLUSTER_SECRET`: the secret itself was removed by D857) —
|
|
70
72
|
* still named so a container template that sets them is told, not silently
|
|
71
73
|
* obeyed or silently dropped. Only their PRESENCE is read, never the value.
|
|
72
74
|
*/
|
|
@@ -194,8 +196,8 @@ function loadBootstrapConfig(configPath) {
|
|
|
194
196
|
const retired = retiredSecretSources(process.env, raw);
|
|
195
197
|
if (retired.length > 0) {
|
|
196
198
|
console.warn(`[Phase1] WARN ${retired.join(', ')} ignored — no secret is read from the environment or ` +
|
|
197
|
-
'config.yaml any more (D797). The session signing key
|
|
198
|
-
'keychain
|
|
199
|
+
'config.yaml any more (D797). The session signing key lives in the ' +
|
|
200
|
+
'keychain; a fresh hub seeds admin / changeme. ' +
|
|
199
201
|
'Remove these entries — after importing their values with scripts/ops/keychain-migrate-hub.mjs.');
|
|
200
202
|
}
|
|
201
203
|
const ignored = ignoredTlsCertSources(process.env, raw);
|
|
@@ -469,16 +469,6 @@ class AgentRegistryService {
|
|
|
469
469
|
this.moleculer.setOnAgentRegistered((agentId) => {
|
|
470
470
|
void this.reconcileAgentAddons(agentId, 'node-registration');
|
|
471
471
|
});
|
|
472
|
-
// D802: the mesh `transition` ends only once every agent this hub has
|
|
473
|
-
// EVER seen has authenticated — offline ones included. `null` (store not
|
|
474
|
-
// wired or not answering) never counts as "no agents".
|
|
475
|
-
this.moleculer.setKnownAgentsProvider(async () => {
|
|
476
|
-
const store = this.historyStore;
|
|
477
|
-
if (!store)
|
|
478
|
-
return null;
|
|
479
|
-
const rows = await store.tryGetAll();
|
|
480
|
-
return rows === null ? null : rows.filter((r) => !r.isHub).map((r) => r.id);
|
|
481
|
-
});
|
|
482
472
|
this.broker.localBus.on('$node.connected', ({ node }) => {
|
|
483
473
|
const kind = classifyNode(node.id);
|
|
484
474
|
if (!kind)
|
|
@@ -2,132 +2,61 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.HubMeshGuard = void 0;
|
|
4
4
|
/**
|
|
5
|
-
* The hub's mesh guard (D802) — one object
|
|
6
|
-
*
|
|
5
|
+
* The hub's mesh guard (D802, D857) — the one object that decides whether
|
|
6
|
+
* another node may talk to the hub.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
8
|
+
* A node is admitted by its CERTIFICATE: the mutual-TLS handshake chained to
|
|
9
|
+
* the mesh CA, the leaf CN is the node id it speaks as, and the serial is not
|
|
10
|
+
* revoked. The transport answers that (`TlsTcpTransporter.verifiedPeer`,
|
|
11
|
+
* published through the process peer lookup); this class turns the answer
|
|
12
|
+
* into the broker middleware and says, once per node and minute, who was
|
|
13
|
+
* turned away and why. There is no shared secret, no mode and no transition.
|
|
13
14
|
*
|
|
14
15
|
* Kept out of `moleculer.service.ts` so that file only wires it.
|
|
15
16
|
*/
|
|
16
17
|
const system_1 = require("@camstack/system");
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const AUTO_ENFORCE_INTERVAL_MS = 60_000;
|
|
18
|
+
/** At most one "refused" line per node per this long. */
|
|
19
|
+
const REFUSED_LOG_INTERVAL_MS = 60_000;
|
|
20
20
|
class HubMeshGuard {
|
|
21
21
|
ownNodeId;
|
|
22
22
|
logger;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
timer = null;
|
|
28
|
-
evaluating = false;
|
|
29
|
-
lastWaitingFor = '';
|
|
30
|
-
constructor(ownNodeId, logger) {
|
|
23
|
+
now;
|
|
24
|
+
gate;
|
|
25
|
+
lastLoggedAt = new Map();
|
|
26
|
+
constructor(ownNodeId, logger, now = Date.now, isVerifiedPeer = system_1.isProcessVerifiedMeshPeer) {
|
|
31
27
|
this.ownNodeId = ownNodeId;
|
|
32
28
|
this.logger = logger;
|
|
33
|
-
this.
|
|
29
|
+
this.now = now;
|
|
30
|
+
this.gate = new system_1.MeshIdentityGate({
|
|
34
31
|
ownNodeId,
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
isVerifiedPeer: system_1.isProcessVerifiedMeshPeer,
|
|
38
|
-
onAuthenticated: (nodeId) => {
|
|
39
|
-
this.logger.info('node authenticated to the mesh (challenge–response)', {
|
|
40
|
-
meta: { nodeId },
|
|
41
|
-
});
|
|
42
|
-
void this.evaluateAutoEnforce();
|
|
43
|
-
},
|
|
44
|
-
onTolerated: (nodeId, action) => {
|
|
45
|
-
this.logger.warn('node is calling the hub WITHOUT having authenticated — tolerated while System → ' +
|
|
46
|
-
'Cluster is in Transition. Enter the cluster secret in this agent.', { meta: { nodeId, firstCall: action } });
|
|
47
|
-
},
|
|
32
|
+
isVerifiedPeer,
|
|
33
|
+
onRefused: (nodeId, what) => this.noteRefused(nodeId, what),
|
|
48
34
|
});
|
|
49
|
-
(0, system_1.setProcessMeshAuthStatusSource)(() => this.authenticator.snapshot());
|
|
50
35
|
}
|
|
51
36
|
/** The middleware the hub broker must be constructed with. */
|
|
52
37
|
middleware() {
|
|
53
38
|
return (0, system_1.createMeshAccessMiddleware)({
|
|
54
39
|
ownNodeId: this.ownNodeId,
|
|
55
|
-
|
|
40
|
+
gate: this.gate,
|
|
56
41
|
logger: {
|
|
57
42
|
warn: (message, meta) => this.logger.warn(message, meta ? { meta } : undefined),
|
|
58
43
|
},
|
|
59
44
|
});
|
|
60
45
|
}
|
|
61
|
-
/**
|
|
62
|
-
start(listConnected) {
|
|
63
|
-
this.listConnected = listConnected;
|
|
64
|
-
if (this.timer !== null)
|
|
65
|
-
return;
|
|
66
|
-
this.timer = setInterval(() => void this.evaluateAutoEnforce(), AUTO_ENFORCE_INTERVAL_MS);
|
|
67
|
-
this.timer.unref?.();
|
|
68
|
-
}
|
|
69
|
-
/** Wired by `AgentRegistryService`, which owns the cluster-node history. */
|
|
70
|
-
setKnownAgentsProvider(provider) {
|
|
71
|
-
this.knownAgents = provider;
|
|
72
|
-
}
|
|
73
|
-
/** A node's connection dropped. */
|
|
46
|
+
/** A node's connection dropped: forget the throttle, not an identity (there is none to forget). */
|
|
74
47
|
forget(nodeId) {
|
|
75
|
-
this.
|
|
76
|
-
}
|
|
77
|
-
stop() {
|
|
78
|
-
if (this.timer !== null)
|
|
79
|
-
clearInterval(this.timer);
|
|
80
|
-
this.timer = null;
|
|
81
|
-
(0, system_1.setProcessMeshAuthStatusSource)(null);
|
|
48
|
+
this.lastLoggedAt.delete(nodeId);
|
|
82
49
|
}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
50
|
+
noteRefused(nodeId, what) {
|
|
51
|
+
const at = this.now();
|
|
52
|
+
const last = this.lastLoggedAt.get(nodeId);
|
|
53
|
+
if (last !== undefined && at - last < REFUSED_LOG_INTERVAL_MS)
|
|
86
54
|
return;
|
|
87
|
-
this.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
knownAgents = await this.knownAgents();
|
|
93
|
-
}
|
|
94
|
-
catch {
|
|
95
|
-
knownAgents = null;
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
const decision = (0, system_1.decideMeshAutoEnforce)({
|
|
99
|
-
mode: (0, system_1.getProcessMeshAuthMode)(),
|
|
100
|
-
gate: (0, system_1.getProcessClusterSecretGate)().kind,
|
|
101
|
-
uptimeMs: Date.now() - this.startedAt,
|
|
102
|
-
knownAgents,
|
|
103
|
-
connected: this.listConnected(),
|
|
104
|
-
toleratedEver: this.authenticator.everToleratedNodeIds(),
|
|
105
|
-
authenticatedEver: this.authenticator.everAuthenticatedNodeIds(),
|
|
106
|
-
ownNodeId: this.ownNodeId,
|
|
107
|
-
});
|
|
108
|
-
if (!decision.enforce) {
|
|
109
|
-
const waiting = decision.waitingFor.join(',');
|
|
110
|
-
if (waiting.length > 0 && waiting !== this.lastWaitingFor) {
|
|
111
|
-
this.logger.info('mesh transition: still waiting for agents to authenticate', {
|
|
112
|
-
meta: { waitingFor: decision.waitingFor },
|
|
113
|
-
});
|
|
114
|
-
}
|
|
115
|
-
this.lastWaitingFor = waiting;
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
const done = await (0, system_1.requestProcessMeshEnforcement)(decision.reason);
|
|
119
|
-
if (done) {
|
|
120
|
-
this.logger.info('mesh authentication switched to ENFORCED', {
|
|
121
|
-
meta: { reason: decision.reason },
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
catch (err) {
|
|
126
|
-
this.logger.warn('mesh auto-enforce check failed', { meta: { error: (0, types_1.errMsg)(err) } });
|
|
127
|
-
}
|
|
128
|
-
finally {
|
|
129
|
-
this.evaluating = false;
|
|
130
|
-
}
|
|
55
|
+
if (this.lastLoggedAt.size >= 256)
|
|
56
|
+
this.lastLoggedAt.clear();
|
|
57
|
+
this.lastLoggedAt.set(nodeId, at);
|
|
58
|
+
this.logger.warn('a node without a valid mesh certificate called the hub — refused. Pair it from ' +
|
|
59
|
+
'System → Cluster → Agents', { tags: { nodeId }, meta: { firstCall: what } });
|
|
131
60
|
}
|
|
132
61
|
}
|
|
133
62
|
exports.HubMeshGuard = HubMeshGuard;
|