agent-comms 8.15.9 → 8.15.10
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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/dist/bridges/user/web/server.d.ts.map +1 -1
- package/dist/bridges/user/web/server.js +44 -24
- package/dist/bridges/user/web/server.js.map +1 -1
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
- package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
- package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
- package/dist/bridges/user/web/web-assets.d.ts +28 -0
- package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
- package/dist/bridges/user/web/web-assets.js +67 -0
- package/dist/bridges/user/web/web-assets.js.map +1 -0
- package/package.json +8 -2
- package/src/account-cli.ts +195 -0
- package/src/account-join-cli.ts +191 -0
- package/src/bridges/cc-peer/approval-commands.ts +82 -0
- package/src/bridges/cc-peer/bridge.ts +138 -0
- package/src/bridges/cc-peer/default-front.ts +58 -0
- package/src/bridges/cc-peer/front-controller.ts +160 -0
- package/src/bridges/cc-peer/front-relay.ts +191 -0
- package/src/bridges/cc-peer/front-runtime.ts +167 -0
- package/src/bridges/cc-peer/front.ts +59 -0
- package/src/bridges/cc-peer/reply-aliases.ts +90 -0
- package/src/bridges/cc-peer/run.ts +100 -0
- package/src/bridges/cc-peer/shared-peer.ts +28 -0
- package/src/bridges/claude-code/channel.ts +318 -0
- package/src/bridges/codex/tool.ts +132 -0
- package/src/bridges/mcp/server.ts +137 -0
- package/src/bridges/opencode/plugin.ts +130 -0
- package/src/bridges/pi/extension.ts +330 -0
- package/src/bridges/registry.ts +17 -0
- package/src/bridges/user/cli.ts +87 -0
- package/src/bridges/user/controller.ts +345 -0
- package/src/bridges/user/tui.ts +300 -0
- package/src/bridges/user/web/actions.ts +173 -0
- package/src/bridges/user/web/contract.ts +222 -0
- package/src/bridges/user/web/dist/bundle.js +49 -0
- package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
- package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
- package/src/bridges/user/web/dist/index.html +21 -0
- package/src/bridges/user/web/dist/manifest.json +26 -0
- package/src/bridges/user/web/dist/mesh-worker.js +64 -0
- package/src/bridges/user/web/dist/sw.js +1 -0
- package/src/bridges/user/web/event-publisher.ts +114 -0
- package/src/bridges/user/web/port-discovery.ts +49 -0
- package/src/bridges/user/web/reads.ts +35 -0
- package/src/bridges/user/web/router.ts +158 -0
- package/src/bridges/user/web/server.ts +782 -0
- package/src/bridges/user/web/vite.config.ts +71 -0
- package/src/bridges/user/web/web-access.ts +404 -0
- package/src/bridges/user/web/web-assets.ts +90 -0
- package/src/bridges/user/web/web-beacon.ts +69 -0
- package/src/bridges/user/web/web-console-static.ts +90 -0
- package/src/cli-main.ts +635 -0
- package/src/cli.ts +13 -0
- package/src/core/account-bundle.ts +264 -0
- package/src/core/account-join.ts +289 -0
- package/src/core/account-ledger-crypto.ts +154 -0
- package/src/core/account-ledger-store.ts +66 -0
- package/src/core/account-ledger.ts +385 -0
- package/src/core/agent-details.ts +216 -0
- package/src/core/agent-list-format.ts +97 -0
- package/src/core/agent-registry.ts +282 -0
- package/src/core/agent-table.ts +58 -0
- package/src/core/atomic-file.ts +46 -0
- package/src/core/bind-retry.ts +38 -0
- package/src/core/bridge-mesh.ts +176 -0
- package/src/core/bridge.ts +718 -0
- package/src/core/capability-ask.ts +162 -0
- package/src/core/capability-bubble-up.ts +237 -0
- package/src/core/comms-store.ts +130 -0
- package/src/core/connection-approval.ts +134 -0
- package/src/core/connection-code-tool.ts +92 -0
- package/src/core/connection-code.ts +296 -0
- package/src/core/coordinator-role.ts +224 -0
- package/src/core/data-directory.ts +32 -0
- package/src/core/data-frame-routing.ts +309 -0
- package/src/core/default-command.ts +12 -0
- package/src/core/delegation-policy.ts +41 -0
- package/src/core/delivery-engine.ts +649 -0
- package/src/core/device-membership-verification.ts +125 -0
- package/src/core/device-membership.ts +130 -0
- package/src/core/directory-admission.ts +155 -0
- package/src/core/discovery-mdns.ts +247 -0
- package/src/core/discovery-tailscale.ts +224 -0
- package/src/core/discovery.ts +303 -0
- package/src/core/display-name.ts +73 -0
- package/src/core/dm-grant-actions.ts +72 -0
- package/src/core/dm-send-delegation.ts +73 -0
- package/src/core/dm-token-verification.ts +76 -0
- package/src/core/election-sessions.ts +135 -0
- package/src/core/error-reporting.ts +85 -0
- package/src/core/first-contact.ts +242 -0
- package/src/core/gateway-trust-actions.ts +184 -0
- package/src/core/gateway-trust.ts +305 -0
- package/src/core/gossip-directory.ts +276 -0
- package/src/core/gossip-extensions.ts +123 -0
- package/src/core/group-proofs.ts +205 -0
- package/src/core/handshake.ts +354 -0
- package/src/core/hub-forwarding.ts +24 -0
- package/src/core/hub-link.ts +176 -0
- package/src/core/hub-session.ts +374 -0
- package/src/core/identity-store.ts +715 -0
- package/src/core/identity.ts +489 -0
- package/src/core/index.ts +65 -0
- package/src/core/issuer-grouping.ts +165 -0
- package/src/core/issuer-identity-file.ts +187 -0
- package/src/core/lan-web-actions.ts +34 -0
- package/src/core/lan-web-uis.ts +55 -0
- package/src/core/ledger-lock.ts +81 -0
- package/src/core/listener-registry.ts +133 -0
- package/src/core/loopback-address.ts +22 -0
- package/src/core/machine-identity.ts +77 -0
- package/src/core/machine-list-groups.ts +53 -0
- package/src/core/mcp-registry-retry.ts +69 -0
- package/src/core/mcp-registry-version.ts +18 -0
- package/src/core/membership-proof.ts +139 -0
- package/src/core/membership-proofs.ts +149 -0
- package/src/core/mesh-graph-trace-tool.ts +81 -0
- package/src/core/mesh-graph.ts +89 -0
- package/src/core/mesh-network-actions.ts +183 -0
- package/src/core/mesh-store-options.ts +23 -0
- package/src/core/mesh-store-shared.ts +134 -0
- package/src/core/mesh-store.ts +1114 -0
- package/src/core/name-claim.ts +145 -0
- package/src/core/naming-actions.ts +90 -0
- package/src/core/naming.ts +138 -0
- package/src/core/nanoid.ts +20 -0
- package/src/core/network-interfaces.ts +24 -0
- package/src/core/package-version.ts +34 -0
- package/src/core/peer-lifecycle.ts +377 -0
- package/src/core/peer-link-error.ts +41 -0
- package/src/core/peer-versions.ts +98 -0
- package/src/core/pending-connection.ts +53 -0
- package/src/core/petnames.ts +121 -0
- package/src/core/pgp-keyserver.ts +32 -0
- package/src/core/pid-lock-file.ts +122 -0
- package/src/core/push-manager.ts +76 -0
- package/src/core/random-id.ts +9 -0
- package/src/core/receiver-set.ts +42 -0
- package/src/core/relay-role.ts +182 -0
- package/src/core/relay-selection.ts +120 -0
- package/src/core/relay-server.ts +198 -0
- package/src/core/relay-uplink.ts +78 -0
- package/src/core/request-timeouts.ts +37 -0
- package/src/core/room-discovery.ts +48 -0
- package/src/core/room-lifecycle.ts +838 -0
- package/src/core/room-lookup.ts +41 -0
- package/src/core/room-messaging.ts +247 -0
- package/src/core/room-notice-log.ts +61 -0
- package/src/core/room-path.ts +84 -0
- package/src/core/room-protocol.ts +893 -0
- package/src/core/room-router.ts +180 -0
- package/src/core/room-token-verification.ts +85 -0
- package/src/core/room-wire-extensions.ts +60 -0
- package/src/core/self-name-claim.ts +67 -0
- package/src/core/send-outcome.ts +69 -0
- package/src/core/shutdown-signals.ts +57 -0
- package/src/core/stale-agent-checker.ts +108 -0
- package/src/core/store.ts +704 -0
- package/src/core/token-text.ts +40 -0
- package/src/core/tool.ts +853 -0
- package/src/core/transport-events.ts +73 -0
- package/src/core/transport-queries.ts +54 -0
- package/src/core/transport.ts +367 -0
- package/src/core/types.ts +550 -0
- package/src/core/user-identity.ts +290 -0
- package/src/core/vapid.ts +81 -0
- package/src/core/version-check.ts +147 -0
- package/src/core/version-report-actions.ts +79 -0
- package/src/core/web-beacon.ts +31 -0
- package/src/core/web-push.ts +322 -0
- package/src/core/wire-mesh-core-version.ts +25 -0
- package/src/core/wire-mesh-identity.ts +36 -0
- package/src/core/wire-mesh-transport-options.ts +82 -0
- package/src/core/wire-mesh-transport.ts +1007 -0
- package/src/core/wire-protocol.ts +153 -0
- package/src/core/ws-dial.ts +191 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device group membership (agent-comms#161, widened to machines by agent-comms#343): the token a bridge device holds proving a grouping issuer admitted it, either its user principal (core/user-identity.ts, "this device speaks for me") or its machine (core/machine-identity.ts, "this device runs on me"). Layered on verifyCapabilityToken exactly the way room-token-verification.ts layers core/room's own obligations on top of it: a token bearing the right capability, scoped to the right group, whose delegation chain actually roots at the claimed issuer, never merely one that happens to carry a superficially matching scope.path. Unlike a room, a group:member token has only one acceptable chain root ever, the group's issuer itself; there is no room-style "owner-named vs DM" distinction to branch on.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
verifyCapabilityToken,
|
|
7
|
+
type TokenVerdictReason,
|
|
8
|
+
type VerifyCapabilityTokenOptions,
|
|
9
|
+
} from "wire-mesh-core/domain/tokens";
|
|
10
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
11
|
+
import type {
|
|
12
|
+
CapabilityToken,
|
|
13
|
+
DeviceId,
|
|
14
|
+
TokenClaims,
|
|
15
|
+
} from "wire-mesh-core/generated/protocol";
|
|
16
|
+
|
|
17
|
+
/** The one capability a device presents to prove a grouping issuer admitted it, mirroring room:member's own "one resource, one verb" shape, scoped to tokens.cddl's `group` kind (a person's, team's, or organisation's own set of owned devices) rather than `room`. Registered at wire-mesh's spec/registry/core-capabilities.md as `group:member`, scope kind `group`, domain `core/management`. */
|
|
18
|
+
export const DEVICE_MEMBER_CAPABILITY = "group:member";
|
|
19
|
+
|
|
20
|
+
/** A grouping issuer's own group scope path: every group:member token's scope.path, and the group every one of that issuer's admission grants roots at. The issuer is a user principal (user-identity.ts) or a machine (machine-identity.ts, agent-comms#343); either way the path is just the issuer's own device-id in hex, since tokens.cddl's `group` scope kind has no identifier of its own separate from the device-id of the key the group is rooted at. */
|
|
21
|
+
export function groupPath(issuerDeviceId: Readonly<DeviceId>): string {
|
|
22
|
+
return deviceIdToHex(issuerDeviceId);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type DeviceMembershipVerdictReason =
|
|
26
|
+
| TokenVerdictReason
|
|
27
|
+
| "wrong_capability"
|
|
28
|
+
| "wrong_scope_kind"
|
|
29
|
+
| "wrong_scope_path"
|
|
30
|
+
| "wrong_chain_root";
|
|
31
|
+
|
|
32
|
+
export type DeviceMembershipVerdict =
|
|
33
|
+
| { ok: true; claims: TokenClaims; depth: number }
|
|
34
|
+
| { ok: false; reason: DeviceMembershipVerdictReason };
|
|
35
|
+
|
|
36
|
+
/** What identifyGroupIssuer reports: the verdict of verifyDeviceMembership, plus the issuer (hex) the token turned out to be rooted at. */
|
|
37
|
+
export type GroupIssuerVerdict =
|
|
38
|
+
| { ok: true; issuerHex: string; claims: TokenClaims; depth: number }
|
|
39
|
+
| { ok: false; reason: DeviceMembershipVerdictReason };
|
|
40
|
+
|
|
41
|
+
export interface VerifyGroupTokenOptions extends Omit<
|
|
42
|
+
VerifyCapabilityTokenOptions,
|
|
43
|
+
"expectedBearer"
|
|
44
|
+
> {
|
|
45
|
+
/** The device actually authenticated on the arriving connection (or otherwise known out of band), never a relay-asserted or gossip-derived value, whenever the result is used to authorise something. Mandatory here, unlike verifyCapabilityToken's own optional field for a caller presenting a token to authorise itself. The callers that pass a gossip-derived id (directory-admission.ts and the machine grouping in list_agents, for a proof read off a gossiped advert) deliberately treat the result as a claim about that id and nothing more. */
|
|
46
|
+
expectedBearer: DeviceId;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VerifyDeviceMembershipOptions extends VerifyGroupTokenOptions {
|
|
50
|
+
/** The grouping issuer (a user principal or a machine) this membership is claimed to belong to. Determines both the expected scope.path (groupPath) and the delegation chain's only acceptable root: a group:member token minted by anyone other than this issuer must never verify, regardless of what its scope claims. */
|
|
51
|
+
groupIssuerId: DeviceId;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type GroupTokenVerdict =
|
|
55
|
+
| { ok: true; claims: TokenClaims; depth: number; rootHex: string }
|
|
56
|
+
| { ok: false; reason: DeviceMembershipVerdictReason };
|
|
57
|
+
|
|
58
|
+
/** The checks every group:member token passes whoever its issuer is: verifyCapabilityToken's own obligations (signature, expiry, revocation, bearer match), the group:member capability (a token minted for some other capability that happens to be scoped to a group path must not pass just because scope.kind and path line up), and a group scope. */
|
|
59
|
+
async function verifyGroupToken(
|
|
60
|
+
token: CapabilityToken,
|
|
61
|
+
options: Readonly<VerifyGroupTokenOptions>,
|
|
62
|
+
): Promise<GroupTokenVerdict> {
|
|
63
|
+
const verdict = await verifyCapabilityToken(token, {
|
|
64
|
+
identity: options.identity,
|
|
65
|
+
clock: options.clock,
|
|
66
|
+
revocation: options.revocation,
|
|
67
|
+
expectedBearer: options.expectedBearer,
|
|
68
|
+
...(options.extraPredicateResolvers !== undefined
|
|
69
|
+
? { extraPredicateResolvers: options.extraPredicateResolvers }
|
|
70
|
+
: {}),
|
|
71
|
+
});
|
|
72
|
+
if (!verdict.ok) {
|
|
73
|
+
return verdict;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (verdict.claims.capability !== DEVICE_MEMBER_CAPABILITY) {
|
|
77
|
+
return { ok: false, reason: "wrong_capability" };
|
|
78
|
+
}
|
|
79
|
+
if (verdict.claims.scope.kind !== "group") {
|
|
80
|
+
return { ok: false, reason: "wrong_scope_kind" };
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
ok: true,
|
|
84
|
+
claims: verdict.claims,
|
|
85
|
+
depth: verdict.depth,
|
|
86
|
+
rootHex: deviceIdToHex(verdict.rootIssuer),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Verifies a `group:member` capability token against a known issuer: verifyGroupToken's checks, then that its scope is the claimed issuer's own group and its delegation chain roots at that issuer's own device-id, never at the bearer itself or any other device, since only the issuer may admit a device to its own group.
|
|
92
|
+
*/
|
|
93
|
+
export async function verifyDeviceMembership(
|
|
94
|
+
token: CapabilityToken,
|
|
95
|
+
options: Readonly<VerifyDeviceMembershipOptions>,
|
|
96
|
+
): Promise<DeviceMembershipVerdict> {
|
|
97
|
+
const verdict = await verifyGroupToken(token, options);
|
|
98
|
+
if (!verdict.ok) return verdict;
|
|
99
|
+
const expectedGroupPath = groupPath(options.groupIssuerId);
|
|
100
|
+
if (verdict.claims.scope.path !== expectedGroupPath) {
|
|
101
|
+
return { ok: false, reason: "wrong_scope_path" };
|
|
102
|
+
}
|
|
103
|
+
if (verdict.rootHex !== expectedGroupPath) {
|
|
104
|
+
return { ok: false, reason: "wrong_chain_root" };
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, claims: verdict.claims, depth: verdict.depth };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Verifies a `group:member` token without knowing its issuer beforehand, and reports which issuer it is rooted at: verifyGroupToken's checks, then that the group it names is the one its chain roots at. Used to group devices by the machine that vouches for them, where the machine is learned from the proof rather than looked up. Anything it reports is still only what that issuer claims about the bearer. */
|
|
110
|
+
export async function identifyGroupIssuer(
|
|
111
|
+
token: CapabilityToken,
|
|
112
|
+
options: Readonly<VerifyGroupTokenOptions>,
|
|
113
|
+
): Promise<GroupIssuerVerdict> {
|
|
114
|
+
const verdict = await verifyGroupToken(token, options);
|
|
115
|
+
if (!verdict.ok) return verdict;
|
|
116
|
+
if (verdict.claims.scope.path !== verdict.rootHex) {
|
|
117
|
+
return { ok: false, reason: "wrong_chain_root" };
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
issuerHex: verdict.rootHex,
|
|
122
|
+
claims: verdict.claims,
|
|
123
|
+
depth: verdict.depth,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device-to-user membership admission and removal (agent-comms#161): mints and revokes a device's own group:member grant from the user principal, the same "ordinary mint and revoke" mechanism room-lifecycle.ts's own kickFromRoom already uses for room-membership exclusion, rooted at the principal (core/user-identity.ts) rather than a room owner. Orchestrates mintCapabilityToken/mintRevocationEntry together with the principal's own issued-grant bookkeeping (user-identity.ts) so a later removal can find the right token-id to revoke -- the same tight coupling room-lifecycle.ts's own admission methods already have with identity-store.ts's issuedGrants, just here as plain functions rather than class methods, since there is no MeshStore-equivalent orchestrator for device membership yet.
|
|
3
|
+
*
|
|
4
|
+
* Deliberately does not touch the admitted device's own identity-store.ts slot (its saveGroupToken/loadGroupTokens/deleteGroupToken): whether an admission happens entirely locally (the same process holding both the principal's key and the device's own slot) or needs to reach a different bridge process is a routing question this module makes no assumption about, so persisting the granted token into the recipient's own slot -- and broadcasting a removal's revocation entry to the mesh -- is left to the caller, which has that context.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
mintCapabilityToken,
|
|
9
|
+
mintRevocationEntry,
|
|
10
|
+
type MintVerdict,
|
|
11
|
+
} from "wire-mesh-core/domain/tokens";
|
|
12
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
13
|
+
import type { RevocationView } from "wire-mesh-core/domain/revocation-view";
|
|
14
|
+
import type { IdentityPort } from "wire-mesh-core/ports/identity";
|
|
15
|
+
import type { Clock } from "wire-mesh-core/ports/clock";
|
|
16
|
+
import type {
|
|
17
|
+
DeviceId,
|
|
18
|
+
RevocationEntry,
|
|
19
|
+
} from "wire-mesh-core/generated/protocol";
|
|
20
|
+
import {
|
|
21
|
+
DEVICE_MEMBER_CAPABILITY,
|
|
22
|
+
groupPath,
|
|
23
|
+
} from "./device-membership-verification.js";
|
|
24
|
+
import type { AccountLedger } from "./account-ledger.js";
|
|
25
|
+
|
|
26
|
+
/** delegationsRemaining for every device-membership grant -- deliberately non-delegable: a device that could re-mint membership for another device would let any admitted device silently admit others, defeating the point of the principal being the one place admission is decided (the same reasoning room-lifecycle.ts's own mintOwnerRootGrant already documents for a room owner's self-grant). */
|
|
27
|
+
const NOT_DELEGABLE = 0;
|
|
28
|
+
|
|
29
|
+
export interface AdmitDeviceOptions {
|
|
30
|
+
/** The user principal's own identity -- signs the membership grant. */
|
|
31
|
+
userIdentity: IdentityPort;
|
|
32
|
+
/** The account's replicated grant ledger, which records the admission so any machine holding the account key can revoke it. */
|
|
33
|
+
accountLedger: AccountLedger;
|
|
34
|
+
clock: Clock;
|
|
35
|
+
tokenId: Uint8Array<ArrayBuffer>;
|
|
36
|
+
/** The device being admitted. */
|
|
37
|
+
deviceId: DeviceId;
|
|
38
|
+
expires: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MintGroupMembershipOptions {
|
|
42
|
+
/** The grouping issuer signing the grant: a user principal (user-identity.ts) or a machine (machine-identity.ts). The grant is scoped to this issuer's own group (groupPath). */
|
|
43
|
+
issuer: IdentityPort;
|
|
44
|
+
clock: Clock;
|
|
45
|
+
tokenId: Uint8Array<ArrayBuffer>;
|
|
46
|
+
/** The device being admitted: the grant's bearer. */
|
|
47
|
+
deviceId: DeviceId;
|
|
48
|
+
expires: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The group:member token itself, with no record kept of having issued it: a root-level, non-delegable grant bearing options.deviceId and scoped to the issuer's own group (groupPath). admitDevice adds the issued-grant bookkeeping a later removeDevice needs; a caller that only needs a short-lived proof (membership-proof.ts) uses this directly, so the shared identity file is not rewritten every time one of its many bridges refreshes its proof. */
|
|
52
|
+
export async function mintGroupMembership(
|
|
53
|
+
options: Readonly<MintGroupMembershipOptions>,
|
|
54
|
+
): Promise<MintVerdict> {
|
|
55
|
+
return mintCapabilityToken({
|
|
56
|
+
identity: options.issuer,
|
|
57
|
+
clock: options.clock,
|
|
58
|
+
tokenId: options.tokenId,
|
|
59
|
+
bearer: options.deviceId,
|
|
60
|
+
capability: DEVICE_MEMBER_CAPABILITY,
|
|
61
|
+
scope: {
|
|
62
|
+
kind: "group",
|
|
63
|
+
path: groupPath(options.issuer.deviceId),
|
|
64
|
+
},
|
|
65
|
+
expires: options.expires,
|
|
66
|
+
delegationsRemaining: NOT_DELEGABLE,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Mints deviceId's own group:member grant from the user principal: a root-level, non-delegable token scoped to the principal's own group (groupPath). On success, records the grant's token-id in the account's replicated ledger so a later removeDevice, on this machine or any other holding the account key, can find it to revoke. An admission without that record would leave removal permanently unable to find what to revoke, so it is what makes revocation possible at all.
|
|
72
|
+
*/
|
|
73
|
+
export async function admitDevice(
|
|
74
|
+
options: Readonly<AdmitDeviceOptions>,
|
|
75
|
+
): Promise<MintVerdict> {
|
|
76
|
+
const verdict = await mintGroupMembership({
|
|
77
|
+
...options,
|
|
78
|
+
issuer: options.userIdentity,
|
|
79
|
+
});
|
|
80
|
+
if (!verdict.ok) return verdict;
|
|
81
|
+
|
|
82
|
+
await options.accountLedger.recordGrant(
|
|
83
|
+
"device",
|
|
84
|
+
deviceIdToHex(options.deviceId),
|
|
85
|
+
options.tokenId,
|
|
86
|
+
);
|
|
87
|
+
return verdict;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RemoveDeviceOptions {
|
|
91
|
+
/** The user principal's own identity -- only the principal that issued a device's membership may revoke it. */
|
|
92
|
+
userIdentity: IdentityPort;
|
|
93
|
+
accountLedger: AccountLedger;
|
|
94
|
+
clock: Clock;
|
|
95
|
+
/** Recorded locally immediately, so this principal's own future verifications see the removal without waiting on gossip -- mirrors room-lifecycle.ts's own revokeMemberGrant ordering (record before broadcast). */
|
|
96
|
+
revocation: RevocationView;
|
|
97
|
+
deviceId: DeviceId;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Revokes every group:member grant for deviceId that the account's ledger holds as outstanding, whichever machine of the account minted it: mints a revocation-entry for each token-id, records it in the given RevocationView immediately, and appends it to the ledger, so every other machine of the account sees the grant as revoked and a later re-admission starts from nothing outstanding. Returns no entries for a device the account never admitted.
|
|
102
|
+
*
|
|
103
|
+
* Returns the minted entries so the caller can broadcast them to the mesh (this module has no MeshTransport dependency to do so itself, mirroring the module-level "no assumption about locality" boundary documented above): every other device that might hold one of this device's grants needs the entry to independently start refusing it.
|
|
104
|
+
*/
|
|
105
|
+
export async function removeDevice(
|
|
106
|
+
options: Readonly<RemoveDeviceOptions>,
|
|
107
|
+
): Promise<RevocationEntry[]> {
|
|
108
|
+
const deviceHex = deviceIdToHex(options.deviceId);
|
|
109
|
+
const outstanding = await options.accountLedger.outstandingGrants(
|
|
110
|
+
"device",
|
|
111
|
+
deviceHex,
|
|
112
|
+
);
|
|
113
|
+
const entries: RevocationEntry[] = [];
|
|
114
|
+
for (const { tokenId } of outstanding) {
|
|
115
|
+
const entry = await mintRevocationEntry({
|
|
116
|
+
identity: options.userIdentity,
|
|
117
|
+
tokenId,
|
|
118
|
+
revokedAt: options.clock.now(),
|
|
119
|
+
});
|
|
120
|
+
await options.revocation.record(entry, { identity: options.userIdentity });
|
|
121
|
+
await options.accountLedger.recordRevocation(
|
|
122
|
+
"device",
|
|
123
|
+
deviceHex,
|
|
124
|
+
tokenId,
|
|
125
|
+
entry,
|
|
126
|
+
);
|
|
127
|
+
entries.push(entry);
|
|
128
|
+
}
|
|
129
|
+
return entries;
|
|
130
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which gossiped directory entries from the hub a gateway merges (agent-comms#156, widened by #187, #266 and #343). Split out of WireMeshTransport, which hands it the two collaborators it needs.
|
|
3
|
+
*
|
|
4
|
+
* A membership proof shows that a trusted principal or machine vouches for a device id. It does not show that whoever sent the advert is that device: the id and the proof both arrive in the same unauthenticated advert, and anyone who has read one can repeat it. So it earns only what GatewayTrust.isReachable grants (a directory entry and a route), never isTrusted, and everything a request to such a device carries is authenticated by the end-to-end session inside the relay. Verifying a proof is cryptography, so the work is bounded here: verdicts are remembered, a batch verifies only so many new proofs, and a verifier that throws refuses the entry rather than breaking the merge.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
8
|
+
import type { DirectoryEntry } from "wire-mesh-core/domain/mesh-session";
|
|
9
|
+
import type { GatewayTrustReader, GroupKind } from "./gateway-trust.js";
|
|
10
|
+
import {
|
|
11
|
+
readMembershipProof,
|
|
12
|
+
type MembershipProofField,
|
|
13
|
+
} from "./gossip-directory.js";
|
|
14
|
+
import type { WireMeshTransportOptions } from "./wire-mesh-transport-options.js";
|
|
15
|
+
|
|
16
|
+
/** How many new proofs one batch of entries may verify. Trusted devices and proofs already judged cost nothing against it, so a flood of fresh adverts costs a bounded amount of work per batch and the rest wait for the next. */
|
|
17
|
+
export const MAX_VERIFICATIONS_PER_BATCH = 8;
|
|
18
|
+
|
|
19
|
+
const MS_PER_SECOND = 1000;
|
|
20
|
+
const NEGATIVE_VERDICT_TTL_SECONDS = 60;
|
|
21
|
+
/** How long a refused proof is remembered before it is worth checking again: long enough that a repeated bad proof is not re-verified on every frame, short enough that a principal trusted a moment later takes effect promptly. */
|
|
22
|
+
export const NEGATIVE_VERDICT_TTL_MS =
|
|
23
|
+
NEGATIVE_VERDICT_TTL_SECONDS * MS_PER_SECOND;
|
|
24
|
+
|
|
25
|
+
/** The most proofs whose last verdict is remembered. */
|
|
26
|
+
const VERDICT_CACHE_LIMIT = 1024;
|
|
27
|
+
|
|
28
|
+
/** Each advert field that can carry a proof, and the kind of issuer whose trusted set it is checked against. */
|
|
29
|
+
const PROOF_SOURCES: readonly {
|
|
30
|
+
field: MembershipProofField;
|
|
31
|
+
kind: GroupKind;
|
|
32
|
+
}[] = [
|
|
33
|
+
{ field: "membership", kind: "principal" },
|
|
34
|
+
{ field: "machine", kind: "machine" },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export interface DirectoryAdmissionDeps {
|
|
38
|
+
gatewayTrust: Readonly<GatewayTrustReader>;
|
|
39
|
+
/** Absent when this side cannot verify proofs, in which case a proof is never enough. */
|
|
40
|
+
verifyMembership: WireMeshTransportOptions["verifyMembership"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
interface Verdict {
|
|
44
|
+
ok: boolean;
|
|
45
|
+
/** When a refusal stops being remembered (epoch ms). */
|
|
46
|
+
until: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Whether an entry this side has already merged from the hub would still be admitted now, with no new proof to judge: its device is reachable (trusted by id, or vouched for by a proof that has not lapsed from an issuer still trusted) or is itself a trusted principal's own device. The read-time counterpart of directoryAdmission, which KnownDevices uses to drop a device as soon as the trust that let it in is withdrawn or lapses. */
|
|
50
|
+
export function staysAdmitted(
|
|
51
|
+
gatewayTrust: Readonly<
|
|
52
|
+
Pick<GatewayTrustReader, "isReachable" | "isTrustedPrincipal">
|
|
53
|
+
>,
|
|
54
|
+
deviceHex: string,
|
|
55
|
+
): boolean {
|
|
56
|
+
return (
|
|
57
|
+
gatewayTrust.isReachable(deviceHex) ||
|
|
58
|
+
gatewayTrust.isTrustedPrincipal(deviceHex)
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Builds the admission check for a transport's directory merge: given a batch of entries, the ones to merge. An entry is merged if its device is trusted by id, is itself a trusted principal's own device, or carries a proof that a trusted principal or machine vouches for it; each proof an entry carries is judged on its own, and every one that verifies records the device in gatewayTrust under that kind until the proof lapses or its issuer stops being trusted. A remembered good verdict is reused only while that kind's record is still live, and a device's newer proof is verified as it arrives, which is how its trust is renewed before the old one lapses. */
|
|
63
|
+
export function directoryAdmission(
|
|
64
|
+
deps: Readonly<DirectoryAdmissionDeps>,
|
|
65
|
+
): (entries: readonly DirectoryEntry[]) => Promise<DirectoryEntry[]> {
|
|
66
|
+
const { gatewayTrust, verifyMembership } = deps;
|
|
67
|
+
/** Keyed by kind, device and proof. A directory event can carry several proofs for one device at once, a stale copy beside a fresh one, so a slot per device would be overwritten by each in turn and every event would verify them all again. */
|
|
68
|
+
const verdicts = new Map<string, Verdict>();
|
|
69
|
+
|
|
70
|
+
const remember = (key: string, verdict: Readonly<Verdict>): void => {
|
|
71
|
+
verdicts.delete(key);
|
|
72
|
+
verdicts.set(key, verdict);
|
|
73
|
+
if (verdicts.size > VERDICT_CACHE_LIMIT) {
|
|
74
|
+
const oldest = verdicts.keys().next();
|
|
75
|
+
if (oldest.done !== true) verdicts.delete(oldest.value);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const trustedIssuers = (kind: GroupKind): string[] =>
|
|
80
|
+
kind === "principal"
|
|
81
|
+
? gatewayTrust.listPrincipals()
|
|
82
|
+
: gatewayTrust.listMachines();
|
|
83
|
+
|
|
84
|
+
/** The first trusted issuer of the given kind that vouches for the proof, with the proof's expiry, or undefined. An issuer the verifier throws on (a malformed id, or no identity attached yet) is skipped, so one bad issuer never blocks the others. */
|
|
85
|
+
const vouchedFor = async (
|
|
86
|
+
proof: string,
|
|
87
|
+
deviceHex: string,
|
|
88
|
+
kind: GroupKind,
|
|
89
|
+
): Promise<{ issuer: string; expires: number } | undefined> => {
|
|
90
|
+
if (verifyMembership === undefined) return undefined;
|
|
91
|
+
for (const issuer of trustedIssuers(kind)) {
|
|
92
|
+
try {
|
|
93
|
+
const verdict = await verifyMembership({
|
|
94
|
+
proof,
|
|
95
|
+
deviceHex,
|
|
96
|
+
issuerHex: issuer,
|
|
97
|
+
});
|
|
98
|
+
if (verdict.ok) return { issuer, expires: verdict.expires };
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return undefined;
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
return async (entries) => {
|
|
107
|
+
const admitted: DirectoryEntry[] = [];
|
|
108
|
+
let budget = MAX_VERIFICATIONS_PER_BATCH;
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
const deviceHex = deviceIdToHex(entry.device);
|
|
111
|
+
if (
|
|
112
|
+
gatewayTrust.isTrusted(deviceHex) ||
|
|
113
|
+
gatewayTrust.isTrustedPrincipal(deviceHex)
|
|
114
|
+
) {
|
|
115
|
+
admitted.push(entry);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (verifyMembership === undefined) continue;
|
|
119
|
+
// Every proof the entry carries is judged, not just the first that passes, so a device vouched for by both a trusted principal and a trusted machine holds a record of each and stays reachable when either issuer is untrusted.
|
|
120
|
+
let vouched = false;
|
|
121
|
+
for (const { field, kind } of PROOF_SOURCES) {
|
|
122
|
+
const proof = readMembershipProof(entry.advert, field);
|
|
123
|
+
if (proof === undefined) continue;
|
|
124
|
+
|
|
125
|
+
const key = `${kind}:${deviceHex}:${proof}`;
|
|
126
|
+
const known = verdicts.get(key);
|
|
127
|
+
if (known !== undefined) {
|
|
128
|
+
if (known.ok && gatewayTrust.isVouchedBy(deviceHex, kind)) {
|
|
129
|
+
vouched = true;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
if (!known.ok && known.until > Date.now()) continue;
|
|
133
|
+
}
|
|
134
|
+
if (budget === 0) continue;
|
|
135
|
+
budget -= 1;
|
|
136
|
+
|
|
137
|
+
const vouch = await vouchedFor(proof, deviceHex, kind);
|
|
138
|
+
remember(key, {
|
|
139
|
+
ok: vouch !== undefined,
|
|
140
|
+
until: Date.now() + NEGATIVE_VERDICT_TTL_MS,
|
|
141
|
+
});
|
|
142
|
+
if (vouch !== undefined) {
|
|
143
|
+
gatewayTrust.noteVerifiedMember(
|
|
144
|
+
deviceHex,
|
|
145
|
+
{ kind, issuer: vouch.issuer },
|
|
146
|
+
vouch.expires,
|
|
147
|
+
);
|
|
148
|
+
vouched = true;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
if (vouched) admitted.push(entry);
|
|
152
|
+
}
|
|
153
|
+
return admitted;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mDNS discovery backend — UDP broadcast beacon fallback.
|
|
3
|
+
*
|
|
4
|
+
* Since the `multicast-dns` package is not a dependency, this backend
|
|
5
|
+
* uses a simple UDP broadcast beacon instead:
|
|
6
|
+
* - Sends a UDP broadcast to port 19877 every 30 seconds
|
|
7
|
+
* - Listens for beacons from other peers on the same port
|
|
8
|
+
* - discover() listens for one beacon cycle and returns results
|
|
9
|
+
*
|
|
10
|
+
* If multicast-dns is added as a dependency in the future, a proper
|
|
11
|
+
* mDNS implementation can replace this with _agent-comms._tcp service
|
|
12
|
+
* broadcast and query/response.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import * as dgram from "node:dgram";
|
|
16
|
+
import type {
|
|
17
|
+
DiscoveredMesh,
|
|
18
|
+
DiscoveryBackend,
|
|
19
|
+
AdvertiseOptions,
|
|
20
|
+
} from "./discovery.js";
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Constants
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const BEACON_PORT = 19877;
|
|
27
|
+
const BEACON_INTERVAL_MS = 30_000;
|
|
28
|
+
const DEFAULT_DISCOVER_TIMEOUT_MS = 5_000;
|
|
29
|
+
|
|
30
|
+
interface BeaconPayload {
|
|
31
|
+
type: "agent-comms-beacon";
|
|
32
|
+
name: string;
|
|
33
|
+
port: number;
|
|
34
|
+
agents?: number;
|
|
35
|
+
policies?: string[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// mDNS (UDP beacon) backend
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
export class MdnsDiscoveryBackend implements DiscoveryBackend {
|
|
43
|
+
readonly name = "mdns";
|
|
44
|
+
|
|
45
|
+
private socket: dgram.Socket | undefined;
|
|
46
|
+
private beaconTimer: ReturnType<typeof setInterval> | undefined;
|
|
47
|
+
private currentPayload: BeaconPayload | undefined;
|
|
48
|
+
private isListening = false;
|
|
49
|
+
|
|
50
|
+
async startAdvertising(opts: Readonly<AdvertiseOptions>): Promise<string> {
|
|
51
|
+
const id = `mdns-${String(opts.port)}`;
|
|
52
|
+
|
|
53
|
+
this.currentPayload = {
|
|
54
|
+
type: "agent-comms-beacon",
|
|
55
|
+
name: opts.name,
|
|
56
|
+
port: opts.port,
|
|
57
|
+
policies: opts.policy === "name-only" ? ["name-only"] : ["full"],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
await this.ensureListening();
|
|
61
|
+
this.startBeacon();
|
|
62
|
+
|
|
63
|
+
return id;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async stopAdvertising(): Promise<void> {
|
|
67
|
+
if (this.beaconTimer !== undefined) {
|
|
68
|
+
clearInterval(this.beaconTimer);
|
|
69
|
+
this.beaconTimer = undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.currentPayload = undefined;
|
|
73
|
+
|
|
74
|
+
// Only close the socket if we're not also discovering
|
|
75
|
+
if (this.socket && !this.hasActiveListeners()) {
|
|
76
|
+
await this.closeSocket();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Stop all beacon activity and close the socket. */
|
|
81
|
+
async stop(): Promise<void> {
|
|
82
|
+
if (this.beaconTimer !== undefined) {
|
|
83
|
+
clearInterval(this.beaconTimer);
|
|
84
|
+
this.beaconTimer = undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
this.currentPayload = undefined;
|
|
88
|
+
|
|
89
|
+
if (this.socket) {
|
|
90
|
+
await this.closeSocket();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Close the UDP socket and wait for the underlying handle to actually release before resolving. */
|
|
95
|
+
private async closeSocket(): Promise<void> {
|
|
96
|
+
const socket = this.socket;
|
|
97
|
+
if (!socket) return;
|
|
98
|
+
|
|
99
|
+
await new Promise<void>((resolve) => {
|
|
100
|
+
socket.close(() => {
|
|
101
|
+
resolve();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
this.socket = undefined;
|
|
106
|
+
this.isListening = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async discover(
|
|
110
|
+
timeout: number = DEFAULT_DISCOVER_TIMEOUT_MS,
|
|
111
|
+
): Promise<DiscoveredMesh[]> {
|
|
112
|
+
const discovered = new Map<string, DiscoveredMesh>();
|
|
113
|
+
|
|
114
|
+
await this.ensureListening();
|
|
115
|
+
|
|
116
|
+
const socket = this.socket;
|
|
117
|
+
if (socket === undefined) return [];
|
|
118
|
+
|
|
119
|
+
// Collect beacons for the specified duration
|
|
120
|
+
const handler = (msg: Buffer, rinfo: dgram.RemoteInfo) => {
|
|
121
|
+
const mesh = this.parseBeacon(msg, rinfo);
|
|
122
|
+
if (mesh) {
|
|
123
|
+
const key = `${mesh.host}:${String(mesh.port)}`;
|
|
124
|
+
discovered.set(key, mesh);
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
socket.on("message", handler);
|
|
129
|
+
|
|
130
|
+
await new Promise<void>((resolve) => {
|
|
131
|
+
setTimeout(resolve, timeout);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
socket.off("message", handler);
|
|
135
|
+
this.cleanupSocket();
|
|
136
|
+
|
|
137
|
+
return [...discovered.values()];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// -----------------------------------------------------------------------
|
|
141
|
+
// Internals
|
|
142
|
+
// -----------------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
private async ensureListening(): Promise<void> {
|
|
145
|
+
if (this.isListening && this.socket) return;
|
|
146
|
+
|
|
147
|
+
const socket = dgram.createSocket({
|
|
148
|
+
type: "udp4",
|
|
149
|
+
reuseAddr: true,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
socket.on("error", () => {
|
|
153
|
+
// Binding errors are non-fatal for discovery — the beacon simply
|
|
154
|
+
// won't reach anyone on this interface.
|
|
155
|
+
this.isListening = false;
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await new Promise<void>((resolve) => {
|
|
159
|
+
socket.bind(BEACON_PORT, () => {
|
|
160
|
+
socket.setBroadcast(true);
|
|
161
|
+
this.isListening = true;
|
|
162
|
+
resolve();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
this.socket = socket;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private startBeacon(): void {
|
|
170
|
+
if (this.beaconTimer !== undefined) {
|
|
171
|
+
clearInterval(this.beaconTimer);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Send immediately, then on interval
|
|
175
|
+
this.sendBeacon();
|
|
176
|
+
this.beaconTimer = setInterval(() => {
|
|
177
|
+
this.sendBeacon();
|
|
178
|
+
}, BEACON_INTERVAL_MS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private sendBeacon(): void {
|
|
182
|
+
if (!this.currentPayload || !this.socket) return;
|
|
183
|
+
|
|
184
|
+
const payload = Buffer.from(JSON.stringify(this.currentPayload));
|
|
185
|
+
// Broadcast to the beacon port on the standard broadcast address
|
|
186
|
+
this.socket.send(payload, BEACON_PORT, "255.255.255.255", (err) => {
|
|
187
|
+
if (err) {
|
|
188
|
+
// Broadcast failures are non-fatal — the network may not
|
|
189
|
+
// support broadcast (e.g. some cloud environments).
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private parseBeacon(
|
|
195
|
+
msg: Buffer,
|
|
196
|
+
rinfo: dgram.RemoteInfo,
|
|
197
|
+
): DiscoveredMesh | undefined {
|
|
198
|
+
try {
|
|
199
|
+
const parsed: unknown = JSON.parse(msg.toString());
|
|
200
|
+
|
|
201
|
+
if (
|
|
202
|
+
typeof parsed !== "object" ||
|
|
203
|
+
parsed === null ||
|
|
204
|
+
!("type" in parsed) ||
|
|
205
|
+
typeof parsed.type !== "string" ||
|
|
206
|
+
parsed.type !== "agent-comms-beacon"
|
|
207
|
+
) {
|
|
208
|
+
return undefined;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (!("port" in parsed) || typeof parsed.port !== "number")
|
|
212
|
+
return undefined;
|
|
213
|
+
if (!("name" in parsed) || typeof parsed.name !== "string")
|
|
214
|
+
return undefined;
|
|
215
|
+
const result: DiscoveredMesh = {
|
|
216
|
+
host: rinfo.address,
|
|
217
|
+
port: parsed.port,
|
|
218
|
+
name: parsed.name,
|
|
219
|
+
};
|
|
220
|
+
if ("agents" in parsed && typeof parsed.agents === "number")
|
|
221
|
+
result.agentCount = parsed.agents;
|
|
222
|
+
if (
|
|
223
|
+
"policies" in parsed &&
|
|
224
|
+
Array.isArray(parsed.policies) &&
|
|
225
|
+
parsed.policies.every((v) => typeof v === "string")
|
|
226
|
+
)
|
|
227
|
+
result.policies = parsed.policies;
|
|
228
|
+
return result;
|
|
229
|
+
} catch {
|
|
230
|
+
return undefined;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private hasActiveListeners(): boolean {
|
|
235
|
+
// Check if there are message listeners beyond our own
|
|
236
|
+
if (!this.socket) return false;
|
|
237
|
+
return this.socket.listenerCount("message") > 0;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private cleanupSocket(): void {
|
|
241
|
+
if (this.socket && !this.currentPayload && !this.hasActiveListeners()) {
|
|
242
|
+
this.socket.close();
|
|
243
|
+
this.socket = undefined;
|
|
244
|
+
this.isListening = false;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|