@camstack/server 1.2.572 → 1.2.573

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.
@@ -46,6 +46,8 @@ const addon_scan_js_1 = require("./addon-scan.js");
46
46
  const agent_addon_updates_js_1 = require("./agent-addon-updates.js");
47
47
  const agent_hub_registration_js_1 = require("./agent-hub-registration.js");
48
48
  const agent_mesh_join_js_1 = require("./agent-mesh-join.js");
49
+ const agent_pairing_local_js_1 = require("./agent-pairing-local.js");
50
+ const agent_pairing_store_js_1 = require("./agent-pairing-store.js");
49
51
  const agent_config_js_1 = require("./agent-config.js");
50
52
  const agent_mesh_enroll_js_1 = require("./agent-mesh-enroll.js");
51
53
  const agent_group_runner_js_1 = require("./agent-group-runner.js");
@@ -186,6 +188,13 @@ async function startAgent(configPath) {
186
188
  const meshJoin = (0, agent_mesh_join_js_1.createAgentMeshJoin)({
187
189
  nodeId: config.nodeId,
188
190
  secret: () => currentSecret,
191
+ // D857 (transition release): the hub is also accepted by its certificate, verified by this
192
+ // agent's own TLS listener, so a hub that stops answering the join still reaches this agent.
193
+ isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
194
+ isVerifiedPeer: (nodeId) => {
195
+ const tx = (0, system_1.currentBrokerTransporter)(broker).tx;
196
+ return tx instanceof system_1.TlsTcpTransporter && tx.verifiedPeer(nodeId) !== undefined;
197
+ },
189
198
  call: (action, params, opts) => broker.call(action, params, opts),
190
199
  log: (msg) => console.log(`[Agent] ${msg}`),
191
200
  onState: (state) => {
@@ -512,6 +521,11 @@ async function startAgent(configPath) {
512
521
  dataDir: config.dataDir,
513
522
  configPath: config.configPath,
514
523
  reconnect,
524
+ // The hub unpairs this agent (D857): answered first, then erase and reconnect unpaired.
525
+ forgetHub: () => {
526
+ const timer = setTimeout(() => void forgetHubNow('the hub unpaired this agent'), 300);
527
+ timer.unref?.();
528
+ },
515
529
  getHttpsListener: () => agentHttpListener,
516
530
  hostFacts: (0, system_2.systemHostFacts)(),
517
531
  log: {
@@ -897,6 +911,54 @@ async function startAgent(configPath) {
897
911
  }),
898
912
  },
899
913
  };
914
+ // -- Pairing (D857) -------------------------------------------------------------
915
+ // A hub pairs this agent from outside; the agent installs the certificate only after
916
+ // its own operator confirmed the code. Its pairing state is advertised over mDNS.
917
+ const agentVersion = process.env['CAMSTACK_AGENT_APP_VERSION'] ?? agentUpdateService.getRunningRootPackage().version;
918
+ const agentMdns = new system_1.AgentMdnsAdvertiser({
919
+ publisher: (0, system_1.createCiaoPublisher)(),
920
+ logger: consoleLogger,
921
+ });
922
+ const advertise = () => {
923
+ void agentMdns.sync({
924
+ nodeId: config.nodeId,
925
+ version: agentVersion,
926
+ port: config.statusPort ?? 4444,
927
+ state: pairing.state(),
928
+ leafFingerprint: (0, agent_pairing_store_js_1.pairedLeafFingerprint)(config.configPath),
929
+ });
930
+ };
931
+ const localPairing = (0, agent_pairing_local_js_1.createLocalPairingAnnouncer)(config.dataDir, consoleLogger);
932
+ const pairing = new system_1.AgentPairing({
933
+ nodeId: config.nodeId,
934
+ version: agentVersion,
935
+ isPaired: () => (0, agent_pairing_store_js_1.isAgentPaired)(config.configPath),
936
+ install: async (installed) => {
937
+ (0, agent_pairing_store_js_1.persistPairing)(config.configPath, installed);
938
+ hubConnectionStateOverride = null;
939
+ consoleLogger.info('Paired with the hub - reconnecting with the new certificate', {
940
+ meta: { hubUrl: installed.hubUrl, hubAddress: installed.hubAddress },
941
+ });
942
+ // The completion is answered first; the wire is replaced right after.
943
+ const timer = setTimeout(() => void reconnect().then(advertise, advertise), 400);
944
+ timer.unref?.();
945
+ },
946
+ log: (message, meta) => consoleLogger.info(`pairing: ${message}`, meta ? { meta } : undefined),
947
+ onStateChange: advertise,
948
+ // The PIN and the agent's own code are shown on THIS machine only (console, 0600 file).
949
+ onLocalSecret: localPairing.onSecret,
950
+ onLocalSecretCleared: localPairing.onCleared,
951
+ onResetCode: localPairing.onResetCode,
952
+ });
953
+ const forgetHubNow = async (why) => {
954
+ consoleLogger.warn(`Leaving the hub: ${why}. This agent is unpaired and advertises again`);
955
+ (0, agent_pairing_store_js_1.forgetPairing)(config.configPath, config.dataDir);
956
+ pairing.noteUnpaired();
957
+ hubConnectionStateOverride = null;
958
+ await reconnect().catch((err) => consoleLogger.error(`reconnect after unpairing failed: ${(0, types_1.errMsg)(err)}`));
959
+ advertise();
960
+ };
961
+ advertise();
900
962
  void (0, agent_http_js_1.startAgentHttpServer)(getBrokerFn, {
901
963
  log: {
902
964
  info: (message, extras) => consoleLogger.info(message, extras),
@@ -908,6 +970,8 @@ async function startAgent(configPath) {
908
970
  dataDir: config.dataDir,
909
971
  configPath: config.configPath,
910
972
  onReconnect: reconnect,
973
+ pairing,
974
+ onForgetHub: () => forgetHubNow('requested on this agent'),
911
975
  getHubConnectionState,
912
976
  getMeshTransport: () => (currentMeshTls !== null ? 'tls' : 'clear'),
913
977
  updateControls,
@@ -990,6 +1054,7 @@ async function startAgent(configPath) {
990
1054
  }
991
1055
  }
992
1056
  // Stop accepting new HTTP/HTTPS connections before the broker goes away.
1057
+ await agentMdns.stop().catch(() => undefined);
993
1058
  await agentHttpListener?.close().catch(() => undefined);
994
1059
  await broker.stop();
995
1060
  process.exit(0);
@@ -0,0 +1,264 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createAgentPairingRouter = createAgentPairingRouter;
4
+ const node_crypto_1 = require("node:crypto");
5
+ /**
6
+ * Agent pairing — fixed core API for System -> Cluster -> Agents (D857).
7
+ *
8
+ * Hand-written (not a `*.cap.ts`) like `certificates.router.ts`: it joins the
9
+ * mesh CA, the certificate registry, mDNS discovery and the agent's own HTTP
10
+ * pairing surface, none of which is a capability.
11
+ *
12
+ * listDiscoveredAgents agents found on the LAN + addresses typed so far
13
+ * addAgentByAddress probe a typed address (GET .../pairing/hello)
14
+ * beginPairing offer the mesh CA to an agent; both screens now show a code
15
+ * getPairing/listPairings progress (also advances the pairing)
16
+ * confirmPairing the operator typed the code the AGENT shows
17
+ * rejectPairing cancel on both sides
18
+ * unpairAgent tell the agent to forget the hub, revoke its certificates
19
+ *
20
+ * The operator MUST type the code read on the agent's screen into `confirmPairing`:
21
+ * the hub checks it against the code it computed itself. A page that sends back
22
+ * `code` from `getPairing` defeats the check and is a bug.
23
+ */
24
+ const hub_discovery_1 = require("@camstack/develop/hub-discovery");
25
+ const system_1 = require("@camstack/system");
26
+ const server_1 = require("@trpc/server");
27
+ const zod_1 = require("zod");
28
+ const agent_pairing_http_js_1 = require("../../core/agent/agent-pairing-http.js");
29
+ const hub_agent_pairing_js_1 = require("../../core/agent/hub-agent-pairing.js");
30
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
31
+ const HUB_NODE_ID = 'hub';
32
+ const BROWSE_TIMEOUT_MS = 3_000;
33
+ const EndpointSchema = zod_1.z.object({
34
+ host: zod_1.z.string().min(1).max(253),
35
+ port: zod_1.z.number().int().min(1).max(65535).default(4444),
36
+ });
37
+ const PairingViewSchema = zod_1.z.object({
38
+ pairingId: zod_1.z.string(),
39
+ nodeId: zod_1.z.string(),
40
+ endpoint: zod_1.z.object({ host: zod_1.z.string(), port: zod_1.z.number() }),
41
+ /**
42
+ * The six digits the hub computed. For DISPLAY next to the agent's own: the operator
43
+ * confirms by typing the code the AGENT shows, never by echoing this one.
44
+ */
45
+ code: zod_1.z.string(),
46
+ state: zod_1.z.enum(['awaiting-confirmation', 'completing', 'paired', 'rejected', 'expired', 'failed']),
47
+ hubConfirmed: zod_1.z.boolean(),
48
+ agentConfirmed: zod_1.z.boolean(),
49
+ expiresAt: zod_1.z.number(),
50
+ failure: zod_1.z.string().nullable(),
51
+ });
52
+ const DiscoveredAgentSchema = zod_1.z.object({
53
+ nodeId: zod_1.z.string(),
54
+ host: zod_1.z.string(),
55
+ port: zod_1.z.number(),
56
+ version: zod_1.z.string().nullable(),
57
+ state: zod_1.z.enum(['unpaired', 'pending', 'paired']),
58
+ fingerprint: zod_1.z.string().nullable(),
59
+ source: zod_1.z.enum(['mdns', 'address']),
60
+ connected: zod_1.z.boolean(),
61
+ pairing: PairingViewSchema.nullable(),
62
+ });
63
+ const UnpairResultSchema = zod_1.z.object({
64
+ nodeId: zod_1.z.string(),
65
+ /** The agent acknowledged and is erasing its certificate; `false` when it could not be reached. */
66
+ agentNotified: zod_1.z.boolean(),
67
+ revokedSerials: zod_1.z.array(zod_1.z.string()),
68
+ disconnected: zod_1.z.number().int(),
69
+ next: zod_1.z.string(),
70
+ });
71
+ function createAgentPairingRouter(deps) {
72
+ const transport = deps.transport ?? (0, agent_pairing_http_js_1.createHttpAgentPairingTransport)();
73
+ const meshCa = deps.meshCa ??
74
+ (() => {
75
+ const state = (0, system_1.getProcessMeshCaState)();
76
+ return state.state === 'ready' ? state.ca : null;
77
+ });
78
+ const meshRegistry = deps.meshRegistry ?? system_1.getProcessMeshCertificateRegistry;
79
+ const crl = deps.crl ?? system_1.processMeshCrl;
80
+ const peers = deps.peers ?? {
81
+ peerLeaf: (nodeId) => (0, system_1.getProcessPeerLeaf)(nodeId),
82
+ dropPeer: (nodeId) => (0, system_1.dropProcessPeer)(nodeId),
83
+ };
84
+ const isNodeConnected = deps.isNodeConnected ?? system_1.isProcessVerifiedMeshPeer;
85
+ const localNetwork = () => {
86
+ const provider = deps.registry?.getSingleton('local-network');
87
+ if (!provider) {
88
+ throw new Error('The local-network capability is not available yet');
89
+ }
90
+ return provider;
91
+ };
92
+ /** The address an agent dials (its `internal` role) and the URL the hub serves its API on. */
93
+ const hubAddresses = deps.hubAddresses ??
94
+ (async () => {
95
+ const book = await localNetwork().getAddresses({ scheme: 'https' });
96
+ const internal = book.roles.internal.resolved;
97
+ const hubUrl = book.roles.publicEndpoint.resolved ?? internal;
98
+ if (internal === null || hubUrl === null) {
99
+ throw new Error('The hub has no LAN address to give an agent yet - set one on System -> Network');
100
+ }
101
+ return {
102
+ hubUrl,
103
+ hubMeshAddress: `${new URL(internal).hostname}:${system_1.DEFAULT_HUB_TLS_TCP_PORT}`,
104
+ };
105
+ });
106
+ const pairing = new system_1.HubPairing({
107
+ transport,
108
+ ca: meshCa,
109
+ hubNodeId: HUB_NODE_ID,
110
+ hubUrl: async () => (await hubAddresses()).hubUrl,
111
+ hubMeshAddress: async () => (await hubAddresses()).hubMeshAddress,
112
+ isNodeConnected,
113
+ recordIssued: async (nodeId, certPem) => {
114
+ const registry = meshRegistry();
115
+ if (registry === null) {
116
+ throw new Error('the certificate registry is not loaded - the pairing was not completed');
117
+ }
118
+ await registry.recordIssued(nodeId, certPem, 'pairing');
119
+ },
120
+ revokeNode: async (nodeId, reason, keepCertPem) => {
121
+ const registry = meshRegistry();
122
+ if (registry === null)
123
+ return;
124
+ const keep = keepCertPem === undefined ? undefined : new node_crypto_1.X509Certificate(keepCertPem).fingerprint256;
125
+ await registry.revokeAllForNode(nodeId, reason, crl(), keep);
126
+ },
127
+ isLeafPresented: (nodeId, certPem) => {
128
+ const seen = peers.peerLeaf?.(nodeId)?.fingerprint;
129
+ const norm = (f) => f.replaceAll(':', '').toLowerCase();
130
+ return seen !== undefined && norm(seen) === norm(new node_crypto_1.X509Certificate(certPem).fingerprint256);
131
+ },
132
+ revokeLeaf: async (_nodeId, certPem, reason) => {
133
+ const registry = meshRegistry();
134
+ if (registry === null)
135
+ return;
136
+ await registry.revoke(new node_crypto_1.X509Certificate(certPem).fingerprint256, reason, crl());
137
+ },
138
+ // Ids the registry has ever issued for: a node id differing only in case from one of them is refused.
139
+ knownNodeIds: () => meshRegistry()
140
+ ?.list()
141
+ .map((r) => r.nodeId) ?? [],
142
+ log: (message, meta) => {
143
+ const nodeId = typeof meta?.['nodeId'] === 'string' ? meta['nodeId'] : undefined;
144
+ deps.log.info(`pairing: ${message}`, {
145
+ ...(nodeId !== undefined ? { tags: { nodeId } } : {}),
146
+ ...(meta ? { meta } : {}),
147
+ });
148
+ },
149
+ });
150
+ const service = new hub_agent_pairing_js_1.HubAgentPairing({
151
+ pairing,
152
+ transport,
153
+ browse: deps.browse ??
154
+ (() => (0, hub_discovery_1.browseAgentsMdns)({
155
+ timeoutMs: BROWSE_TIMEOUT_MS,
156
+ onProblem: (message) => deps.log.warn(`agent browse problem: ${message}`),
157
+ })),
158
+ isNodeConnected,
159
+ log: deps.log,
160
+ });
161
+ /** A refusal the operator can act on is a PRECONDITION_FAILED with the reason, not a 500. */
162
+ const guard = async (run) => {
163
+ try {
164
+ return await run();
165
+ }
166
+ catch (err) {
167
+ if (err instanceof server_1.TRPCError)
168
+ throw err;
169
+ throw new server_1.TRPCError({
170
+ code: 'PRECONDITION_FAILED',
171
+ message: err instanceof Error ? err.message : String(err),
172
+ });
173
+ }
174
+ };
175
+ return (0, trpc_middleware_js_1.trpcRouter)({
176
+ /** Agents found over mDNS and addresses typed so far, with any pairing in progress. */
177
+ listDiscoveredAgents: trpc_middleware_js_1.adminProcedure
178
+ .input(zod_1.z.object({ refresh: zod_1.z.boolean().optional() }).optional())
179
+ .output(zod_1.z.object({
180
+ agents: zod_1.z.array(DiscoveredAgentSchema),
181
+ scannedAt: zod_1.z.number().nullable(),
182
+ }))
183
+ .query(({ input }) => guard(() => service.listDiscovered({ refresh: input?.refresh === true }))),
184
+ /** Probe an address the operator typed. The agent must be on a private / local address. */
185
+ addAgentByAddress: trpc_middleware_js_1.adminProcedure
186
+ .input(zod_1.z.object({
187
+ host: zod_1.z.string().min(1).max(253),
188
+ port: zod_1.z.number().int().min(1).max(65535).optional(),
189
+ }))
190
+ .output(DiscoveredAgentSchema)
191
+ .mutation(({ input }) => guard(() => service.addByAddress(input.host.trim(), input.port))),
192
+ /** Offer the mesh CA to an agent and derive the code both screens show. */
193
+ beginPairing: trpc_middleware_js_1.adminProcedure
194
+ .input(EndpointSchema)
195
+ .output(PairingViewSchema)
196
+ .mutation(({ input }) => guard(() => service.begin({ host: input.host.trim(), port: input.port }))),
197
+ getPairing: trpc_middleware_js_1.adminProcedure
198
+ .input(zod_1.z.object({ pairingId: zod_1.z.string().min(1) }))
199
+ .output(PairingViewSchema)
200
+ .query(({ input }) => guard(() => service.get(input.pairingId))),
201
+ listPairings: trpc_middleware_js_1.adminProcedure
202
+ .input(zod_1.z.void())
203
+ .output(zod_1.z.array(PairingViewSchema))
204
+ .query(() => service.list().map((p) => p)),
205
+ /** The operator typed the code shown on the AGENT. Wrong three times cancels the pairing. */
206
+ confirmPairing: trpc_middleware_js_1.adminProcedure
207
+ .input(zod_1.z.object({ pairingId: zod_1.z.string().min(1), code: zod_1.z.string().min(1).max(16) }))
208
+ .output(PairingViewSchema)
209
+ .mutation(({ input }) => guard(() => service.confirm(input.pairingId, input.code))),
210
+ rejectPairing: trpc_middleware_js_1.adminProcedure
211
+ .input(zod_1.z.object({ pairingId: zod_1.z.string().min(1) }))
212
+ .output(PairingViewSchema)
213
+ .mutation(({ input }) => guard(() => service.reject(input.pairingId))),
214
+ /**
215
+ * Unpair an agent: it is told to erase its certificate and advertise itself again, and every
216
+ * certificate the hub issued to it goes on the CRL. Revoke last - the agent is told first,
217
+ * because revoking drops its connection. An unreachable agent is still revoked (it can no longer
218
+ * connect); it is then unpaired by hand on its own page.
219
+ */
220
+ unpairAgent: trpc_middleware_js_1.adminProcedure
221
+ .input(zod_1.z.object({ nodeId: zod_1.z.string().min(1) }))
222
+ .output(UnpairResultSchema)
223
+ .mutation(({ input }) => guard(async () => {
224
+ if (input.nodeId === HUB_NODE_ID) {
225
+ throw new Error('The hub is not an agent and cannot be unpaired');
226
+ }
227
+ const registry = meshRegistry();
228
+ if (registry === null) {
229
+ throw new Error('The certificate registry is not loaded - nothing can be revoked yet');
230
+ }
231
+ const tags = { nodeId: input.nodeId };
232
+ let agentNotified = false;
233
+ const client = deps.nodeClient(input.nodeId);
234
+ if (client !== null) {
235
+ try {
236
+ await client.forgetHub();
237
+ agentNotified = true;
238
+ }
239
+ catch (err) {
240
+ deps.log.warn('The agent could not be told to forget the hub - revoking anyway', {
241
+ tags,
242
+ meta: { error: err instanceof Error ? err.message : String(err) },
243
+ });
244
+ }
245
+ }
246
+ const revoked = await (0, system_1.revokeMeshCertificate)({
247
+ nodeId: input.nodeId,
248
+ registry,
249
+ crl: crl(),
250
+ peers,
251
+ log: deps.log,
252
+ });
253
+ return {
254
+ nodeId: input.nodeId,
255
+ agentNotified,
256
+ revokedSerials: [...revoked.revokedSerials],
257
+ disconnected: revoked.disconnected,
258
+ next: agentNotified
259
+ ? 'The agent erased its certificate and advertises itself as unpaired. Pair it again from this page, or forget the node.'
260
+ : 'The agent could not be reached: its certificate is revoked, but it still holds it until it is unpaired on its own page. Pair it again afterwards.',
261
+ };
262
+ })),
263
+ });
264
+ }
@@ -55,8 +55,8 @@ const MeshLeafRowSchema = zod_1.z.object({
55
55
  notBefore: zod_1.z.number(),
56
56
  notAfter: zod_1.z.number(),
57
57
  issuedAt: zod_1.z.number(),
58
- /** `enroll` | `renewal` | `boot` were issued by the hub; `observed` was read off a live connection. */
59
- issuedVia: zod_1.z.enum(['enroll', 'renewal', 'boot', 'observed']),
58
+ /** `enroll` | `pairing` | `renewal` | `boot` were issued by the hub; `observed` was read off a live connection. */
59
+ issuedVia: zod_1.z.enum(['enroll', 'pairing', 'renewal', 'boot', 'observed']),
60
60
  status: zod_1.z.enum(['valid', 'expiring', 'expired', 'revoked']),
61
61
  revokedAt: zod_1.z.number().nullable(),
62
62
  revokedReason: zod_1.z.string().nullable(),
@@ -6204,6 +6204,15 @@ function createCapRouter_nodeCertificates(getProvider, createRemoteProxy) {
6204
6204
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6205
6205
  return p.prepareHttpsCertificate(methodInput);
6206
6206
  }),
6207
+ forgetHub: trpc_middleware_js_1.adminProcedure
6208
+ .input(types_88.nodeCertificatesCapability.methods.forgetHub.input.loose())
6209
+ .output(types_88.nodeCertificatesCapability.methods.forgetHub.output)
6210
+ .mutation(async ({ input, ctx }) => {
6211
+ const { nodeId, ...methodInput } = input;
6212
+ const p = resolveProvider('node-certificates', nodeId, () => getProvider(ctx), createRemoteProxy);
6213
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6214
+ return p.forgetHub(methodInput);
6215
+ }),
6207
6216
  installHttpsCertificate: trpc_middleware_js_1.adminProcedure
6208
6217
  .input(types_88.nodeCertificatesCapability.methods.installHttpsCertificate.input.loose())
6209
6218
  .output(types_88.nodeCertificatesCapability.methods.installHttpsCertificate.output)
@@ -41,6 +41,7 @@ exports.wrapWebrtcSessionProviderWithRelay = wrapWebrtcSessionProviderWithRelay;
41
41
  exports.buildAppRouter = buildAppRouter;
42
42
  const path = __importStar(require("node:path"));
43
43
  const addon_settings_router_js_1 = require("../core/addon-settings.router.js");
44
+ const agent_pairing_router_js_1 = require("../core/agent-pairing.router.js");
44
45
  const certificates_router_js_1 = require("../core/certificates.router.js");
45
46
  const node_certificates_client_js_1 = require("../../core/agent/node-certificates-client.js");
46
47
  const auth_router_js_1 = require("../core/auth.router.js");
@@ -314,6 +315,12 @@ function buildCapabilityRouters(services) {
314
315
  hubDataDir: path.resolve(services.configService.get('server.dataPath') ?? 'camstack-data'),
315
316
  log: services.loggingService.createLogger('certificates'),
316
317
  }),
318
+ // agentPairing - hub-initiated pairing of agents, unpairing (D857).
319
+ agentPairing: (0, agent_pairing_router_js_1.createAgentPairingRouter)({
320
+ registry: services.capabilityRegistry,
321
+ nodeClient: (nodeId) => (0, node_certificates_client_js_1.createNodeCertificatesClient)(services.moleculer, nodeId),
322
+ log: services.loggingService.createLogger('agent-pairing'),
323
+ }),
317
324
  auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService, services.handoffCodeService, services.externalLinkIntents, services.nativeLink ?? null, {
318
325
  ...(services.authNetworkPolicy !== undefined
319
326
  ? { network: services.authNetworkPolicy }
@@ -0,0 +1,150 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.assertPairableHost = assertPairableHost;
4
+ exports.createHttpAgentPairingTransport = createHttpAgentPairingTransport;
5
+ /**
6
+ * The hub's HTTP client for an agent's pairing endpoints (D857).
7
+ *
8
+ * An unpaired agent speaks plain HTTP on its API port (it has no certificate to
9
+ * speak TLS with); a paired one answers `hello` the same way. Everything the agent
10
+ * says is untrusted data: every answer is read through a size cap and a Zod
11
+ * schema, a redirect is an error, and a refusal keeps the agent's own code so the
12
+ * hub can tell "awaiting local confirmation" from a failure.
13
+ *
14
+ * The target is checked before anything is sent: a pairing talks to a machine on
15
+ * the operator's own network, never to a public address (the hub must not be a
16
+ * way to make requests to arbitrary hosts).
17
+ */
18
+ const node_net_1 = require("node:net");
19
+ const system_1 = require("@camstack/system");
20
+ const zod_1 = require("zod");
21
+ const REQUEST_TIMEOUT_MS = 8_000;
22
+ const MAX_RESPONSE_BYTES = 128 * 1024;
23
+ const HelloSchema = zod_1.z.object({
24
+ protocol: zod_1.z.number(),
25
+ nodeId: zod_1.z.string().min(1).max(128),
26
+ version: zod_1.z.string().nullable(),
27
+ state: zod_1.z.enum(['unpaired', 'pending', 'paired']),
28
+ pendingExpiresAt: zod_1.z.number().nullable(),
29
+ });
30
+ const OfferAnswerSchema = zod_1.z.object({
31
+ ok: zod_1.z.literal(true),
32
+ answer: zod_1.z.object({
33
+ pairingId: zod_1.z.string().min(1).max(128),
34
+ csrPem: zod_1.z.string().min(1).max(16_384),
35
+ agentNonce: zod_1.z.string().min(1).max(256),
36
+ expiresAt: zod_1.z.number(),
37
+ }),
38
+ });
39
+ const PollSchema = zod_1.z.object({
40
+ ok: zod_1.z.literal(true),
41
+ poll: zod_1.z.object({
42
+ state: zod_1.z.enum(['pending', 'idle']),
43
+ localConfirmed: zod_1.z.boolean(),
44
+ last: zod_1.z
45
+ .object({
46
+ pairingId: zod_1.z.string(),
47
+ outcome: zod_1.z.enum(['completed', 'rejected', 'expired', 'code-mismatch', 'failed']),
48
+ })
49
+ .nullable(),
50
+ }),
51
+ });
52
+ const FailureSchema = zod_1.z.object({
53
+ error: zod_1.z.string().optional(),
54
+ message: zod_1.z.string().optional(),
55
+ });
56
+ /** A pairing target must be on the operator's own network. Throws with the reason. */
57
+ function assertPairableHost(host) {
58
+ const bare = host.replace(/^\[|\]$/g, '');
59
+ const isLoopback = bare === 'localhost' || bare === '::1' || bare.startsWith('127.');
60
+ if (isLoopback)
61
+ return;
62
+ if ((0, node_net_1.isIP)(bare) !== 0) {
63
+ if ((0, system_1.isPrivateAddress)(bare))
64
+ return;
65
+ throw new Error(`"${host}" is not a private network address - an agent is paired from the LAN`);
66
+ }
67
+ if ((0, system_1.isLocalOnlyHostname)(bare.toLowerCase()))
68
+ return;
69
+ throw new Error(`"${host}" is not a local name - use the agent's LAN address or a .local / .lan host name`);
70
+ }
71
+ function baseUrl(endpoint) {
72
+ const host = (0, node_net_1.isIP)(endpoint.host) === 6 ? `[${endpoint.host}]` : endpoint.host;
73
+ return `http://${host}:${endpoint.port}`;
74
+ }
75
+ async function readCapped(response) {
76
+ const reader = response.body?.getReader();
77
+ if (reader === undefined)
78
+ return '';
79
+ const chunks = [];
80
+ let total = 0;
81
+ for (;;) {
82
+ const { done, value } = await reader.read();
83
+ if (done)
84
+ break;
85
+ total += value.byteLength;
86
+ if (total > MAX_RESPONSE_BYTES) {
87
+ await reader.cancel();
88
+ throw new Error('the agent answered with more than 128 KiB');
89
+ }
90
+ chunks.push(value);
91
+ }
92
+ return Buffer.concat(chunks).toString('utf8');
93
+ }
94
+ function createHttpAgentPairingTransport(options = {}) {
95
+ const fetchFn = options.fetchFn ?? fetch;
96
+ const timeoutMs = options.timeoutMs ?? REQUEST_TIMEOUT_MS;
97
+ async function call(endpoint, method, path, schema, body) {
98
+ assertPairableHost(endpoint.host);
99
+ let response;
100
+ try {
101
+ response = await fetchFn(`${baseUrl(endpoint)}${path}`, {
102
+ method,
103
+ redirect: 'error',
104
+ signal: AbortSignal.timeout(timeoutMs),
105
+ ...(body === undefined
106
+ ? {}
107
+ : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
108
+ });
109
+ }
110
+ catch (err) {
111
+ throw new Error(`could not reach the agent at ${endpoint.host}:${endpoint.port}: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
112
+ }
113
+ const text = await readCapped(response);
114
+ let json;
115
+ try {
116
+ json = text.length === 0 ? null : JSON.parse(text);
117
+ }
118
+ catch {
119
+ json = null;
120
+ }
121
+ if (!response.ok) {
122
+ const failure = FailureSchema.safeParse(json);
123
+ const code = failure.success ? (failure.data.error ?? 'refused') : 'refused';
124
+ const message = failure.success
125
+ ? (failure.data.message ?? failure.data.error ?? `HTTP ${response.status}`)
126
+ : `HTTP ${response.status}`;
127
+ throw new system_1.PairingHttpError(response.status, code, message);
128
+ }
129
+ const parsed = schema.safeParse(json);
130
+ if (!parsed.success) {
131
+ throw new Error(`the agent at ${endpoint.host}:${endpoint.port} answered with an unexpected shape`);
132
+ }
133
+ return parsed.data;
134
+ }
135
+ const Ok = zod_1.z.object({ ok: zod_1.z.literal(true) });
136
+ return {
137
+ hello: (endpoint) => call(endpoint, 'GET', system_1.PAIRING_PATHS.hello, HelloSchema),
138
+ offer: async (endpoint, body) => (await call(endpoint, 'POST', system_1.PAIRING_PATHS.offer, OfferAnswerSchema, body)).answer,
139
+ reveal: async (endpoint, body) => {
140
+ await call(endpoint, 'POST', system_1.PAIRING_PATHS.reveal, Ok, body);
141
+ },
142
+ poll: async (endpoint, pairingId) => (await call(endpoint, 'POST', system_1.PAIRING_PATHS.poll, PollSchema, { pairingId })).poll,
143
+ complete: async (endpoint, body) => {
144
+ await call(endpoint, 'POST', system_1.PAIRING_PATHS.complete, Ok, body);
145
+ },
146
+ reject: async (endpoint, pairingId) => {
147
+ await call(endpoint, 'POST', system_1.PAIRING_PATHS.cancel, Ok, { pairingId });
148
+ },
149
+ };
150
+ }