@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.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- 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
|
+
}
|