@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
|
@@ -73,7 +73,7 @@ function ensurePersistedNodeId(configPath, dataDir) {
|
|
|
73
73
|
const id = envSeed && envSeed.length > 0 ? envSeed : `agent-${crypto.randomBytes(4).toString('hex')}`;
|
|
74
74
|
raw.nodeId = id;
|
|
75
75
|
try {
|
|
76
|
-
// Owner-only: the same file holds the
|
|
76
|
+
// Owner-only: the same file holds the agent's private mesh key.
|
|
77
77
|
(0, node_1.writeOwnerOnlyFileSync)(resolvedPath, JSON.stringify(raw, null, 2));
|
|
78
78
|
}
|
|
79
79
|
catch {
|
|
@@ -105,26 +105,20 @@ function loadAgentConfig(configPath, dataDirOverride) {
|
|
|
105
105
|
process.env.CAMSTACK_AGENT_NAME ??
|
|
106
106
|
`${os.hostname()}-${os.arch()}`;
|
|
107
107
|
// Environment variable for the hub connection (optional — agent starts
|
|
108
|
-
// without).
|
|
109
|
-
// agent.json.
|
|
108
|
+
// without). Pairing material lives only in agent.json.
|
|
110
109
|
const envHubAddress = process.env.CAMSTACK_HUB_ADDRESS || undefined;
|
|
111
110
|
const configFullPath = path.resolve(resolvedDataDir, filePath);
|
|
112
111
|
const nodeId = ensurePersistedNodeId(filePath, resolvedDataDir);
|
|
113
112
|
// Read persisted config — these override env vars
|
|
114
113
|
let fileHubAddress;
|
|
115
114
|
let fileName;
|
|
116
|
-
let fileSecret;
|
|
117
115
|
let fileMeshTls;
|
|
118
|
-
let filePendingJoinToken;
|
|
119
116
|
if (fs.existsSync(configFullPath)) {
|
|
120
117
|
try {
|
|
121
118
|
const raw = JSON.parse(fs.readFileSync(configFullPath, 'utf-8'));
|
|
122
119
|
fileHubAddress = typeof raw.hubAddress === 'string' ? raw.hubAddress : undefined;
|
|
123
120
|
fileName = typeof raw.name === 'string' ? raw.name : undefined;
|
|
124
|
-
fileSecret = typeof raw.secret === 'string' ? raw.secret : undefined;
|
|
125
121
|
fileMeshTls = parseMeshTls(raw.meshTls);
|
|
126
|
-
filePendingJoinToken =
|
|
127
|
-
typeof raw.joinToken === 'string' && raw.joinToken.length > 0 ? raw.joinToken : undefined;
|
|
128
122
|
}
|
|
129
123
|
catch {
|
|
130
124
|
/* corrupt config */
|
|
@@ -133,7 +127,6 @@ function loadAgentConfig(configPath, dataDirOverride) {
|
|
|
133
127
|
// Config file wins over env for all user-editable fields
|
|
134
128
|
const effectiveName = fileName ?? envName;
|
|
135
129
|
const effectiveHub = fileHubAddress ?? envHubAddress;
|
|
136
|
-
const effectiveSecret = fileSecret;
|
|
137
130
|
return {
|
|
138
131
|
nodeId,
|
|
139
132
|
name: effectiveName,
|
|
@@ -141,9 +134,7 @@ function loadAgentConfig(configPath, dataDirOverride) {
|
|
|
141
134
|
dataDir: resolvedDataDir,
|
|
142
135
|
addonsDir: path.resolve(resolvedDataDir, 'addons'),
|
|
143
136
|
logLevel: process.env.CAMSTACK_LOG_LEVEL ?? 'info',
|
|
144
|
-
secret: effectiveSecret,
|
|
145
137
|
...(fileMeshTls !== undefined ? { meshTls: fileMeshTls } : {}),
|
|
146
|
-
...(filePendingJoinToken !== undefined ? { pendingJoinToken: filePendingJoinToken } : {}),
|
|
147
138
|
configPath: configFullPath,
|
|
148
139
|
statusPort: Number(process.env.CAMSTACK_STATUS_PORT) || 4444,
|
|
149
140
|
};
|
|
@@ -3,30 +3,83 @@
|
|
|
3
3
|
* Agent HTTP auth — pure request-authorization helpers for the agent's
|
|
4
4
|
* port-4444 API (mirrors the hub's /health hardening).
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
6
|
+
* Model (D857, which replaced the Bearer cluster secret):
|
|
7
|
+
* - loopback callers (the Electron renderer, the agent's own page on its own
|
|
8
|
+
* machine, in-container probes) are always allowed — whoever is at the
|
|
9
|
+
* machine is the operator;
|
|
10
|
+
* - remote callers need a session token, minted by redeeming a hub unlock
|
|
11
|
+
* ticket (D821): an ES256 ticket signed by the mesh CA the agent was paired
|
|
12
|
+
* with, redeemed through `POST /api/agent/session`;
|
|
13
|
+
* - the PAIRING protocol endpoints are reachable by anyone on the network,
|
|
14
|
+
* in either state: each is authorized by what it carries (a pairing id the
|
|
15
|
+
* agent made up, a proof the mesh CA signed), never by who calls. An
|
|
16
|
+
* unpaired agent offers nothing else remotely.
|
|
17
17
|
*/
|
|
18
18
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.isLocalHostHeader = isLocalHostHeader;
|
|
20
|
+
exports.authInputOf = authInputOf;
|
|
19
21
|
exports.hasForwardingHeader = hasForwardingHeader;
|
|
20
22
|
exports.isGenuineLoopback = isGenuineLoopback;
|
|
21
23
|
exports.isLoopbackAddress = isLoopbackAddress;
|
|
22
|
-
exports.bearerMatchesSecret = bearerMatchesSecret;
|
|
23
24
|
exports.readBearerToken = readBearerToken;
|
|
24
25
|
exports.isAuthorizedAgentRequest = isAuthorizedAgentRequest;
|
|
25
|
-
exports.
|
|
26
|
+
exports.isPairingProtocolRequest = isPairingProtocolRequest;
|
|
26
27
|
exports.isUnlockSessionRequest = isUnlockSessionRequest;
|
|
27
28
|
exports.isUnlockInfoRequest = isUnlockInfoRequest;
|
|
28
|
-
|
|
29
|
-
|
|
29
|
+
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
30
|
+
/** `host[:port]` of the agent's own machine: localhost, 127.0.0.1 or [::1]. */
|
|
31
|
+
function isLocalHostHeader(host) {
|
|
32
|
+
const lower = host.trim().toLowerCase();
|
|
33
|
+
const name = lower.startsWith('[') ? lower.slice(0, lower.indexOf(']') + 1) : lower.split(':')[0];
|
|
34
|
+
return name !== undefined && LOCAL_HOSTNAMES.has(name);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* The Host/Origin/Content-Type checks that keep a WEB PAGE from being "this machine". A page on
|
|
38
|
+
* any site can make the browser call 127.0.0.1:4444 (CORS blocks reading, not sending) or rebind
|
|
39
|
+
* a hostname to it; what it cannot do is send a local Host, a same-origin Origin or a JSON
|
|
40
|
+
* content type on a cross-site request (D857).
|
|
41
|
+
*/
|
|
42
|
+
function isBrowserSafeLocal(input) {
|
|
43
|
+
if (input.host !== undefined && !isLocalHostHeader(input.host))
|
|
44
|
+
return false;
|
|
45
|
+
if (input.origin !== undefined) {
|
|
46
|
+
let originHost;
|
|
47
|
+
try {
|
|
48
|
+
originHost = new URL(input.origin).host;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (!isLocalHostHeader(originHost))
|
|
54
|
+
return false;
|
|
55
|
+
if (input.host !== undefined && originHost.toLowerCase() !== input.host.trim().toLowerCase()) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
const method = input.method?.toUpperCase();
|
|
60
|
+
if (method !== undefined && method !== 'GET' && method !== 'HEAD' && method !== 'OPTIONS') {
|
|
61
|
+
const type = (input.contentType ?? '').split(';')[0]?.trim().toLowerCase();
|
|
62
|
+
if (type !== 'application/json')
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
/** Build the auth input of a Node/Fastify request. */
|
|
68
|
+
function authInputOf(req) {
|
|
69
|
+
const one = (name) => {
|
|
70
|
+
const v = req.headers[name];
|
|
71
|
+
return typeof v === 'string' ? v : undefined;
|
|
72
|
+
};
|
|
73
|
+
return {
|
|
74
|
+
remoteAddress: req.socket.remoteAddress ?? undefined,
|
|
75
|
+
forwarded: hasForwardingHeader(req.headers),
|
|
76
|
+
method: req.method,
|
|
77
|
+
host: one('host'),
|
|
78
|
+
origin: one('origin'),
|
|
79
|
+
contentType: one('content-type'),
|
|
80
|
+
authorization: one('authorization'),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
30
83
|
const FORWARDING_HEADERS = [
|
|
31
84
|
'forwarded',
|
|
32
85
|
'x-forwarded-for',
|
|
@@ -46,7 +99,7 @@ function hasForwardingHeader(headers) {
|
|
|
46
99
|
* the same host makes every caller look like 127.0.0.1; the header it adds is what gives it away.
|
|
47
100
|
*/
|
|
48
101
|
function isGenuineLoopback(input) {
|
|
49
|
-
return input.forwarded !== true && isLoopbackAddress(input.remoteAddress);
|
|
102
|
+
return (input.forwarded !== true && isLoopbackAddress(input.remoteAddress) && isBrowserSafeLocal(input));
|
|
50
103
|
}
|
|
51
104
|
/** IPv4/IPv6 loopback (incl. the IPv4-mapped IPv6 form Node reports). */
|
|
52
105
|
function isLoopbackAddress(addr) {
|
|
@@ -61,16 +114,6 @@ function isLoopbackAddress(addr) {
|
|
|
61
114
|
return false;
|
|
62
115
|
return parts.every((p) => /^\d{1,3}$/.test(p));
|
|
63
116
|
}
|
|
64
|
-
/** Timing-safe `Authorization: Bearer <secret>` compare. Empty secrets never match. */
|
|
65
|
-
function bearerMatchesSecret(authorization, secret) {
|
|
66
|
-
if (secret.length === 0)
|
|
67
|
-
return false;
|
|
68
|
-
if (!authorization?.startsWith('Bearer '))
|
|
69
|
-
return false;
|
|
70
|
-
const token = Buffer.from(authorization.slice('Bearer '.length));
|
|
71
|
-
const expected = Buffer.from(secret);
|
|
72
|
-
return token.length === expected.length && (0, node_crypto_1.timingSafeEqual)(token, expected);
|
|
73
|
-
}
|
|
74
117
|
/** Extract the bearer token value, or `null`. */
|
|
75
118
|
function readBearerToken(authorization) {
|
|
76
119
|
if (!authorization?.startsWith('Bearer '))
|
|
@@ -79,61 +122,15 @@ function readBearerToken(authorization) {
|
|
|
79
122
|
return token.length > 0 ? token : null;
|
|
80
123
|
}
|
|
81
124
|
/**
|
|
82
|
-
* True when the request may access the protected agent API
|
|
83
|
-
*
|
|
84
|
-
* remote credential can be verified, so remote access is denied except for
|
|
85
|
-
* the pairing surface, which the route layer allows via {@link isPairingRequest}).
|
|
86
|
-
* `sessionTokenOk` admits a hub-minted unlock session (D821).
|
|
125
|
+
* True when the request may access the protected agent API: it comes from this
|
|
126
|
+
* machine, or it carries a session token minted from a hub unlock ticket.
|
|
87
127
|
*/
|
|
88
|
-
function isAuthorizedAgentRequest(input,
|
|
128
|
+
function isAuthorizedAgentRequest(input, sessionTokenOk = () => false) {
|
|
89
129
|
if (isGenuineLoopback(input))
|
|
90
130
|
return true;
|
|
91
|
-
// A secretless agent (paired by certificate, D857) is still reachable through a hub-unlocked session.
|
|
92
|
-
const hasSecret = clusterSecret !== null && clusterSecret.length > 0;
|
|
93
|
-
if (hasSecret && bearerMatchesSecret(input.authorization, clusterSecret))
|
|
94
|
-
return true;
|
|
95
131
|
const token = readBearerToken(input.authorization);
|
|
96
132
|
return token !== null && sessionTokenOk(token);
|
|
97
133
|
}
|
|
98
|
-
/**
|
|
99
|
-
* The first-boot pairing surface: what a remote browser needs to configure
|
|
100
|
-
* a factory-fresh agent (read status/config, write hub address + secret,
|
|
101
|
-
* trigger the reconnect). Process control and health details are NEVER
|
|
102
|
-
* part of it. Only consulted while the agent has no secret configured.
|
|
103
|
-
*/
|
|
104
|
-
function isPairingRequest(method, urlPath,
|
|
105
|
-
/** Pairing exists: the first-boot surface is read-only, writes need loopback or a hub session (D857). */
|
|
106
|
-
readOnly = false) {
|
|
107
|
-
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
108
|
-
if (method === 'GET') {
|
|
109
|
-
return (pathOnly === '/api/agent/status' ||
|
|
110
|
-
pathOnly === '/api/agent/config' ||
|
|
111
|
-
pathOnly === '/api/agent/discovered-nodes' ||
|
|
112
|
-
pathOnly === '/api/agent/hubs-found');
|
|
113
|
-
}
|
|
114
|
-
if (method === 'POST' && !readOnly) {
|
|
115
|
-
return (pathOnly === '/api/agent/config' ||
|
|
116
|
-
pathOnly === '/api/agent/restart' ||
|
|
117
|
-
pathOnly === '/api/agent/hubs-found/refresh');
|
|
118
|
-
}
|
|
119
|
-
return false;
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* Redeem a hub unlock ticket for a session token — reachable remotely without
|
|
123
|
-
* a prior Bearer (the ticket IS the proof). Paired agents only.
|
|
124
|
-
*/
|
|
125
|
-
function isUnlockSessionRequest(method, urlPath) {
|
|
126
|
-
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
127
|
-
return method === 'POST' && pathOnly === '/api/agent/session';
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Public unlock probe: `nodeId` + hub HTTPS origin for Continue-with-hub (D821).
|
|
131
|
-
* No secrets, no metrics — only what the locked unlock screen needs.
|
|
132
|
-
*/
|
|
133
|
-
function isUnlockInfoRequest(method, urlPath) {
|
|
134
|
-
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
135
|
-
return method === 'GET' && pathOnly === '/api/agent/unlock-info';
|
|
136
|
-
}
|
|
137
134
|
const PAIRING_GET = new Set([
|
|
138
135
|
'/api/agent/pairing/hello',
|
|
139
136
|
'/api/agent/pairing/status',
|
|
@@ -149,9 +146,9 @@ const PAIRING_POST = new Set([
|
|
|
149
146
|
'/api/agent/pairing/complete',
|
|
150
147
|
]);
|
|
151
148
|
/**
|
|
152
|
-
* The pairing protocol endpoints (D857):
|
|
153
|
-
*
|
|
154
|
-
*
|
|
149
|
+
* The pairing protocol endpoints (D857): the only `/api/` routes reachable from
|
|
150
|
+
* the network without a session. `forget` is NOT among them — leaving a hub is an
|
|
151
|
+
* authenticated action (this machine, or a hub-unlocked session).
|
|
155
152
|
*/
|
|
156
153
|
function isPairingProtocolRequest(method, urlPath) {
|
|
157
154
|
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
@@ -161,3 +158,19 @@ function isPairingProtocolRequest(method, urlPath) {
|
|
|
161
158
|
return PAIRING_POST.has(pathOnly);
|
|
162
159
|
return false;
|
|
163
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* Redeem a hub unlock ticket for a session token — reachable remotely without
|
|
163
|
+
* a prior session (the ticket IS the proof). Paired agents only.
|
|
164
|
+
*/
|
|
165
|
+
function isUnlockSessionRequest(method, urlPath) {
|
|
166
|
+
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
167
|
+
return method === 'POST' && pathOnly === '/api/agent/session';
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Public unlock probe: `nodeId` + hub HTTPS origin for Continue-with-hub (D821).
|
|
171
|
+
* No secrets, no metrics — only what the locked unlock screen needs.
|
|
172
|
+
*/
|
|
173
|
+
function isUnlockInfoRequest(method, urlPath) {
|
|
174
|
+
const pathOnly = urlPath.split('?')[0] ?? urlPath;
|
|
175
|
+
return method === 'GET' && pathOnly === '/api/agent/unlock-info';
|
|
176
|
+
}
|