@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/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/hosted.ts
CHANGED
|
@@ -1,64 +1,42 @@
|
|
|
1
|
-
// W59
|
|
1
|
+
// W59 §16: the `--hosted` profile's rules (plan §16, A-J). Every number names its source there.
|
|
2
2
|
//
|
|
3
|
-
// The governor holds
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
-
//
|
|
9
|
-
// group" is
|
|
10
|
-
//
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
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
|
|
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
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
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: (
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
-
// ----
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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):
|
|
320
|
+
function deniedClient(ip: string, origin: string): Why | null {
|
|
302
321
|
const a = parseIP(ip)
|
|
303
|
-
if (a && denyAddr.some(x =>
|
|
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
|
-
// ----
|
|
314
|
-
const
|
|
315
|
-
const
|
|
316
|
-
const
|
|
317
|
-
|
|
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
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
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 (
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
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
|
|
395
|
-
return
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
-
// ----
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (!
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
return
|
|
424
|
+
a.set(site, t)
|
|
425
|
+
n.set(site, t)
|
|
426
|
+
return null
|
|
430
427
|
}
|
|
431
428
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
const
|
|
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
|
|
488
|
-
if (
|
|
489
|
-
|
|
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:
|
|
508
|
-
function openStream(
|
|
509
|
-
const
|
|
510
|
-
|
|
511
|
-
|
|
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
|
-
/**
|
|
529
|
-
function dialFailed(s: Session) {
|
|
530
|
-
const
|
|
531
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
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
|
-
|
|
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).
|
|
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
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
return
|
|
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
|
-
/**
|
|
571
|
-
function
|
|
572
|
-
|
|
573
|
-
|
|
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
|
-
|
|
577
|
-
|
|
578
|
-
const
|
|
579
|
-
if (
|
|
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,
|
|
592
|
-
const list
|
|
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
|
-
|
|
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 [
|
|
604
|
-
for (const [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
|
-
|
|
656
|
+
state,
|
|
657
|
+
setSites,
|
|
658
|
+
siteAllowed: (origin: string) => siteOk(origin),
|
|
631
659
|
reloadDeny,
|
|
632
660
|
deniedClient,
|
|
633
661
|
isDeniedDest: deniedDestination,
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
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
|
-
|
|
647
|
-
liveStreams: () => liveStreams,
|
|
648
|
-
originStats: () => ({ table: origins.size, other: other.sessions, none: none.sessions }),
|
|
692
|
+
liveStreams: () => used,
|
|
649
693
|
limitsLine: () =>
|
|
650
|
-
|
|
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
|
-
|
|
697
|
+
clearInterval(ticker)
|
|
653
698
|
clearInterval(sweep)
|
|
654
699
|
if (saver) clearInterval(saver)
|
|
655
700
|
saveUsage()
|