@threadedstack/wmd-web 0.1.13 → 0.1.14
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 +11 -0
- package/RELEASE.json +13 -11
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/package.json +4 -4
- package/relay/engine.ts +103 -61
- package/relay/hosted.ts +437 -392
- package/relay/main.ts +61 -33
- package/relay/relay.ts +37 -25
- package/relay/wisp.ts +111 -51
- package/src/host/ctl.js +45 -4
- package/src/net/wisp-client.js +71 -17
- package/src/net/wisp.js +52 -6
- package/wmdpack.wasm +0 -0
package/relay/main.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
13
13
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
14
14
|
import { networkInterfaces } from 'node:os'
|
|
15
15
|
import { dirname, join } from 'node:path'
|
|
16
|
-
import { type HostedOptions } from './hosted.ts'
|
|
16
|
+
import { type HostedOptions, compileSites } from './hosted.ts'
|
|
17
17
|
import { type OwnerKey, newKey } from './keys.ts'
|
|
18
18
|
import { ControlError, COMMANDS, ctl, startControl } from './control.ts'
|
|
19
19
|
import { createLogFile } from './logfile.ts'
|
|
@@ -51,18 +51,23 @@ Flags:
|
|
|
51
51
|
--proxy-header <name> the header the proxy sets (X-Forwarded-For; e.g. CF-Connecting-IP)
|
|
52
52
|
--allow-lan guests may reach private addresses (10/8, 192.168/16, ...)
|
|
53
53
|
--allow-loopback-ports <list> guests may reach this computer's loopback on these ports
|
|
54
|
-
--block-ports <list> destination ports refused (
|
|
54
|
+
--block-ports <list> destination ports refused (none)
|
|
55
55
|
--allow-hosts <list> only these names (name[:port|port], a leading . for subdomains)
|
|
56
|
-
--hosted the public hosted profile (docs/relay-hosted.md):
|
|
57
|
-
|
|
56
|
+
--hosted the public hosted profile (docs/relay-hosted.md): only listed sites, no crawling,
|
|
57
|
+
failed dials slowed, the month's data cap, host limits, deny lists, a control
|
|
58
|
+
socket, /healthz, /readyz
|
|
58
59
|
--abuse-contact <text> required: an address or URL, shown in the MOTD, /healthz and every refusal
|
|
60
|
+
--sites-file <file> required: JSON {"sites": ["https://name", "https://*.domain", ...]}, the page
|
|
61
|
+
origins served; re-read on SIGHUP
|
|
62
|
+
--month-bytes <n> bytes relayed in a UTC month (the provider's free outbound data less headers);
|
|
63
|
+
off pace, heavy addresses are slowed first; at it, new connections stop
|
|
64
|
+
--link-bytes-per-sec <n> the host's link speed: when it is full, networks, then addresses, then browsers
|
|
65
|
+
share it evenly
|
|
59
66
|
--deny-cidr <list> dialled addresses to refuse besides the built-in ranges (the fleet's private neighbours)
|
|
60
67
|
--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
68
|
--allow-ports <list> only these destination ports (25, 465 and 587 are always blocked)
|
|
64
69
|
--control-dir <dir> the control socket's directory, mode 0700, never under /tmp (~/.wmd/relay/ctl)
|
|
65
|
-
--state-dir <dir> where the byte
|
|
70
|
+
--state-dir <dir> where the month's byte counter is kept (~/.wmd/relay)
|
|
66
71
|
--start-stopped start in stop-new until 'ctl resume' (a stop does not persist a restart otherwise)
|
|
67
72
|
--log-dir <dir> metadata log files, rotated by size and age; --log-retention-days <n> (14)
|
|
68
73
|
--config <file> JSON or TOML with any of the above, and the caps
|
|
@@ -70,11 +75,14 @@ Flags:
|
|
|
70
75
|
`
|
|
71
76
|
|
|
72
77
|
// 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', '
|
|
78
|
+
const FLAG_KEYS = ['host', 'port', 'origins', 'keys', 'tlsCert', 'tlsKey', 'localTls', 'behindTls', 'trustProxy', 'proxyHeader', 'allowLan', 'allowLoopbackPorts', 'blockPorts', 'allowHosts', 'log', 'hosted', 'abuseContact', 'sitesFile', 'monthBytes', 'linkBytesPerSec', 'denyCidr', 'denyFile', 'allowPorts', 'controlDir', 'stateDir', 'startStopped', 'logDir', 'logRetentionDays', 'logMaxBytes', 'logDirMaxBytes'] as const
|
|
74
79
|
const CAP_KEYS = ['maxConns', 'maxSessions', 'maxSessionsPerGroup', 'opensPerMin', 'connectsPerMin', 'dnsPerMin', 'rateDown', 'rateUp', 'maxBytes', 'selfAddrs', 'dnsServers', 'connectTimeoutMs', 'dnsTimeoutMs'] as const
|
|
75
80
|
const WISP_KEYS = ['path', 'buffer', 'maxPayload', 'maxHeldBytes', 'maxSessionHeldBytes', 'maxHeldTotal', 'maxGroupHeldBytes', 'instancesPerHour', 'maxPrincipals', 'preAuthPerAddr', 'handshakeMs', 'streamIdleMs', 'prefixKeys'] as const
|
|
76
|
-
// the
|
|
77
|
-
const HOSTED_KEYS = ['
|
|
81
|
+
// the numbers of the hosted rules, under "hosted" in a config file (hosted.ts HostedOptions; all whole numbers)
|
|
82
|
+
const HOSTED_KEYS = ['sitesPerAddrPerHour', 'sitesPerNetPerHour'] as const
|
|
83
|
+
// keys of the limits plan §16 removed: a config that still has them starts, with one line saying they do nothing
|
|
84
|
+
const LEGACY_KEYS = new Set(['maxBytesPerDay', 'maxBytesPerMonth'])
|
|
85
|
+
const LEGACY_HOSTED_KEYS = new Set(['groupStreams', 'groupConnectsPerMin', 'groupBytesPerHour', 'groupDnsPerMin', 'coarseSessions', 'coarseInstancesPerHour', 'coarseRateDown', 'coarseRateUp', 'originTable', 'originSessions', 'originShare', 'otherOriginSessions', 'noOriginSessions', 'originsPerAddrPerHour', 'originRateDown', 'originRateUp', 'maxStreams', 'maxRateDown', 'maxRateUp', 'destConnectsPerMin', 'destStreams', 'distinctDests', 'failedConnects', 'failedWindowMs', 'failedCoolMs'])
|
|
78
86
|
const BOOL_FLAGS = new Set(['localTls', 'behindTls', 'allowLan', 'log', 'hosted', 'startStopped'])
|
|
79
87
|
const LIST_FLAGS = new Set(['origins', 'allowLoopbackPorts', 'blockPorts', 'allowHosts', 'denyCidr', 'allowPorts'])
|
|
80
88
|
|
|
@@ -118,17 +126,27 @@ export function parseFlags(argv: string[]): Conf {
|
|
|
118
126
|
return c
|
|
119
127
|
}
|
|
120
128
|
|
|
121
|
-
/** A config file (JSON, or TOML by extension). Unknown keys are refused, so a typo is not a silent default.
|
|
122
|
-
|
|
129
|
+
/** A config file (JSON, or TOML by extension). Unknown keys are refused, so a typo is not a silent default. Keys of
|
|
130
|
+
removed limits are dropped with one line to `warn` each. */
|
|
131
|
+
export function readConfig(file: string, warn: (line: string) => void = l => void process.stderr.write(`${l}\n`)): Conf {
|
|
123
132
|
const text = readFileSync(file, 'utf8')
|
|
124
133
|
const c: Conf = /\.toml$/i.test(file) ? (Bun.TOML.parse(text) as Conf) : JSON.parse(text)
|
|
125
134
|
if (!c || typeof c !== 'object' || Array.isArray(c)) throw new UsageError(`${file}: expected an object`)
|
|
135
|
+
const gone = (k: string) => warn(`wmd-relay: ${file}: '${k}' is a limit this version no longer has (docs/relay-hosted.md); it is ignored`)
|
|
126
136
|
for (const k of Object.keys(c)) {
|
|
127
|
-
if (k
|
|
137
|
+
if (LEGACY_KEYS.has(k)) {
|
|
138
|
+
gone(k)
|
|
139
|
+
delete c[k]
|
|
140
|
+
} else if (k === 'wisp') {
|
|
128
141
|
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
142
|
} else if (k === 'hosted' && c.hosted && typeof c.hosted === 'object') {
|
|
130
|
-
// "hosted": true turns the profile on; "hosted": {
|
|
131
|
-
for (const w of Object.keys(c.hosted))
|
|
143
|
+
// "hosted": true turns the profile on; "hosted": {numbers} turns it on with those numbers
|
|
144
|
+
for (const w of Object.keys(c.hosted)) {
|
|
145
|
+
if (LEGACY_HOSTED_KEYS.has(w)) {
|
|
146
|
+
gone(`hosted.${w}`)
|
|
147
|
+
delete c.hosted[w]
|
|
148
|
+
} else if (!(HOSTED_KEYS as readonly string[]).includes(w)) throw new UsageError(`${file}: unknown key hosted.${w}`)
|
|
149
|
+
}
|
|
132
150
|
} else if (k === 'origin') throw new UsageError(`${file}: the key is 'origins' (a list)`)
|
|
133
151
|
else if (!(FLAG_KEYS as readonly string[]).includes(k) && !(CAP_KEYS as readonly string[]).includes(k)) throw new UsageError(`${file}: unknown key '${k}'`)
|
|
134
152
|
}
|
|
@@ -159,7 +177,7 @@ function checkTypes(c: Conf) {
|
|
|
159
177
|
if (k === 'hosted' && c.hosted && typeof c.hosted === 'object' && !Array.isArray(c.hosted)) continue
|
|
160
178
|
if (c[k] !== undefined && typeof c[k] !== 'boolean') throw new UsageError(`${k} must be true or false (got ${JSON.stringify(c[k])})`)
|
|
161
179
|
}
|
|
162
|
-
for (const k of ['host', 'keys', 'tlsCert', 'tlsKey', 'proxyHeader', 'abuseContact', 'denyFile', 'controlDir', 'stateDir', 'logDir'] as const) {
|
|
180
|
+
for (const k of ['host', 'keys', 'tlsCert', 'tlsKey', 'proxyHeader', 'abuseContact', 'sitesFile', 'denyFile', 'controlDir', 'stateDir', 'logDir'] as const) {
|
|
163
181
|
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
182
|
}
|
|
165
183
|
if (c.host === null) delete c.host
|
|
@@ -171,7 +189,7 @@ function checkTypes(c: Conf) {
|
|
|
171
189
|
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
190
|
}
|
|
173
191
|
}
|
|
174
|
-
for (const k of ['
|
|
192
|
+
for (const k of ['monthBytes', 'linkBytesPerSec', 'logRetentionDays', 'logMaxBytes', 'logDirMaxBytes'] as const) if (c[k] !== undefined) c[k] = num(c[k], k, k === 'logRetentionDays' || k === 'logMaxBytes' || k === 'logDirMaxBytes' ? 1 : 0)
|
|
175
193
|
if (c.hosted && typeof c.hosted === 'object') {
|
|
176
194
|
if (Array.isArray(c.hosted)) throw new UsageError('hosted must be true or a table of limits')
|
|
177
195
|
for (const k of HOSTED_KEYS) if (c.hosted[k] !== undefined) c.hosted[k] = num(c.hosted[k], `hosted.${k}`, 0)
|
|
@@ -264,16 +282,14 @@ async function withLock<T>(file: string, fn: () => Promise<T>): Promise<T> {
|
|
|
264
282
|
|
|
265
283
|
/** The relay options for a config (flags merged over the file). */
|
|
266
284
|
/** 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 }
|
|
285
|
+
export type HostedRun = { controlDir: string; stateDir: string; sitesFile: string; denyFile?: string; logDir?: string; logMaxBytes?: number; logDirMaxBytes?: number; logRetentionDays?: number; tlsCert?: string; tlsKey?: string }
|
|
268
286
|
|
|
269
|
-
/**
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
wisp: { instancesPerHour: 30 },
|
|
276
|
-
hosted: {} as Partial<HostedOptions>,
|
|
287
|
+
/** A sites file: {"sites": [origin patterns]} (hosted.ts compileSites checks each). */
|
|
288
|
+
export function readSites(file: string): string[] {
|
|
289
|
+
const j = JSON.parse(readFileSync(file, 'utf8'))
|
|
290
|
+
if (!Array.isArray(j?.sites) || j.sites.some((x: any) => typeof x !== 'string')) throw new UsageError(`${file}: expected {"sites": ["https://name", ...]}`)
|
|
291
|
+
compileSites(j.sites)
|
|
292
|
+
return j.sites
|
|
277
293
|
}
|
|
278
294
|
|
|
279
295
|
export function relayOptions(c: Conf, env: Record<string, string | undefined> = process.env): { opts: RelayOptions; notes: string[]; url: string; localTls: boolean; hosted?: HostedRun } {
|
|
@@ -310,6 +326,7 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
|
|
|
310
326
|
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
327
|
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
328
|
if (c.localTls) throw new UsageError('--local-tls is for a person\'s own computer, not --hosted')
|
|
329
|
+
if (!c.sitesFile) throw new UsageError('--hosted needs --sites-file <file>: the page origins it serves (JSON {"sites": [...]}); a hosted relay serves only listed sites')
|
|
313
330
|
}
|
|
314
331
|
const w = c.wisp ?? {}
|
|
315
332
|
const secret = env.WMD_RELAY_SECRET || randomBytes(32).toString('base64url') // tokens (wmn's issuer) only when the secret is shared
|
|
@@ -325,7 +342,6 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
|
|
|
325
342
|
allowLoopbackPorts: ports(c.allowLoopbackPorts, '--allow-loopback-ports'),
|
|
326
343
|
blockedPorts: ports(c.blockPorts, '--block-ports'),
|
|
327
344
|
allowHosts: c.allowHosts,
|
|
328
|
-
...(isHosted ? HOSTED_DEFAULTS.caps : {}),
|
|
329
345
|
...Object.fromEntries(CAP_KEYS.filter(k => c[k] !== undefined).map(k => [k, c[k]])),
|
|
330
346
|
...(Array.isArray(c.trustProxy) ? { trustProxy: c.trustProxy } : {}),
|
|
331
347
|
...(c.proxyHeader ? { proxyHeader: c.proxyHeader } : {}),
|
|
@@ -333,14 +349,14 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
|
|
|
333
349
|
...(isHosted
|
|
334
350
|
? {
|
|
335
351
|
hosted: {
|
|
336
|
-
...HOSTED_DEFAULTS.hosted,
|
|
337
352
|
...(typeof c.hosted === 'object' ? c.hosted : {}),
|
|
338
353
|
abuseContact: String(c.abuseContact).trim(),
|
|
339
354
|
version: VERSION,
|
|
340
355
|
startStopped: !!c.startStopped,
|
|
341
356
|
usageFile: join(c.stateDir ?? relayDir(), 'usage.json'),
|
|
342
|
-
|
|
343
|
-
|
|
357
|
+
sites: readSites(c.sitesFile),
|
|
358
|
+
...(c.monthBytes ? { monthBytes: c.monthBytes } : {}),
|
|
359
|
+
...(c.linkBytesPerSec ? { linkBytesPerSec: c.linkBytesPerSec } : {}),
|
|
344
360
|
denyCidrs: c.denyCidr,
|
|
345
361
|
} as HostedOptions,
|
|
346
362
|
}
|
|
@@ -355,7 +371,6 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
|
|
|
355
371
|
checkHost: loopback && !isHosted, // a hosted relay answers to its own name
|
|
356
372
|
trustProxy: c.trustProxy === true, // a list is the relay's `trustProxy` option (only those peers are believed)
|
|
357
373
|
version: VERSION,
|
|
358
|
-
...(isHosted ? HOSTED_DEFAULTS.wisp : {}),
|
|
359
374
|
...Object.fromEntries(WISP_KEYS.filter(k => w[k] !== undefined).map(k => [k, w[k]])),
|
|
360
375
|
},
|
|
361
376
|
}
|
|
@@ -371,7 +386,7 @@ export function relayOptions(c: Conf, env: Record<string, string | undefined> =
|
|
|
371
386
|
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
387
|
const url = `${tls ? 'wss' : 'ws'}://${shown}:${port}${w.path ?? '/wisp/'}`
|
|
373
388
|
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 }
|
|
389
|
+
? { controlDir: c.controlDir ?? join(relayDir(), 'ctl'), stateDir: c.stateDir ?? relayDir(), sitesFile: c.sitesFile, denyFile: c.denyFile, logDir: c.logDir, logMaxBytes: c.logMaxBytes, logDirMaxBytes: c.logDirMaxBytes, logRetentionDays: c.logRetentionDays, tlsCert: c.tlsCert, tlsKey: c.tlsKey }
|
|
375
390
|
: undefined
|
|
376
391
|
return { opts, notes, url, localTls: !!c.localTls, hosted: hostedRun }
|
|
377
392
|
}
|
|
@@ -382,6 +397,13 @@ function ownAddress(host: string): boolean {
|
|
|
382
397
|
return Object.values(networkInterfaces()).some(l => (l || []).some(i => i.address.split('%')[0].toLowerCase() === h))
|
|
383
398
|
}
|
|
384
399
|
|
|
400
|
+
/** Flags over the file. `--hosted` alone turns the profile on and keeps the file's `"hosted": {...}` settings. */
|
|
401
|
+
export function mergeConfig(file: Conf, flags: Conf): Conf {
|
|
402
|
+
const c = { ...file, ...flags }
|
|
403
|
+
if (flags.hosted === true && file.hosted && typeof file.hosted === 'object') c.hosted = file.hosted
|
|
404
|
+
return c
|
|
405
|
+
}
|
|
406
|
+
|
|
385
407
|
async function serve(argv: string[]) {
|
|
386
408
|
let c = parseFlags(argv)
|
|
387
409
|
let fromFile: [string, string][] = [] // [flag text, key in the file] for the settings the file gave and no flag overrode
|
|
@@ -391,7 +413,7 @@ async function serve(argv: string[]) {
|
|
|
391
413
|
delete c.config
|
|
392
414
|
const f = readConfig(file)
|
|
393
415
|
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 =
|
|
416
|
+
c = mergeConfig(f, c)
|
|
395
417
|
}
|
|
396
418
|
let made: ReturnType<typeof relayOptions>
|
|
397
419
|
try {
|
|
@@ -463,6 +485,12 @@ async function serve(argv: string[]) {
|
|
|
463
485
|
say(`certificate NOT reloaded (the old one stays): ${e?.message || e}`)
|
|
464
486
|
}
|
|
465
487
|
}
|
|
488
|
+
try {
|
|
489
|
+
r.hosted!.setSites(readSites(hostedRun.sitesFile))
|
|
490
|
+
say('sites reloaded')
|
|
491
|
+
} catch (e: any) {
|
|
492
|
+
say(`sites NOT reloaded (the old list stays): ${e?.message || e}`)
|
|
493
|
+
}
|
|
466
494
|
if (hostedRun.denyFile) {
|
|
467
495
|
try {
|
|
468
496
|
const x = r.reloadDeny(readDeny())
|
package/relay/relay.ts
CHANGED
|
@@ -15,16 +15,16 @@
|
|
|
15
15
|
// WMD_RELAY_SELF extra addresses that are this host (a public IP behind NAT); every
|
|
16
16
|
// interface address is included anyway
|
|
17
17
|
// WMD_RELAY_DNS resolvers to ask (ip or ip:port, comma list; default: the system's)
|
|
18
|
-
// Caps
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
// WMD_RELAY_RATE_DOWN
|
|
23
|
-
// stream's first window too); WMD_RELAY_MAX_BYTES
|
|
24
|
-
// One live WebSocket per identity (a newer one replaces it). WMD_RELAY_MAX_SESSIONS
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
// WMD_RELAY_BLOCK_PORTS
|
|
18
|
+
// Caps, none by default (plan §16: the owner sets what they want), are per guest identity (the
|
|
19
|
+
// token's uid: one guest machine of one page; the token's sid when it has none) and survive
|
|
20
|
+
// reconnects for as long as that identity holds a token:
|
|
21
|
+
// WMD_RELAY_MAX_CONNS connections at once; WMD_RELAY_CONNECTS_PER_MIN, WMD_RELAY_DNS_PER_MIN,
|
|
22
|
+
// WMD_RELAY_OPENS_PER_MIN WebSocket opens; WMD_RELAY_RATE_DOWN, WMD_RELAY_RATE_UP bytes/s,
|
|
23
|
+
// every byte (a new stream's first window too); WMD_RELAY_MAX_BYTES bytes, both ways.
|
|
24
|
+
// One live WebSocket per identity (a newer one replaces it). WMD_RELAY_MAX_SESSIONS in all,
|
|
25
|
+
// WMD_RELAY_MAX_SESSIONS_PER_GROUP per token group (grp: the client the issuer saw, e.g. its
|
|
26
|
+
// address or user). The relay's state is in memory: a relay restart resets it.
|
|
27
|
+
// WMD_RELAY_BLOCK_PORTS destination ports refused (none)
|
|
28
28
|
// WMD_RELAY_LOG=0 no JSON log lines on stdout
|
|
29
29
|
// WMD_RELAY_WISP=0 do not serve Wisp at /wisp/ (W59, on by default since D-444; token
|
|
30
30
|
// clients only here: the standalone wmd-relay, relay/main.ts, is the keyless one)
|
|
@@ -36,12 +36,12 @@
|
|
|
36
36
|
// own resolution (policy.ts): private, loopback, link-local, metadata, reserved and
|
|
37
37
|
// multicast ranges and this host's own addresses are refused, so a name that resolves (or
|
|
38
38
|
// rebinds) to one of them cannot reach it. A session needs a token (token.ts); connections,
|
|
39
|
-
// connect and DNS rates, bandwidth and total bytes are capped per session.
|
|
39
|
+
// connect and DNS rates, bandwidth and total bytes are capped per session when set.
|
|
40
40
|
|
|
41
41
|
import { E, MAX_DATA, PROTOCOL } from '../src/net/proto.js'
|
|
42
42
|
import { type DialFn, type EngineOptions, type ResolveFn, createEngine } from './engine.ts'
|
|
43
43
|
import { createV1 } from './v1.ts'
|
|
44
|
-
import { type Governor, type HostedOptions, type DenySpec, createGovernor } from './hosted.ts'
|
|
44
|
+
import { type Governor, type HostedOptions, type DenySpec, coarseKey, createGovernor, pickVictim } from './hosted.ts'
|
|
45
45
|
import { type Cidr, parseCidr } from './policy.ts'
|
|
46
46
|
import { DRAIN_WORDS, type WispOptions, createWisp } from './wisp.ts'
|
|
47
47
|
|
|
@@ -57,7 +57,7 @@ export type RelayOptions = EngineOptions & {
|
|
|
57
57
|
tls?: { cert: string; key: string }
|
|
58
58
|
/** A TLS proxy or tunnel is in front: secrets on a reachable plain-ws bind are acceptable. */
|
|
59
59
|
behindTls?: boolean
|
|
60
|
-
/** The hosted profile (plan §
|
|
60
|
+
/** The hosted profile (plan §16, hosted.ts): the rules, lists, /healthz and /readyz. */
|
|
61
61
|
hosted?: HostedOptions
|
|
62
62
|
/** Proxy addresses or CIDRs: only these peers are believed about the client address, which is the first entry from the right of `proxyHeader` that is not one of them. No "trust any". */
|
|
63
63
|
trustProxy?: string[]
|
|
@@ -76,11 +76,8 @@ export function createRelay(o: RelayOptions) {
|
|
|
76
76
|
throw new Error(`keys on plain ws:// at ${host} would cross the network in clear text; give a TLS certificate or say a TLS proxy is in front (behindTls)`)
|
|
77
77
|
}
|
|
78
78
|
let port = 0
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
for (const s of [...engine.sessions.values()]) for (const st of [...s.streams.values()]) engine.fail(s, st, E.LIMIT, words)
|
|
82
|
-
}
|
|
83
|
-
const gov: Governor | null = o.hosted ? createGovernor({ onCeilingGrace: w => endStreams(w), ...o.hosted }) : null
|
|
79
|
+
const dialLimitMs = o.connectTimeoutMs ?? 10_000
|
|
80
|
+
const gov: Governor | null = o.hosted ? createGovernor({ dialLimitMs, ...(o.dnsServers?.length ? { dnsServers: o.dnsServers.length } : {}), ...o.hosted }) : null
|
|
84
81
|
// hosted: mail ports are always refused, whatever the operator adds
|
|
85
82
|
const blockedPorts = gov ? [...new Set([...MAIL_PORTS, ...(o.blockedPorts ?? [])])] : o.blockedPorts
|
|
86
83
|
const trusted: Cidr[] = (o.trustProxy ?? []).map(x => {
|
|
@@ -94,6 +91,21 @@ export function createRelay(o: RelayOptions) {
|
|
|
94
91
|
const v1 = createV1(engine, o)
|
|
95
92
|
const wisp = wispOn ? createWisp(engine, { ...o, blockedPorts, port: () => port, wisp: { ...o.wisp, origins: o.wisp?.origins ?? o.origins, enabled: true, ...(gov ? { hosted: gov } : {}), ...(proxy ? { proxy } : {}) } }) : null
|
|
96
93
|
|
|
94
|
+
// rule G: free one socket for a newcomer: the oldest unfinished handshake, else the longest-idle stream of whoever holds the most
|
|
95
|
+
gov?.setReclaim(() => {
|
|
96
|
+
const hs = (wisp?.handshakes() ?? []).map(ws => ({ kind: 'handshake' as const, at: ws.data.at as number, ws }))
|
|
97
|
+
const sts: { kind: 'stream'; net: string; addr: string; sid: string; last: number; s: any; st: any }[] = []
|
|
98
|
+
for (const s of engine.sessions.values()) {
|
|
99
|
+
const grp = s.hx?.grp ?? s.p.grp
|
|
100
|
+
for (const st of s.streams.values()) if (st.connected) sts.push({ kind: 'stream', net: coarseKey(grp), addr: grp, sid: s.sid, last: st.w?.last ?? st.openedAt, s, st })
|
|
101
|
+
}
|
|
102
|
+
const v = pickVictim(hs, sts, performance.now(), dialLimitMs)
|
|
103
|
+
if (!v) return false
|
|
104
|
+
if (v.kind === 'handshake') wisp!.closeHandshake(v.ws)
|
|
105
|
+
else engine.fail(v.s, v.st, E.LIMIT, 'the relay is full: this idle connection was closed to free a socket', 'busy')
|
|
106
|
+
return true
|
|
107
|
+
})
|
|
108
|
+
|
|
97
109
|
const streams = () => {
|
|
98
110
|
let n = 0
|
|
99
111
|
for (const s of engine.sessions.values()) n += s.streams.size
|
|
@@ -102,11 +114,10 @@ export function createRelay(o: RelayOptions) {
|
|
|
102
114
|
// /healthz and /readyz hold counts and the abuse contact, nothing about any client, origin, destination or key
|
|
103
115
|
function health(path: string): Response {
|
|
104
116
|
const why = gov!.notAccepting()
|
|
105
|
-
|
|
106
|
-
if (path === '/readyz') return new Response(why ? `not ready: ${why}\n${gov!.contactLine}` : 'ready\n', { status: why ? 503 : 200 })
|
|
117
|
+
if (path === '/readyz') return new Response(why ? `not ready: ${why.words}\n${gov!.contactLine}` : 'ready\n', { status: why ? 503 : 200 })
|
|
107
118
|
return Response.json({
|
|
108
|
-
version: gov!.version ?? null, sessions: engine.sessions.size, streams: streams(), accepting: !why,
|
|
109
|
-
state: gov!.isDraining() ? 'draining' : gov!.isStopped() ? 'stopped' : 'running',
|
|
119
|
+
version: gov!.version ?? null, sessions: engine.sessions.size, streams: streams(), accepting: !why, rule: why?.rule ?? null,
|
|
120
|
+
state: gov!.isDraining() ? 'draining' : gov!.isStopped() ? 'stopped' : 'running', ...gov!.state(), abuseContact: gov!.contact,
|
|
110
121
|
})
|
|
111
122
|
}
|
|
112
123
|
|
|
@@ -178,7 +189,7 @@ export function createRelay(o: RelayOptions) {
|
|
|
178
189
|
status() {
|
|
179
190
|
return {
|
|
180
191
|
accepting: gov ? gov.accepting() : true, state: gov?.isDraining() ? 'draining' : gov?.isStopped() ? 'stopped' : 'running',
|
|
181
|
-
|
|
192
|
+
rule: gov?.notAccepting()?.rule ?? null, sessions: engine.sessions.size, streams: streams(), ...(gov ? gov.state() : {}),
|
|
182
193
|
}
|
|
183
194
|
},
|
|
184
195
|
/** Hosted: replace the deny lists; sessions and streams they now deny end. Returns how many sessions and streams. */
|
|
@@ -188,9 +199,10 @@ export function createRelay(o: RelayOptions) {
|
|
|
188
199
|
for (const s of [...engine.sessions.values()]) {
|
|
189
200
|
for (const st of [...s.streams.values()]) {
|
|
190
201
|
const i = st.to.lastIndexOf(':')
|
|
191
|
-
|
|
202
|
+
const port = Number(st.to.slice(i + 1))
|
|
203
|
+
if (gov!.isDeniedDest(st.to.slice(0, i), port) || (st.site && gov!.isDeniedDest(st.site, port))) {
|
|
192
204
|
n++
|
|
193
|
-
engine.fail(s, st, E.BLOCKED, 'this destination is blocked by the operator')
|
|
205
|
+
engine.fail(s, st, E.BLOCKED, 'this destination is blocked by the operator', 'denied-destination')
|
|
194
206
|
}
|
|
195
207
|
}
|
|
196
208
|
}
|