agent-comms 8.15.9 → 8.15.11
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/README.md +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/dist/test/pi-manifest-packed.integration.test.d.ts +2 -0
- package/dist/test/pi-manifest-packed.integration.test.d.ts.map +1 -0
- package/dist/test/pi-manifest-packed.integration.test.js +68 -0
- package/dist/test/pi-manifest-packed.integration.test.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,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vite build config for the web bridge frontend.
|
|
3
|
+
*
|
|
4
|
+
* Builds main.tsx (React) into dist/bundle.js, injects the precache manifest into a hand-written service worker, and copies the PWA manifest + icons from frontend/public/ into dist/.
|
|
5
|
+
*
|
|
6
|
+
* Usage: pnpm build:frontend
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as path from "node:path";
|
|
10
|
+
import { fileURLToPath } from "node:url";
|
|
11
|
+
import { build as viteBuild, defineConfig, type Plugin } from "vite";
|
|
12
|
+
import react from "@vitejs/plugin-react";
|
|
13
|
+
import { VitePWA } from "vite-plugin-pwa";
|
|
14
|
+
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const FRONTEND_DIR = path.join(__dirname, "frontend");
|
|
17
|
+
const DIST_DIR = path.join(__dirname, "dist");
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Builds the mesh SharedWorker as a separate, non-hashed bundle and writes it directly into the main build's outDir on Rollup's writeBundle hook -- after Vite's own emptyOutDir and after the main chunk is written to disk, but before vite-plugin-pwa's closeBundle hook globs the outDir for its precache manifest, so mesh-worker.js is present when the PWA plugin scans for files to precache. mesh-client.ts creates this SharedWorker from a runtime string literal ("./mesh-worker.js"), invisible to Rollup's module graph, which is why it must stay a fixed-filename artifact built outside the main bundle rather than an ordinary import.
|
|
21
|
+
*/
|
|
22
|
+
function meshWorkerPlugin(): Plugin {
|
|
23
|
+
return {
|
|
24
|
+
name: "agent-comms-mesh-worker",
|
|
25
|
+
async writeBundle() {
|
|
26
|
+
await viteBuild({
|
|
27
|
+
configFile: false,
|
|
28
|
+
logLevel: "warn",
|
|
29
|
+
build: {
|
|
30
|
+
outDir: DIST_DIR,
|
|
31
|
+
emptyOutDir: false,
|
|
32
|
+
minify: true,
|
|
33
|
+
lib: {
|
|
34
|
+
entry: path.join(FRONTEND_DIR, "mesh-worker.ts"),
|
|
35
|
+
formats: ["iife"],
|
|
36
|
+
name: "AgentCommsMeshWorker",
|
|
37
|
+
fileName: () => "mesh-worker.js",
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default defineConfig({
|
|
46
|
+
root: FRONTEND_DIR,
|
|
47
|
+
plugins: [
|
|
48
|
+
react(),
|
|
49
|
+
meshWorkerPlugin(),
|
|
50
|
+
VitePWA({
|
|
51
|
+
strategies: "injectManifest",
|
|
52
|
+
srcDir: ".",
|
|
53
|
+
filename: "sw.ts",
|
|
54
|
+
injectRegister: false,
|
|
55
|
+
manifest: false,
|
|
56
|
+
// vite-plugin-pwa builds the service worker in its own nested build. For the default "es" format it hard-codes Rollup's inlineDynamicImports: true, which Rolldown (Vite 8) deprecates in favour of codeSplitting: false and warns about on every build. The "iife" format takes the plugin's library-mode path, which sets no such option. The service worker has no imports or exports, so the only change to sw.js is an immediately-invoked function wrapper around the same code.
|
|
57
|
+
injectManifest: { rollupFormat: "iife" },
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
build: {
|
|
61
|
+
outDir: DIST_DIR,
|
|
62
|
+
emptyOutDir: true,
|
|
63
|
+
rollupOptions: {
|
|
64
|
+
// No dynamic import() anywhere in this codebase (banned outright by eslint.config.ts's no-restricted-syntax rule) and exactly one HTML entry point, so Rollup/Rolldown can never produce more than the one chunk named here -- no code-splitting option is needed to force it.
|
|
65
|
+
output: {
|
|
66
|
+
entryFileNames: "bundle.js",
|
|
67
|
+
assetFileNames: "assets/[name][extname]",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Network exposure policy for the bridge web server (agent-comms#346).
|
|
3
|
+
*
|
|
4
|
+
* The default is loopback only, with no token. Setting AGENT_COMMS_WEB_HOST to a non-loopback address is the explicit opt-in to LAN reachability. Because the server exposes every mutating action (POST /api/action and the /ws/mesh oRPC socket), a non-loopback bind always comes with a per-process secret token. Every request, from any client, must carry a Host header naming this server (the DNS rebinding defence: a rebound name resolves here but arrives with the attacker's name in Host), and any Origin it carries must be this server over http (the cross-site defence: a page open in the operator's own browser reaches loopback as readily as a LAN page does, so the check cannot be limited to remote clients). The one other origin admitted, and only from a client on this machine, is the hosted dashboard (see HOSTED_DASHBOARD_ORIGIN). A client on this machine (loopback, or one of this machine's own interface addresses) needs nothing more. Any other client must also present the token, as a bearer token or as the cookie a one-time `?token=` URL exchange sets.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as crypto from "node:crypto";
|
|
8
|
+
import * as net from "node:net";
|
|
9
|
+
import * as os from "node:os";
|
|
10
|
+
|
|
11
|
+
/** The environment variable that opts the web server into binding beyond loopback. Its value is an IP address literal to bind: `0.0.0.0` or `::` for every interface, or one interface's own address. */
|
|
12
|
+
export const WEB_HOST_ENV = "AGENT_COMMS_WEB_HOST";
|
|
13
|
+
|
|
14
|
+
/** The default bind address, reachable from this machine only. */
|
|
15
|
+
export const LOOPBACK_HOST = "127.0.0.1";
|
|
16
|
+
|
|
17
|
+
/** The prefix of the cookie a `?token=` exchange sets. Cookies are scoped to a host and not to a port (RFC 6265 section 8.5), and every bridge on a machine runs its own server on its own port with its own token, so the name carries the port to keep one browser's cookies for two bridges from overwriting each other. */
|
|
18
|
+
export const ACCESS_COOKIE_PREFIX = "agent_comms_web_token";
|
|
19
|
+
|
|
20
|
+
/** The name of the cookie a server listening on `port` sets and reads. */
|
|
21
|
+
export function accessCookieName(port: number): string {
|
|
22
|
+
return `${ACCESS_COOKIE_PREFIX}_${String(port)}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The query parameter a one-time URL exchange carries the token in. */
|
|
26
|
+
export const ACCESS_QUERY_PARAM = "token";
|
|
27
|
+
|
|
28
|
+
/** Random bytes in the access token: 256 bits, so guessing is infeasible however many requests a LAN peer can send. */
|
|
29
|
+
const ACCESS_TOKEN_BYTES = 32;
|
|
30
|
+
|
|
31
|
+
/** The port a Host header with no explicit port names (HTTP). */
|
|
32
|
+
const HTTP_DEFAULT_PORT = 80;
|
|
33
|
+
|
|
34
|
+
const HTTP_UNAUTHORIZED = 401;
|
|
35
|
+
const HTTP_FORBIDDEN = 403;
|
|
36
|
+
const HTTP_FOUND = 302;
|
|
37
|
+
|
|
38
|
+
/** The authentication scheme of an `Authorization` header carrying the token. Scheme names are case-insensitive (RFC 9110 section 11.1). */
|
|
39
|
+
const BEARER_SCHEME = "bearer";
|
|
40
|
+
|
|
41
|
+
/** The origin of the dashboard deployed to GitHub Pages. It discovers a bridge's server by probing loopback ports and then opens its `/ws/mesh` socket cross-origin, so its Origin is admitted from a client on this machine; every other foreign origin is refused. */
|
|
42
|
+
export const HOSTED_DASHBOARD_ORIGIN = "https://exadev.github.io";
|
|
43
|
+
|
|
44
|
+
/** How long the address sets derived from the machine's interfaces and hostname are reused. Reading them costs a system call per interface, which every request would otherwise pay; this bounds how long a newly attached interface waits to be recognised. */
|
|
45
|
+
const INTERFACE_CACHE_MS = 5000;
|
|
46
|
+
|
|
47
|
+
const LINK_LOCAL_V6 = /^fe[89ab]/i;
|
|
48
|
+
|
|
49
|
+
const LOOPBACK_V4_PREFIX = "127.";
|
|
50
|
+
const IPV4_MAPPED_PREFIX = "::ffff:";
|
|
51
|
+
const LOOPBACK_V6 = "::1";
|
|
52
|
+
const IPV4_WILDCARD = "0.0.0.0";
|
|
53
|
+
const IPV6_WILDCARD = "::";
|
|
54
|
+
|
|
55
|
+
/** The names a client on this machine uses for loopback, always accepted in Host whatever the bind. */
|
|
56
|
+
const LOOPBACK_HOSTNAMES = ["localhost", LOOPBACK_HOST, LOOPBACK_V6];
|
|
57
|
+
|
|
58
|
+
/** The radix IPv6 groups are written in, and the bits and mask of one byte of the IPv4 address a mapped group pair spells. */
|
|
59
|
+
const HEX_RADIX = 16;
|
|
60
|
+
const BYTE_BITS = 8;
|
|
61
|
+
const BYTE_MASK = 0xff;
|
|
62
|
+
|
|
63
|
+
/** True for any address in 127.0.0.0/8, for ::1, and for an IPv4-mapped form of either (what a dual-stack socket reports for an IPv4 client). */
|
|
64
|
+
export function isLoopbackAddress(address: string): boolean {
|
|
65
|
+
const unmapped = address.startsWith(IPV4_MAPPED_PREFIX)
|
|
66
|
+
? address.slice(IPV4_MAPPED_PREFIX.length)
|
|
67
|
+
: address;
|
|
68
|
+
return unmapped === LOOPBACK_V6 || unmapped.startsWith(LOOPBACK_V4_PREFIX);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The canonical spelling of an IP address literal: IPv6 in its compressed form, and an IPv4-mapped IPv6 address as plain IPv4. Every spelling of one address (`::0`, `0:0:0:0:0:0:0:0`, `::ffff:0.0.0.0`) then compares equal, which classification and the Host allow-list rely on. A scoped address (`fe80::1%en0`) is returned unchanged: it is neither a wildcard nor a loopback.
|
|
73
|
+
*/
|
|
74
|
+
export function canonicalAddress(address: string): string {
|
|
75
|
+
if (net.isIPv4(address) || address.includes("%")) return address;
|
|
76
|
+
const compressed = new URL(`http://[${address}]`).hostname.slice(1, -1);
|
|
77
|
+
if (!compressed.startsWith(IPV4_MAPPED_PREFIX)) return compressed;
|
|
78
|
+
const tail = compressed.slice(IPV4_MAPPED_PREFIX.length);
|
|
79
|
+
if (net.isIPv4(tail)) return tail;
|
|
80
|
+
return tail
|
|
81
|
+
.split(":")
|
|
82
|
+
.flatMap((group) => {
|
|
83
|
+
const value = Number.parseInt(group, HEX_RADIX);
|
|
84
|
+
return [value >> BYTE_BITS, value & BYTE_MASK];
|
|
85
|
+
})
|
|
86
|
+
.join(".");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True when `host` binds every interface, in any spelling (see canonicalAddress). */
|
|
90
|
+
export function isWildcardHost(host: string): boolean {
|
|
91
|
+
const canonical = canonicalAddress(host);
|
|
92
|
+
return canonical === IPV4_WILDCARD || canonical === IPV6_WILDCARD;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The host part of an `http` URL for `address`: IPv6 literals are bracketed. */
|
|
96
|
+
export function urlHost(address: string): string {
|
|
97
|
+
return net.isIPv6(address) ? `[${address}]` : address;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The URL a client on this machine opens for a server bound to `bindHost` on `port`. A wildcard bind is reached on loopback, which needs no token. */
|
|
101
|
+
export function localWebUrl(bindHost: string, port: number): string {
|
|
102
|
+
const host = isWildcardHost(bindHost) ? LOOPBACK_HOST : bindHost;
|
|
103
|
+
return `http://${urlHost(host)}:${String(port)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Resolves the address the web server binds. An explicit `host` option wins, then the environment variable, then loopback. The result is the canonical spelling of the address. Anything that is not an IP address literal throws: a typo must not silently fall back to a different exposure than the operator asked for.
|
|
108
|
+
*/
|
|
109
|
+
export function resolveWebBindHost(
|
|
110
|
+
explicit: string | undefined,
|
|
111
|
+
env: NodeJS.ProcessEnv,
|
|
112
|
+
): string {
|
|
113
|
+
const requested = explicit ?? env[WEB_HOST_ENV];
|
|
114
|
+
if (requested === undefined || requested === "") return LOOPBACK_HOST;
|
|
115
|
+
if (net.isIP(requested) === 0) {
|
|
116
|
+
throw new Error(
|
|
117
|
+
`${WEB_HOST_ENV} must be an IP address (for example 0.0.0.0), received "${requested}"`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return canonicalAddress(requested);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True when binding `host` makes the server reachable from another machine, so the access token applies. */
|
|
124
|
+
export function requiresAccessToken(host: string): boolean {
|
|
125
|
+
return !isLoopbackAddress(host);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function generateAccessToken(): string {
|
|
129
|
+
return crypto.randomBytes(ACCESS_TOKEN_BYTES).toString("hex");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Constant-time token comparison. Both sides are hashed to a fixed length first, because crypto.timingSafeEqual requires equal-length inputs and comparing lengths directly would leak the expected token's length.
|
|
134
|
+
*/
|
|
135
|
+
export function tokensMatch(presented: string, expected: string): boolean {
|
|
136
|
+
const digest = (value: string): Buffer =>
|
|
137
|
+
crypto.createHash("sha256").update(value).digest();
|
|
138
|
+
return crypto.timingSafeEqual(digest(presented), digest(expected));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Returns `compute()`, reusing the previous result for INTERFACE_CACHE_MS. */
|
|
142
|
+
function cachedFor<T>(compute: () => T): () => T {
|
|
143
|
+
let cached: { readonly value: T; readonly expires: number } | undefined;
|
|
144
|
+
return () => {
|
|
145
|
+
const now = Date.now();
|
|
146
|
+
if (cached === undefined || now >= cached.expires) {
|
|
147
|
+
cached = { value: compute(), expires: now + INTERFACE_CACHE_MS };
|
|
148
|
+
}
|
|
149
|
+
return cached.value;
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function readInterfaceAddresses(): ReadonlySet<string> {
|
|
154
|
+
const addresses = new Set<string>();
|
|
155
|
+
for (const entries of Object.values(os.networkInterfaces())) {
|
|
156
|
+
for (const entry of entries ?? []) {
|
|
157
|
+
addresses.add(canonicalAddress(entry.address));
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return addresses;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** The addresses of this machine's own network interfaces, as of at most INTERFACE_CACHE_MS ago. */
|
|
164
|
+
export const ownInterfaceAddresses: () => ReadonlySet<string> = cachedFor(
|
|
165
|
+
readInterfaceAddresses,
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** The exposure a running server is under. */
|
|
169
|
+
export interface AccessPolicy {
|
|
170
|
+
/** The token every client not on this machine must present. Undefined for a loopback bind, which no such client can reach. */
|
|
171
|
+
readonly token: string | undefined;
|
|
172
|
+
readonly bindHost: string;
|
|
173
|
+
/** The port the server listens on; assigned once it is listening when the OS chose it. */
|
|
174
|
+
port: number;
|
|
175
|
+
/** The addresses whose clients count as on this machine besides loopback, read per request. A client connecting to this machine's own LAN address arrives from that address and not from loopback, so without this the `web_url` of a server bound to one interface address would answer 401 to the operator. Tests replace it to stand in for a remote client. */
|
|
176
|
+
readonly localClientAddresses: () => ReadonlySet<string>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The parts of an incoming request the gate looks at. Shared by HTTP requests and WebSocket upgrades (an upgrade is an HTTP request). */
|
|
180
|
+
export interface AccessRequest {
|
|
181
|
+
readonly method: string;
|
|
182
|
+
readonly url: string;
|
|
183
|
+
readonly remoteAddress: string | undefined;
|
|
184
|
+
readonly headers: Readonly<Record<string, string | string[] | undefined>>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type AccessDecision =
|
|
188
|
+
| { readonly kind: "allow" }
|
|
189
|
+
| { readonly kind: "deny"; readonly status: number; readonly message: string }
|
|
190
|
+
| {
|
|
191
|
+
/** The one-time URL exchange: set the cookie and redirect to a URL without the token, so it does not stay in the address bar or history. */
|
|
192
|
+
readonly kind: "exchange";
|
|
193
|
+
readonly status: number;
|
|
194
|
+
readonly cookie: string;
|
|
195
|
+
readonly location: string;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
function headerValue(
|
|
199
|
+
headers: AccessRequest["headers"],
|
|
200
|
+
name: string,
|
|
201
|
+
): string | undefined {
|
|
202
|
+
const value = headers[name];
|
|
203
|
+
return Array.isArray(value) ? value[0] : value;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function readAllowedHostnames(bindHost: string): Set<string> {
|
|
207
|
+
const names = new Set<string>(LOOPBACK_HOSTNAMES);
|
|
208
|
+
if (isWildcardHost(bindHost)) {
|
|
209
|
+
for (const address of ownInterfaceAddresses()) names.add(address);
|
|
210
|
+
} else {
|
|
211
|
+
names.add(bindHost.toLowerCase());
|
|
212
|
+
}
|
|
213
|
+
const machine = os
|
|
214
|
+
.hostname()
|
|
215
|
+
.toLowerCase()
|
|
216
|
+
.replace(/\.local$/, "");
|
|
217
|
+
names.add(machine);
|
|
218
|
+
names.add(`${machine}.local`);
|
|
219
|
+
return names;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const hostnameSets = new Map<string, () => ReadonlySet<string>>();
|
|
223
|
+
|
|
224
|
+
/** Every hostname a client may legitimately use to reach this server: loopback names, the addresses it is bound to and this machine's own name (plus its `.local` mDNS form). Never a name an outside party could make resolve here, which is what DNS rebinding relies on, and never the wildcard address itself, which a page in a local browser can also reach. Reused for INTERFACE_CACHE_MS per bind address. */
|
|
225
|
+
function allowedHostnames(bindHost: string): ReadonlySet<string> {
|
|
226
|
+
let read = hostnameSets.get(bindHost);
|
|
227
|
+
if (read === undefined) {
|
|
228
|
+
read = cachedFor(() => readAllowedHostnames(bindHost));
|
|
229
|
+
hostnameSets.set(bindHost, read);
|
|
230
|
+
}
|
|
231
|
+
return read();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A Host header names either an IP literal (compared in canonical form) or a DNS name (compared as is). */
|
|
235
|
+
function canonicalAddressOrName(hostname: string): string {
|
|
236
|
+
return net.isIP(hostname) === 0 ? hostname : canonicalAddress(hostname);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parseAuthority(
|
|
240
|
+
authority: string,
|
|
241
|
+
): { hostname: string; port: number } | undefined {
|
|
242
|
+
let parsed: URL;
|
|
243
|
+
try {
|
|
244
|
+
parsed = new URL(`http://${authority}`);
|
|
245
|
+
} catch {
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
const hostname = canonicalAddressOrName(
|
|
249
|
+
parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase(),
|
|
250
|
+
);
|
|
251
|
+
const port = parsed.port === "" ? HTTP_DEFAULT_PORT : Number(parsed.port);
|
|
252
|
+
return { hostname, port };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function hostHeaderAllowed(
|
|
256
|
+
policy: Readonly<AccessPolicy>,
|
|
257
|
+
headers: AccessRequest["headers"],
|
|
258
|
+
): boolean {
|
|
259
|
+
const host = headerValue(headers, "host");
|
|
260
|
+
if (host === undefined) return false;
|
|
261
|
+
const authority = parseAuthority(host);
|
|
262
|
+
if (authority === undefined) return false;
|
|
263
|
+
if (authority.port !== policy.port) return false;
|
|
264
|
+
return allowedHostnames(policy.bindHost).has(authority.hostname);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** An Origin, when the browser sends one, must be this very server over http: otherwise a page from anywhere (on the LAN, or open in the operator's own browser) could drive it. The hosted dashboard is the one other origin, and only for a client on this machine. */
|
|
268
|
+
function originAllowed(
|
|
269
|
+
policy: Readonly<AccessPolicy>,
|
|
270
|
+
headers: AccessRequest["headers"],
|
|
271
|
+
ownClient: boolean,
|
|
272
|
+
): boolean {
|
|
273
|
+
const origin = headerValue(headers, "origin");
|
|
274
|
+
if (origin === undefined) return true;
|
|
275
|
+
let parsed: URL;
|
|
276
|
+
try {
|
|
277
|
+
parsed = new URL(origin);
|
|
278
|
+
} catch {
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
if (ownClient && parsed.origin === HOSTED_DASHBOARD_ORIGIN) return true;
|
|
282
|
+
if (parsed.protocol !== "http:") return false;
|
|
283
|
+
const authority = parseAuthority(parsed.host);
|
|
284
|
+
if (authority?.port !== policy.port) return false;
|
|
285
|
+
return allowedHostnames(policy.bindHost).has(authority.hostname);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function cookieToken(
|
|
289
|
+
headers: AccessRequest["headers"],
|
|
290
|
+
cookieName: string,
|
|
291
|
+
): string | undefined {
|
|
292
|
+
const cookies = headerValue(headers, "cookie");
|
|
293
|
+
if (cookies === undefined) return undefined;
|
|
294
|
+
for (const part of cookies.split(";")) {
|
|
295
|
+
const [name, ...rest] = part.trim().split("=");
|
|
296
|
+
if (name === cookieName) return rest.join("=");
|
|
297
|
+
}
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function bearerToken(headers: AccessRequest["headers"]): string | undefined {
|
|
302
|
+
const authorization = headerValue(headers, "authorization");
|
|
303
|
+
if (authorization === undefined) return undefined;
|
|
304
|
+
const separator = authorization.indexOf(" ");
|
|
305
|
+
if (separator === -1) return undefined;
|
|
306
|
+
const scheme = authorization.slice(0, separator).toLowerCase();
|
|
307
|
+
return scheme === BEARER_SCHEME
|
|
308
|
+
? authorization.slice(separator + 1).trim()
|
|
309
|
+
: undefined;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function presentedCredential(
|
|
313
|
+
policy: Readonly<AccessPolicy>,
|
|
314
|
+
token: string,
|
|
315
|
+
headers: AccessRequest["headers"],
|
|
316
|
+
): boolean {
|
|
317
|
+
const presented = [
|
|
318
|
+
bearerToken(headers),
|
|
319
|
+
cookieToken(headers, accessCookieName(policy.port)),
|
|
320
|
+
];
|
|
321
|
+
return presented.some(
|
|
322
|
+
(candidate) => candidate !== undefined && tokensMatch(candidate, token),
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** True for a client on this machine: loopback, or one of this machine's own interface addresses. */
|
|
327
|
+
function isOwnClient(
|
|
328
|
+
policy: Readonly<AccessPolicy>,
|
|
329
|
+
remoteAddress: string | undefined,
|
|
330
|
+
): boolean {
|
|
331
|
+
if (remoteAddress === undefined) return false;
|
|
332
|
+
return (
|
|
333
|
+
isLoopbackAddress(remoteAddress) ||
|
|
334
|
+
policy.localClientAddresses().has(canonicalAddress(remoteAddress))
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Decides whether a request may proceed. The Host header must name this server and any Origin must be this server, for every client. A client on this machine is then allowed. Any other client needs the token (a bearer token or the cookie); a GET carrying the right `?token=` instead exchanges it for the cookie.
|
|
340
|
+
*/
|
|
341
|
+
export function authorise(
|
|
342
|
+
policy: Readonly<AccessPolicy>,
|
|
343
|
+
request: AccessRequest,
|
|
344
|
+
): AccessDecision {
|
|
345
|
+
if (!hostHeaderAllowed(policy, request.headers)) {
|
|
346
|
+
return { kind: "deny", status: HTTP_FORBIDDEN, message: "Forbidden host" };
|
|
347
|
+
}
|
|
348
|
+
const ownClient = isOwnClient(policy, request.remoteAddress);
|
|
349
|
+
if (!originAllowed(policy, request.headers, ownClient)) {
|
|
350
|
+
return {
|
|
351
|
+
kind: "deny",
|
|
352
|
+
status: HTTP_FORBIDDEN,
|
|
353
|
+
message: "Forbidden origin",
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
if (ownClient) return { kind: "allow" };
|
|
357
|
+
const { token } = policy;
|
|
358
|
+
if (token === undefined) {
|
|
359
|
+
return { kind: "deny", status: HTTP_UNAUTHORIZED, message: "Unauthorized" };
|
|
360
|
+
}
|
|
361
|
+
if (presentedCredential(policy, token, request.headers)) {
|
|
362
|
+
return { kind: "allow" };
|
|
363
|
+
}
|
|
364
|
+
if (request.method === "GET") {
|
|
365
|
+
const url = new URL(request.url, "http://placeholder");
|
|
366
|
+
const offered = url.searchParams.get(ACCESS_QUERY_PARAM);
|
|
367
|
+
if (offered !== null && tokensMatch(offered, token)) {
|
|
368
|
+
url.searchParams.delete(ACCESS_QUERY_PARAM);
|
|
369
|
+
return {
|
|
370
|
+
kind: "exchange",
|
|
371
|
+
status: HTTP_FOUND,
|
|
372
|
+
cookie: `${accessCookieName(policy.port)}=${token}; HttpOnly; SameSite=Lax; Path=/`,
|
|
373
|
+
location: `${url.pathname}${url.search}`,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
return { kind: "deny", status: HTTP_UNAUTHORIZED, message: "Unauthorized" };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** The URLs an operator can open on the LAN, each carrying the token for the one-time exchange. A wildcard bind lists every external, non-link-local address of this machine that the bind family can reach (IPv6 bracketed, and only under `::`, since `0.0.0.0` accepts IPv4 alone); a specific bind lists that address. The list is empty when a wildcard bind finds no such address. */
|
|
381
|
+
export function accessUrls(
|
|
382
|
+
policy: Readonly<AccessPolicy> & { readonly token: string },
|
|
383
|
+
interfaces: () => NodeJS.Dict<
|
|
384
|
+
os.NetworkInterfaceInfo[]
|
|
385
|
+
> = os.networkInterfaces,
|
|
386
|
+
): string[] {
|
|
387
|
+
const hosts: string[] = [];
|
|
388
|
+
if (isWildcardHost(policy.bindHost)) {
|
|
389
|
+
const ipv4Only = canonicalAddress(policy.bindHost) === IPV4_WILDCARD;
|
|
390
|
+
for (const addresses of Object.values(interfaces())) {
|
|
391
|
+
for (const entry of addresses ?? []) {
|
|
392
|
+
if (entry.internal || LINK_LOCAL_V6.test(entry.address)) continue;
|
|
393
|
+
if (ipv4Only && entry.family !== "IPv4") continue;
|
|
394
|
+
hosts.push(urlHost(entry.address));
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
} else {
|
|
398
|
+
hosts.push(urlHost(policy.bindHost));
|
|
399
|
+
}
|
|
400
|
+
return hosts.map(
|
|
401
|
+
(host) =>
|
|
402
|
+
`http://${host}:${String(policy.port)}/?${ACCESS_QUERY_PARAM}=${policy.token}`,
|
|
403
|
+
);
|
|
404
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The built web frontend the dashboard server serves: index.html, the JS bundle, the service worker, the mesh worker, the manifest and the PWA icons.
|
|
3
|
+
*
|
|
4
|
+
* These are build output (`build:frontend`), so a source checkout that was never built, such as a `pi install git:` clone whose install omits the development dependencies, does not have them. They are read on first use rather than when the server module is imported, so a missing frontend costs the routes that serve it and nothing else: the bridge still starts, and every API, WebSocket and mesh feature still works.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as fs from "node:fs";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
|
|
10
|
+
/** The PWA icon files, all required alongside index.html. */
|
|
11
|
+
const ICON_FILENAMES = [
|
|
12
|
+
"icon-96x96.svg",
|
|
13
|
+
"icon-192x192.svg",
|
|
14
|
+
"icon-512x512.svg",
|
|
15
|
+
] as const;
|
|
16
|
+
|
|
17
|
+
export interface FrontendAssets {
|
|
18
|
+
indexHtml: string;
|
|
19
|
+
bundleJs: string;
|
|
20
|
+
/** Optional in the build: a placeholder stands in when it was not produced. */
|
|
21
|
+
meshWorkerJs: string;
|
|
22
|
+
swJs: string;
|
|
23
|
+
manifestJson: string;
|
|
24
|
+
icons: Readonly<Record<string, Buffer>>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The frontend build output is absent from the directory it should be in. */
|
|
28
|
+
export class FrontendNotBuiltError extends Error {
|
|
29
|
+
constructor(public readonly assetDir: string) {
|
|
30
|
+
super(
|
|
31
|
+
`The web frontend is not built: ${path.join(assetDir, "index.html")} is missing. A git checkout does not include the build output; install the npm package, or run \`pnpm build:frontend\` in a checkout with its development dependencies installed.`,
|
|
32
|
+
);
|
|
33
|
+
this.name = "FrontendNotBuiltError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readIfPresent(file: string): string | undefined {
|
|
38
|
+
try {
|
|
39
|
+
return fs.readFileSync(file, "utf-8");
|
|
40
|
+
} catch (error) {
|
|
41
|
+
if (
|
|
42
|
+
error instanceof Error &&
|
|
43
|
+
"code" in error &&
|
|
44
|
+
(error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
45
|
+
) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
throw error;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Reads the frontend build output from `assetDir` into memory. Throws FrontendNotBuiltError when `index.html` is absent, the marker that the build never ran; any other filesystem error, including a missing required file beside a present `index.html`, propagates, since that is a partial build and not an unbuilt checkout.
|
|
54
|
+
*/
|
|
55
|
+
export function loadFrontendAssets(assetDir: string): FrontendAssets {
|
|
56
|
+
const indexHtml = readIfPresent(path.join(assetDir, "index.html"));
|
|
57
|
+
if (indexHtml === undefined) throw new FrontendNotBuiltError(assetDir);
|
|
58
|
+
|
|
59
|
+
const icons: Record<string, Buffer> = {};
|
|
60
|
+
for (const filename of ICON_FILENAMES) {
|
|
61
|
+
icons[filename] = fs.readFileSync(path.join(assetDir, "icons", filename));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
indexHtml,
|
|
66
|
+
bundleJs: fs.readFileSync(path.join(assetDir, "bundle.js"), "utf-8"),
|
|
67
|
+
meshWorkerJs:
|
|
68
|
+
readIfPresent(path.join(assetDir, "mesh-worker.js")) ??
|
|
69
|
+
"/* mesh-worker not built */",
|
|
70
|
+
swJs: fs.readFileSync(path.join(assetDir, "sw.js"), "utf-8"),
|
|
71
|
+
manifestJson: fs.readFileSync(
|
|
72
|
+
path.join(assetDir, "manifest.json"),
|
|
73
|
+
"utf-8",
|
|
74
|
+
),
|
|
75
|
+
icons,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A loader over `assetDir` that reads the build output on first call and returns the same assets afterwards. A failed load is not remembered, so a build that finishes while the server runs is picked up by the next request.
|
|
81
|
+
*/
|
|
82
|
+
export function createFrontendAssetLoader(
|
|
83
|
+
assetDir: string,
|
|
84
|
+
): () => FrontendAssets {
|
|
85
|
+
let loaded: FrontendAssets | undefined;
|
|
86
|
+
return () => {
|
|
87
|
+
loaded ??= loadFrontendAssets(assetDir);
|
|
88
|
+
return loaded;
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* LAN advertisement of a bridge's web UI (agent-comms#346).
|
|
3
|
+
*
|
|
4
|
+
* A send-only UDP beacon on the first-contact port announcing where this bridge's web UI listens. It carries the peer id and the web port and nothing else: the receiver learns the host from the datagram's source address, and the access token is never advertised, so seeing the beacon grants no access. The type is distinct from first-contact's own `agent-comms-beacon`, which peers parse for a data port to dial; first-contact ignores any other type, so the two never interfere.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import * as dgram from "node:dgram";
|
|
8
|
+
import {
|
|
9
|
+
BROADCAST_ADDRESS,
|
|
10
|
+
FIRST_CONTACT_INTERVAL_MS,
|
|
11
|
+
FIRST_CONTACT_PORT,
|
|
12
|
+
} from "../../../core/first-contact.js";
|
|
13
|
+
|
|
14
|
+
/** The beacon type, distinct from first-contact's `agent-comms-beacon`. */
|
|
15
|
+
export const WEB_BEACON_TYPE = "agent-comms-web-beacon";
|
|
16
|
+
|
|
17
|
+
export interface WebBeaconPayload {
|
|
18
|
+
type: typeof WEB_BEACON_TYPE;
|
|
19
|
+
peerId: string;
|
|
20
|
+
webPort: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface WebBeaconOptions {
|
|
24
|
+
peerId: string;
|
|
25
|
+
webPort: number;
|
|
26
|
+
/** The UDP port beacons are sent to. Defaults to the first-contact port; tests pass an OS-assigned free port. */
|
|
27
|
+
port?: number | undefined;
|
|
28
|
+
/** The destination address. Defaults to limited broadcast; tests pass a loopback address so no real network is needed. */
|
|
29
|
+
address?: string;
|
|
30
|
+
onError?: (error: Error) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface WebBeacon {
|
|
34
|
+
stop: () => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Starts beaconing immediately and on first-contact's interval. A send failure (broadcast unavailable on this network) is reported through onError and never fatal. */
|
|
38
|
+
export function startWebBeacon(options: Readonly<WebBeaconOptions>): WebBeacon {
|
|
39
|
+
const socket = dgram.createSocket({ type: "udp4" });
|
|
40
|
+
const destinationPort = options.port ?? FIRST_CONTACT_PORT;
|
|
41
|
+
const address = options.address ?? BROADCAST_ADDRESS;
|
|
42
|
+
const payload: WebBeaconPayload = {
|
|
43
|
+
type: WEB_BEACON_TYPE,
|
|
44
|
+
peerId: options.peerId,
|
|
45
|
+
webPort: options.webPort,
|
|
46
|
+
};
|
|
47
|
+
const message = JSON.stringify(payload);
|
|
48
|
+
const reportError = (error: Error): void => {
|
|
49
|
+
options.onError?.(error);
|
|
50
|
+
};
|
|
51
|
+
socket.on("error", reportError);
|
|
52
|
+
const send = (): void => {
|
|
53
|
+
socket.send(message, destinationPort, address, (error) => {
|
|
54
|
+
if (error) reportError(error);
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
socket.bind(0, () => {
|
|
58
|
+
if (address === BROADCAST_ADDRESS) socket.setBroadcast(true);
|
|
59
|
+
send();
|
|
60
|
+
});
|
|
61
|
+
const timer = setInterval(send, FIRST_CONTACT_INTERVAL_MS);
|
|
62
|
+
timer.unref();
|
|
63
|
+
return {
|
|
64
|
+
stop(): void {
|
|
65
|
+
clearInterval(timer);
|
|
66
|
+
socket.close();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|