@threadedstack/wmd-web 0.1.13 → 0.1.15

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/relay/logfile.ts CHANGED
@@ -26,14 +26,22 @@ export function createLogFile(o: LogFileOptions) {
26
26
  const retainMs = (o.retainDays ?? 14) * 24 * 3600_000
27
27
  const maxDir = o.maxLogDirBytes ?? 2 ** 30
28
28
  const warn = o.warn ?? ((m: string) => void process.stderr.write(`${m}\n`))
29
- const append = o.append ?? ((p: string, t: string) => appendFileSync(p, t, { mode: 0o600 }))
30
- // the directory holds connection metadata: owner only, also when it already existed with a looser mode
31
- mkdirSync(o.dir, { recursive: true, mode: 0o700 })
29
+ // The directory holds connection metadata: never open to others. Its group bits are the operator's: on Linux they
30
+ // are the ACL mask, and a chmod to 0700 would switch off a reader granted with setfacl. New files are group-readable
31
+ // only when the directory is, so such a reader can read them too.
32
+ const lockDown = () => {
33
+ mkdirSync(o.dir, { recursive: true, mode: 0o700 })
34
+ const m = statSync(o.dir).mode & 0o777
35
+ if (m & 0o007) chmodSync(o.dir, m & 0o770)
36
+ return m & 0o040 ? 0o640 : 0o600
37
+ }
38
+ let fileMode: number
32
39
  try {
33
- chmodSync(o.dir, 0o700)
40
+ fileMode = lockDown()
34
41
  } catch (e: any) {
35
- throw new Error(`the log directory ${o.dir} must be owner-only (0700) and cannot be made so: ${e?.message ?? e}`)
42
+ throw new Error(`the log directory ${o.dir} must be closed to others and cannot be made so: ${e?.message ?? e}`)
36
43
  }
44
+ const append = o.append ?? ((p: string, t: string) => appendFileSync(p, t, { mode: fileMode }))
37
45
  const live = join(o.dir, LIVE)
38
46
  let size = existsSync(live) ? statSync(live).size : 0
39
47
  let born = existsSync(live) ? statSync(live).birthtimeMs || statSync(live).mtimeMs : now()
@@ -93,8 +101,7 @@ export function createLogFile(o: LogFileOptions) {
93
101
  append(live, text)
94
102
  } catch (e: any) {
95
103
  if (e?.code !== 'ENOENT') throw e
96
- mkdirSync(o.dir, { recursive: true, mode: 0o700 }) // the directory was removed: make it again and try once more
97
- chmodSync(o.dir, 0o700)
104
+ fileMode = lockDown() // the directory was removed: make it again and try once more
98
105
  size = 0
99
106
  append(live, text)
100
107
  }
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 (25)
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): per-address, network and origin
57
- limits, byte ceilings, deny lists, a control socket, /healthz, /readyz
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 counters are kept (~/.wmd/relay)
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', 'denyCidr', 'denyFile', 'maxBytesPerDay', 'maxBytesPerMonth', 'allowPorts', 'controlDir', 'stateDir', 'startStopped', 'logDir', 'logRetentionDays', 'logMaxBytes', 'logDirMaxBytes'] as const
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 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
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
- export function readConfig(file: string): Conf {
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 === 'wisp') {
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": {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}`)
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 ['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)
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
- * 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>,
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
- maxBytesPerDay: c.maxBytesPerDay,
343
- maxBytesPerMonth: c.maxBytesPerMonth,
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 = { ...f, ...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 are per guest identity (the token's uid: one guest machine of one page; the token's
19
- // sid when it has none) and survive reconnects for as long as that identity holds a token:
20
- // WMD_RELAY_MAX_CONNS (64) connections at once; WMD_RELAY_CONNECTS_PER_MIN (600),
21
- // WMD_RELAY_DNS_PER_MIN (1200), WMD_RELAY_OPENS_PER_MIN (30) WebSocket opens;
22
- // WMD_RELAY_RATE_DOWN (16777216), WMD_RELAY_RATE_UP (4194304) bytes/s, every byte (a new
23
- // stream's first window too); WMD_RELAY_MAX_BYTES (4294967296) bytes, both ways.
24
- // One live WebSocket per identity (a newer one replaces it). WMD_RELAY_MAX_SESSIONS (100) in
25
- // all, WMD_RELAY_MAX_SESSIONS_PER_GROUP (8) per token group (grp: the client the issuer saw,
26
- // e.g. its address or user). The relay's state is in memory: a relay restart resets it.
27
- // WMD_RELAY_BLOCK_PORTS (25) destination ports refused
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 §15, hosted.ts): limits, lists, ceilings, /healthz and /readyz. */
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
- // the grace after a byte ceiling is over: every open stream ends (CLOSE 0x49); the sessions stay for the next day
80
- const endStreams = (words: string) => {
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
- const ceiling = gov!.ceiling()
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', ceiling, abuseContact: gov!.contact,
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
- ceiling: gov?.ceiling() ?? null, sessions: engine.sessions.size, streams: streams(), usage: gov?.usage() ?? null, origins: gov?.originStats() ?? null,
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
- if (gov!.isDeniedDest(st.to.slice(0, i), Number(st.to.slice(i + 1)))) {
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
  }