agent-comms 8.15.8 → 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,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay selection (agent-comms#342): which relay a store's one hub session is held over. The hub is a set rather than a single URL: every relay offer this store has heard (wire-mesh-core's relay-offer gossip extension) plus the configured public hub, ranked by a fixed policy.
|
|
3
|
+
*
|
|
4
|
+
* A direct connection always comes first, and needs nothing here: MeshTransport.sendRoomRequest reaches a device over its direct session whenever one exists and only falls back to the hub session for a device it has no session with. Among relays the order is: a relay served on this machine (offered by a peer this store holds a machine-local session with), then a relay on the local network (a trusted device offering a private or link-local address), then any trusted relay-offering device, then the configured public hub as the fallback.
|
|
5
|
+
*
|
|
6
|
+
* Trust stays deny-by-default: an offer from a device reached only through a hub is considered only when gateway trust admits that device (by id, or through a principal it proves membership of), exactly the check that already decides whether a hub-learned device is reachable at all. A machine-local peer's offer needs no separate trust decision, since that peer is already a full member of this store's local mesh. Relay payloads stay end-to-end encrypted between the two devices using it, so trusting a relay is a decision about availability and metadata, never content. A relay forwards between its own directly connected clients, one hop; nothing here routes through a chain of relays.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { BlockList, isIPv4, isIPv6 } from "node:net";
|
|
10
|
+
|
|
11
|
+
/** A relay offer this store has heard, with what it knows about the device making it. */
|
|
12
|
+
export interface RelayOffer {
|
|
13
|
+
deviceHex: string;
|
|
14
|
+
/** The ws:// or wss:// URLs the device offers relay service at. */
|
|
15
|
+
addresses: readonly string[];
|
|
16
|
+
/** Whether this store holds a machine-local session with the offering device. */
|
|
17
|
+
machineLocal: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Which tier of the policy a selected relay came from, most preferred first. */
|
|
21
|
+
export type RelayTier = "machine" | "lan" | "trusted" | "public";
|
|
22
|
+
|
|
23
|
+
export interface SelectedRelay {
|
|
24
|
+
url: string;
|
|
25
|
+
tier: RelayTier;
|
|
26
|
+
/** The offering device, absent for the configured public hub. */
|
|
27
|
+
deviceHex?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface RelaySelectionInput {
|
|
31
|
+
offers: readonly RelayOffer[];
|
|
32
|
+
/** Gateway trust's reachability check for a device learned only through a hub. */
|
|
33
|
+
isTrusted: (deviceHex: string) => boolean;
|
|
34
|
+
/** This store's own device-id: it never relays through its own offer. */
|
|
35
|
+
ownDeviceHex: string;
|
|
36
|
+
/** The configured public hub, the fallback when no offer qualifies. */
|
|
37
|
+
publicHubUrl: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The private (RFC 1918), IPv4 link-local (RFC 3927), IPv6 unique-local (RFC 4193) and IPv6 link-local ranges: addresses reachable on the local network and nowhere else. */
|
|
41
|
+
const LOCAL_NETWORK_RANGES = new BlockList();
|
|
42
|
+
LOCAL_NETWORK_RANGES.addRange("10.0.0.0", "10.255.255.255", "ipv4");
|
|
43
|
+
LOCAL_NETWORK_RANGES.addRange("172.16.0.0", "172.31.255.255", "ipv4");
|
|
44
|
+
LOCAL_NETWORK_RANGES.addRange("192.168.0.0", "192.168.255.255", "ipv4");
|
|
45
|
+
LOCAL_NETWORK_RANGES.addRange("169.254.0.0", "169.254.255.255", "ipv4");
|
|
46
|
+
LOCAL_NETWORK_RANGES.addRange(
|
|
47
|
+
"fc00::",
|
|
48
|
+
"fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
|
|
49
|
+
"ipv6",
|
|
50
|
+
);
|
|
51
|
+
LOCAL_NETWORK_RANGES.addRange(
|
|
52
|
+
"fe80::",
|
|
53
|
+
"febf:ffff:ffff:ffff:ffff:ffff:ffff:ffff",
|
|
54
|
+
"ipv6",
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
/** Whether a host names a loopback address, which only reaches a relay on this machine. */
|
|
58
|
+
function isLoopbackHost(host: string): boolean {
|
|
59
|
+
return host === "localhost" || host === "::1" || host.startsWith("127.");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Whether a host literal is a private or link-local address: reachable on the local network and nowhere else. A host name is neither, since what it resolves to is not known here. */
|
|
63
|
+
function isPrivateHost(host: string): boolean {
|
|
64
|
+
if (isIPv4(host)) return LOCAL_NETWORK_RANGES.check(host, "ipv4");
|
|
65
|
+
if (isIPv6(host)) return LOCAL_NETWORK_RANGES.check(host, "ipv6");
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The host a relay URL names, or undefined for an address that is not a ws:// or wss:// URL (which is never dialled). */
|
|
70
|
+
function relayHost(address: string): string | undefined {
|
|
71
|
+
if (!/^wss?:\/\//.test(address)) return undefined;
|
|
72
|
+
try {
|
|
73
|
+
// URL keeps an IPv6 literal's brackets in hostname; strip them so isIP recognises it.
|
|
74
|
+
return new URL(address).hostname.replace(/^\[(.*)\]$/, "$1");
|
|
75
|
+
} catch {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The best address an offer has for a tier, or undefined when it has none that tier accepts. */
|
|
81
|
+
function addressFor(
|
|
82
|
+
offer: Readonly<RelayOffer>,
|
|
83
|
+
tier: RelayTier,
|
|
84
|
+
): string | undefined {
|
|
85
|
+
for (const address of offer.addresses) {
|
|
86
|
+
const host = relayHost(address);
|
|
87
|
+
if (host === undefined) continue;
|
|
88
|
+
if (tier === "machine" && isLoopbackHost(host)) return address;
|
|
89
|
+
if (tier === "lan" && isPrivateHost(host)) return address;
|
|
90
|
+
if (tier === "trusted" && !isLoopbackHost(host)) return address;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const OFFER_TIERS: readonly Exclude<RelayTier, "public">[] = [
|
|
96
|
+
"machine",
|
|
97
|
+
"lan",
|
|
98
|
+
"trusted",
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
/** Picks the relay to hold this store's hub session over. Within a tier the lowest device-id wins, so every store that hears the same offers makes the same choice. */
|
|
102
|
+
export function selectRelay(
|
|
103
|
+
input: Readonly<RelaySelectionInput>,
|
|
104
|
+
): SelectedRelay {
|
|
105
|
+
const eligible = input.offers
|
|
106
|
+
.filter((offer) => offer.deviceHex !== input.ownDeviceHex)
|
|
107
|
+
.toSorted((a, b) => a.deviceHex.localeCompare(b.deviceHex));
|
|
108
|
+
for (const tier of OFFER_TIERS) {
|
|
109
|
+
for (const offer of eligible) {
|
|
110
|
+
const admitted =
|
|
111
|
+
tier === "machine"
|
|
112
|
+
? offer.machineLocal
|
|
113
|
+
: input.isTrusted(offer.deviceHex);
|
|
114
|
+
if (!admitted) continue;
|
|
115
|
+
const url = addressFor(offer, tier);
|
|
116
|
+
if (url !== undefined) return { url, tier, deviceHex: offer.deviceHex };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { url: input.publicHubUrl, tier: "public" };
|
|
120
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RelayServer: the relay role a bridge serves to its machine and local network (agent-comms#342), wire-mesh-core's transport-agnostic relay-hub domain logic (the same createRelayHub the public hub runs) behind a WebSocket listener. It forwards between its own directly connected clients and, when given an uplink, carries them over it to the upstream hub and back (attachUplink), so a device on the upstream hub and a client of this relay reach each other with each end's own device-id intact. It never routes beyond that: relay payloads are end-to-end encrypted between the two devices using it, so what the relay learns is who talks to whom, never what they say.
|
|
3
|
+
*
|
|
4
|
+
* Admission is deny-by-default, like every other cross-machine decision here: a connection is registered with the hub only once it gossips an advert for a device isAdmitted accepts, and every gossiped advert naming a device it does not accept is dropped before the hub sees it. A connection that never gossips an admitted device can neither be named by a relay-connect nor relay anything, and learns nothing, since the hub's catch-up is sent only in answer to a registered connection's gossip.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { createServer, type Server } from "node:http";
|
|
8
|
+
import * as os from "node:os";
|
|
9
|
+
import { WebSocketServer } from "ws";
|
|
10
|
+
import { createRelayHub, type RelayHub } from "wire-mesh-core/domain/relay-hub";
|
|
11
|
+
import { deviceIdToHex } from "wire-mesh-core/domain/device-id";
|
|
12
|
+
import {
|
|
13
|
+
deriveDeviceId,
|
|
14
|
+
verifyWithPublicKey,
|
|
15
|
+
} from "wire-mesh-core/adapters/node-identity";
|
|
16
|
+
import type { Frame } from "wire-mesh-core/generated/protocol";
|
|
17
|
+
import type { Connection } from "wire-mesh-core/ports/transport";
|
|
18
|
+
import { wrapWsSocket } from "./ws-dial.js";
|
|
19
|
+
import { createUplink, type UplinkFeed } from "./relay-uplink.js";
|
|
20
|
+
|
|
21
|
+
/** The wildcard IPv4 address: listening on it serves every interface, loopback and the local network alike. */
|
|
22
|
+
export const ALL_INTERFACES_HOST = "0.0.0.0";
|
|
23
|
+
|
|
24
|
+
/** The loopback address every relay also offers, so a peer on the same machine reaches it without leaving the host. */
|
|
25
|
+
const LOOPBACK_HOST = "127.0.0.1";
|
|
26
|
+
|
|
27
|
+
export interface RelayServerOptions {
|
|
28
|
+
/** The interface to listen on: ALL_INTERFACES_HOST to serve the local network, or a single address (a test serves loopback only). */
|
|
29
|
+
host: string;
|
|
30
|
+
/** Whether a device (hex) may use this relay. */
|
|
31
|
+
isAdmitted: (deviceHex: string) => boolean;
|
|
32
|
+
/** Reports a failure of the uplink, which is otherwise detached from any caller. */
|
|
33
|
+
onError: (error: Error) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class RelayServer {
|
|
37
|
+
private readonly hub: RelayHub = createRelayHub({
|
|
38
|
+
identity: { verify: verifyWithPublicKey, deriveDeviceId },
|
|
39
|
+
});
|
|
40
|
+
/** The devices (hex) each connected client has registered, so an advert for one of them arriving from upstream never displaces the client's own registration. */
|
|
41
|
+
private readonly clientDevices = new Map<Readonly<Connection>, Set<string>>();
|
|
42
|
+
private readonly http: Server = createServer();
|
|
43
|
+
private readonly wss = new WebSocketServer({ server: this.http });
|
|
44
|
+
private port = 0;
|
|
45
|
+
/** Settles once the previous uplink has been forgotten by the hub, which refuses a second while one is attached. */
|
|
46
|
+
private uplinkDone: Promise<void> = Promise.resolve();
|
|
47
|
+
|
|
48
|
+
constructor(private readonly options: Readonly<RelayServerOptions>) {
|
|
49
|
+
this.wss.on("connection", (socket) => {
|
|
50
|
+
socket.binaryType = "arraybuffer";
|
|
51
|
+
void this.serve(wrapWsSocket(socket));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Binds an OS-assigned port and resolves once listening. */
|
|
56
|
+
async start(): Promise<void> {
|
|
57
|
+
await new Promise<void>((resolve, reject) => {
|
|
58
|
+
this.http.once("error", reject);
|
|
59
|
+
this.http.listen(0, this.options.host, () => {
|
|
60
|
+
this.http.off("error", reject);
|
|
61
|
+
resolve();
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
const address = this.http.address();
|
|
65
|
+
if (address === null || typeof address === "string") {
|
|
66
|
+
throw new Error("RelayServer: expected a TCP listen address");
|
|
67
|
+
}
|
|
68
|
+
this.port = address.port;
|
|
69
|
+
// Never what keeps a process alive: the bridge that serves the relay decides when it exits.
|
|
70
|
+
this.http.unref();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Takes the connection to the upstream hub as this relay's uplink (wire-mesh-core's relay-hub fronting, wire-mesh#311): the clients registered here become reachable through the upstream hub and can reach the devices registered there, each end keeping its own device-id. The upstream connection belongs to the caller's own session, so the frames it reads are fed in through the returned feed, and ending the feed detaches the uplink. */
|
|
74
|
+
attachUplink(upstream: Readonly<Connection>): UplinkFeed {
|
|
75
|
+
const uplink = createUplink(upstream);
|
|
76
|
+
this.uplinkDone = this.uplinkDone
|
|
77
|
+
.then(async () => this.hub.handleUplink(uplink.connection))
|
|
78
|
+
.catch((error: unknown) => {
|
|
79
|
+
this.options.onError(
|
|
80
|
+
error instanceof Error ? error : new Error(String(error)),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
return {
|
|
84
|
+
feed: (frame) => {
|
|
85
|
+
const local = this.withoutClientDevices(frame);
|
|
86
|
+
if (local !== undefined) uplink.feed.feed(local);
|
|
87
|
+
},
|
|
88
|
+
end: () => {
|
|
89
|
+
uplink.feed.end();
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The URLs this relay is reachable at, for its relay offer: loopback always, and each external IPv4 interface too when it listens on every interface. */
|
|
95
|
+
addresses(): string[] {
|
|
96
|
+
const hosts =
|
|
97
|
+
this.options.host === ALL_INTERFACES_HOST
|
|
98
|
+
? [LOOPBACK_HOST, ...externalIpv4Addresses()]
|
|
99
|
+
: [this.options.host];
|
|
100
|
+
return hosts.map((host) => `ws://${host}:${String(this.port)}/`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** How many clients are connected, admitted or not. */
|
|
104
|
+
connectionCount(): number {
|
|
105
|
+
return this.wss.clients.size;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** How many clients the hub can route to: those whose advert it has accepted. A client that has only just connected is not one until it gossips an advert the hub takes over any fresher one it already holds for that device, which a device moving here from another hub within the same second does not do until its next gossip. */
|
|
109
|
+
routableClientCount(): number {
|
|
110
|
+
let routable = 0;
|
|
111
|
+
for (const connection of this.clientDevices.keys()) {
|
|
112
|
+
if (this.hub.exportConnection(connection) !== undefined) routable += 1;
|
|
113
|
+
}
|
|
114
|
+
return routable;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Closes every client and the listener. */
|
|
118
|
+
async stop(): Promise<void> {
|
|
119
|
+
this.hub.stop();
|
|
120
|
+
for (const client of this.wss.clients) client.terminate();
|
|
121
|
+
await new Promise<void>((resolve) => {
|
|
122
|
+
this.wss.close(() => {
|
|
123
|
+
resolve();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
await new Promise<void>((resolve) => {
|
|
127
|
+
this.http.close(() => {
|
|
128
|
+
resolve();
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Drives one connection: filters its gossip to admitted devices, registers it with the hub on its first admitted advert, and hands the hub every frame from then on. */
|
|
134
|
+
private async serve(connection: Readonly<Connection>): Promise<void> {
|
|
135
|
+
let registered = false;
|
|
136
|
+
const devices = new Set<string>();
|
|
137
|
+
this.clientDevices.set(connection, devices);
|
|
138
|
+
try {
|
|
139
|
+
for await (const frame of connection.receive()) {
|
|
140
|
+
const admitted = this.admit(frame);
|
|
141
|
+
if (admitted?.type === "gossip") {
|
|
142
|
+
for (const advert of admitted.peers) {
|
|
143
|
+
devices.add(deviceIdToHex(advert.device));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (admitted === undefined) continue;
|
|
147
|
+
if (!registered) {
|
|
148
|
+
if (admitted.type !== "gossip") continue;
|
|
149
|
+
this.hub.registerConnection(connection);
|
|
150
|
+
registered = true;
|
|
151
|
+
}
|
|
152
|
+
await this.hub.onFrame(connection, admitted);
|
|
153
|
+
}
|
|
154
|
+
} catch {
|
|
155
|
+
// A connection that fails mid-stream is simply gone; its registration is cleaned up below.
|
|
156
|
+
} finally {
|
|
157
|
+
this.clientDevices.delete(connection);
|
|
158
|
+
if (registered) this.hub.onDisconnect(connection);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The frame from upstream without the adverts of devices a client of this relay has registered, or undefined when nothing is left of it. An advert that came back round through the upstream hub would otherwise displace the client's own registration whenever it was the fresher, and a relay-connect naming the client would then find no route to it. */
|
|
163
|
+
private withoutClientDevices(frame: Readonly<Frame>): Frame | undefined {
|
|
164
|
+
if (frame.type !== "gossip") return frame;
|
|
165
|
+
const peers = frame.peers.filter((advert) => {
|
|
166
|
+
const deviceHex = deviceIdToHex(advert.device);
|
|
167
|
+
for (const devices of this.clientDevices.values()) {
|
|
168
|
+
if (devices.has(deviceHex)) return false;
|
|
169
|
+
}
|
|
170
|
+
return true;
|
|
171
|
+
});
|
|
172
|
+
if (peers.length === 0) return undefined;
|
|
173
|
+
return { ...frame, peers };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The frame to pass on, or undefined to drop it: gossip keeps only admitted adverts, and nothing else passes before the connection has gossiped one. */
|
|
177
|
+
private admit(frame: Frame): Frame | undefined {
|
|
178
|
+
if (frame.type !== "gossip") return frame;
|
|
179
|
+
const peers = frame.peers.filter((advert) =>
|
|
180
|
+
this.options.isAdmitted(deviceIdToHex(advert.device)),
|
|
181
|
+
);
|
|
182
|
+
if (peers.length === 0) return undefined;
|
|
183
|
+
return { ...frame, peers };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function externalIpv4Addresses(): string[] {
|
|
188
|
+
const result: string[] = [];
|
|
189
|
+
for (const addresses of Object.values(os.networkInterfaces())) {
|
|
190
|
+
if (addresses === undefined) continue;
|
|
191
|
+
for (const address of addresses) {
|
|
192
|
+
if (address.family === "IPv4" && !address.internal) {
|
|
193
|
+
result.push(address.address);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The connection a relay hub takes to its upstream hub as its uplink (agent-comms#342), built over a hub session that already owns that connection.
|
|
3
|
+
*
|
|
4
|
+
* A HubSession drives the connection to the public hub through wire-mesh-core's session, whose own receive loop is the one consumer of the connection's frames. The relay hub's handleUplink needs a frame stream of its own, so the session's per-frame observer feeds each frame it reads into this uplink as well, and sends from the hub go out over the same underlying connection. Closing the uplink ends the stream the hub reads and leaves the underlying connection to the session that owns it.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Frame } from "wire-mesh-core/generated/protocol";
|
|
8
|
+
import type { Connection } from "wire-mesh-core/ports/transport";
|
|
9
|
+
|
|
10
|
+
/** What the owner of the upstream connection drives: each frame it reads from upstream, and the end of the connection. */
|
|
11
|
+
export interface UplinkFeed {
|
|
12
|
+
/** Hands the hub one frame read from upstream. Ignored once the uplink has ended. */
|
|
13
|
+
feed: (frame: Readonly<Frame>) => void;
|
|
14
|
+
/** Ends the stream the hub reads, which makes it forget the uplink. Idempotent. */
|
|
15
|
+
end: () => void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Uplink {
|
|
19
|
+
/** The connection handed to the relay hub's handleUplink. */
|
|
20
|
+
connection: Connection;
|
|
21
|
+
feed: UplinkFeed;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Builds the uplink over an upstream connection some other session owns: sends go to it directly, receives come from what the owner feeds in. */
|
|
25
|
+
export function createUplink(upstream: Readonly<Connection>): Uplink {
|
|
26
|
+
const backlog: Frame[] = [];
|
|
27
|
+
const waiters: ((result: IteratorResult<Frame>) => void)[] = [];
|
|
28
|
+
let ended = false;
|
|
29
|
+
|
|
30
|
+
const feed: UplinkFeed = {
|
|
31
|
+
feed(frame) {
|
|
32
|
+
if (ended) return;
|
|
33
|
+
const waiter = waiters.shift();
|
|
34
|
+
if (waiter === undefined) {
|
|
35
|
+
backlog.push(frame);
|
|
36
|
+
} else {
|
|
37
|
+
waiter({ value: frame, done: false });
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
end() {
|
|
41
|
+
if (ended) return;
|
|
42
|
+
ended = true;
|
|
43
|
+
for (const waiter of waiters.splice(0)) {
|
|
44
|
+
waiter({ value: undefined, done: true });
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const stream: AsyncIterable<Frame> = {
|
|
50
|
+
[Symbol.asyncIterator]() {
|
|
51
|
+
return {
|
|
52
|
+
next: async (): Promise<IteratorResult<Frame>> =>
|
|
53
|
+
new Promise((resolve) => {
|
|
54
|
+
const next = backlog.shift();
|
|
55
|
+
if (next !== undefined) {
|
|
56
|
+
resolve({ value: next, done: false });
|
|
57
|
+
} else if (ended) {
|
|
58
|
+
resolve({ value: undefined, done: true });
|
|
59
|
+
} else {
|
|
60
|
+
waiters.push(resolve);
|
|
61
|
+
}
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
connection: {
|
|
69
|
+
send: async (frame) => upstream.send(frame),
|
|
70
|
+
receive: () => stream,
|
|
71
|
+
close: async () => {
|
|
72
|
+
feed.end();
|
|
73
|
+
return Promise.resolve();
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
feed,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deadlines for manage-requests sent to another device. A request that waits on a human decision needs a far longer window than one answered by code, and every request needs some bound: wire-mesh-core waits forever for a response unless it is given a timeout.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ManageCommand } from "wire-mesh-core/generated/protocol";
|
|
6
|
+
|
|
7
|
+
const MS_PER_SECOND = 1000;
|
|
8
|
+
const SECONDS_PER_MINUTE = 60;
|
|
9
|
+
|
|
10
|
+
const ROOM_REQUEST_TIMEOUT_SECONDS = 15;
|
|
11
|
+
/** How long a request answered by code (room.send, room.read, room.members, ...) may take before the sender gives up. */
|
|
12
|
+
export const ROOM_REQUEST_TIMEOUT_MS =
|
|
13
|
+
ROOM_REQUEST_TIMEOUT_SECONDS * MS_PER_SECOND;
|
|
14
|
+
|
|
15
|
+
const ROOM_JOIN_APPROVAL_TIMEOUT_MINUTES = 5;
|
|
16
|
+
/** How long a room.join (which includes a first-contact DM request) may sit awaiting a human decision before the receiving side expires it. Generous on purpose: this bounds a human approval window, not a network timeout, the same reasoning that gives a connect_request its own five minutes. */
|
|
17
|
+
export const ROOM_JOIN_APPROVAL_TIMEOUT_MS =
|
|
18
|
+
ROOM_JOIN_APPROVAL_TIMEOUT_MINUTES * SECONDS_PER_MINUTE * MS_PER_SECOND;
|
|
19
|
+
|
|
20
|
+
const APPROVAL_RESPONSE_GRACE_SECONDS = 10;
|
|
21
|
+
/** Extra time the requester waits beyond the receiver's approval window, so the receiver's own definitive timeout answer is normally the one the requester sees. The requester's deadline is the backstop for a receiver that never answers at all, such as one running a version without the expiry. */
|
|
22
|
+
export const APPROVAL_RESPONSE_GRACE_MS =
|
|
23
|
+
APPROVAL_RESPONSE_GRACE_SECONDS * MS_PER_SECOND;
|
|
24
|
+
|
|
25
|
+
/** The room verb whose response is a human's accept or reject decision. */
|
|
26
|
+
const ROOM_JOIN_VERB = "room.join";
|
|
27
|
+
|
|
28
|
+
/** The deadline the sender should give this request. A room.join waits on a human, so it gets the receiver's approval window plus the response grace; every other request gets the short network deadline. */
|
|
29
|
+
export function manageRequestTimeoutMs(
|
|
30
|
+
command: Readonly<ManageCommand>,
|
|
31
|
+
approvalWindowMs: number,
|
|
32
|
+
): number {
|
|
33
|
+
const waitsOnHuman = command.params.verb === ROOM_JOIN_VERB;
|
|
34
|
+
return waitsOnHuman
|
|
35
|
+
? approvalWindowMs + APPROVAL_RESPONSE_GRACE_MS
|
|
36
|
+
: ROOM_REQUEST_TIMEOUT_MS;
|
|
37
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reads the rooms other devices advertise as hosted from the transport's gossip aggregation -- the one source of room discovery that list_rooms and bare-name room resolution (room-lookup.ts) both need to agree on.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { HOSTED_ROOMS_GOSSIP_KEY } from "./gossip-extensions.js";
|
|
6
|
+
import type { ReceivedHostedRoomAdvert } from "./gossip-extensions.js";
|
|
7
|
+
import type { MeshTransport } from "./transport.js";
|
|
8
|
+
|
|
9
|
+
/** A hosted room together with the device that advertised it. */
|
|
10
|
+
export type DiscoveredRoom = ReceivedHostedRoomAdvert & {
|
|
11
|
+
ownerDeviceId: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Narrows an untrusted gossiped value (a peer's own self-asserted `room/hosted` entry) into a ReceivedHostedRoomAdvert. A malformed or non-conforming entry is skipped by the caller rather than treated as an error: this is a discovery hint over self-asserted data, not a security check. */
|
|
15
|
+
export function isHostedRoomAdvert(
|
|
16
|
+
value: unknown,
|
|
17
|
+
): value is ReceivedHostedRoomAdvert {
|
|
18
|
+
if (typeof value !== "object" || value === null) return false;
|
|
19
|
+
if (!("path" in value) || typeof value.path !== "string") return false;
|
|
20
|
+
if (!("name" in value) || typeof value.name !== "string") return false;
|
|
21
|
+
if (
|
|
22
|
+
!("type" in value) ||
|
|
23
|
+
(value.type !== "public" && value.type !== "private")
|
|
24
|
+
)
|
|
25
|
+
return false;
|
|
26
|
+
if ("description" in value && typeof value.description !== "string")
|
|
27
|
+
return false;
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Every public/private room another device has gossiped as hosted, each tagged with the advertising device. Empty for a transport with no `listKnownDevices` capability (WireMeshTransport is the only implementation that has one). Secret rooms never appear: HostedRoomAdvert's own type field excludes them at the source, so a secret room is never gossiped under this key at all.
|
|
33
|
+
*/
|
|
34
|
+
export function listDiscoveredRooms(
|
|
35
|
+
transport: Readonly<MeshTransport>,
|
|
36
|
+
): readonly DiscoveredRoom[] {
|
|
37
|
+
if (transport.listKnownDevices === undefined) return [];
|
|
38
|
+
const result: DiscoveredRoom[] = [];
|
|
39
|
+
for (const { deviceId, advert } of transport.listKnownDevices()) {
|
|
40
|
+
const hosted = advert[HOSTED_ROOMS_GOSSIP_KEY];
|
|
41
|
+
if (!Array.isArray(hosted)) continue;
|
|
42
|
+
for (const candidate of hosted) {
|
|
43
|
+
if (!isHostedRoomAdvert(candidate)) continue;
|
|
44
|
+
result.push({ ...candidate, ownerDeviceId: deviceId });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return result;
|
|
48
|
+
}
|