@threadedstack/wmd-web 0.0.0-stage → 0.1.12

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.
Files changed (92) hide show
  1. package/README.md +2601 -2
  2. package/RELEASE.json +123 -0
  3. package/export-page/coi-sw.js +20 -0
  4. package/export-page/index.html +17 -0
  5. package/export-page/run.css +10 -0
  6. package/export-page/run.js +236 -0
  7. package/guest/arm64/Image +4 -0
  8. package/guest/arm64/Image-4k +4 -0
  9. package/guest/arm64/initramfs.cpio.gz +0 -0
  10. package/guest/arm64/vm.wasm +0 -0
  11. package/package.json +36 -4
  12. package/page/sw.js +381 -0
  13. package/relay/control.ts +139 -0
  14. package/relay/engine.ts +787 -0
  15. package/relay/hosted.ts +660 -0
  16. package/relay/keys.ts +53 -0
  17. package/relay/local-tls.ts +121 -0
  18. package/relay/lock.ts +81 -0
  19. package/relay/logfile.ts +138 -0
  20. package/relay/main.ts +642 -0
  21. package/relay/policy.ts +241 -0
  22. package/relay/relay.ts +266 -0
  23. package/relay/token.ts +116 -0
  24. package/relay/v1.ts +107 -0
  25. package/relay/wisp.ts +615 -0
  26. package/src/base.js +325 -0
  27. package/src/boot.js +2037 -0
  28. package/src/calib-worker.js +40 -0
  29. package/src/fs/9p/codec.js +303 -0
  30. package/src/fs/9p/server.js +521 -0
  31. package/src/fs/errno.js +43 -0
  32. package/src/fs/graph.js +380 -0
  33. package/src/fs/overlay.js +1018 -0
  34. package/src/fs/pack-write.js +165 -0
  35. package/src/fs/pack.js +427 -0
  36. package/src/fs/seed.js +22 -0
  37. package/src/fs/svc-snapshot.js +65 -0
  38. package/src/fs/upper-pack.js +112 -0
  39. package/src/host/base-cache.js +195 -0
  40. package/src/host/base-file.js +328 -0
  41. package/src/host/base-io.js +93 -0
  42. package/src/host/boot-events.js +209 -0
  43. package/src/host/chan-service.js +300 -0
  44. package/src/host/chan-services.js +689 -0
  45. package/src/host/chan.js +655 -0
  46. package/src/host/ckpt-file.js +395 -0
  47. package/src/host/ckpt-io.js +207 -0
  48. package/src/host/ckpt-point.js +160 -0
  49. package/src/host/ckpt-save.js +64 -0
  50. package/src/host/ckpt.js +434 -0
  51. package/src/host/clock.js +111 -0
  52. package/src/host/ctl.js +778 -0
  53. package/src/host/errno.js +18 -0
  54. package/src/host/fs.js +167 -0
  55. package/src/host/gcnudge.js +83 -0
  56. package/src/host/i64shim.js +81 -0
  57. package/src/host/jit-cache.js +341 -0
  58. package/src/host/jit-hub-core.js +208 -0
  59. package/src/host/jit-hub.js +93 -0
  60. package/src/host/jit-snap.js +226 -0
  61. package/src/host/jit.js +1903 -0
  62. package/src/host/layout.js +100 -0
  63. package/src/host/monitor.js +110 -0
  64. package/src/host/p9mount.js +27 -0
  65. package/src/host/ram-mem.js +125 -0
  66. package/src/host/stdio.js +143 -0
  67. package/src/host/threads.js +109 -0
  68. package/src/host/wasi.js +335 -0
  69. package/src/host/wmd-imports.js +71 -0
  70. package/src/host/yamux.js +387 -0
  71. package/src/image-files.js +154 -0
  72. package/src/net/dhcp-dns.js +126 -0
  73. package/src/net/net-worker.js +172 -0
  74. package/src/net/packet.js +227 -0
  75. package/src/net/peer.js +230 -0
  76. package/src/net/proto.js +189 -0
  77. package/src/net/relay-client.js +226 -0
  78. package/src/net/stack.js +867 -0
  79. package/src/net/switch.js +160 -0
  80. package/src/net/wisp-client.js +570 -0
  81. package/src/net/wisp-transport.js +196 -0
  82. package/src/net/wisp.js +511 -0
  83. package/src/registry/client.js +176 -0
  84. package/src/registry/converter.js +68 -0
  85. package/src/registry/hub.js +221 -0
  86. package/src/registry/oci.js +76 -0
  87. package/src/registry/pack-worker.js +70 -0
  88. package/src/registry/pull.js +258 -0
  89. package/src/registry/ref.js +110 -0
  90. package/src/registry/wasi-mem.js +355 -0
  91. package/src/worker.js +315 -0
  92. package/wmdpack.wasm +0 -0
package/relay/main.ts ADDED
@@ -0,0 +1,642 @@
1
+ // W59 S4: the standalone relay, `wmd-relay`. No setup: with no arguments it serves Wisp (and
2
+ // the wmd-relay.v1 endpoint, for token clients) on 127.0.0.1:8780 and prints one line, the URL.
3
+ //
4
+ // wmd-relay [flags] serve (flags below; --help lists them)
5
+ // wmd-relay key add <id> [--keys F] make an owner key; the secret is printed once, F keeps its hash
6
+ // wmd-relay key list|remove <id> (F defaults to ~/.wmd/relay/keys.json)
7
+ // wmd-relay version
8
+ //
9
+ // Everything a flag sets can also come from --config <file.json|file.toml>; flags win. Logs
10
+ // (connection metadata only) go to stderr with --log. Plan: docs/plans/w59-wisp-relay.md §7.
11
+
12
+ import { randomBytes } from 'node:crypto'
13
+ import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
14
+ import { networkInterfaces } from 'node:os'
15
+ import { dirname, join } from 'node:path'
16
+ import { type HostedOptions } from './hosted.ts'
17
+ import { type OwnerKey, newKey } from './keys.ts'
18
+ import { ControlError, COMMANDS, ctl, startControl } from './control.ts'
19
+ import { createLogFile } from './logfile.ts'
20
+ import { isPrivateBind, parseCidr } from './policy.ts'
21
+ import { breakIfStale, release, tryTake } from './lock.ts'
22
+ import { localTls, relayDir } from './local-tls.ts'
23
+ import { type RelayOptions, createRelay } from './relay.ts'
24
+
25
+ declare const RELAY_VERSION: string // --define at `make relay`
26
+ export const VERSION = typeof RELAY_VERSION === 'string' ? RELAY_VERSION : 'dev'
27
+ export const DEFAULT_PORT = 8780
28
+ export const DEFAULT_TLS_PORT = 8781
29
+
30
+ const HELP = `wmd-relay ${VERSION}: a Wisp relay for wmd guests in the browser
31
+
32
+ wmd-relay serve on 127.0.0.1:${DEFAULT_PORT}, no key; prints the URL
33
+ wmd-relay key add <id> make an owner key (the secret is shown once)
34
+ wmd-relay key list | remove <id>
35
+ wmd-relay version
36
+ wmd-relay ctl stop-new|resume|drain|status operate a running --hosted relay (its control socket)
37
+ wmd-relay check can this machine run it? (own addresses refused, lookups work)
38
+
39
+ Flags:
40
+ --host <addr> bind address (127.0.0.1). A reachable address with no key
41
+ lets anyone who can reach it use the relay; see --origin
42
+ --port <n> port (${DEFAULT_PORT})
43
+ --origin <url> only pages from this origin (repeatable)
44
+ --keys <file> owner keys (see 'key add'); then every client needs one
45
+ --tls-cert <f> --tls-key <f> serve wss:// with this certificate
46
+ --local-tls also serve wss://127.0.0.1:${DEFAULT_TLS_PORT} with a certificate made
47
+ for this computer (for Safari; you trust it once)
48
+ --behind-tls a TLS proxy or tunnel is in front (keys on a reachable bind)
49
+ --trust-proxy [list] per-client caps by X-Forwarded-For (only behind a proxy you run). With a list of
50
+ proxy addresses or CIDRs, only those peers are believed (required by --hosted)
51
+ --proxy-header <name> the header the proxy sets (X-Forwarded-For; e.g. CF-Connecting-IP)
52
+ --allow-lan guests may reach private addresses (10/8, 192.168/16, ...)
53
+ --allow-loopback-ports <list> guests may reach this computer's loopback on these ports
54
+ --block-ports <list> destination ports refused (25)
55
+ --allow-hosts <list> only these names (name[:port|port], a leading . for subdomains)
56
+ --hosted the public hosted profile (docs/relay-hosted.md): per-address, network and origin
57
+ limits, byte ceilings, deny lists, a control socket, /healthz, /readyz
58
+ --abuse-contact <text> required: an address or URL, shown in the MOTD, /healthz and every refusal
59
+ --deny-cidr <list> dialled addresses to refuse besides the built-in ranges (the fleet's private neighbours)
60
+ --deny-file <file> JSON {addresses, origins, destinations}; re-read on SIGHUP, denied sessions end
61
+ --max-bytes-per-day <n> stop taking new connections after this many bytes relayed in a UTC day (both ways)
62
+ --max-bytes-per-month <n> the same for a UTC month
63
+ --allow-ports <list> only these destination ports (25, 465 and 587 are always blocked)
64
+ --control-dir <dir> the control socket's directory, mode 0700, never under /tmp (~/.wmd/relay/ctl)
65
+ --state-dir <dir> where the byte counters are kept (~/.wmd/relay)
66
+ --start-stopped start in stop-new until 'ctl resume' (a stop does not persist a restart otherwise)
67
+ --log-dir <dir> metadata log files, rotated by size and age; --log-retention-days <n> (14)
68
+ --config <file> JSON or TOML with any of the above, and the caps
69
+ --log connection metadata (never payloads or secrets) on stderr
70
+ `
71
+
72
+ // config keys: flags (camelCase) plus the relay's caps
73
+ const FLAG_KEYS = ['host', 'port', 'origins', 'keys', 'tlsCert', 'tlsKey', 'localTls', 'behindTls', 'trustProxy', 'proxyHeader', 'allowLan', 'allowLoopbackPorts', 'blockPorts', 'allowHosts', 'log', 'hosted', 'abuseContact', 'denyCidr', 'denyFile', 'maxBytesPerDay', 'maxBytesPerMonth', 'allowPorts', 'controlDir', 'stateDir', 'startStopped', 'logDir', 'logRetentionDays', 'logMaxBytes', 'logDirMaxBytes'] as const
74
+ const CAP_KEYS = ['maxConns', 'maxSessions', 'maxSessionsPerGroup', 'opensPerMin', 'connectsPerMin', 'dnsPerMin', 'rateDown', 'rateUp', 'maxBytes', 'selfAddrs', 'dnsServers', 'connectTimeoutMs', 'dnsTimeoutMs'] as const
75
+ const WISP_KEYS = ['path', 'buffer', 'maxPayload', 'maxHeldBytes', 'maxSessionHeldBytes', 'maxHeldTotal', 'maxGroupHeldBytes', 'instancesPerHour', 'maxPrincipals', 'preAuthPerAddr', 'handshakeMs', 'streamIdleMs', 'prefixKeys'] as const
76
+ // the limits of the hosted profile, under "hosted" in a config file (hosted.ts HostedOptions; all whole numbers)
77
+ const HOSTED_KEYS = ['groupStreams', 'groupConnectsPerMin', 'groupBytesPerHour', 'groupDnsPerMin', 'coarseSessions', 'coarseInstancesPerHour', 'coarseRateDown', 'coarseRateUp', 'originTable', 'originSessions', 'otherOriginSessions', 'noOriginSessions', 'originsPerAddrPerHour', 'originRateDown', 'originRateUp', 'maxStreams', 'maxRateDown', 'maxRateUp', 'destConnectsPerMin', 'destStreams', 'distinctDests', 'failedConnects', 'failedWindowMs', 'failedCoolMs'] as const
78
+ const BOOL_FLAGS = new Set(['localTls', 'behindTls', 'allowLan', 'log', 'hosted', 'startStopped'])
79
+ const LIST_FLAGS = new Set(['origins', 'allowLoopbackPorts', 'blockPorts', 'allowHosts', 'denyCidr', 'allowPorts'])
80
+
81
+ export class UsageError extends Error {}
82
+
83
+ type Conf = Record<string, any>
84
+
85
+ const camel = (f: string) => f.replace(/-([a-z])/g, (_, c) => c.toUpperCase())
86
+
87
+ /** argv (after the program) to a config object. Throws UsageError in words. */
88
+ export function parseFlags(argv: string[]): Conf {
89
+ const c: Conf = {}
90
+ for (let i = 0; i < argv.length; i++) {
91
+ const a = argv[i]
92
+ if (!a.startsWith('--')) throw new UsageError(`unexpected argument '${a}' (see --help)`)
93
+ let [name, val] = a.slice(2).split(/=(.*)/s, 2) as [string, string | undefined]
94
+ let k = camel(name)
95
+ if (k === 'origin') k = 'origins'
96
+ if (k === 'config') {
97
+ c.config = val ?? argv[++i]
98
+ if (!c.config) throw new UsageError('--config needs a file')
99
+ continue
100
+ }
101
+ if (!(FLAG_KEYS as readonly string[]).includes(k)) throw new UsageError(`unknown flag --${name} (see --help)`)
102
+ if (k === 'trustProxy') {
103
+ // bare: the legacy "the last X-Forwarded-For entry"; with a list: only those proxies (required by --hosted)
104
+ const v = val ?? (argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[++i] : undefined)
105
+ c.trustProxy = v === undefined ? true : [...(Array.isArray(c.trustProxy) ? c.trustProxy : []), ...v.split(',').map(x => x.trim()).filter(Boolean)]
106
+ continue
107
+ }
108
+ if (BOOL_FLAGS.has(k)) {
109
+ if (val !== undefined) throw new UsageError(`--${name} takes no value`)
110
+ c[k] = true
111
+ continue
112
+ }
113
+ if (val === undefined) val = argv[++i]
114
+ if (val === undefined || val.startsWith('--')) throw new UsageError(`--${name} needs a value`)
115
+ if (LIST_FLAGS.has(k)) c[k] = [...(c[k] ?? []), ...val.split(',').map(x => x.trim()).filter(Boolean)]
116
+ else c[k] = val
117
+ }
118
+ return c
119
+ }
120
+
121
+ /** A config file (JSON, or TOML by extension). Unknown keys are refused, so a typo is not a silent default. */
122
+ export function readConfig(file: string): Conf {
123
+ const text = readFileSync(file, 'utf8')
124
+ const c: Conf = /\.toml$/i.test(file) ? (Bun.TOML.parse(text) as Conf) : JSON.parse(text)
125
+ if (!c || typeof c !== 'object' || Array.isArray(c)) throw new UsageError(`${file}: expected an object`)
126
+ for (const k of Object.keys(c)) {
127
+ if (k === 'wisp') {
128
+ for (const w of Object.keys(c.wisp ?? {})) if (!(WISP_KEYS as readonly string[]).includes(w)) throw new UsageError(`${file}: unknown key wisp.${w}`)
129
+ } else if (k === 'hosted' && c.hosted && typeof c.hosted === 'object') {
130
+ // "hosted": true turns the profile on; "hosted": {limits} turns it on with those limits
131
+ for (const w of Object.keys(c.hosted)) if (!(HOSTED_KEYS as readonly string[]).includes(w)) throw new UsageError(`${file}: unknown key hosted.${w}`)
132
+ } else if (k === 'origin') throw new UsageError(`${file}: the key is 'origins' (a list)`)
133
+ else if (!(FLAG_KEYS as readonly string[]).includes(k) && !(CAP_KEYS as readonly string[]).includes(k)) throw new UsageError(`${file}: unknown key '${k}'`)
134
+ }
135
+ return c
136
+ }
137
+
138
+ // A timer longer than 2^31-1 ms fires after 1 ms (Bun and Node both clamp it), so a huge handshakeMs or connectTimeoutMs
139
+ // would end every connection at once; CONTINUE carries `buffer` as a u32; Bun refuses a frame limit past what it can hold.
140
+ const MS_MAX = 2 ** 31 - 1
141
+ const BOUNDS: Record<string, [number, number]> = {
142
+ connectTimeoutMs: [1, MS_MAX], dnsTimeoutMs: [1, MS_MAX],
143
+ 'wisp.handshakeMs': [1, MS_MAX], 'wisp.buffer': [1, 2 ** 32 - 1], 'wisp.maxPayload': [1, MS_MAX],
144
+ }
145
+ const num = (v: any, what: string, lo = 0, hi = Number.MAX_SAFE_INTEGER) => {
146
+ if (v === undefined) return undefined
147
+ // digits only: Number('') is 0, Number('1e3') is 1000 and Number('0x10') is 16, none of which a person typed as a port
148
+ const n = typeof v === 'number' ? v : typeof v === 'string' && /^\d{1,16}$/.test(v.trim()) ? Number(v) : NaN
149
+ if (!Number.isInteger(n) || n < lo || n > hi) throw new UsageError(`${what} must be a whole number from ${lo} to ${hi} (got '${v}')`)
150
+ return n
151
+ }
152
+ const ports = (v: any, what: string) => (v === undefined ? undefined : (Array.isArray(v) ? v : typeof v === 'string' ? v.split(',').filter(x => x.trim()) : [v]).map((p: any) => num(p, what, 1, 65535)!))
153
+ const isLoopbackBind = (h: string) => /^(127\.\d+\.\d+\.\d+|::1|localhost)$/i.test(h)
154
+
155
+
156
+ /** Config values with the types a flag would have: a wrong type is refused in words, never coerced ("false" is not false). */
157
+ function checkTypes(c: Conf) {
158
+ for (const k of BOOL_FLAGS) {
159
+ if (k === 'hosted' && c.hosted && typeof c.hosted === 'object' && !Array.isArray(c.hosted)) continue
160
+ if (c[k] !== undefined && typeof c[k] !== 'boolean') throw new UsageError(`${k} must be true or false (got ${JSON.stringify(c[k])})`)
161
+ }
162
+ for (const k of ['host', 'keys', 'tlsCert', 'tlsKey', 'proxyHeader', 'abuseContact', 'denyFile', 'controlDir', 'stateDir', 'logDir'] as const) {
163
+ if (c[k] !== undefined && c[k] !== null && (typeof c[k] !== 'string' || !c[k])) throw new UsageError(`${k} must be a non-empty string (got ${JSON.stringify(c[k])})`)
164
+ }
165
+ if (c.host === null) delete c.host
166
+ if (c.trustProxy !== undefined && c.trustProxy !== true && c.trustProxy !== false) {
167
+ if (!Array.isArray(c.trustProxy) || c.trustProxy.some((x: any) => typeof x !== 'string')) throw new UsageError(`trustProxy must be true or a list of proxy addresses or CIDRs (got ${JSON.stringify(c.trustProxy)})`)
168
+ for (const x of c.trustProxy) {
169
+ const cidr = parseCidr(x)
170
+ if (!cidr) throw new UsageError(`--trust-proxy ${x} is not an address or CIDR (e.g. 10.0.0.0/8)`)
171
+ if (cidr.bits < (cidr.v === 4 ? 8 : 16)) throw new UsageError(`--trust-proxy ${x} trusts too much of the internet: a prefix shorter than /8 (IPv4) or /16 (IPv6) is refused in every mode, because ranges like 0.0.0.0/1,128.0.0.0/1 together cover every address and any sender could pick the client address the limits use; list your proxy's address or network`)
172
+ }
173
+ }
174
+ for (const k of ['maxBytesPerDay', 'maxBytesPerMonth', 'logRetentionDays', 'logMaxBytes', 'logDirMaxBytes'] as const) if (c[k] !== undefined) c[k] = num(c[k], k, k === 'logRetentionDays' || k === 'logMaxBytes' || k === 'logDirMaxBytes' ? 1 : 0)
175
+ if (c.hosted && typeof c.hosted === 'object') {
176
+ if (Array.isArray(c.hosted)) throw new UsageError('hosted must be true or a table of limits')
177
+ for (const k of HOSTED_KEYS) if (c.hosted[k] !== undefined) c.hosted[k] = num(c.hosted[k], `hosted.${k}`, 0)
178
+ }
179
+ for (const k of LIST_FLAGS) {
180
+ if (c[k] === undefined) continue
181
+ if (typeof c[k] === 'string') c[k] = c[k].split(',').map((x: string) => x.trim()).filter(Boolean)
182
+ if (!Array.isArray(c[k]) || c[k].some((x: any) => (k === 'allowLoopbackPorts' || k === 'blockPorts' ? typeof x !== 'string' && typeof x !== 'number' : typeof x !== 'string'))) {
183
+ throw new UsageError(`${k} must be a list of ${k.endsWith('Ports') ? 'ports' : 'strings'} (got ${JSON.stringify(c[k])})`)
184
+ }
185
+ }
186
+ for (const k of CAP_KEYS) {
187
+ if (c[k] === undefined) continue
188
+ if (k === 'selfAddrs' || k === 'dnsServers') {
189
+ if (!Array.isArray(c[k]) || c[k].some((x: any) => typeof x !== 'string')) throw new UsageError(`${k} must be a list of addresses (got ${JSON.stringify(c[k])})`)
190
+ } else c[k] = num(c[k], k, ...(BOUNDS[k] ?? []))
191
+ }
192
+ if (c.wisp !== undefined && (!c.wisp || typeof c.wisp !== 'object' || Array.isArray(c.wisp))) throw new UsageError('wisp must be a table of settings')
193
+ if (c.wisp) {
194
+ // the same rule as the top level: "false" is not false, 'x' is not a size (Bun refused it with a raw message, or a limit went NaN)
195
+ if (c.wisp.prefixKeys !== undefined && typeof c.wisp.prefixKeys !== 'boolean') throw new UsageError(`wisp.prefixKeys must be true or false (got ${JSON.stringify(c.wisp.prefixKeys)})`)
196
+ for (const k of WISP_KEYS) {
197
+ if (k === 'path' || k === 'prefixKeys' || c.wisp[k] === undefined) continue
198
+ const n = num(c.wisp[k], `wisp.${k}`, ...(BOUNDS[`wisp.${k}`] ?? []))
199
+ c.wisp[k] = n
200
+ }
201
+ }
202
+ const path = c.wisp?.path
203
+ if (path !== undefined && (typeof path !== 'string' || !/^\/([A-Za-z0-9._~-]+\/)*$/.test(path))) throw new UsageError(`wisp.path must start and end with '/' (got ${JSON.stringify(path)})`)
204
+ }
205
+
206
+ /** Owner keys file: {"keys": [OwnerKey, ...]}, mode 0600 (refused when others can read it). */
207
+ export function readKeys(file: string): OwnerKey[] {
208
+ const st = statSync(file)
209
+ if (process.platform !== 'win32' && st.mode & 0o077) {
210
+ throw new UsageError(`${file} can be read by other users (mode ${(st.mode & 0o777).toString(8)}); run: chmod 600 ${file}`)
211
+ }
212
+ const dir = dirname(realpathSync(file))
213
+ if (process.platform !== 'win32' && !safeDir(dir)) {
214
+ throw new UsageError(`${file} is in ${dir}, which other users can write to: they could swap the file. Move it to a directory only you can write (the default is ~/.wmd/relay)`)
215
+ }
216
+ const j = JSON.parse(readFileSync(file, 'utf8'))
217
+ const keys = Array.isArray(j?.keys) ? j.keys : null
218
+ if (!keys) throw new UsageError(`${file}: expected {"keys": [...]}`)
219
+ for (const k of keys) {
220
+ if (typeof k?.id !== 'string' || !/^[A-Za-z0-9_-]{1,64}$/.test(k.id)) throw new UsageError(`${file}: a key id must be 1-64 letters, digits, '-' or '_'`)
221
+ if (typeof k.hash !== 'string' || !k.hash.startsWith('scrypt:')) throw new UsageError(`${file}: key '${k.id}' has no scrypt hash (secrets are never stored, only hashes)`)
222
+ }
223
+ return keys
224
+ }
225
+
226
+ /** A directory only its owner (or root) can change entries in: not group/other writable, unless sticky (/tmp). */
227
+ const safeDir = (d: string) => {
228
+ const m = statSync(d).mode
229
+ return !(m & 0o022) || !!(m & 0o1000)
230
+ }
231
+
232
+ function writeKeys(file: string, keys: OwnerKey[]) {
233
+ const real = existsSync(file) ? realpathSync(file) : file // a symlinked keys file stays a symlink
234
+ mkdirSync(dirname(real), { recursive: true, mode: 0o700 })
235
+ if (process.platform !== 'win32' && !safeDir(dirname(real))) throw new UsageError(`${dirname(real)} can be written by other users; keep the keys file in a directory only you can write`)
236
+ // a name nobody can have planted ('wx' fails on an existing path, a symlink included) and a mode of 0600 from the first byte
237
+ const tmp = `${real}.${process.pid}.${randomBytes(6).toString('hex')}.new`
238
+ try {
239
+ writeFileSync(tmp, JSON.stringify({ keys }, null, 2) + '\n', { mode: 0o600, flag: 'wx' })
240
+ renameSync(tmp, real)
241
+ } catch (e) {
242
+ rmSync(tmp, { force: true })
243
+ throw e
244
+ }
245
+ }
246
+
247
+ /** One writer at a time (two `key add` at once must not lose a key whose secret was already printed). */
248
+ async function withLock<T>(file: string, fn: () => Promise<T>): Promise<T> {
249
+ const real = existsSync(file) ? realpathSync(file) : file
250
+ mkdirSync(dirname(real), { recursive: true, mode: 0o700 })
251
+ const lock = `${real}.lock`
252
+ const t0 = Date.now()
253
+ while (!tryTake(lock, 'file')) {
254
+ breakIfStale(lock, 'file') // left by a killed process
255
+ if (Date.now() - t0 > 20_000) throw new UsageError(`${lock} is held by another wmd-relay; if none is running, delete it`)
256
+ await Bun.sleep(25)
257
+ }
258
+ try {
259
+ return await fn()
260
+ } finally {
261
+ release(lock)
262
+ }
263
+ }
264
+
265
+ /** The relay options for a config (flags merged over the file). */
266
+ /** What `serve` needs besides the relay's options when --hosted is on. */
267
+ export type HostedRun = { controlDir: string; stateDir: string; denyFile?: string; logDir?: string; logMaxBytes?: number; logDirMaxBytes?: number; logRetentionDays?: number; tlsCert?: string; tlsKey?: string }
268
+
269
+ /**
270
+ * Defaults of the hosted profile (docs/relay-hosted.md, section "Measured on this machine"): the per-guest and per-address
271
+ * numbers one instance carried in the loopback load test, with headroom. Each is a flag, a config key or a `hosted` table key.
272
+ */
273
+ export const HOSTED_DEFAULTS = {
274
+ caps: { maxSessions: 4000, maxSessionsPerGroup: 8, maxConns: 32, opensPerMin: 20, connectsPerMin: 120, dnsPerMin: 300, rateDown: 4 << 20, rateUp: 1 << 20, maxBytes: 2 ** 30 },
275
+ wisp: { instancesPerHour: 30 },
276
+ hosted: {} as Partial<HostedOptions>,
277
+ }
278
+
279
+ export function relayOptions(c: Conf, env: Record<string, string | undefined> = process.env): { opts: RelayOptions; notes: string[]; url: string; localTls: boolean; hosted?: HostedRun } {
280
+ const notes: string[] = []
281
+ checkTypes(c)
282
+ const host = c.host ?? '127.0.0.1'
283
+ const loopback = isLoopbackBind(host)
284
+ const port = num(c.port, '--port', 0, 65535) ?? DEFAULT_PORT
285
+ const keys = c.keys ? readKeys(c.keys) : undefined
286
+ if (keys && !keys.length) throw new UsageError(`${c.keys} has no keys; add one with: wmd-relay key add <id> --keys ${c.keys}`)
287
+ if (!!c.tlsCert !== !!c.tlsKey) throw new UsageError('--tls-cert and --tls-key go together')
288
+ const tls = c.tlsCert ? { cert: readFileSync(c.tlsCert, 'utf8'), key: readFileSync(c.tlsKey, 'utf8') } : undefined
289
+ if (tls && c.localTls) throw new UsageError('--local-tls makes its own certificate; give it or --tls-cert, not both')
290
+ const origins: string[] = c.origins ?? []
291
+ for (const o of origins) {
292
+ let u: URL
293
+ try {
294
+ u = new URL(o)
295
+ } catch {
296
+ throw new UsageError(`--origin ${o} is not a URL (e.g. https://site.example)`)
297
+ }
298
+ if (u.origin !== o.replace(/\/$/, '')) throw new UsageError(`--origin ${o}: give only the origin, e.g. ${u.origin}`)
299
+ }
300
+ const isHosted = !!c.hosted
301
+ if (isHosted) {
302
+ // the refusals a public relay owes its operator before it takes a single connection (plan §15)
303
+ if (!c.abuseContact || !String(c.abuseContact).trim()) throw new UsageError('--hosted needs --abuse-contact <email or URL>: it is shown in the MOTD, /healthz and every refusal, and abuse complaints are answered there')
304
+ if (/[\x00-\x1f]/.test(c.abuseContact)) throw new UsageError('--abuse-contact must be one line of plain text')
305
+ if (c.allowLan) throw new UsageError('--hosted does not allow --allow-lan: a public relay must never reach private (LAN) addresses')
306
+ if (Array.isArray(c.allowLoopbackPorts) ? c.allowLoopbackPorts.length : c.allowLoopbackPorts) throw new UsageError('--hosted does not allow --allow-loopback-ports: a public relay must never reach its own loopback')
307
+ const list = Array.isArray(c.trustProxy) ? c.trustProxy : []
308
+ if (c.trustProxy === true) throw new UsageError('--hosted needs --trust-proxy <list of proxy addresses or CIDRs>: a bare --trust-proxy would believe any sender about the client address')
309
+ if (c.behindTls && !list.length) throw new UsageError('--hosted --behind-tls needs --trust-proxy <your proxy address or CIDR>: otherwise every reader shares the proxy\'s address and its limits')
310
+ if (c.behindTls && !isPrivateBind(host)) throw new UsageError(`--hosted --behind-tls binds only to loopback or a private address (the proxy\'s side), not ${host}: a public bind would let anyone skip the proxy and its TLS`)
311
+ if (!tls && !c.behindTls && !isPrivateBind(host)) throw new UsageError(`--hosted refuses plain ws:// on the public address ${host}: give --tls-cert and --tls-key, or put a TLS proxy in front with --behind-tls (bound to loopback)`)
312
+ if (c.localTls) throw new UsageError('--local-tls is for a person\'s own computer, not --hosted')
313
+ }
314
+ const w = c.wisp ?? {}
315
+ const secret = env.WMD_RELAY_SECRET || randomBytes(32).toString('base64url') // tokens (wmn's issuer) only when the secret is shared
316
+ if (env.WMD_RELAY_SECRET && env.WMD_RELAY_SECRET.length < 16) throw new UsageError('WMD_RELAY_SECRET must be at least 16 characters')
317
+ const opts: RelayOptions = {
318
+ secret,
319
+ host,
320
+ port,
321
+ tls,
322
+ behindTls: !!c.behindTls,
323
+ origins: origins.map(o => o.replace(/\/$/, '')),
324
+ allowLan: !!c.allowLan,
325
+ allowLoopbackPorts: ports(c.allowLoopbackPorts, '--allow-loopback-ports'),
326
+ blockedPorts: ports(c.blockPorts, '--block-ports'),
327
+ allowHosts: c.allowHosts,
328
+ ...(isHosted ? HOSTED_DEFAULTS.caps : {}),
329
+ ...Object.fromEntries(CAP_KEYS.filter(k => c[k] !== undefined).map(k => [k, c[k]])),
330
+ ...(Array.isArray(c.trustProxy) ? { trustProxy: c.trustProxy } : {}),
331
+ ...(c.proxyHeader ? { proxyHeader: c.proxyHeader } : {}),
332
+ ...(c.allowPorts ? { allowPorts: ports(c.allowPorts, '--allow-ports') } : {}),
333
+ ...(isHosted
334
+ ? {
335
+ hosted: {
336
+ ...HOSTED_DEFAULTS.hosted,
337
+ ...(typeof c.hosted === 'object' ? c.hosted : {}),
338
+ abuseContact: String(c.abuseContact).trim(),
339
+ version: VERSION,
340
+ startStopped: !!c.startStopped,
341
+ usageFile: join(c.stateDir ?? relayDir(), 'usage.json'),
342
+ maxBytesPerDay: c.maxBytesPerDay,
343
+ maxBytesPerMonth: c.maxBytesPerMonth,
344
+ denyCidrs: c.denyCidr,
345
+ } as HostedOptions,
346
+ }
347
+ : {}),
348
+ log: c.log ? e => process.stderr.write(JSON.stringify({ t: Date.now(), ...e }) + '\n') : undefined,
349
+ wisp: {
350
+ ...w,
351
+ enabled: true,
352
+ open: !keys,
353
+ keys,
354
+ origins: origins.length ? origins.map(o => o.replace(/\/$/, '')) : undefined,
355
+ checkHost: loopback && !isHosted, // a hosted relay answers to its own name
356
+ trustProxy: c.trustProxy === true, // a list is the relay's `trustProxy` option (only those peers are believed)
357
+ version: VERSION,
358
+ ...(isHosted ? HOSTED_DEFAULTS.wisp : {}),
359
+ ...Object.fromEntries(WISP_KEYS.filter(k => w[k] !== undefined).map(k => [k, w[k]])),
360
+ },
361
+ }
362
+ if (!loopback && !keys && !isHosted) {
363
+ notes.push(
364
+ `note: ${host} can be reached from other machines and there is no key: anyone who can reach port {port} can open connections from this machine through it.` +
365
+ (origins.length ? '' : ' To allow only your site, add --origin https://your.site'),
366
+ )
367
+ }
368
+ if (c.trustProxy && loopback) notes.push('note: --trust-proxy on a loopback bind: only right if a proxy on this computer forwards to it')
369
+ if (opts.allowLoopbackPorts?.length) notes.push(`note: guests may reach this computer's loopback on port(s) ${opts.allowLoopbackPorts.join(', ')}`)
370
+ if (opts.allowLan) notes.push('note: guests may reach private (LAN) addresses')
371
+ const shown = host === '0.0.0.0' || host === '::' ? 'HOST' : host.includes(':') ? `[${host.replace(/%/g, '%25')}]` : host // a zone id is %25 in a URL
372
+ const url = `${tls ? 'wss' : 'ws'}://${shown}:${port}${w.path ?? '/wisp/'}`
373
+ const hostedRun: HostedRun | undefined = isHosted
374
+ ? { controlDir: c.controlDir ?? join(relayDir(), 'ctl'), stateDir: c.stateDir ?? relayDir(), denyFile: c.denyFile, logDir: c.logDir, logMaxBytes: c.logMaxBytes, logDirMaxBytes: c.logDirMaxBytes, logRetentionDays: c.logRetentionDays, tlsCert: c.tlsCert, tlsKey: c.tlsKey }
375
+ : undefined
376
+ return { opts, notes, url, localTls: !!c.localTls, hosted: hostedRun }
377
+ }
378
+
379
+ function ownAddress(host: string): boolean {
380
+ if (/^(0\.0\.0\.0|::|localhost)$/i.test(host)) return true
381
+ const h = host.replace(/^\[|\]$/g, '').toLowerCase()
382
+ return Object.values(networkInterfaces()).some(l => (l || []).some(i => i.address.split('%')[0].toLowerCase() === h))
383
+ }
384
+
385
+ async function serve(argv: string[]) {
386
+ let c = parseFlags(argv)
387
+ let fromFile: [string, string][] = [] // [flag text, key in the file] for the settings the file gave and no flag overrode
388
+ let file = ''
389
+ if (c.config) {
390
+ file = c.config
391
+ delete c.config
392
+ const f = readConfig(file)
393
+ fromFile = Object.keys(f).filter(k => !(k in c)).map(k => [`--${(k === 'origins' ? 'origin' : k).replace(/[A-Z]/g, m => '-' + m.toLowerCase())}`, k])
394
+ c = { ...f, ...c }
395
+ }
396
+ let made: ReturnType<typeof relayOptions>
397
+ try {
398
+ made = relayOptions(c)
399
+ } catch (e) {
400
+ // a value from the file is not a flag the person typed: name where it is
401
+ if (e instanceof UsageError) for (const [flag, k] of fromFile) e.message = e.message.replace(new RegExp(`${flag}(?![\\w-])`, 'g'), `${k} (in ${file})`)
402
+ throw e
403
+ }
404
+ const { opts, notes, url, localTls: wantTls, hosted: hostedRun } = made
405
+ // hosted: metadata logs go to rotating files with --log-dir, else to stderr
406
+ const logFile = hostedRun?.logDir ? createLogFile({ dir: hostedRun.logDir, maxBytes: hostedRun.logMaxBytes, maxLogDirBytes: hostedRun.logDirMaxBytes, retainDays: hostedRun.logRetentionDays }) : null
407
+ if (logFile) opts.log = e => logFile.write(e)
408
+ else if (hostedRun && !opts.log) opts.log = e => process.stderr.write(JSON.stringify({ t: Date.now(), ...e }) + '\n')
409
+ const relays: ReturnType<typeof createRelay>[] = []
410
+ const start = (o: RelayOptions) => {
411
+ try {
412
+ relays.push(createRelay({ ...o, alsoOwnPorts: () => relays.map(r => r.port) }))
413
+ } catch (e: any) {
414
+ const m = String(e?.message || e)
415
+ if (e?.code === 'EADDRINUSE' || e?.code === 'EADDRNOTAVAIL' || /EADDRINUSE|in use/i.test(m)) {
416
+ // Bun says EADDRINUSE for an address this computer does not have, too
417
+ if (!ownAddress(o.host!)) throw new UsageError(`${o.host} is not an address of this computer`)
418
+ throw new UsageError(`port ${o.port} on ${o.host} is already in use (another wmd-relay?). Pick another with --port`)
419
+ }
420
+ if (e?.code === 'EACCES' || /EACCES|permission/i.test(m)) throw new UsageError(`not allowed to listen on ${o.host}:${o.port}`)
421
+ throw new UsageError(m)
422
+ }
423
+ }
424
+ if (wantTls && !isLoopbackBind(opts.host!)) throw new UsageError('--local-tls is for this computer only; use --tls-cert and --tls-key on a reachable address')
425
+ start(opts)
426
+ const urls = [url.replace(/:\d+(\/[^:]*)$/, `:${relays[0].port}$1`)]
427
+ for (let i = 0; i < notes.length; i++) notes[i] = notes[i].replace('{port}', String(relays[0].port))
428
+ if (wantTls) {
429
+ const t = localTls()
430
+ if (opts.port === 65535) throw new UsageError('--local-tls serves wss on the next port up; 65535 has none (pick a lower --port)')
431
+ const tlsPort = opts.port === 0 ? 0 : opts.port === DEFAULT_PORT ? DEFAULT_TLS_PORT : opts.port! + 1 // --port N: wss on N+1
432
+ start({ ...opts, port: tlsPort, tls: { cert: t.cert, key: t.key } })
433
+ urls.push(`wss://127.0.0.1:${relays[1].port}${opts.wisp?.path ?? '/wisp/'}`)
434
+ notes.push(`${t.made ? 'made' : 'using'} ${t.certPath}`, ...t.trust)
435
+ }
436
+ let control: { path: string; stop: () => void } | null = null
437
+ if (hostedRun) {
438
+ const r = relays[0]
439
+ const readDeny = () => JSON.parse(readFileSync(hostedRun.denyFile!, 'utf8'))
440
+ try {
441
+ if (hostedRun.denyFile) r.reloadDeny(readDeny())
442
+ control = await startControl(hostedRun.controlDir, {
443
+ 'stop-new': () => (r.hosted!.stopNew(), r.status()),
444
+ resume: () => (r.hosted!.resume(), r.status()),
445
+ drain: () => ({ drained: r.drain(), ...r.status() }),
446
+ status: () => r.status(),
447
+ })
448
+ } catch (e: any) {
449
+ for (const x of relays) x.stop()
450
+ logFile?.close()
451
+ if (e instanceof ControlError) throw new UsageError(e.message)
452
+ throw new UsageError(hostedRun.denyFile && !control ? `${hostedRun.denyFile}: ${e?.message || e}` : e?.message || String(e))
453
+ }
454
+ notes.push(`control socket: ${control.path} (wmd-relay ctl status --control-dir ${hostedRun.controlDir})`)
455
+ // SIGHUP: the new certificate for new connections, and the deny lists again (denied sessions end). A bad file keeps the old one.
456
+ process.on('SIGHUP', () => {
457
+ const say = (m: string) => process.stderr.write(`wmd-relay: ${m}\n`)
458
+ if (hostedRun.tlsCert) {
459
+ try {
460
+ r.reloadTls({ cert: readFileSync(hostedRun.tlsCert, 'utf8'), key: readFileSync(hostedRun.tlsKey!, 'utf8') })
461
+ say('certificate reloaded')
462
+ } catch (e: any) {
463
+ say(`certificate NOT reloaded (the old one stays): ${e?.message || e}`)
464
+ }
465
+ }
466
+ if (hostedRun.denyFile) {
467
+ try {
468
+ const x = r.reloadDeny(readDeny())
469
+ say(`deny lists reloaded (ended ${x.sessions} session(s), ${x.streams} stream(s))`)
470
+ } catch (e: any) {
471
+ say(`deny lists NOT reloaded (the old ones stay): ${e?.message || e}`)
472
+ }
473
+ }
474
+ })
475
+ }
476
+ for (const n of notes) process.stderr.write(n + '\n') // before the URLs: whoever waits for a URL has the notes too
477
+ for (const u of urls) console.log(u)
478
+ const stop = () => {
479
+ control?.stop()
480
+ for (const r of relays) r.stop()
481
+ logFile?.close()
482
+ process.exit(0)
483
+ }
484
+ process.on('SIGINT', stop)
485
+ process.on('SIGTERM', stop)
486
+ }
487
+
488
+ async function key(argv: string[]) {
489
+ const [sub, ...rest] = argv
490
+ let file = join(relayDir(), 'keys.json')
491
+ const args: string[] = []
492
+ for (let i = 0; i < rest.length; i++) {
493
+ if (rest[i] === '--keys') file = rest[++i] ?? ''
494
+ else if (rest[i].startsWith('--keys=')) file = rest[i].slice(7)
495
+ else args.push(rest[i])
496
+ }
497
+ if (!file) throw new UsageError('--keys needs a file')
498
+ const body = async () => {
499
+ const keys = existsSync(file) ? readKeys(file) : []
500
+ if (sub === 'add') {
501
+ const id = args[0]
502
+ if (!id || !/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new UsageError('usage: wmd-relay key add <id> (1-64 letters, digits, - or _)')
503
+ if (keys.some(k => k.id === id)) throw new UsageError(`a key '${id}' is already in ${file}; remove it first`)
504
+ const k = await newKey(id)
505
+ writeKeys(file, [...keys, { id: k.id, hash: k.hash }])
506
+ console.log(`key id: ${k.id}`)
507
+ console.log(`secret: ${k.secret}`)
508
+ process.stderr.write(`The secret is shown only now; ${file} keeps only its hash. Serve with: wmd-relay --keys ${file}\n`)
509
+ } else if (sub === 'list') {
510
+ for (const k of keys) console.log(k.id)
511
+ } else if (sub === 'remove') {
512
+ const id = args[0]
513
+ if (!keys.some(k => k.id === id)) throw new UsageError(`no key '${id}' in ${file}`)
514
+ writeKeys(file, keys.filter(k => k.id !== id))
515
+ process.stderr.write(`removed '${id}'; a running relay keeps it until restarted\n`)
516
+ } else throw new UsageError('usage: wmd-relay key add <id> | list | remove <id> [--keys file]')
517
+ }
518
+ if (sub === 'add' || sub === 'remove') await withLock(file, body)
519
+ else await body()
520
+ }
521
+
522
+ /** `wmd-relay ctl stop-new|resume|drain|status [--control-dir D]`: one command to a running hosted relay. Prints its JSON answer. */
523
+ async function ctlCommand(argv: string[]): Promise<number> {
524
+ let dir = join(relayDir(), 'ctl')
525
+ const rest: string[] = []
526
+ for (let i = 0; i < argv.length; i++) {
527
+ if (argv[i] === '--control-dir') dir = argv[++i] ?? ''
528
+ else if (argv[i].startsWith('--control-dir=')) dir = argv[i].slice(14)
529
+ else rest.push(argv[i])
530
+ }
531
+ if (!dir) throw new UsageError('--control-dir needs a directory')
532
+ if (rest.length !== 1 || !(COMMANDS as readonly string[]).includes(rest[0])) throw new UsageError(`usage: wmd-relay ctl ${COMMANDS.join('|')} [--control-dir <dir>]`)
533
+ const sock = join(dir, 'ctl.sock')
534
+ let r: any
535
+ try {
536
+ r = await ctl(sock, rest[0])
537
+ } catch (e: any) {
538
+ throw new UsageError(`cannot reach the relay's control socket ${sock}: ${e?.code === 'EACCES' ? 'permission denied (it is for the relay\'s user)' : e?.code === 'ENOENT' || e?.code === 'ECONNREFUSED' ? 'no hosted relay is listening there' : e?.message || e}`)
539
+ }
540
+ console.log(JSON.stringify(r))
541
+ return r.ok ? 0 : 1
542
+ }
543
+
544
+ /**
545
+ * `wmd-relay check`: can this machine run the relay as built? Starts one on a free loopback port, connects to it as
546
+ * a Wisp client, and checks that this host's own addresses (os.networkInterfaces) and loopback are refused, that
547
+ * names are looked up (node:dns), and that a public name gets an answer. Prints a line per check; exit 0 when the
548
+ * refusals hold (the lookup of a public name only reports, since the network may be offline).
549
+ */
550
+ async function check(): Promise<number> {
551
+ const { CLOSE, ClientHandshake, PT, connectPacket, ext, lookupPacket, parse } = await import('../src/net/wisp.js')
552
+ const r = createRelay({ secret: randomBytes(32).toString('base64url'), port: 0, host: '127.0.0.1', wisp: { enabled: true, open: true, checkHost: true, version: VERSION } })
553
+ console.log(`relay: ws://127.0.0.1:${r.port}/wisp/ (wmd-relay ${VERSION}, ${process.platform}-${process.arch})`)
554
+ const ws = new WebSocket(`ws://127.0.0.1:${r.port}/wisp/`, ['wisp-v2'])
555
+ ws.binaryType = 'arraybuffer'
556
+ const hs = new ClientHandshake({ exts: [ext.confirm(), ext.wmdDns(), ext.wmdHalfclose(), ext.wmdCredit(65536), ext.wmdInstance(crypto.getRandomValues(new Uint8Array(16)))] })
557
+ const msgs: any[] = []
558
+ const ready = new Promise<any>(res => {
559
+ let done = false
560
+ ws.onmessage = e => {
561
+ const m = parse(e.data)
562
+ if (done) return void msgs.push(m)
563
+ const x = hs.step(m)
564
+ for (const b of x.send) ws.send(b)
565
+ if (x.done || x.fail) res(((done = true), x))
566
+ }
567
+ ws.onclose = () => res({ fail: 'closed' })
568
+ })
569
+ const wait = async (pred: (m: any) => boolean, ms = 8000) => {
570
+ const t0 = Date.now()
571
+ while (Date.now() - t0 < ms) {
572
+ const m = msgs.find(pred)
573
+ if (m) return m
574
+ await Bun.sleep(10)
575
+ }
576
+ return null
577
+ }
578
+ let bad = 0
579
+ const say = (ok: boolean, what: string) => {
580
+ if (!ok) bad++
581
+ console.log(`${ok ? 'ok' : 'FAIL'}: ${what}`)
582
+ }
583
+ const h = await Promise.race([ready, Bun.sleep(8000).then(() => ({ fail: 'timeout' }))])
584
+ say(!!h.done, `Wisp ${h.done?.version ?? '?'} handshake${h.done ? '' : ` (${JSON.stringify(h.fail)})`}`)
585
+ if (h.done) {
586
+ let id = 1
587
+ const refused = async (host: string, port: number, what: string) => {
588
+ const n = (id += 2)
589
+ ws.send(connectPacket(n, host, port))
590
+ const m = await wait(x => x.id === n && (x.type === PT.CLOSE || x.type === PT.CONTINUE))
591
+ say(m?.type === PT.CLOSE && m.reason === CLOSE.BLOCKED, `${what} ${host}:${port} refused${m ? (m.type === PT.CLOSE ? ` (0x${m.reason.toString(16)})` : ' -- it was OPENED') : ' -- no answer'}`)
592
+ }
593
+ const nics = Object.values(networkInterfaces()).flat().filter(i => i && !i.internal)
594
+ const own = [...new Set([...nics.filter(i => i!.family === 'IPv4'), ...nics.filter(i => i!.family !== 'IPv4')].map(i => i!.address.split('%')[0]))]
595
+ if (own.length) for (const a of own.slice(0, 4)) await refused(a, 22, 'own address')
596
+ else console.log('note: no non-loopback interface; own-address refusal not checked')
597
+ await refused('127.0.0.1', r.port, 'loopback (the relay itself)')
598
+ await refused('169.254.169.254', 80, 'metadata')
599
+ const look = async (name: string) => {
600
+ const n = (id += 2)
601
+ ws.send(lookupPacket(n, name, 1))
602
+ return wait(x => x.id === n && x.type === PT.ANSWER)
603
+ }
604
+ const lh = await look('localhost')
605
+ say(lh?.rcode === 5, `lookup localhost refused${lh ? ` (rcode ${lh.rcode})` : ' -- no answer'}`)
606
+ const pub = await look('one.one.one.one')
607
+ console.log(`info: lookup one.one.one.one: ${pub ? `rcode ${pub.rcode}${pub.rcode === 0 ? ' (the resolver works)' : ' (offline, or the resolver refused)'}` : 'no answer'}`)
608
+ }
609
+ ws.close()
610
+ r.stop()
611
+ console.log(bad ? `${bad} check(s) failed` : 'all checks passed')
612
+ return bad ? 1 : 0
613
+ }
614
+
615
+ export async function main(argv: string[]): Promise<number> {
616
+ try {
617
+ if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
618
+ process.stdout.write(HELP)
619
+ return 0
620
+ }
621
+ if (argv[0] === 'version' || argv[0] === '--version') {
622
+ console.log(`wmd-relay ${VERSION}`)
623
+ return 0
624
+ }
625
+ if (argv[0] === 'check') return await check()
626
+ if (argv[0] === 'ctl') return await ctlCommand(argv.slice(1))
627
+ if (argv[0] === 'key') {
628
+ await key(argv.slice(1))
629
+ return 0
630
+ }
631
+ await serve(argv)
632
+ return -1 // serving
633
+ } catch (e: any) {
634
+ process.stderr.write(`wmd-relay: ${e instanceof UsageError || e instanceof SyntaxError ? e.message : e?.code === 'ENOENT' ? `no such file: ${e.path}` : e?.message || e}\n`)
635
+ return 2
636
+ }
637
+ }
638
+
639
+ if (import.meta.main) {
640
+ const code = await main(process.argv.slice(2))
641
+ if (code >= 0) process.exit(code)
642
+ }