@threadedstack/wmd-web 0.1.12 → 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/relay/hosted.ts CHANGED
@@ -1,64 +1,42 @@
1
- // W59 S7: the `--hosted` profile (plan §15): what a public relay we run adds to the engine.
1
+ // W59 §16: the `--hosted` profile's rules (plan §16, A-J). Every number names its source there.
2
2
  //
3
- // The governor holds every hosted-only limit and list, and nothing else: it does not touch sockets. The engine
4
- // (engine.ts, its `guard` hook) and the Wisp adapter (wisp.ts) ask it, at the points where a session is admitted,
5
- // a stream is opened or bytes move, and the relay (relay.ts) asks it for the state shown at /healthz and set by the
6
- // control socket. Clocks are injectable, so every limit is tested without waiting.
3
+ // The governor holds the hosted rules and lists and touches no socket. The engine (its `guard` hook) and the Wisp
4
+ // adapter ask it where a session is admitted, a stream is opened or bytes move; the relay asks it for /healthz and the
5
+ // control socket, and gives it `reclaim` (rule G). Clocks and the host are injectable, so every rule is tested without
6
+ // waiting.
7
7
  //
8
- // Keys: an "address group" is wisp.ts's `grp` (an IPv4 address, or an IPv6 /64: token.ts clientKey); a "coarse
9
- // group" is the IPv4 /24 or IPv6 /48 around it. Page origins are a fairness control for cooperating browsers only: any
10
- // script can send any Origin, so every origin budget is also bounded by the address's own share and caps.
11
-
12
- import { readFileSync, renameSync, writeFileSync } from 'node:fs'
13
- import { Bucket, type Session, type Shared, type Stream } from './engine.ts'
8
+ // Abuse rules are keyed on the client address, never on the browser's instance id, which a page picks itself. An
9
+ // "address group" is wisp.ts's `grp` (an IPv4 address, or an IPv6 /64: token.ts clientKey); a "network" is the IPv4 /24
10
+ // or IPv6 /48 around it.
11
+
12
+ import { spawnSync } from 'node:child_process'
13
+ import { getServers } from 'node:dns'
14
+ import { readFileSync, readdirSync, renameSync, writeFileSync } from 'node:fs'
15
+ import { freemem } from 'node:os'
16
+ import { join } from 'node:path'
17
+ import { Bucket, type Session, type Stream } from './engine.ts'
14
18
  import { type Addr, type Cidr, addrString, cidrContains, hostedBlockedReason, parseCidr, parseIP } from './policy.ts'
15
19
 
16
- const MIB = 1 << 20
17
- const GIB = 2 ** 30
20
+ const HOUR = 3_600_000
18
21
 
19
22
  export type HostedOptions = {
20
23
  abuseContact: string
21
24
  version?: string
22
25
  startStopped?: boolean // begin in stop-new: nothing is taken until `ctl resume` (a stop does not persist a restart otherwise)
23
26
  now?: () => number // ms since the epoch (tests)
24
- // per address group
25
- groupStreams?: number // streams open at once, all its sessions (128)
26
- groupConnectsPerMin?: number // (300)
27
- groupBytesPerHour?: number // both ways (2 GiB)
28
- groupDnsPerMin?: number // (600)
29
- // per coarse group (IPv4 /24, IPv6 /48)
30
- coarseSessions?: number // (64)
31
- coarseInstancesPerHour?: number // new instances (240)
32
- coarseRateDown?: number // bytes/s, all its addresses together (16 MiB/s)
33
- coarseRateUp?: number // (4 MiB/s)
34
- // per page origin
35
- originTable?: number // origins remembered (1000, LRU)
36
- originSessions?: number // sessions of one origin (400)
37
- originShare?: number // the most of an origin one address group may use, 0-1 (0.25)
38
- otherOriginSessions?: number // "other origins" and `Origin: null`: one shared budget (200)
39
- noOriginSessions?: number // requests without an Origin (16)
40
- originsPerAddrPerHour?: number // distinct origins one address group may use before it falls into "other" (8)
41
- originRateDown?: number // bytes/s of one origin (32 MiB/s)
42
- originRateUp?: number // (8 MiB/s)
43
- // the whole relay
44
- maxStreams?: number // streams open at once, all sessions: a file-descriptor budget below the host's limit (20,000)
45
- maxRateDown?: number // bytes/s (64 MiB/s)
46
- maxRateUp?: number // (16 MiB/s)
47
- maxBytesPerDay?: number // both ways; 0 = no ceiling
48
- maxBytesPerMonth?: number
49
- usageFile?: string // the day and month counters survive a restart here
50
- ceilingGraceMs?: number // after a ceiling is reached, streams already open may run this long, then they end (60 s)
51
- onCeilingGrace?: (words: string) => void // called when the grace is over and the ceiling is still in force: end the open streams
52
27
  warn?: (line: string) => void // one-line operator notices (stderr)
53
- // against third parties
54
- destConnectsPerMin?: number // per destination (host:port) per coarse group (600)
55
- destStreams?: number // streams to one destination at once per coarse group (200)
56
- distinctDests?: number // distinct destinations per address group per 10 minutes (100)
57
- failedConnects?: number // refused, unreachable or timed-out dials per address group before it cools off (20)
58
- failedWindowMs?: number // (5 min)
59
- failedCoolMs?: number // (5 min)
28
+ sites?: string[] // A: page origin patterns served (required: none listed serves no page)
29
+ sitesPerAddrPerHour?: number // B: different sites per address group per hour (200: plan §16)
30
+ sitesPerNetPerHour?: number // B: per network (800)
31
+ monthBytes?: number // C: bytes relayed per UTC month (the operator's figure from the provider's free outbound data); 0 = no cap
32
+ usageFile?: string // C: the month's counter survives a restart here
33
+ dialLimitMs?: number // D, G: the engine's dial limit (connectTimeoutMs, 10 s)
34
+ fdLimit?: number // E: the open-file limit (read from the host when absent)
35
+ fdOpen?: number // E: descriptors open at start (read from the host when absent)
36
+ dnsServers?: number // E: resolvers the relay asks (each may hold a UDP and a TCP socket)
37
+ freeMem?: () => number // E: free memory in bytes (os.freemem)
38
+ linkBytesPerSec?: number // F: the host's link speed, from its network shape; absent = no bandwidth sharing
60
39
  denyCidrs?: string[] // dialled addresses refused besides the built-in ranges (the fleet's own neighbours)
61
- tableMax?: number // address and coarse groups remembered per table (50000)
62
40
  }
63
41
 
64
42
  export type DenySpec = {
@@ -67,75 +45,32 @@ export type DenySpec = {
67
45
  destinations?: string[] // "name", "name:port", ".suffix" (and its apex), or a literal address
68
46
  }
69
47
 
70
- export type Refusal = { status: number; body: string }
48
+ /** A refusal or slowdown, as the page hears it (wmd-reason): the rule's name, words, and when it lifts (ms, 0 = no time). */
49
+ export type Why = { rule: string; words: string; until?: number }
50
+
51
+ /** What the relay tells a live session (wmd-reason); kind 1 refused, 2 slowed, 3 cleared. */
52
+ export type Tell = (kind: number, rule: string, until: number, words: string) => void
71
53
 
72
- /** A live session, as the deny lists see it. */
73
- export type Handle = { ip: string; origin: string; end: (words: string) => void }
54
+ /** A live session, as the deny lists and the slowdowns see it. */
55
+ export type Handle = { ip: string; grp: string; origin: string; end: (why: Why) => void; tell?: Tell }
74
56
 
75
57
  /** What a session holds from the governor until it ends. */
76
58
  export type Admission = {
77
59
  grp: string
78
- coarse: string
79
- budget: OriginBudget
80
- addrBucket: Shared
81
- down: Bucket[]
82
- up: Bucket[]
60
+ net: string
83
61
  handle: Handle
62
+ fair: Bucket | null
63
+ sent: number
64
+ waiting: boolean
84
65
  release: () => void
85
66
  }
86
67
 
87
- type OriginBudget = {
88
- key: string
89
- cap: number
90
- sessions: number
91
- perAddr: Map<string, { n: number; shared: Shared }>
92
- shared: Shared
93
- }
94
-
95
- /** A sliding window of event times on an injectable clock. */
96
- class Hits {
97
- times: number[] = []
98
- constructor(public max: number, public ms: number) {}
99
- prune(t: number) {
100
- let i = 0
101
- while (i < this.times.length && t - this.times[i] > this.ms) i++
102
- if (i) this.times.splice(0, i)
103
- }
104
- hit(t: number): boolean {
105
- this.prune(t)
106
- if (this.times.length >= this.max) return false
107
- this.times.push(t)
108
- return true
109
- }
110
- last(): number {
111
- return this.times.length ? this.times[this.times.length - 1] : 0
112
- }
113
- }
114
-
115
- /** A table that forgets its least recently used entry past `max`. */
116
- class Lru<V> {
117
- m = new Map<string, V>()
118
- constructor(public max: number) {}
119
- get(k: string): V | undefined {
120
- const v = this.m.get(k)
121
- if (v !== undefined) {
122
- this.m.delete(k)
123
- this.m.set(k, v)
124
- }
125
- return v
126
- }
127
- set(k: string, v: V) {
128
- this.m.delete(k)
129
- this.m.set(k, v)
130
- while (this.m.size > this.max) this.m.delete(this.m.keys().next().value as string)
131
- }
132
- delete(k: string) {
133
- this.m.delete(k)
134
- }
135
- get size() {
136
- return this.m.size
137
- }
138
- }
68
+ // E: what one session and one stream held in the loopback load test (docs/relay-hosted.md, "Measured on this machine")
69
+ const SESSION_MEM = 30 << 10
70
+ const STREAM_MEM = 8 << 10
71
+ // E: descriptors the relay opens for itself besides the resolvers: a log file being rotated, the usage file being
72
+ // written, and a control socket connection
73
+ const OWN_FDS = 3
139
74
 
140
75
  const ipv4Group = /^\d+\.\d+\.\d+\.\d+$/
141
76
 
@@ -195,41 +130,143 @@ function compact6(a: Addr): string {
195
130
  return best < 0 ? h(w) : `${h(w.slice(0, best))}::${h(w.slice(best + bestLen))}`
196
131
  }
197
132
 
133
+
198
134
  type Dest = { name: string; port: number; suffix: boolean }
199
135
 
136
+ /** A: origin patterns. `scheme://name[:port]`; a leading `*.` is any name under the domain, a `*` inside a label is
137
+ one or more of [a-z0-9-] (preview channels: `name--*.web.app`), `:*` is any port; no port is the default port. */
138
+ export function compileSites(list: string[]): (origin: string) => boolean {
139
+ const res = list.map(p => {
140
+ const bad = new Error(`site list: '${p}' is not an origin pattern (https://name, https://*.domain, https://name--*.web.app, http://localhost:*)`)
141
+ const m = String(p).trim().toLowerCase().match(/^(https?):\/\/([a-z0-9*.-]+|\[::1\])(?::(\d{1,5}|\*))?$/)
142
+ if (!m) throw bad
143
+ let host = '\\[::1\\]'
144
+ if (m[2] !== '[::1]') {
145
+ const labels = m[2].split('.')
146
+ const any = labels[0] === '*'
147
+ const rest = any ? labels.slice(1) : labels
148
+ if (!rest.length || rest.some(l => !l || l === '*' || !/^[a-z0-9-]*(\*[a-z0-9-]*)?$/.test(l)) || rest[rest.length - 1].includes('*')) throw bad
149
+ host = (any ? '(?:[a-z0-9-]+\\.)+' : '') + rest.map(l => l.replace('*', '[a-z0-9-]+')).join('\\.')
150
+ }
151
+ const port = m[3] === '*' ? '(?::\\d{1,5})?' : m[3] ? `:${m[3]}` : ''
152
+ return new RegExp(`^${m[1]}://${host}${port}$`)
153
+ })
154
+ return (origin: string) => !!origin && res.some(r => r.test(origin.toLowerCase()))
155
+ }
156
+
157
+ /** E: the open-file limit and the descriptors open now. */
158
+ export function hostLimits(): { fdLimit: number; fdOpen: number } {
159
+ const r = spawnSync('sh', ['-c', 'ulimit -n'], { encoding: 'utf8' })
160
+ const t = String(r.stdout || '').trim()
161
+ const fdLimit = t === 'unlimited' ? Number.MAX_SAFE_INTEGER : Number(t)
162
+ if (!Number.isFinite(fdLimit) || fdLimit <= 0) throw new Error(`cannot read the open-file limit (ulimit -n said '${t}')`)
163
+ let fdOpen: number
164
+ try {
165
+ fdOpen = readdirSync('/dev/fd').length - 1 // less the descriptor that lists the directory
166
+ } catch {
167
+ fdOpen = readdirSync('/proc/self/fd').length - 1
168
+ }
169
+ return { fdLimit, fdOpen }
170
+ }
171
+
172
+ /** E: free memory as the relay can use it: the host's free memory, or the room left under the service's own memory cap
173
+ (cgroup v2 memory.max less memory.current, as systemd's MemoryMax= sets it) when that is smaller. Read on each call. */
174
+ export function memoryRoom(o: { procCgroup?: string; cgroupRoot?: string; hostFree?: () => number } = {}): () => number {
175
+ const hostFree = o.hostFree ?? freemem
176
+ const root = o.cgroupRoot ?? '/sys/fs/cgroup'
177
+ let dir: string | null = null
178
+ try {
179
+ const line = readFileSync(o.procCgroup ?? '/proc/self/cgroup', 'utf8').split('\n').find(l => l.startsWith('0::'))
180
+ if (line) dir = join(root, line.slice(3).trim())
181
+ } catch {
182
+ dir = null // no cgroup v2 here (macOS, or a v1 host): the host's free memory is all there is
183
+ }
184
+ return () => {
185
+ const free = hostFree()
186
+ if (!dir) return free
187
+ try {
188
+ const max = readFileSync(join(dir, 'memory.max'), 'utf8').trim()
189
+ if (max === 'max') return free
190
+ const room = Number(max) - Number(readFileSync(join(dir, 'memory.current'), 'utf8').trim())
191
+ return Number.isFinite(room) ? Math.min(free, Math.max(0, room)) : free
192
+ } catch {
193
+ return free
194
+ }
195
+ }
196
+ }
197
+
198
+ type Victim = { kind: 'handshake'; at: number } | { kind: 'stream'; net: string; addr: string; sid: string; last: number }
199
+
200
+ /** G: what to close when sockets run out: the oldest unfinished handshake; else, of the streams idle for at least the
201
+ dial limit, the longest idle one of the network, then address, then session holding the most streams. */
202
+ export function pickVictim<H extends { kind: 'handshake'; at: number }, S extends { kind: 'stream'; net: string; addr: string; sid: string; last: number }>(handshakes: H[], streams: S[], now: number, idleMs: number): H | S | null {
203
+ if (handshakes.length) return handshakes.reduce((a, b) => (b.at < a.at ? b : a))
204
+ const count = (key: (x: S) => string) => {
205
+ const m = new Map<string, number>()
206
+ for (const x of streams) m.set(key(x), (m.get(key(x)) || 0) + 1)
207
+ return m
208
+ }
209
+ let idle = streams.filter(x => now - x.last >= idleMs)
210
+ if (!idle.length) return null
211
+ for (const key of [(x: S) => x.net, (x: S) => `${x.net}|${x.addr}`, (x: S) => `${x.net}|${x.addr}|${x.sid}`]) {
212
+ const n = count(key)
213
+ const top = Math.max(...idle.map(x => n.get(key(x))!))
214
+ idle = idle.filter(x => n.get(key(x)) === top)
215
+ }
216
+ return idle.reduce((a, b) => (b.last < a.last ? b : a))
217
+ }
218
+
219
+ /** F: max-min shares of `capacity` over networks, then addresses, then sessions. Infinity for all when they want less. */
220
+ export function maxMinShares(capacity: number, list: { net: string; addr: string; sid: string; want: number }[]): Map<string, number> {
221
+ const out = new Map<string, number>()
222
+ if (list.reduce((t, x) => t + x.want, 0) <= capacity) {
223
+ for (const x of list) out.set(x.sid, Infinity)
224
+ return out
225
+ }
226
+ type Node = { want: number; kids?: Map<string, Node>; sid?: string }
227
+ const root: Node = { want: 0, kids: new Map() }
228
+ for (const x of list) {
229
+ let n = root
230
+ for (const k of [x.net, x.addr]) {
231
+ let c = n.kids!.get(k)
232
+ if (!c) n.kids!.set(k, (c = { want: 0, kids: new Map() }))
233
+ n = c
234
+ }
235
+ n.kids!.set(x.sid, { want: x.want, sid: x.sid })
236
+ }
237
+ const total = (n: Node): number => (n.kids ? (n.want = [...n.kids.values()].reduce((t, c) => t + total(c), 0)) : n.want)
238
+ total(root)
239
+ const share = (n: Node, cap: number) => {
240
+ if (!n.kids) return void out.set(n.sid!, cap)
241
+ const kids = [...n.kids.values()].sort((a, b) => a.want - b.want)
242
+ let left = cap
243
+ for (let i = 0; i < kids.length; i++) {
244
+ const even = left / (kids.length - i)
245
+ const give = Math.min(kids[i].want, even)
246
+ share(kids[i], give)
247
+ left -= give
248
+ }
249
+ }
250
+ share(root, capacity)
251
+ return out
252
+ }
253
+
200
254
  export function createGovernor(o: HostedOptions) {
201
255
  const now = o.now ?? Date.now
202
- const tableMax = o.tableMax ?? 50_000
256
+ const warn = o.warn ?? ((m: string) => void process.stderr.write(`${m}\n`))
257
+ const host = o.fdLimit === undefined || o.fdOpen === undefined ? hostLimits() : null
203
258
  const c = {
204
- groupStreams: o.groupStreams ?? 128,
205
- groupConnectsPerMin: o.groupConnectsPerMin ?? 300,
206
- groupBytesPerHour: o.groupBytesPerHour ?? 2 * GIB,
207
- groupDnsPerMin: o.groupDnsPerMin ?? 600,
208
- coarseSessions: o.coarseSessions ?? 64,
209
- coarseInstancesPerHour: o.coarseInstancesPerHour ?? 240,
210
- coarseRateDown: o.coarseRateDown ?? 16 * MIB,
211
- coarseRateUp: o.coarseRateUp ?? 4 * MIB,
212
- originTable: o.originTable ?? 1000,
213
- originSessions: o.originSessions ?? 400,
214
- originShare: o.originShare ?? 0.25,
215
- otherOriginSessions: o.otherOriginSessions ?? 200,
216
- noOriginSessions: o.noOriginSessions ?? 16,
217
- originsPerAddrPerHour: o.originsPerAddrPerHour ?? 8,
218
- originRateDown: o.originRateDown ?? 32 * MIB,
219
- originRateUp: o.originRateUp ?? 8 * MIB,
220
- maxStreams: o.maxStreams ?? 20_000,
221
- maxRateDown: o.maxRateDown ?? 64 * MIB,
222
- maxRateUp: o.maxRateUp ?? 16 * MIB,
223
- maxBytesPerDay: o.maxBytesPerDay ?? 0,
224
- maxBytesPerMonth: o.maxBytesPerMonth ?? 0,
225
- destConnectsPerMin: o.destConnectsPerMin ?? 600,
226
- destStreams: o.destStreams ?? 200,
227
- distinctDests: o.distinctDests ?? 100,
228
- failedConnects: o.failedConnects ?? 20,
229
- failedWindowMs: o.failedWindowMs ?? 5 * 60_000,
230
- failedCoolMs: o.failedCoolMs ?? 5 * 60_000,
231
- ceilingGraceMs: o.ceilingGraceMs ?? 60_000,
232
- }
259
+ sitesPerAddrPerHour: o.sitesPerAddrPerHour ?? 200,
260
+ sitesPerNetPerHour: o.sitesPerNetPerHour ?? 800,
261
+ monthBytes: o.monthBytes ?? 0,
262
+ dialLimitMs: o.dialLimitMs ?? 10_000,
263
+ fdLimit: o.fdLimit ?? host!.fdLimit,
264
+ fdOpen: o.fdOpen ?? host!.fdOpen,
265
+ dnsServers: o.dnsServers ?? getServers().length,
266
+ linkBytesPerSec: o.linkBytesPerSec ?? 0,
267
+ }
268
+ const freeMem = o.freeMem ?? memoryRoom()
269
+ const budget = Math.max(0, c.fdLimit - c.fdOpen - 2 * c.dnsServers - OWN_FDS)
233
270
  const denyCidrs = (o.denyCidrs ?? []).map(s => {
234
271
  const x = parseCidr(s)
235
272
  if (!x) throw new Error(`deny-cidr '${s}' is not an address or CIDR`)
@@ -238,38 +275,20 @@ export function createGovernor(o: HostedOptions) {
238
275
  const contact = o.abuseContact
239
276
  const contactLine = `abuse contact: ${contact}\n`
240
277
 
241
- // ---- state set by the operator (control socket) and by the ceilings
242
278
  let stopped = !!o.startStopped
243
279
  let draining = false
244
280
 
245
- // ---- tables
246
- const groupStreams = new Map<string, number>()
247
- const groupConnects = new Lru<Hits>(tableMax)
248
- const groupDns = new Lru<Hits>(tableMax)
249
- const groupBytes = new Lru<{ hour: number; n: number }>(tableMax)
250
- const groupDests = new Lru<Map<string, number>>(tableMax)
251
- const groupFails = new Lru<{ hits: Hits; cool: number }>(tableMax)
252
- const coarseSessions = new Map<string, number>()
253
- const coarseInstances = new Lru<Hits>(tableMax)
254
- const coarseShared = new Lru<Shared>(tableMax)
255
- const destConnects = new Lru<Hits>(tableMax)
256
- const destLive = new Map<string, number>()
257
- const addrOrigins = new Lru<Map<string, number>>(tableMax) // per address group: origin -> first seen (this hour)
258
- const origins = new Lru<OriginBudget>(c.originTable) // by origin; entries with live sessions are never evicted
259
- const mk = (key: string, cap: number): OriginBudget => ({
260
- key, cap, sessions: 0, perAddr: new Map(),
261
- shared: { down: new Bucket(c.originRateDown), up: new Bucket(c.originRateUp) },
262
- })
263
- const other = mk('other', c.otherOriginSessions)
264
- const none = mk('none', c.noOriginSessions)
265
- const globalShared: Shared = { down: new Bucket(c.maxRateDown), up: new Bucket(c.maxRateUp) }
266
- const live = new Set<Handle>()
267
- let liveStreams = 0
281
+ // ---- A
282
+ let siteOk = compileSites(o.sites ?? [])
283
+ function setSites(list: string[]) {
284
+ siteOk = compileSites(list)
285
+ }
268
286
 
269
287
  // ---- deny lists
270
288
  let denyAddr: Cidr[] = []
271
289
  let denyOrigin = new Set<string>()
272
290
  let denyDest: Dest[] = []
291
+ const live = new Set<Handle>()
273
292
 
274
293
  function reloadDeny(spec: DenySpec): { ended: number } {
275
294
  const addrs: Cidr[] = []
@@ -298,10 +317,10 @@ export function createGovernor(o: HostedOptions) {
298
317
  return { ended }
299
318
  }
300
319
 
301
- function deniedClient(ip: string, origin: string): string | null {
320
+ function deniedClient(ip: string, origin: string): Why | null {
302
321
  const a = parseIP(ip)
303
- if (a && denyAddr.some(x => cidrHas(x, unmap(a)))) return 'this address is blocked by the operator'
304
- if (origin && denyOrigin.has(origin)) return 'this page origin is blocked by the operator'
322
+ if (a && denyAddr.some(x => cidrContains(x, unmap(a)))) return { rule: 'denied-address', words: 'this address is blocked by the operator' }
323
+ if (origin && denyOrigin.has(origin)) return { rule: 'denied-origin', words: 'this page origin is blocked by the operator' }
305
324
  return null
306
325
  }
307
326
 
@@ -310,11 +329,13 @@ export function createGovernor(o: HostedOptions) {
310
329
  return denyDest.some(d => (!d.port || d.port === port) && (h === d.name || (d.suffix && h.endsWith(`.${d.name}`))))
311
330
  }
312
331
 
313
- // ---- ceilings
314
- const day = (t: number) => new Date(t).toISOString().slice(0, 10)
315
- const month = (t: number) => new Date(t).toISOString().slice(0, 7)
316
- const warn = o.warn ?? ((m: string) => void process.stderr.write(`${m}\n`))
317
- let usage = { day: day(now()), dayBytes: 0, month: month(now()), monthBytes: 0 }
332
+ // ---- C: the month
333
+ const monthOf = (t: number) => new Date(t).toISOString().slice(0, 7)
334
+ const dayOf = (t: number) => new Date(t).toISOString().slice(0, 10)
335
+ const monthStart = (t: number) => Date.UTC(new Date(t).getUTCFullYear(), new Date(t).getUTCMonth(), 1)
336
+ const monthEnd = (t: number) => Date.UTC(new Date(t).getUTCFullYear(), new Date(t).getUTCMonth() + 1, 1)
337
+ const dayEnd = (t: number) => Date.UTC(new Date(t).getUTCFullYear(), new Date(t).getUTCMonth(), new Date(t).getUTCDate() + 1)
338
+ let usage = { month: monthOf(now()), monthBytes: 0 }
318
339
  if (o.usageFile) {
319
340
  let raw: string | null = null
320
341
  try { raw = readFileSync(o.usageFile, 'utf8') } catch {}
@@ -322,64 +343,29 @@ export function createGovernor(o: HostedOptions) {
322
343
  let u: any = null
323
344
  try { u = JSON.parse(raw) } catch {}
324
345
  const t = now()
325
- const dayOk = (v: unknown) => typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v) && Number.isFinite(Date.parse(`${v}T00:00:00Z`)) && day(Date.parse(`${v}T00:00:00Z`)) === v && v <= day(t + 86_400_000)
326
- const next = new Date(t)
327
- const monthMax = month(Date.UTC(next.getUTCFullYear(), next.getUTCMonth() + 1, 1))
328
- const monthOk = (v: unknown) => typeof v === 'string' && /^\d{4}-\d{2}$/.test(v) && Number.isFinite(Date.parse(`${v}-01T00:00:00Z`)) && month(Date.parse(`${v}-01T00:00:00Z`)) === v && v <= monthMax
329
- // a counter that is not a finite, safe, non-negative number, or is far past its own ceiling, is corrupt
330
- const sane = (v: unknown, ceil?: number) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER && (!ceil || v <= ceil * 2)
331
- const problems: string[] = []
332
- if (!u || typeof u !== 'object') problems.push('not a JSON object')
333
- else {
334
- usage.day = dayOk(u.day) ? u.day : (u.day !== undefined && problems.push('day'), usage.day)
335
- usage.month = monthOk(u.month) ? u.month : (u.month !== undefined && problems.push('month'), usage.month)
336
- if (sane(u.dayBytes, c.maxBytesPerDay)) usage.dayBytes = u.dayBytes
337
- else problems.push('dayBytes')
338
- if (sane(u.monthBytes, c.maxBytesPerMonth)) usage.monthBytes = u.monthBytes
339
- else problems.push('monthBytes')
340
- // a date that was reset starts its counter at 0 too
341
- if (usage.day !== u.day) usage.dayBytes = 0
342
- if (usage.month !== u.month) usage.monthBytes = 0
343
- }
344
- if (problems.length) warn(`wmd-relay: ${o.usageFile} is damaged (${problems.join(', ')}); the affected counters and dates start again from 0 and today`)
346
+ const monthOk = (v: unknown) => typeof v === 'string' && /^\d{4}-\d{2}$/.test(v) && Number.isFinite(Date.parse(`${v}-01T00:00:00Z`)) && monthOf(Date.parse(`${v}-01T00:00:00Z`)) === v && v <= monthOf(monthEnd(t))
347
+ const sane = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER && Number.isInteger(v)
348
+ if (u && typeof u === 'object' && monthOk(u.month) && sane(u.monthBytes)) usage = { month: u.month, monthBytes: u.monthBytes }
349
+ else warn(`wmd-relay: ${o.usageFile} is damaged; the month's counter starts again from 0`)
345
350
  }
346
351
  }
352
+ let today = { day: dayOf(now()), per: new Map<string, number>() }
347
353
  /** The counters restart only when the date moves forward: a clock set back must not zero them. */
348
354
  function roll() {
349
355
  const t = now()
350
- if (day(t) > usage.day) {
351
- usage.day = day(t)
352
- usage.dayBytes = 0
353
- }
354
- if (month(t) > usage.month) {
355
- usage.month = month(t)
356
- usage.monthBytes = 0
357
- }
358
- }
359
- /** The ceiling in force, or null. The rollover lifts it: the counters restart with the day and the month. */
360
- function ceiling(): 'day' | 'month' | null {
361
- roll()
362
- const k = c.maxBytesPerMonth && usage.monthBytes >= c.maxBytesPerMonth ? 'month' : c.maxBytesPerDay && usage.dayBytes >= c.maxBytesPerDay ? 'day' : null
363
- if (!k) {
364
- ceilingSince = 0
365
- if (graceTimer) clearTimeout(graceTimer)
366
- graceTimer = null
367
- } else if (!ceilingSince) {
368
- ceilingSince = now()
369
- graceTimer = setTimeout(() => {
370
- graceTimer = null
371
- const w = ceiling()
372
- if (w) o.onCeilingGrace?.(ceilingWords(w))
373
- }, c.ceilingGraceMs)
374
- ;(graceTimer as any).unref?.()
375
- }
376
- return k
356
+ if (monthOf(t) > usage.month) usage = { month: monthOf(t), monthBytes: 0 }
357
+ if (dayOf(t) > today.day) today = { day: dayOf(t), per: new Map() }
358
+ }
359
+ const atCap = () => (roll(), !!c.monthBytes && usage.monthBytes >= c.monthBytes)
360
+ const capWhy = (): Why => ({ rule: 'data-cap', words: 'the relay has used its data for this month', until: monthEnd(now()) })
361
+ /** An address group over its share of today: the day's even share of the cap, split among the groups active today. */
362
+ function overShare(grp: string): boolean {
363
+ if (!c.monthBytes) return false
364
+ const t = now()
365
+ const dayShare = c.monthBytes / Math.round((monthEnd(t) - monthStart(t)) / 86_400_000)
366
+ return (today.per.get(grp) || 0) > dayShare / Math.max(1, today.per.size)
377
367
  }
378
- let ceilingSince = 0
379
- let graceTimer: ReturnType<typeof setTimeout> | null = null
380
- /** True once a ceiling has been in force longer than the grace: even streams that are open end. */
381
- const pastGrace = () => !!ceiling() && now() - ceilingSince >= c.ceilingGraceMs
382
- const ceilingWords = (k: 'day' | 'month') => `the relay has reached its traffic limit for ${k === 'day' ? 'today' : 'this month'}`
368
+ const slow = new Map<string, { rate: number; until: number; bucket: Bucket }>()
383
369
  function saveUsage() {
384
370
  if (!o.usageFile) return
385
371
  try {
@@ -388,195 +374,219 @@ export function createGovernor(o: HostedOptions) {
388
374
  renameSync(tmp, o.usageFile)
389
375
  } catch {}
390
376
  }
391
- const saver = o.usageFile ? setInterval(saveUsage, 15_000) : null
392
- saver?.unref?.()
393
377
 
394
- function accepting(): boolean {
395
- return !stopped && !draining && !ceiling()
378
+ function notAccepting(): Why | null {
379
+ if (draining) return { rule: 'draining', words: 'the relay is closed for maintenance' }
380
+ if (stopped) return { rule: 'stopped', words: 'the relay is not taking new connections now' }
381
+ return atCap() ? capWhy() : null
396
382
  }
397
383
 
398
- /** Why new sessions are not taken now, or null. */
399
- function notAccepting(): string | null {
400
- if (draining) return 'the relay is closed for maintenance'
401
- if (stopped) return 'the relay is not taking new connections now'
402
- const k = ceiling()
403
- return k ? ceilingWords(k) : null
384
+ // ---- E, G: sockets
385
+ let used = 0
386
+ let reclaim: () => boolean = () => false
387
+ const busy = (): Why => ({ rule: 'busy', words: 'the relay is full right now', until: now() + c.dialLimitMs })
388
+ /** One socket more, or why not. A full relay first asks the relay to free one (G). */
389
+ function claim(mem: number): Why | null {
390
+ if (freeMem() < mem) return busy()
391
+ if (used >= budget && !reclaim()) return busy()
392
+ if (used >= budget) return busy()
393
+ used++
394
+ return null
404
395
  }
396
+ const unclaim = () => void (used = Math.max(0, used - 1))
405
397
 
406
- // ---- sessions
407
- function pickBudget(grp: string, origin: string): OriginBudget {
408
- if (!origin) return none
409
- if (origin === 'null') return other
398
+ // ---- B
399
+ const addrSites = new Map<string, Map<string, number>>()
400
+ const netSites = new Map<string, Map<string, number>>()
401
+ const fresh = (m: Map<string, number>, t: number) => {
402
+ for (const [k, at] of m) if (t - at >= HOUR) m.delete(k)
403
+ return m
404
+ }
405
+ const oldest = (m: Map<string, number>) => Math.min(...m.values()) + HOUR
406
+ function sites(grp: string, host: string): Why | null {
410
407
  const t = now()
411
- let seen = addrOrigins.get(grp)
412
- if (!seen) addrOrigins.set(grp, (seen = new Map()))
413
- for (const [k, at] of seen) if (t - at > 3_600_000) seen.delete(k)
414
- if (!seen.has(origin)) {
415
- if (seen.size >= c.originsPerAddrPerHour) return other // a ninth distinct origin from one address
416
- seen.set(origin, t)
417
- }
418
- let b = origins.get(origin)
419
- if (b) return b
420
- if (origins.size >= c.originTable) {
421
- // evict the least recently used origin that has no live session; none free: this one is "other"
422
- let victim: string | null = null
423
- for (const [k, v] of origins.m) if (!v.sessions) { victim = k; break }
424
- if (victim === null) return other
425
- origins.delete(victim)
408
+ const site = host.toLowerCase().replace(/\.$/, '')
409
+ const net = coarseKey(grp)
410
+ let a = addrSites.get(grp)
411
+ if (!a) addrSites.set(grp, (a = new Map()))
412
+ let n = netSites.get(net)
413
+ if (!n) netSites.set(net, (n = new Map()))
414
+ fresh(a, t)
415
+ fresh(n, t)
416
+ if (!a.has(site)) {
417
+ if (a.size >= c.sitesPerAddrPerHour) {
418
+ return { rule: 'too-many-sites', words: `${c.sitesPerAddrPerHour} different sites in an hour from this address: a new site waits until one ages out; sites already reached keep working`, until: oldest(a) }
419
+ }
420
+ if (!n.has(site) && n.size >= c.sitesPerNetPerHour) {
421
+ return { rule: 'too-many-sites', words: `${c.sitesPerNetPerHour} different sites in an hour from this network: a new site waits until one ages out; sites already reached keep working`, until: oldest(n) }
422
+ }
426
423
  }
427
- b = mk(origin, c.originSessions)
428
- origins.set(origin, b)
429
- return b
424
+ a.set(site, t)
425
+ n.set(site, t)
426
+ return null
430
427
  }
431
428
 
432
- /** A session of an address group from `ip` with this page origin is about to open. */
433
- function admitSession(i: { ip: string; grp: string; origin: string; fresh: boolean; end: (words: string) => void }): { ok: true; adm: Admission } | { ok: false; why: string } {
434
- const coarse = coarseKey(i.grp)
429
+ // ---- D
430
+ const dials = new Map<string, { w: number; gate: number; told: boolean }>()
431
+ const dialOf = (grp: string) => {
432
+ let d = dials.get(grp)
433
+ if (!d) dials.set(grp, (d = { w: 0, gate: 0, told: false }))
434
+ return d
435
+ }
436
+ function dialGate(s: Session): Why | { waitMs: number } | null {
437
+ const d = dials.get(grpOf(s))
438
+ if (!d || !d.w) return null
435
439
  const t = now()
436
- if ((coarseSessions.get(coarse) || 0) >= c.coarseSessions) return { ok: false, why: `more than ${c.coarseSessions} sessions from this network` }
437
- if (i.fresh) {
438
- let w = coarseInstances.get(coarse)
439
- if (!w) coarseInstances.set(coarse, (w = new Hits(c.coarseInstancesPerHour, 3_600_000)))
440
- if (!w.hit(t)) return { ok: false, why: `more than ${c.coarseInstancesPerHour} new instances an hour from this network` }
440
+ const start = Math.max(t, d.gate)
441
+ const wait = start + d.w - t
442
+ if (wait > c.dialLimitMs) return { rule: 'failed-dials', words: 'too many failed connections from this address: new connections wait', until: d.gate }
443
+ d.gate = start + d.w
444
+ if (!d.told) {
445
+ d.told = true
446
+ s.hx?.handle.tell?.(2, 'failed-dials', d.gate, 'failed connections from this address: new connections are slowed')
447
+ }
448
+ return { waitMs: wait }
449
+ }
450
+
451
+ // ---- sessions
452
+ const grpOf = (s: Session): string => s.hx?.grp ?? s.p.grp
453
+
454
+ /** A session from `ip` (address group `grp`) with this page origin is about to open. */
455
+ function admitSession(i: { ip: string; grp: string; origin: string; end: (why: Why) => void; tell?: Tell; claimed?: boolean }): { ok: true; adm: Admission } | ({ ok: false } & Why) {
456
+ const no = (w: Why) => ({ ok: false as const, ...w })
457
+ const denied = deniedClient(i.ip, i.origin)
458
+ if (denied) return no(denied)
459
+ const closed = notAccepting()
460
+ if (closed) return no(closed)
461
+ if (!siteOk(i.origin)) return no({ rule: 'not-allowed-site', words: `this page (${i.origin || 'no origin'}) is not one of the sites this relay serves` })
462
+ if (!i.claimed) {
463
+ const full = claim(SESSION_MEM)
464
+ if (full) return no(full)
441
465
  }
442
- const budget = pickBudget(i.grp, i.origin)
443
- const share = Math.max(1, Math.floor(budget.cap * c.originShare))
444
- const mine = budget.perAddr.get(i.grp)
445
- if (budget.sessions >= budget.cap) return { ok: false, why: `this page origin has no room for more sessions (${budget.cap})` }
446
- if ((mine?.n ?? 0) >= share) return { ok: false, why: `more than ${share} sessions of this page origin from one address` }
447
- let cs = coarseShared.get(coarse)
448
- if (!cs) coarseShared.set(coarse, (cs = { down: new Bucket(c.coarseRateDown), up: new Bucket(c.coarseRateUp) }))
449
- const entry = mine ?? { n: 0, shared: { down: new Bucket(Math.max(1, c.originRateDown * c.originShare)), up: new Bucket(Math.max(1, c.originRateUp * c.originShare)) } }
450
- entry.n++
451
- budget.perAddr.set(i.grp, entry)
452
- budget.sessions++
453
- coarseSessions.set(coarse, (coarseSessions.get(coarse) || 0) + 1)
454
- const handle: Handle = { ip: i.ip, origin: i.origin, end: i.end }
466
+ const handle: Handle = { ip: i.ip, grp: i.grp, origin: i.origin, end: i.end, tell: i.tell }
455
467
  live.add(handle)
456
468
  let done = false
457
469
  const adm: Admission = {
458
- grp: i.grp, coarse, budget, addrBucket: entry.shared,
459
- down: [cs.down, entry.shared.down, budget.shared.down, globalShared.down],
460
- up: [cs.up, entry.shared.up, budget.shared.up, globalShared.up],
461
- handle,
470
+ grp: i.grp, net: coarseKey(i.grp), handle, fair: c.linkBytesPerSec ? new Bucket(Infinity) : null, sent: 0, waiting: false,
462
471
  release() {
463
472
  if (done) return
464
473
  done = true
465
474
  live.delete(handle)
466
- budget.sessions--
467
- const e = budget.perAddr.get(i.grp)
468
- if (e && --e.n <= 0) budget.perAddr.delete(i.grp)
469
- const n = (coarseSessions.get(coarse) || 1) - 1
470
- if (n > 0) coarseSessions.set(coarse, n)
471
- else coarseSessions.delete(coarse)
475
+ unclaim()
472
476
  },
473
477
  }
474
478
  return { ok: true, adm }
475
479
  }
476
480
 
477
- // ---- streams (engine guard)
478
- const grpOf = (s: Session): string => s.hx?.grp ?? s.p.grp
479
- const destKey = (host: string, port: number) => `${host.toLowerCase().replace(/\.$/, '')}:${port}`
481
+ /** The socket of a WebSocket that has not finished its handshake (counted from the upgrade). */
482
+ function claimHandshake(): Why | null {
483
+ return claim(SESSION_MEM)
484
+ }
480
485
 
481
- /** Before a connect is resolved: the cooling-off, the group's rate, the scan shape and the destination's rate. Null, or the words. */
482
- function admitConnect(s: Session, host: string, port: number): string | null {
483
- const t = now()
484
- const grp = grpOf(s)
485
- const closed = notAccepting() // stop-new, drain and a ceiling end new streams too: open streams run to their end
486
+ // ---- streams (engine guard)
487
+ /** Before a connect is resolved: the kill switch and the cap, then B and D. A refusal, a wait, or null. */
488
+ function admitConnect(s: Session, host: string, _port: number): Why | { waitMs: number } | null {
489
+ const closed = notAccepting()
486
490
  if (closed) return closed
487
- const f = groupFails.get(grp)
488
- if (f && f.cool > t) return `too many failed connections from this address; new connections wait ${Math.ceil((f.cool - t) / 1000)} s`
489
- const h = bytesHour(grp)
490
- if (h.n >= c.groupBytesPerHour) return 'this address has used its bytes for this hour'
491
- let w = groupConnects.get(grp)
492
- if (!w) groupConnects.set(grp, (w = new Hits(c.groupConnectsPerMin, 60_000)))
493
- if (!w.hit(t)) return `more than ${c.groupConnectsPerMin} connections a minute from this address`
494
- const key = destKey(host, port)
495
- let seen = groupDests.get(grp)
496
- if (!seen) groupDests.set(grp, (seen = new Map()))
497
- for (const [k, at] of seen) if (t - at > 600_000) seen.delete(k)
498
- if (!seen.has(key) && seen.size >= c.distinctDests) return `more than ${c.distinctDests} different destinations in 10 minutes from this address`
499
- seen.set(key, t)
500
- const ck = `${coarseKey(grp)}|${key}`
501
- let dw = destConnects.get(ck)
502
- if (!dw) destConnects.set(ck, (dw = new Hits(c.destConnectsPerMin, 60_000)))
503
- if (!dw.hit(t)) return `more than ${c.destConnectsPerMin} connections a minute to ${key} from this network`
504
- return null
491
+ const crawl = sites(grpOf(s), host)
492
+ if (crawl) return crawl
493
+ return dialGate(s)
505
494
  }
506
495
 
507
- /** Just before the dial: the concurrent counts. Sets `st.rel`, which the engine calls once when the stream is gone. */
508
- function openStream(s: Session, st: Stream, host: string, port: number): string | null {
509
- const grp = grpOf(s)
510
- const ck = `${coarseKey(grp)}|${destKey(host, port)}`
511
- if (liveStreams >= c.maxStreams) return `the relay has ${c.maxStreams} streams open; try again shortly`
512
- if ((groupStreams.get(grp) || 0) >= c.groupStreams) return `more than ${c.groupStreams} streams open from this address`
513
- if ((destLive.get(ck) || 0) >= c.destStreams) return `more than ${c.destStreams} streams open to ${destKey(host, port)} from this network`
514
- liveStreams++
515
- groupStreams.set(grp, (groupStreams.get(grp) || 0) + 1)
516
- destLive.set(ck, (destLive.get(ck) || 0) + 1)
517
- st.rel = () => {
518
- liveStreams--
519
- for (const [m, k] of [[groupStreams, grp], [destLive, ck]] as const) {
520
- const n = (m.get(k) || 1) - 1
521
- if (n > 0) m.set(k, n)
522
- else m.delete(k)
523
- }
524
- }
496
+ /** Just before the dial: a socket (E, G). Sets `st.rel`, which the engine calls once when the stream is gone. */
497
+ function openStream(_s: Session, st: Stream, _host: string, _port: number): Why | null {
498
+ const full = claim(STREAM_MEM)
499
+ if (full) return full
500
+ st.rel = unclaim
525
501
  return null
526
502
  }
527
503
 
528
- /** A dial the remote refused, that found no route or that timed out (or a name that did not resolve). */
529
- function dialFailed(s: Session) {
530
- const t = now()
531
- const grp = grpOf(s)
532
- let f = groupFails.get(grp)
533
- if (!f) groupFails.set(grp, (f = { hits: new Hits(Infinity, c.failedWindowMs), cool: 0 }))
534
- f.hits.prune(t)
535
- f.hits.times.push(t)
536
- if (f.hits.times.length >= c.failedConnects) {
537
- f.cool = t + c.failedCoolMs
538
- f.hits.times = []
539
- }
504
+ /** D: a dial was refused, found no route, timed out or named a host that does not exist, after `ms`. */
505
+ function dialFailed(s: Session, ms: number) {
506
+ const d = dialOf(grpOf(s))
507
+ d.w = Math.min(c.dialLimitMs, Math.max(1, Math.round(ms), d.w * 2))
540
508
  }
541
-
542
- /** DNS answers stop with new connections: stop-new, a drain and a ceiling (the same words). */
543
- const dnsClosed = (): string | null => notAccepting()
544
-
545
- function dnsAllowed(s: Session): boolean {
546
- const grp = grpOf(s)
547
- let w = groupDns.get(grp)
548
- if (!w) groupDns.set(grp, (w = new Hits(c.groupDnsPerMin, 60_000)))
549
- return w.hit(now())
509
+ function dialOk(s: Session) {
510
+ const d = dials.get(grpOf(s))
511
+ if (!d) return
512
+ d.w = Math.floor(d.w / 2)
513
+ if (!d.w) {
514
+ dials.delete(grpOf(s))
515
+ if (d.told) s.hx?.handle.tell?.(3, 'failed-dials', 0, '')
516
+ }
550
517
  }
518
+ const dialWait = (s: Session) => dials.get(grpOf(s))?.w ?? 0
551
519
 
552
- function bytesHour(grp: string) {
553
- const hour = Math.floor(now() / 3_600_000)
554
- let b = groupBytes.get(grp)
555
- if (!b || b.hour !== hour) groupBytes.set(grp, (b = { hour, n: 0 }))
556
- return b
557
- }
520
+ const dnsClosed = (): string | null => notAccepting()?.words ?? null
558
521
 
559
- /** Bytes relayed for a session (either way). True while the group is within its hourly bytes. */
522
+ /** Bytes relayed for a session (either way: each leaves the host once). False when the month is at its cap and this
523
+ address group is over its share: its streams end. */
560
524
  function bytes(s: Session, n: number): boolean {
561
525
  roll()
562
- usage.dayBytes += n
563
526
  usage.monthBytes += n
564
- const b = bytesHour(grpOf(s))
565
- b.n += n
566
- ceiling() // starts the grace clock the moment the counter crosses
567
- return b.n <= c.groupBytesPerHour && !pastGrace()
527
+ const grp = grpOf(s)
528
+ today.per.set(grp, (today.per.get(grp) || 0) + n)
529
+ if (s.hx) s.hx.sent += n
530
+ return !(atCap() && overShare(grp))
568
531
  }
532
+ const limitWhy = (): Why => capWhy()
569
533
 
570
- /** Why `bytes` just said no. */
571
- function limitWhy(): string {
572
- const k = ceiling()
573
- return k && pastGrace() ? ceilingWords(k) : 'this address has used its bytes for this hour'
534
+ /** C and F, once a second: who is slowed for the month's pace, and each session's share of a full link. */
535
+ function tick() {
536
+ roll()
537
+ const t = now()
538
+ const next = new Map<string, number>()
539
+ if (c.monthBytes && !atCap()) {
540
+ const pace = c.monthBytes * ((t - monthStart(t)) / (monthEnd(t) - monthStart(t)))
541
+ if (usage.monthBytes > pace) {
542
+ const over = [...today.per.keys()].filter(overShare)
543
+ const rate = (c.monthBytes - usage.monthBytes) / ((monthEnd(t) - t) / 1000) / Math.max(1, over.length)
544
+ for (const g of over) next.set(g, rate)
545
+ }
546
+ }
547
+ for (const [g, x] of slow) if (!next.has(g)) {
548
+ slow.delete(g)
549
+ for (const h of live) if (h.grp === g) h.tell?.(3, 'data-share', 0, '')
550
+ }
551
+ for (const [g, rate] of next) {
552
+ const x = slow.get(g)
553
+ if (x) {
554
+ x.bucket.rate = rate
555
+ x.until = dayEnd(t)
556
+ continue
557
+ }
558
+ slow.set(g, { rate, until: dayEnd(t), bucket: new Bucket(rate) })
559
+ for (const h of live) if (h.grp === g) h.tell?.(2, 'data-share', dayEnd(t), 'this address used more than its share of today\'s data while the month is over its pace: slowed until midnight UTC')
560
+ }
561
+ if (c.linkBytesPerSec) {
562
+ const list: { net: string; addr: string; sid: string; want: number; adm: Admission }[] = []
563
+ let i = 0
564
+ for (const adm of admissions) list.push({ net: adm.net, addr: adm.grp, sid: String(i++), want: adm.waiting ? Infinity : adm.sent, adm })
565
+ const shares = maxMinShares(c.linkBytesPerSec, list)
566
+ for (const x of list) {
567
+ x.adm.fair!.rate = shares.get(x.sid)!
568
+ x.adm.sent = 0
569
+ x.adm.waiting = false
570
+ }
571
+ }
572
+ }
573
+ const admissions = new Set<Admission>()
574
+ const slowed = (s: Session) => {
575
+ const x = slow.get(grpOf(s))
576
+ return x ? { rule: 'data-share', rate: x.rate, until: x.until } : null
574
577
  }
575
578
 
576
- /** Up to n bytes from the coarse, origin and relay-wide buckets of the session (what the group's own bucket let through). */
577
- function take(s: Session, dir: 'down' | 'up', n: number): number {
578
- const list: Bucket[] | undefined = s.hx?.[dir]
579
- if (!list) return n
579
+ function bucketsOf(s: Session): Bucket[] {
580
+ const b: Bucket[] = []
581
+ const x = slow.get(grpOf(s))
582
+ if (x) b.push(x.bucket)
583
+ if (s.hx?.fair && Number.isFinite(s.hx.fair.rate)) b.push(s.hx.fair)
584
+ return b
585
+ }
586
+ /** Of n bytes the session's own bucket let through, how many the slowdown (C) and the link share (F) allow. */
587
+ function take(s: Session, _dir: 'down' | 'up', n: number): number {
588
+ const list = bucketsOf(s)
589
+ if (!list.length) return n
580
590
  const got: number[] = []
581
591
  let k = n
582
592
  for (const b of list) {
@@ -586,33 +596,51 @@ export function createGovernor(o: HostedOptions) {
586
596
  }
587
597
  const last = got[got.length - 1]
588
598
  for (let i = 0; i < got.length; i++) if (got[i] > last) list[i].tokens = Math.min(list[i].rate, list[i].tokens + got[i] - last)
599
+ if (last < n && s.hx) s.hx.waiting = true
589
600
  return last
590
601
  }
591
- function waitMs(s: Session, dir: 'down' | 'up', n: number): number {
592
- const list: Bucket[] | undefined = s.hx?.[dir]
593
- return list ? Math.max(1, ...list.map(b => b.waitMs(n))) : 1
602
+ function waitMs(s: Session, _dir: 'down' | 'up', n: number): number {
603
+ const list = bucketsOf(s)
604
+ return list.length ? Math.max(1, ...list.map(b => b.waitMs(n))) : 1
605
+ }
606
+ /** The session's WebSocket is backed up (its bytes wait): it wants more than it got (F). */
607
+ function backpressure(s: Session) {
608
+ if (s.hx) s.hx.waiting = true
594
609
  }
595
610
 
596
611
  function blockedAddr(a: Addr): string | null {
597
612
  return hostedBlockedReason(a, denyCidrs)
598
613
  }
599
614
 
600
- // ---- housekeeping
615
+ const ticker = setInterval(tick, 1000)
616
+ ticker.unref?.()
617
+ const saver = o.usageFile ? setInterval(saveUsage, 15_000) : null
618
+ saver?.unref?.()
601
619
  const sweep = setInterval(() => {
602
620
  const t = now()
603
- for (const m of [groupConnects, groupDns, coarseInstances]) for (const [k, w] of m.m) { w.prune(t); if (!w.times.length) m.delete(k) }
604
- for (const [k, w] of destConnects.m) { w.prune(t); if (!w.times.length && !destLive.has(k)) destConnects.delete(k) }
605
- for (const [k, f] of groupFails.m) { f.hits.prune(t); if (!f.hits.times.length && f.cool < t) groupFails.delete(k) }
606
- for (const [k, b] of groupBytes.m) if (b.hour !== Math.floor(t / 3_600_000)) groupBytes.delete(k)
621
+ for (const m of [addrSites, netSites]) for (const [k, x] of m) if (!fresh(x, t).size) m.delete(k)
622
+ for (const [k, d] of dials) if (d.gate + HOUR < t) dials.delete(k)
607
623
  }, 60_000)
608
624
  sweep.unref?.()
609
625
 
626
+ const state = () => {
627
+ roll()
628
+ const full: string[] = []
629
+ if (used >= budget) full.push('sockets')
630
+ if (freeMem() < SESSION_MEM) full.push('memory')
631
+ if (atCap()) full.push('month')
632
+ return {
633
+ full,
634
+ sockets: { used, budget },
635
+ month: { month: usage.month, bytes: usage.monthBytes, cap: c.monthBytes || null, slowed: slow.size },
636
+ }
637
+ }
638
+
610
639
  return {
611
640
  options: c,
612
641
  contact,
613
642
  contactLine,
614
643
  version: o.version,
615
- // operator state
616
644
  stopNew: () => void (stopped = true),
617
645
  resume() {
618
646
  stopped = false
@@ -621,35 +649,52 @@ export function createGovernor(o: HostedOptions) {
621
649
  startDrain: () => void ((draining = true), (stopped = true)),
622
650
  isStopped: () => stopped,
623
651
  isDraining: () => draining,
624
- accepting,
652
+ accepting: () => !notAccepting(),
625
653
  notAccepting,
626
- ceiling,
627
- ceilingWords,
628
654
  usage: () => (roll(), { ...usage }),
629
655
  saveUsage,
630
- // deny lists
656
+ state,
657
+ setSites,
658
+ siteAllowed: (origin: string) => siteOk(origin),
631
659
  reloadDeny,
632
660
  deniedClient,
633
661
  isDeniedDest: deniedDestination,
634
- // admission
635
- admitSession,
662
+ admitSession(i: Parameters<typeof admitSession>[0]) {
663
+ const r = admitSession(i)
664
+ if (r.ok) {
665
+ admissions.add(r.adm)
666
+ const rel = r.adm.release
667
+ r.adm.release = () => {
668
+ admissions.delete(r.adm)
669
+ rel()
670
+ }
671
+ }
672
+ return r
673
+ },
674
+ claimHandshake,
675
+ unclaim,
676
+ setReclaim: (fn: () => boolean) => void (reclaim = fn),
677
+ socketBudget: () => budget,
636
678
  admitConnect,
637
679
  openStream,
638
680
  dialFailed,
639
- dnsAllowed,
681
+ dialOk,
682
+ dialWait,
640
683
  dnsClosed,
641
684
  bytes,
642
685
  limitWhy,
686
+ tick,
687
+ slowed,
643
688
  take,
644
689
  waitMs,
690
+ backpressure,
645
691
  blockedAddr,
646
- /** Sessions per origin class, for tests and status (counts only). */
647
- liveStreams: () => liveStreams,
648
- originStats: () => ({ table: origins.size, other: other.sessions, none: none.sessions }),
692
+ liveStreams: () => used,
649
693
  limitsLine: () =>
650
- `${c.groupStreams} streams, ${c.groupConnectsPerMin} connects a minute, ${Math.round(c.groupBytesPerHour / MIB)} MiB an hour per address`,
694
+ `only listed sites; ${c.sitesPerAddrPerHour} different sites an hour per address, ${c.sitesPerNetPerHour} per network; failed dials slow the sender` +
695
+ (c.monthBytes ? `; ${Math.round(c.monthBytes / 1e9)} GB a month, heavy users slowed first when the month runs ahead` : ''),
651
696
  stop() {
652
- if (graceTimer) clearTimeout(graceTimer)
697
+ clearInterval(ticker)
653
698
  clearInterval(sweep)
654
699
  if (saver) clearInterval(saver)
655
700
  saveUsage()