@camstack/server 1.2.578 → 1.2.579

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.
@@ -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 cluster secret.
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). The cluster secret is NOT one of them (D797): it lives only in
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
- * The surface was fully unauthenticated on 0.0.0.0 — including
7
- * `POST /api/agent/config`, which can rewrite `hubAddress` and the
8
- * cluster secret itself. Model:
9
- * - loopback callers (the Electron renderer, in-container probes) are
10
- * always allowed — the desktop app keeps working with zero changes,
11
- * - remote callers must present `Authorization: Bearer <cluster secret>`
12
- * OR a short-lived unlock session token minted after a hub admin
13
- * unlock ticket (D821) — the cluster secret stays a mesh / pairing
14
- * credential, not a daily paste into the browser,
15
- * - an UNPAIRED agent (no secret configured yet) can be set up remotely
16
- * through the pairing surface only; everything else stays denied.
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.isPairingRequest = isPairingRequest;
26
+ exports.isPairingProtocolRequest = isPairingProtocolRequest;
26
27
  exports.isUnlockSessionRequest = isUnlockSessionRequest;
27
28
  exports.isUnlockInfoRequest = isUnlockInfoRequest;
28
- exports.isPairingProtocolRequest = isPairingProtocolRequest;
29
- const node_crypto_1 = require("node:crypto");
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. `clusterSecret`
83
- * is the CURRENTLY configured secret (`null`/empty = unpaired agent — no
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, clusterSecret, sessionTokenOk = () => false) {
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): reachable from the network without a session, each
153
- * authorized by what it carries (a pairing id the agent made up, a proof the mesh CA signed).
154
- * `forget` is NOT among them - leaving a hub is an authenticated action.
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
+ }