@threadedstack/wmd-web 0.0.0-stage → 0.1.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2601 -2
- package/RELEASE.json +123 -0
- package/export-page/coi-sw.js +20 -0
- package/export-page/index.html +17 -0
- package/export-page/run.css +10 -0
- package/export-page/run.js +236 -0
- package/guest/arm64/Image +4 -0
- package/guest/arm64/Image-4k +4 -0
- package/guest/arm64/initramfs.cpio.gz +0 -0
- package/guest/arm64/vm.wasm +0 -0
- package/package.json +36 -4
- package/page/sw.js +381 -0
- package/relay/control.ts +139 -0
- package/relay/engine.ts +787 -0
- package/relay/hosted.ts +660 -0
- package/relay/keys.ts +53 -0
- package/relay/local-tls.ts +121 -0
- package/relay/lock.ts +81 -0
- package/relay/logfile.ts +138 -0
- package/relay/main.ts +642 -0
- package/relay/policy.ts +241 -0
- package/relay/relay.ts +266 -0
- package/relay/token.ts +116 -0
- package/relay/v1.ts +107 -0
- package/relay/wisp.ts +615 -0
- package/src/base.js +325 -0
- package/src/boot.js +2037 -0
- package/src/calib-worker.js +40 -0
- package/src/fs/9p/codec.js +303 -0
- package/src/fs/9p/server.js +521 -0
- package/src/fs/errno.js +43 -0
- package/src/fs/graph.js +380 -0
- package/src/fs/overlay.js +1018 -0
- package/src/fs/pack-write.js +165 -0
- package/src/fs/pack.js +427 -0
- package/src/fs/seed.js +22 -0
- package/src/fs/svc-snapshot.js +65 -0
- package/src/fs/upper-pack.js +112 -0
- package/src/host/base-cache.js +195 -0
- package/src/host/base-file.js +328 -0
- package/src/host/base-io.js +93 -0
- package/src/host/boot-events.js +209 -0
- package/src/host/chan-service.js +300 -0
- package/src/host/chan-services.js +689 -0
- package/src/host/chan.js +655 -0
- package/src/host/ckpt-file.js +395 -0
- package/src/host/ckpt-io.js +207 -0
- package/src/host/ckpt-point.js +160 -0
- package/src/host/ckpt-save.js +64 -0
- package/src/host/ckpt.js +434 -0
- package/src/host/clock.js +111 -0
- package/src/host/ctl.js +778 -0
- package/src/host/errno.js +18 -0
- package/src/host/fs.js +167 -0
- package/src/host/gcnudge.js +83 -0
- package/src/host/i64shim.js +81 -0
- package/src/host/jit-cache.js +341 -0
- package/src/host/jit-hub-core.js +208 -0
- package/src/host/jit-hub.js +93 -0
- package/src/host/jit-snap.js +226 -0
- package/src/host/jit.js +1903 -0
- package/src/host/layout.js +100 -0
- package/src/host/monitor.js +110 -0
- package/src/host/p9mount.js +27 -0
- package/src/host/ram-mem.js +125 -0
- package/src/host/stdio.js +143 -0
- package/src/host/threads.js +109 -0
- package/src/host/wasi.js +335 -0
- package/src/host/wmd-imports.js +71 -0
- package/src/host/yamux.js +387 -0
- package/src/image-files.js +154 -0
- package/src/net/dhcp-dns.js +126 -0
- package/src/net/net-worker.js +172 -0
- package/src/net/packet.js +227 -0
- package/src/net/peer.js +230 -0
- package/src/net/proto.js +189 -0
- package/src/net/relay-client.js +226 -0
- package/src/net/stack.js +867 -0
- package/src/net/switch.js +160 -0
- package/src/net/wisp-client.js +570 -0
- package/src/net/wisp-transport.js +196 -0
- package/src/net/wisp.js +511 -0
- package/src/registry/client.js +176 -0
- package/src/registry/converter.js +68 -0
- package/src/registry/hub.js +221 -0
- package/src/registry/oci.js +76 -0
- package/src/registry/pack-worker.js +70 -0
- package/src/registry/pull.js +258 -0
- package/src/registry/ref.js +110 -0
- package/src/registry/wasi-mem.js +355 -0
- package/src/worker.js +315 -0
- package/wmdpack.wasm +0 -0
package/relay/engine.ts
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
// W59: the relay engine, shared by both wire protocols (v1.ts: wmd-relay.v1, wisp.ts: Wisp v2).
|
|
2
|
+
//
|
|
3
|
+
// The engine owns everything that is not the wire format:
|
|
4
|
+
// - policy (policy.ts): names and addresses checked on the address actually dialled, the
|
|
5
|
+
// allowlist with its per-guest pins, blocked ports
|
|
6
|
+
// - principals (a guest identity) and their caps: connections, connect/DNS/open rates,
|
|
7
|
+
// bandwidth buckets, total bytes; sessions per group; one live session per identity
|
|
8
|
+
// - resolve-and-check, dial, the stream lifecycle and byte accounting
|
|
9
|
+
// - logging (metadata only, never payloads)
|
|
10
|
+
//
|
|
11
|
+
// An adapter owns the wire: it decodes client messages into engine calls, and implements `Wire`
|
|
12
|
+
// for what the engine tells the client. It also owns the flow-control *unit*: the engine counts
|
|
13
|
+
// bytes (download credit, upload bytes written), the adapter turns them into its own messages
|
|
14
|
+
// (v1: byte CREDIT; Wisp: CONTINUE packet counts, the 0xE1 byte credit).
|
|
15
|
+
|
|
16
|
+
import { Resolver } from 'node:dns'
|
|
17
|
+
import { connect as netConnect, type Socket } from 'node:net'
|
|
18
|
+
import { E, MAX_DATA, RCODE, WINDOW } from '../src/net/proto.js'
|
|
19
|
+
import { type Addr, addrKey, addrString, badName, blockedReason, hostAddresses, isLanRange, parseIP } from './policy.ts'
|
|
20
|
+
|
|
21
|
+
// a principal's caps outlive its tokens by at least this long (short-lived tokens must not reset them)
|
|
22
|
+
const PRINCIPAL_FLOOR_MS = 60 * 60_000
|
|
23
|
+
|
|
24
|
+
export type ResolveFn = (name: string) => Promise<{ address: string; ttl: number }[]>
|
|
25
|
+
export type DialFn = (host: string, port: number) => Socket
|
|
26
|
+
|
|
27
|
+
/** What the hosted profile (hosted.ts) asks of the engine; absent outside it. Each method is a hook at one point of the stream lifecycle. */
|
|
28
|
+
export interface Guard {
|
|
29
|
+
/** An extra reason to refuse a dialled address (NAT64, `--deny-cidr`). */
|
|
30
|
+
blockedAddr(a: Addr): string | null
|
|
31
|
+
/** A destination on the operator's deny list. */
|
|
32
|
+
isDeniedDest(host: string, port: number): boolean
|
|
33
|
+
/** Before a connect is resolved: the words to refuse it with, or null. */
|
|
34
|
+
admitConnect(s: Session, host: string, port: number): string | null
|
|
35
|
+
/** Just before the dial: concurrent caps. Sets `st.rel`. */
|
|
36
|
+
openStream(s: Session, st: Stream, host: string, port: number): string | null
|
|
37
|
+
dialFailed(s: Session): void
|
|
38
|
+
dnsAllowed(s: Session): boolean
|
|
39
|
+
dnsClosed?(): string | null // words when lookups are refused too (stop-new, drain, ceiling)
|
|
40
|
+
/** Bytes moved for a session; false when its address group is over its bytes for the hour. */
|
|
41
|
+
bytes(s: Session, n: number): boolean
|
|
42
|
+
limitWhy?(): string // why `bytes` said no
|
|
43
|
+
/** Of n bytes the session's own bucket let through, how many the coarse, origin and relay-wide buckets allow. */
|
|
44
|
+
take(s: Session, dir: 'down' | 'up', n: number): number
|
|
45
|
+
waitMs(s: Session, dir: 'down' | 'up', n: number): number
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type EngineOptions = {
|
|
49
|
+
maxConns?: number
|
|
50
|
+
maxSessions?: number
|
|
51
|
+
maxSessionsPerGroup?: number
|
|
52
|
+
opensPerMin?: number
|
|
53
|
+
connectsPerMin?: number
|
|
54
|
+
dnsPerMin?: number
|
|
55
|
+
rateDown?: number
|
|
56
|
+
rateUp?: number
|
|
57
|
+
maxBytes?: number
|
|
58
|
+
allowHosts?: string[]
|
|
59
|
+
selfAddrs?: string[]
|
|
60
|
+
dnsServers?: string[]
|
|
61
|
+
blockedPorts?: number[]
|
|
62
|
+
allowPorts?: number[] // when set, only these destination ports (a stricter deployment)
|
|
63
|
+
guard?: Guard
|
|
64
|
+
allowLan?: boolean // private ranges (10/8, 172.16/12, 192.168/16, fc00::/7) may be dialled; metadata, link-local and this host's LAN addresses stay refused
|
|
65
|
+
allowLoopbackPorts?: number[] // loopback (127/8, ::1) may be dialled on these ports only; never the relay's own port
|
|
66
|
+
ownPort?: () => number // the relay's own port (refused on loopback whatever allowLoopbackPorts says)
|
|
67
|
+
alsoOwnPorts?: () => number[] // listeners of the same program (the --local-tls wss port): refused on loopback too
|
|
68
|
+
connectTimeoutMs?: number
|
|
69
|
+
dnsTimeoutMs?: number
|
|
70
|
+
testRoutes?: Record<string, string>
|
|
71
|
+
testHosts?: Record<string, string>
|
|
72
|
+
resolve4?: ResolveFn
|
|
73
|
+
dial?: DialFn
|
|
74
|
+
log?: (e: Record<string, unknown>) => void
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export class Bucket {
|
|
78
|
+
tokens: number
|
|
79
|
+
last = performance.now()
|
|
80
|
+
constructor(public rate: number) {
|
|
81
|
+
this.tokens = rate
|
|
82
|
+
}
|
|
83
|
+
/** The smallest take worth a message: 16 KiB, or less for a small request or a small rate (the bucket holds at most `rate`). */
|
|
84
|
+
floor(n: number): number {
|
|
85
|
+
return Math.max(1, Math.min(n, 16 << 10, Math.floor(this.rate)))
|
|
86
|
+
}
|
|
87
|
+
/** Up to n bytes, or 0 when fewer than floor(n) are there (no trickle of tiny messages). */
|
|
88
|
+
take(n: number): number {
|
|
89
|
+
const t = performance.now()
|
|
90
|
+
this.tokens = Math.min(this.rate, this.tokens + ((t - this.last) / 1000) * this.rate)
|
|
91
|
+
this.last = t
|
|
92
|
+
if (this.tokens < this.floor(n)) return 0
|
|
93
|
+
const k = Math.max(0, Math.min(n, Math.floor(this.tokens)))
|
|
94
|
+
this.tokens -= k
|
|
95
|
+
return k
|
|
96
|
+
}
|
|
97
|
+
/** ms until n tokens are there. */
|
|
98
|
+
waitMs(n: number): number {
|
|
99
|
+
return Math.max(1, Math.ceil(((this.floor(n) - this.tokens) / this.rate) * 1000))
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class Window {
|
|
104
|
+
times: number[] = []
|
|
105
|
+
constructor(public max: number, public ms: number) {}
|
|
106
|
+
hit(): boolean {
|
|
107
|
+
const t = performance.now()
|
|
108
|
+
while (this.times.length && t - this.times[0] > this.ms) this.times.shift()
|
|
109
|
+
if (this.times.length >= this.max) return false
|
|
110
|
+
this.times.push(t)
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export type Stream = {
|
|
116
|
+
id: number
|
|
117
|
+
sock: Socket | null
|
|
118
|
+
connected: boolean
|
|
119
|
+
clientCredit: number // bytes we may send the client
|
|
120
|
+
ourGrant: number // v1: bytes the client may still send us (the v1 adapter's unit)
|
|
121
|
+
outQ: Buffer[]
|
|
122
|
+
outBytes: number
|
|
123
|
+
remoteEnded: boolean // the remote's FIN arrived
|
|
124
|
+
endSent: boolean
|
|
125
|
+
clientEnded: boolean
|
|
126
|
+
closed: boolean
|
|
127
|
+
to: string
|
|
128
|
+
bytesDown: number
|
|
129
|
+
bytesUp: number
|
|
130
|
+
openedAt: number
|
|
131
|
+
w?: any // adapter state for the stream
|
|
132
|
+
rel?: () => void // the guard's release, called once when the stream is gone
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A guest identity: every cap and counter, kept across its WebSocket sessions. */
|
|
136
|
+
export type Principal = {
|
|
137
|
+
uid: string
|
|
138
|
+
grp: string
|
|
139
|
+
connects: Window
|
|
140
|
+
dnsHits: Window
|
|
141
|
+
opens: Window
|
|
142
|
+
down: Bucket
|
|
143
|
+
up: Bucket
|
|
144
|
+
bytes: number
|
|
145
|
+
exp: number // the latest expiry of a token seen for it; the state is dropped after it
|
|
146
|
+
live: Session | null
|
|
147
|
+
pins: Map<string, number> // allowlist: "address:port" -> expiry (ms)
|
|
148
|
+
overBytes: boolean
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** What the engine tells the client. The adapter encodes it. */
|
|
152
|
+
export interface Wire {
|
|
153
|
+
/** The stream is open; `detail` is "address:port" dialled. The adapter grants the first upload window (engine.takeUp / engine.owe). */
|
|
154
|
+
connected(s: Session, st: Stream, detail: string): void
|
|
155
|
+
/** The connect failed or was refused before the stream opened. */
|
|
156
|
+
refused(s: Session, id: number, code: number, why: string): void
|
|
157
|
+
data(s: Session, st: Stream, bytes: Uint8Array): void
|
|
158
|
+
/** The remote closed cleanly (FIN) and every byte was sent. */
|
|
159
|
+
end(s: Session, st: Stream): void
|
|
160
|
+
/** The stream failed after it opened: never a clean end. */
|
|
161
|
+
abort(s: Session, st: Stream, code: number, why: string): void
|
|
162
|
+
/** Upload credit: `n` more bytes of the stream may come (out of the up bucket). */
|
|
163
|
+
grant(s: Session, st: Stream, n: number): void
|
|
164
|
+
dnsReply(s: Session, id: number, rcode: number, ttl: number, addrs: Uint8Array[], why: string): void
|
|
165
|
+
/** The stream is done both ways (after end() and the client's end). Optional. */
|
|
166
|
+
done?(s: Session, st: Stream): void
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type Session = {
|
|
170
|
+
ws: any
|
|
171
|
+
sid: string
|
|
172
|
+
p: Principal
|
|
173
|
+
wire: Wire
|
|
174
|
+
streams: Map<number, Stream>
|
|
175
|
+
bytes: number // this session's bytes (for the log)
|
|
176
|
+
pumpTimer: ReturnType<typeof setTimeout> | null
|
|
177
|
+
closed: boolean
|
|
178
|
+
expTimer: ReturnType<typeof setTimeout> | null
|
|
179
|
+
grantQueued?: boolean
|
|
180
|
+
/** The download credit a new stream starts with (v1: WINDOW; Wisp without 0xE1: Infinity). */
|
|
181
|
+
window: number
|
|
182
|
+
w?: any // adapter state for the session
|
|
183
|
+
hx?: any // the hosted governor's admission for this session
|
|
184
|
+
allow?: AllowEntry[] // replaces the relay's allowlist for this session (an owner key's)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export type AllowEntry = { name: string; ports: Set<number> }
|
|
188
|
+
|
|
189
|
+
/** Allowlist entries: name[:port|port...]; no ports: 80 and 443; a leading "." matches subdomains. */
|
|
190
|
+
export function compileAllow(list: string[] | undefined): AllowEntry[] {
|
|
191
|
+
return (list || []).map(h => h.toLowerCase().trim()).filter(Boolean).map(h => {
|
|
192
|
+
const i = h.indexOf(':')
|
|
193
|
+
const name = i < 0 ? h : h.slice(0, i)
|
|
194
|
+
const ports = i < 0 ? [80, 443] : h.slice(i + 1).split('|').map(Number).filter(n => n > 0 && n < 65536)
|
|
195
|
+
return { name, ports: new Set(ports) }
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Bandwidth buckets shared by several identities (Wisp: one client address, or one owner key). */
|
|
200
|
+
export type Shared = { down: Bucket; up: Bucket }
|
|
201
|
+
export const newShared = (o: { rateDown?: number; rateUp?: number }): Shared => ({ down: new Bucket(o.rateDown ?? 16 << 20), up: new Bucket(o.rateUp ?? 4 << 20) })
|
|
202
|
+
|
|
203
|
+
export type Admission = { ok: true; p: Principal } | { ok: false; status: number; body: string }
|
|
204
|
+
|
|
205
|
+
const ERR_CODE: Record<string, number> = {
|
|
206
|
+
ECONNREFUSED: E.REFUSED, ETIMEDOUT: E.TIMEOUT, ECONNRESET: E.RESET, EPIPE: E.RESET,
|
|
207
|
+
EHOSTUNREACH: E.UNREACHABLE, ENETUNREACH: E.UNREACHABLE, EADDRNOTAVAIL: E.UNREACHABLE,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const codeName = (code: number) => Object.keys(E).find(k => (E as any)[k] === code)?.toLowerCase()
|
|
211
|
+
|
|
212
|
+
export type Engine = ReturnType<typeof createEngine>
|
|
213
|
+
|
|
214
|
+
export function createEngine(o: EngineOptions) {
|
|
215
|
+
const log = o.log || (() => {})
|
|
216
|
+
const maxConns = o.maxConns ?? 64
|
|
217
|
+
const maxSessions = o.maxSessions ?? 100
|
|
218
|
+
const maxBytes = o.maxBytes ?? 4 * 2 ** 30
|
|
219
|
+
const blockedPorts = new Set(o.blockedPorts ?? [25])
|
|
220
|
+
const allowPorts = o.allowPorts?.length ? new Set(o.allowPorts) : null
|
|
221
|
+
const guard = o.guard
|
|
222
|
+
const relStream = (st: Stream) => {
|
|
223
|
+
const r = st.rel
|
|
224
|
+
st.rel = undefined
|
|
225
|
+
r?.()
|
|
226
|
+
}
|
|
227
|
+
const connectTimeoutMs = o.connectTimeoutMs ?? 10_000
|
|
228
|
+
// allowlist entries: name[:port|port...]; no ports: 80 and 443
|
|
229
|
+
const allow = compileAllow(o.allowHosts)
|
|
230
|
+
const maxPerGroup = o.maxSessionsPerGroup ?? 8
|
|
231
|
+
const testRoutes: Record<string, string> = Object.assign(Object.create(null), o.testRoutes) // no prototype keys ("constructor", "__proto__")
|
|
232
|
+
const testHosts: Record<string, string> = Object.assign(Object.create(null), o.testHosts)
|
|
233
|
+
let self = hostAddresses(o.selfAddrs)
|
|
234
|
+
const loopPorts = new Set(o.allowLoopbackPorts ?? [])
|
|
235
|
+
const isLoop = (a: Addr) => (a.v === 4 ? a.b[0] === 127 : a.b.every((x, i) => x === (i === 15 ? 1 : 0)))
|
|
236
|
+
/** blockedReason with the owner's exceptions; `port` undefined is a lookup (the port is checked at the connect). */
|
|
237
|
+
function whyBlocked(a: Addr, port?: number): string | null {
|
|
238
|
+
const extra = o.guard?.blockedAddr(a)
|
|
239
|
+
if (extra) return extra
|
|
240
|
+
const why = blockedReason(a, self)
|
|
241
|
+
if (!why) return null
|
|
242
|
+
if (o.allowLan && why === 'private' && isLanRange(a)) return null
|
|
243
|
+
if (loopPorts.size && isLoop(a) && (why === 'loopback' || why === 'self')) {
|
|
244
|
+
if (port === undefined) return null
|
|
245
|
+
if (port === o.ownPort?.() || o.alsoOwnPorts?.().includes(port)) return `${why} (the relay's own port)`
|
|
246
|
+
if (loopPorts.has(port)) return null
|
|
247
|
+
return `${why} (port ${port} is not in allowLoopbackPorts)`
|
|
248
|
+
}
|
|
249
|
+
return why
|
|
250
|
+
}
|
|
251
|
+
const selfTimer = setInterval(() => (self = hostAddresses(o.selfAddrs)), 30_000)
|
|
252
|
+
const sessions = new Map<string, Session>() // by sid
|
|
253
|
+
const principals = new Map<string, Principal>() // by uid
|
|
254
|
+
const groupLive = new Map<string, number>()
|
|
255
|
+
// a principal's state lives as long as a token for it can: dropped after its latest expiry
|
|
256
|
+
const pruneTimer = setInterval(() => {
|
|
257
|
+
const t = Date.now()
|
|
258
|
+
for (const [uid, p] of principals) {
|
|
259
|
+
if (!p.live && p.exp + PRINCIPAL_FLOOR_MS < t) principals.delete(uid)
|
|
260
|
+
else for (const [k, exp] of p.pins) if (exp < t) p.pins.delete(k)
|
|
261
|
+
}
|
|
262
|
+
}, 60_000)
|
|
263
|
+
function principal(uid: string, grp: string, exp: number, share?: Shared): Principal {
|
|
264
|
+
let p = principals.get(uid)
|
|
265
|
+
if (p) {
|
|
266
|
+
principals.delete(uid) // re-inserted: the map is least recently admitted first
|
|
267
|
+
principals.set(uid, p)
|
|
268
|
+
} else {
|
|
269
|
+
p = {
|
|
270
|
+
uid, grp, connects: new Window(o.connectsPerMin ?? 600, 60_000), dnsHits: new Window(o.dnsPerMin ?? 1200, 60_000),
|
|
271
|
+
opens: new Window(o.opensPerMin ?? 30, 60_000), down: share?.down ?? new Bucket(o.rateDown ?? 16 << 20), up: share?.up ?? new Bucket(o.rateUp ?? 4 << 20),
|
|
272
|
+
bytes: 0, exp, live: null, pins: new Map(), overBytes: false,
|
|
273
|
+
}
|
|
274
|
+
principals.set(uid, p)
|
|
275
|
+
}
|
|
276
|
+
p.exp = Math.max(p.exp, exp)
|
|
277
|
+
return p
|
|
278
|
+
}
|
|
279
|
+
const stats = { sessions: 0, refusedSessions: 0, connects: 0, refused: {} as Record<string, number>, dns: 0, bytesDown: 0, bytesUp: 0 }
|
|
280
|
+
|
|
281
|
+
let resolve4: ResolveFn = o.resolve4 as ResolveFn
|
|
282
|
+
if (!resolve4) {
|
|
283
|
+
const r = new Resolver({ timeout: o.dnsTimeoutMs ?? 3000, tries: 2 })
|
|
284
|
+
if (o.dnsServers?.length) r.setServers(o.dnsServers)
|
|
285
|
+
resolve4 = name =>
|
|
286
|
+
new Promise((res, rej) => r.resolve4(name, { ttl: true }, (err, addrs) => (err ? rej(err) : res(addrs as { address: string; ttl: number }[]))))
|
|
287
|
+
}
|
|
288
|
+
const dial: DialFn = o.dial || ((host, port) => netConnect({ host, port, allowHalfOpen: true, noDelay: true } as any))
|
|
289
|
+
|
|
290
|
+
// a session may carry its own allowlist (an owner key's), which replaces the relay's
|
|
291
|
+
const listFor = (s: Session) => s.allow ?? allow
|
|
292
|
+
const entryFor = (A: AllowEntry[], name: string) =>
|
|
293
|
+
A.find(a => (a.name.startsWith('.') ? name.endsWith(a.name) || name === a.name.slice(1) : name === a.name))
|
|
294
|
+
const allowed = (A: AllowEntry[], name: string) => !A.length || !!entryFor(A, name)
|
|
295
|
+
|
|
296
|
+
function refuse(s: Session, id: number, code: number, why: string, to: string) {
|
|
297
|
+
const name = codeName(code)!
|
|
298
|
+
stats.refused[name] = (stats.refused[name] || 0) + 1
|
|
299
|
+
log({ ev: 'refused', sid: s.sid, id, to, code: name, why })
|
|
300
|
+
if (!s.closed) s.wire.refused(s, id, code, why)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The address to dial for a name: resolved here, filtered by the policy. */
|
|
304
|
+
async function resolveChecked(s: Session, name: string, port?: number): Promise<{ addrs: { a: Addr; ttl: number }[]; rcode: number; why: string; code: number }> {
|
|
305
|
+
const n = name.toLowerCase().replace(/\.$/, '')
|
|
306
|
+
const A = listFor(s)
|
|
307
|
+
const bad = badName(n)
|
|
308
|
+
if (bad) return { addrs: [], rcode: RCODE.REFUSED, why: `name refused: ${bad}`, code: E.BAD }
|
|
309
|
+
if (testHosts[n]) return { addrs: [{ a: parseIP(testHosts[n])!, ttl: 60 }], rcode: RCODE.NOERROR, why: '', code: E.OK } // test only
|
|
310
|
+
if (!allowed(A, n)) return { addrs: [], rcode: RCODE.REFUSED, why: 'name not on the allowlist', code: E.NOT_ALLOWED }
|
|
311
|
+
let list: { address: string; ttl: number }[]
|
|
312
|
+
{
|
|
313
|
+
try {
|
|
314
|
+
list = await resolve4(n)
|
|
315
|
+
} catch (e: any) {
|
|
316
|
+
const code = e?.code || ''
|
|
317
|
+
if (code === 'ENOTFOUND' || code === 'ENODATA' || code === 'NXDOMAIN') return { addrs: [], rcode: code === 'ENODATA' ? RCODE.NOERROR : RCODE.NXDOMAIN, why: code, code: E.DNS }
|
|
318
|
+
return { addrs: [], rcode: RCODE.SERVFAIL, why: `resolver: ${code || e?.message}`, code: E.DNS }
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
const out: { a: Addr; ttl: number }[] = []
|
|
322
|
+
const blocked: string[] = []
|
|
323
|
+
for (const x of list) {
|
|
324
|
+
const a = parseIP(x.address)
|
|
325
|
+
if (!a || a.v !== 4) continue
|
|
326
|
+
const why = whyBlocked(a, port)
|
|
327
|
+
if (why) blocked.push(`${x.address} (${why})`)
|
|
328
|
+
else out.push({ a, ttl: x.ttl })
|
|
329
|
+
}
|
|
330
|
+
if (!out.length && blocked.length) return { addrs: [], rcode: RCODE.REFUSED, why: `resolves only to refused addresses: ${blocked.join(', ')}`, code: E.BLOCKED }
|
|
331
|
+
if (A.length) {
|
|
332
|
+
const ports = entryFor(A, n)!.ports
|
|
333
|
+
const exp = Date.now() + Math.max(maxTtl(out), 300) * 1000
|
|
334
|
+
for (const x of out) for (const port of ports) s.p.pins.set(`${addrKey(x.a)}:${port}`, exp)
|
|
335
|
+
}
|
|
336
|
+
return { addrs: out, rcode: RCODE.NOERROR, why: blocked.length ? `refused: ${blocked.join(', ')}` : '', code: out.length ? E.OK : E.DNS }
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function maxTtl(out: { ttl: number }[]) {
|
|
340
|
+
return out.reduce((m, x) => Math.max(m, x.ttl), 0)
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function dns(s: Session, id: number, name: string, qtype: number) {
|
|
344
|
+
stats.dns++
|
|
345
|
+
if (!s.p.dnsHits.hit()) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit')
|
|
346
|
+
const closed = guard?.dnsClosed?.()
|
|
347
|
+
if (closed) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], closed)
|
|
348
|
+
if (guard && !guard.dnsAllowed(s)) return s.wire.dnsReply(s, id, RCODE.REFUSED, 0, [], 'dns rate limit for this address')
|
|
349
|
+
if (qtype !== 1) return s.wire.dnsReply(s, id, RCODE.NOERROR, 60, [], 'only A')
|
|
350
|
+
let r: Awaited<ReturnType<typeof resolveChecked>>
|
|
351
|
+
try {
|
|
352
|
+
r = await resolveChecked(s, name)
|
|
353
|
+
} catch (e: any) {
|
|
354
|
+
log({ ev: 'error', sid: s.sid, error: String(e) })
|
|
355
|
+
return s.wire.dnsReply(s, id, RCODE.SERVFAIL, 0, [], 'lookup failed')
|
|
356
|
+
}
|
|
357
|
+
if (r.rcode !== RCODE.NOERROR || r.why) log({ ev: 'dns', sid: s.sid, name, rcode: r.rcode, why: r.why })
|
|
358
|
+
const ttl = r.addrs.length ? Math.min(...r.addrs.map(x => x.ttl)) : 30
|
|
359
|
+
if (!s.closed) s.wire.dnsReply(s, id, r.rcode, ttl, r.addrs.slice(0, 16).map(x => x.a.b), r.why)
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async function connect(s: Session, id: number, host: string, port: number) {
|
|
363
|
+
const to = `${host}:${port}`
|
|
364
|
+
const A = listFor(s)
|
|
365
|
+
if (s.streams.has(id)) return refuse(s, id, E.BAD, 'stream id in use', to)
|
|
366
|
+
if (!port) return refuse(s, id, E.BAD, 'port 0', to)
|
|
367
|
+
if (blockedPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is blocked`, to)
|
|
368
|
+
if (allowPorts && !allowPorts.has(port)) return refuse(s, id, E.PORT, `port ${port} is not allowed on this relay`, to)
|
|
369
|
+
if (s.streams.size >= maxConns) return refuse(s, id, E.LIMIT, `more than ${maxConns} connections at once`, to)
|
|
370
|
+
if (!s.p.connects.hit()) return refuse(s, id, E.LIMIT, 'connect rate limit', to)
|
|
371
|
+
if (guard?.isDeniedDest(host, port)) return refuse(s, id, E.BLOCKED, 'this destination is blocked by the operator', to)
|
|
372
|
+
const held = guard?.admitConnect(s, host, port)
|
|
373
|
+
if (held) return refuse(s, id, E.LIMIT, held, to)
|
|
374
|
+
const st: Stream = {
|
|
375
|
+
id, sock: null, connected: false, clientCredit: s.window, ourGrant: 0, outQ: [], outBytes: 0, remoteEnded: false, endSent: false,
|
|
376
|
+
clientEnded: false, closed: false, to, bytesDown: 0, bytesUp: 0, openedAt: performance.now(),
|
|
377
|
+
}
|
|
378
|
+
s.streams.set(id, st) // reserve the id while resolving
|
|
379
|
+
let addr = parseIP(host)
|
|
380
|
+
let dialHost: string
|
|
381
|
+
let dialPort = port
|
|
382
|
+
if (addr) {
|
|
383
|
+
const key = addrKey(addr)
|
|
384
|
+
const route = testRoutes[`${key}:${port}`]
|
|
385
|
+
const why = route ? null : whyBlocked(addr, port)
|
|
386
|
+
if (why) {
|
|
387
|
+
s.streams.delete(id)
|
|
388
|
+
return refuse(s, id, E.BLOCKED, `${key} is ${why}`, to)
|
|
389
|
+
}
|
|
390
|
+
if (A.length && !route) {
|
|
391
|
+
const exp = s.p.pins.get(`${key}:${port}`)
|
|
392
|
+
if (!exp || exp < Date.now()) {
|
|
393
|
+
s.streams.delete(id)
|
|
394
|
+
return refuse(s, id, E.NOT_ALLOWED, `${key}:${port} was not resolved from an allowed name (for this port) by this guest`, to)
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (route) {
|
|
398
|
+
const i = route.lastIndexOf(':')
|
|
399
|
+
dialHost = route.slice(0, i)
|
|
400
|
+
dialPort = Number(route.slice(i + 1))
|
|
401
|
+
} else dialHost = addrString(addr)
|
|
402
|
+
} else {
|
|
403
|
+
let r: Awaited<ReturnType<typeof resolveChecked>>
|
|
404
|
+
try {
|
|
405
|
+
r = await resolveChecked(s, host, port)
|
|
406
|
+
} catch (e: any) {
|
|
407
|
+
// whatever fails, the stream id is freed and the guest gets an answer
|
|
408
|
+
if (!st.closed && !s.closed) {
|
|
409
|
+
s.streams.delete(id)
|
|
410
|
+
log({ ev: 'error', sid: s.sid, error: String(e) })
|
|
411
|
+
refuse(s, id, E.DNS, 'lookup failed', to)
|
|
412
|
+
}
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
if (st.closed || s.closed) return
|
|
416
|
+
if (r.addrs.length && A.length && !testHosts[host.toLowerCase().replace(/\.$/, '')] && !entryFor(A, host.toLowerCase().replace(/\.$/, ''))?.ports.has(port)) {
|
|
417
|
+
s.streams.delete(id)
|
|
418
|
+
return refuse(s, id, E.NOT_ALLOWED, `port ${port} is not allowed for ${host}`, to)
|
|
419
|
+
}
|
|
420
|
+
if (!r.addrs.length) {
|
|
421
|
+
s.streams.delete(id)
|
|
422
|
+
if (r.code === E.DNS) guard?.dialFailed(s)
|
|
423
|
+
return refuse(s, id, r.code || E.DNS, r.why || 'no address', to)
|
|
424
|
+
}
|
|
425
|
+
addr = r.addrs[0].a
|
|
426
|
+
const route = testRoutes[`${addrKey(addr)}:${port}`]
|
|
427
|
+
if (route) {
|
|
428
|
+
const i = route.lastIndexOf(':')
|
|
429
|
+
dialHost = route.slice(0, i)
|
|
430
|
+
dialPort = Number(route.slice(i + 1))
|
|
431
|
+
} else dialHost = addrString(addr) // exactly the address that was checked: no second lookup
|
|
432
|
+
}
|
|
433
|
+
if (st.closed || s.closed) return
|
|
434
|
+
const full = guard?.openStream(s, st, host, port)
|
|
435
|
+
if (full) {
|
|
436
|
+
s.streams.delete(id)
|
|
437
|
+
return refuse(s, id, E.LIMIT, full, to)
|
|
438
|
+
}
|
|
439
|
+
stats.connects++
|
|
440
|
+
let sock: Socket
|
|
441
|
+
try {
|
|
442
|
+
sock = dial(dialHost, dialPort)
|
|
443
|
+
} catch (e: any) {
|
|
444
|
+
relStream(st)
|
|
445
|
+
guard?.dialFailed(s)
|
|
446
|
+
s.streams.delete(id)
|
|
447
|
+
return refuse(s, id, E.UNREACHABLE, e?.message || 'dial failed', to)
|
|
448
|
+
}
|
|
449
|
+
st.sock = sock
|
|
450
|
+
const timer = setTimeout(() => {
|
|
451
|
+
if (!st.connected) {
|
|
452
|
+
sock.destroy()
|
|
453
|
+
fail(s, st, E.TIMEOUT, `no connection in ${connectTimeoutMs} ms`)
|
|
454
|
+
}
|
|
455
|
+
}, connectTimeoutMs)
|
|
456
|
+
sock.on('connect', () => {
|
|
457
|
+
clearTimeout(timer)
|
|
458
|
+
if (st.closed) return sock.destroy()
|
|
459
|
+
st.connected = true
|
|
460
|
+
log({ ev: 'connect', sid: s.sid, id, to, via: `${dialHost}:${dialPort}`, ms: Math.round(performance.now() - st.openedAt) })
|
|
461
|
+
if (!s.closed) s.wire.connected(s, st, `${addrKey(addr!)}:${port}`)
|
|
462
|
+
})
|
|
463
|
+
sock.on('data', (b: Buffer) => {
|
|
464
|
+
if (st.closed) return
|
|
465
|
+
st.outQ.push(b)
|
|
466
|
+
st.outBytes += b.length
|
|
467
|
+
pump(s, st)
|
|
468
|
+
})
|
|
469
|
+
sock.on('end', () => {
|
|
470
|
+
st.remoteEnded = true
|
|
471
|
+
pump(s, st)
|
|
472
|
+
})
|
|
473
|
+
sock.on('error', (e: any) => {
|
|
474
|
+
clearTimeout(timer)
|
|
475
|
+
fail(s, st, ERR_CODE[e?.code] ?? (st.connected ? E.RESET : E.UNREACHABLE), e?.code || e?.message || 'socket error')
|
|
476
|
+
})
|
|
477
|
+
sock.on('close', () => {
|
|
478
|
+
clearTimeout(timer)
|
|
479
|
+
if (st.closed) return
|
|
480
|
+
// closed without an error: clean only if the remote's FIN came and everything was sent
|
|
481
|
+
if (!st.connected) return fail(s, st, E.UNREACHABLE, 'closed before connecting')
|
|
482
|
+
if (st.remoteEnded) {
|
|
483
|
+
st.sock = null
|
|
484
|
+
pump(s, st)
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
fail(s, st, E.RESET, 'closed')
|
|
488
|
+
})
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/** connect(), with any failure before the dial answered as a refusal (the stream id is freed). */
|
|
492
|
+
function connectSafe(s: Session, id: number, host: string, port: number) {
|
|
493
|
+
connect(s, id, host, port).catch(e => {
|
|
494
|
+
log({ ev: 'error', sid: s.sid, error: String(e) })
|
|
495
|
+
const st = s.streams.get(id) // a failure before the dial: free the id and answer
|
|
496
|
+
if (st && !st.sock && !st.connected && !st.closed) {
|
|
497
|
+
s.streams.delete(id)
|
|
498
|
+
refuse(s, id, E.UNREACHABLE, 'connect failed', `${host}:${port}`)
|
|
499
|
+
}
|
|
500
|
+
})
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Send the remote's bytes as credit, bandwidth and the WebSocket allow; end() after the last. */
|
|
504
|
+
function pump(s: Session, st: Stream) {
|
|
505
|
+
if (st.closed || s.closed) return
|
|
506
|
+
while (st.outBytes > 0 && st.clientCredit > 0) {
|
|
507
|
+
if (s.ws.getBufferedAmount?.() > 8 << 20) break // the drain handler pumps again
|
|
508
|
+
const want = Math.min(st.outBytes, st.clientCredit, MAX_DATA)
|
|
509
|
+
let n = s.p.down.take(want)
|
|
510
|
+
if (!n) {
|
|
511
|
+
schedulePump(s, s.p.down.waitMs(want))
|
|
512
|
+
break
|
|
513
|
+
}
|
|
514
|
+
if (guard) {
|
|
515
|
+
const k = guard.take(s, 'down', n)
|
|
516
|
+
if (k < n) s.p.down.tokens = Math.min(s.p.down.rate, s.p.down.tokens + n - k)
|
|
517
|
+
n = k
|
|
518
|
+
if (!n) {
|
|
519
|
+
schedulePump(s, guard.waitMs(s, 'down', want))
|
|
520
|
+
break
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const out = Buffer.allocUnsafe(n)
|
|
524
|
+
let o = 0
|
|
525
|
+
while (o < n) {
|
|
526
|
+
const h = st.outQ[0]
|
|
527
|
+
const k = Math.min(h.length, n - o)
|
|
528
|
+
h.copy(out, o, 0, k)
|
|
529
|
+
o += k
|
|
530
|
+
if (k === h.length) st.outQ.shift()
|
|
531
|
+
else st.outQ[0] = h.subarray(k)
|
|
532
|
+
}
|
|
533
|
+
st.outBytes -= n
|
|
534
|
+
st.clientCredit -= n
|
|
535
|
+
st.bytesDown += n
|
|
536
|
+
stats.bytesDown += n
|
|
537
|
+
s.bytes += n
|
|
538
|
+
s.p.bytes += n
|
|
539
|
+
s.wire.data(s, st, out)
|
|
540
|
+
if (overBytes(s) || overHour(s, n)) return
|
|
541
|
+
}
|
|
542
|
+
if (st.sock) {
|
|
543
|
+
if (st.outBytes > 0) st.sock.pause()
|
|
544
|
+
else if (!st.remoteEnded) st.sock.resume()
|
|
545
|
+
}
|
|
546
|
+
if (st.remoteEnded && st.outBytes === 0 && !st.endSent) {
|
|
547
|
+
st.endSent = true
|
|
548
|
+
s.wire.end(s, st)
|
|
549
|
+
maybeDone(s, st)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function pumpAll(s: Session) {
|
|
554
|
+
for (const st of s.streams.values()) pump(s, st)
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function schedulePump(s: Session, ms: number) {
|
|
558
|
+
if (s.pumpTimer) return
|
|
559
|
+
s.pumpTimer = setTimeout(() => {
|
|
560
|
+
s.pumpTimer = null
|
|
561
|
+
for (const st of s.streams.values()) pump(s, st)
|
|
562
|
+
grantPending(s)
|
|
563
|
+
}, ms)
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/** The hosted profile's per-hour bytes for the session's address group: past them the session's streams end. */
|
|
567
|
+
function overHour(s: Session, n: number): boolean {
|
|
568
|
+
if (!guard || guard.bytes(s, n)) return false
|
|
569
|
+
const why = guard.limitWhy?.() ?? 'this address has used its bytes for this hour'
|
|
570
|
+
log({ ev: 'limit', sid: s.sid, why })
|
|
571
|
+
for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, why)
|
|
572
|
+
return true
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
function overBytes(s: Session): boolean {
|
|
576
|
+
if (s.p.bytes <= maxBytes) return false
|
|
577
|
+
s.p.overBytes = true // the identity stays over its cap: later sessions are refused
|
|
578
|
+
log({ ev: 'limit', sid: s.sid, why: 'session byte limit' })
|
|
579
|
+
for (const st of [...s.streams.values()]) fail(s, st, E.LIMIT, 'session byte limit')
|
|
580
|
+
s.ws.close(4008, 'session byte limit')
|
|
581
|
+
return true
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// upload credit waiting for bandwidth
|
|
585
|
+
const owed = new Map<Session, Map<number, number>>()
|
|
586
|
+
function owe(s: Session, id: number, n: number, now = true) {
|
|
587
|
+
let m = owed.get(s)
|
|
588
|
+
if (!m) owed.set(s, (m = new Map()))
|
|
589
|
+
m.set(id, (m.get(id) || 0) + n)
|
|
590
|
+
if (now) grantPending(s)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function grantPending(s: Session) {
|
|
594
|
+
const m = owed.get(s)
|
|
595
|
+
if (!m) return
|
|
596
|
+
for (const [id, n] of m) {
|
|
597
|
+
const st = s.streams.get(id)
|
|
598
|
+
if (!st || st.closed) {
|
|
599
|
+
m.delete(id)
|
|
600
|
+
continue
|
|
601
|
+
}
|
|
602
|
+
let k = s.p.up.take(n)
|
|
603
|
+
if (k && guard) {
|
|
604
|
+
const g = guard.take(s, 'up', k)
|
|
605
|
+
if (g < k) s.p.up.tokens = Math.min(s.p.up.rate, s.p.up.tokens + k - g)
|
|
606
|
+
k = g
|
|
607
|
+
}
|
|
608
|
+
if (k) s.wire.grant(s, st, k)
|
|
609
|
+
if (k === n) m.delete(id)
|
|
610
|
+
else {
|
|
611
|
+
m.set(id, n - k)
|
|
612
|
+
schedulePump(s, Math.max(s.p.up.waitMs(n - k), guard ? guard.waitMs(s, 'up', n - k) : 1))
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** Upload bytes for a connected stream (the adapter has checked its flow-control unit). */
|
|
618
|
+
function write(s: Session, st: Stream, data: Uint8Array) {
|
|
619
|
+
st.bytesUp += data.length
|
|
620
|
+
stats.bytesUp += data.length
|
|
621
|
+
s.bytes += data.length
|
|
622
|
+
s.p.bytes += data.length
|
|
623
|
+
if (overBytes(s) || overHour(s, data.length)) return
|
|
624
|
+
const n = data.length
|
|
625
|
+
st.sock!.write(Buffer.from(data.buffer, data.byteOffset, data.byteLength), () => {
|
|
626
|
+
if (st.closed) return
|
|
627
|
+
owe(s, st.id, n, false)
|
|
628
|
+
// one grant per stream for all the writes that finished in this turn, not one per write
|
|
629
|
+
if (!s.grantQueued) {
|
|
630
|
+
s.grantQueued = true
|
|
631
|
+
setImmediate(() => {
|
|
632
|
+
s.grantQueued = false
|
|
633
|
+
grantPending(s)
|
|
634
|
+
})
|
|
635
|
+
}
|
|
636
|
+
})
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function fail(s: Session, st: Stream, code: number, why: string) {
|
|
640
|
+
if (st.closed) return
|
|
641
|
+
st.closed = true
|
|
642
|
+
relStream(st)
|
|
643
|
+
s.streams.delete(st.id)
|
|
644
|
+
st.sock?.destroy()
|
|
645
|
+
if (!st.connected && guard && (code === E.REFUSED || code === E.UNREACHABLE || code === E.TIMEOUT)) guard.dialFailed(s)
|
|
646
|
+
if (!st.connected) return refuse(s, st.id, code, why, st.to)
|
|
647
|
+
log({ ev: 'abort', sid: s.sid, id: st.id, to: st.to, code: codeName(code), why, down: st.bytesDown, up: st.bytesUp })
|
|
648
|
+
if (!s.closed) s.wire.abort(s, st, code, why)
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function maybeDone(s: Session, st: Stream) {
|
|
652
|
+
if (st.endSent && st.clientEnded && !st.closed) {
|
|
653
|
+
st.closed = true
|
|
654
|
+
relStream(st)
|
|
655
|
+
s.streams.delete(st.id)
|
|
656
|
+
st.sock?.end()
|
|
657
|
+
log({ ev: 'done', sid: s.sid, id: st.id, to: st.to, down: st.bytesDown, up: st.bytesUp })
|
|
658
|
+
s.wire.done?.(s, st)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** The client half-closed the stream (the guest sent FIN). */
|
|
663
|
+
function clientEnd(s: Session, id: number) {
|
|
664
|
+
const st = s.streams.get(id)
|
|
665
|
+
if (st && st.connected && !st.clientEnded) {
|
|
666
|
+
st.clientEnded = true
|
|
667
|
+
st.sock?.end()
|
|
668
|
+
maybeDone(s, st)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/** The client reset the stream. */
|
|
673
|
+
function clientAbort(s: Session, id: number) {
|
|
674
|
+
const st = s.streams.get(id)
|
|
675
|
+
if (st) {
|
|
676
|
+
st.closed = true
|
|
677
|
+
relStream(st)
|
|
678
|
+
s.streams.delete(id)
|
|
679
|
+
st.sock?.destroy()
|
|
680
|
+
log({ ev: 'client-abort', sid: s.sid, id, to: st.to, down: st.bytesDown, up: st.bytesUp })
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
/** The client may take n more download bytes on the stream. */
|
|
685
|
+
function credit(s: Session, id: number, n: number) {
|
|
686
|
+
const st = s.streams.get(id)
|
|
687
|
+
if (st) {
|
|
688
|
+
st.clientCredit = Math.min(st.clientCredit + n, 0xffffffff)
|
|
689
|
+
pump(s, st)
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* Session admission for an identity, after the adapter's own checks (origin, auth). Counts the
|
|
695
|
+
* open against the identity's rate. On success the caller upgrades and then calls open().
|
|
696
|
+
*/
|
|
697
|
+
function admit(uid: string, grp: string, exp: number, extra: Record<string, unknown> = {}, share?: Shared): Admission {
|
|
698
|
+
const known = principals.get(uid)
|
|
699
|
+
const replaces = !!known?.live
|
|
700
|
+
if (sessions.size >= maxSessions && !replaces) {
|
|
701
|
+
stats.refusedSessions++
|
|
702
|
+
return { ok: false, status: 503, body: 'too many sessions\n' }
|
|
703
|
+
}
|
|
704
|
+
if ((groupLive.get(grp) || 0) >= maxPerGroup && !replaces) {
|
|
705
|
+
stats.refusedSessions++
|
|
706
|
+
log({ ev: 'session-refused', why: 'group session limit', grp, ...extra })
|
|
707
|
+
return { ok: false, status: 429, body: `more than ${maxPerGroup} sessions for this client\n` }
|
|
708
|
+
}
|
|
709
|
+
const p = principal(uid, grp, exp, share)
|
|
710
|
+
if (p.overBytes) {
|
|
711
|
+
stats.refusedSessions++
|
|
712
|
+
return { ok: false, status: 429, body: 'byte limit reached for this guest\n' }
|
|
713
|
+
}
|
|
714
|
+
if (!p.opens.hit()) {
|
|
715
|
+
stats.refusedSessions++
|
|
716
|
+
log({ ev: 'session-refused', why: 'open rate limit', uid, ...extra })
|
|
717
|
+
return { ok: false, status: 429, body: 'too many connections for this guest; wait\n' }
|
|
718
|
+
}
|
|
719
|
+
return { ok: true, p }
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function refusedSession(why: string, fields: Record<string, unknown> = {}) {
|
|
723
|
+
stats.refusedSessions++
|
|
724
|
+
if (why) log({ ev: 'session-refused', why, ...fields })
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** A WebSocket opened for an admitted identity. Returns null when the sid is already live. */
|
|
728
|
+
function open(ws: any, sid: string, p: Principal, exp: number, wire: Wire, window: number, logFields: Record<string, unknown> = {}): Session | null {
|
|
729
|
+
if (sessions.has(sid)) {
|
|
730
|
+
ws.close(4009, 'session already connected')
|
|
731
|
+
return null
|
|
732
|
+
}
|
|
733
|
+
// one live session per guest identity: a newer one (a reconnect the old side has not noticed) replaces it
|
|
734
|
+
if (p.live) {
|
|
735
|
+
const old = p.live
|
|
736
|
+
old.ws.close(4010, 'replaced by a newer session of this guest')
|
|
737
|
+
closeSession(old, 'replaced by a newer session')
|
|
738
|
+
}
|
|
739
|
+
const s: Session = { ws, sid, p, wire, streams: new Map(), bytes: 0, pumpTimer: null, closed: false, expTimer: null, window }
|
|
740
|
+
p.live = s
|
|
741
|
+
groupLive.set(p.grp, (groupLive.get(p.grp) || 0) + 1)
|
|
742
|
+
if (Number.isFinite(exp)) s.expTimer = setTimeout(() => ws.close(4001, 'token expired'), Math.max(0, exp - Date.now()))
|
|
743
|
+
sessions.set(sid, s)
|
|
744
|
+
stats.sessions++
|
|
745
|
+
log({ ev: 'session', sid, uid: p.uid, grp: p.grp, ...logFields })
|
|
746
|
+
return s
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function closeSession(s: Session, why: string) {
|
|
750
|
+
if (s.closed) return
|
|
751
|
+
s.closed = true
|
|
752
|
+
if (s.pumpTimer) clearTimeout(s.pumpTimer)
|
|
753
|
+
if (s.expTimer) clearTimeout(s.expTimer)
|
|
754
|
+
for (const st of s.streams.values()) {
|
|
755
|
+
st.closed = true
|
|
756
|
+
relStream(st)
|
|
757
|
+
st.sock?.destroy()
|
|
758
|
+
}
|
|
759
|
+
s.streams.clear()
|
|
760
|
+
owed.delete(s)
|
|
761
|
+
if (sessions.get(s.sid) === s) sessions.delete(s.sid)
|
|
762
|
+
if (s.p.live === s) s.p.live = null
|
|
763
|
+
const g = (groupLive.get(s.p.grp) || 1) - 1
|
|
764
|
+
if (g > 0) groupLive.set(s.p.grp, g)
|
|
765
|
+
else groupLive.delete(s.p.grp)
|
|
766
|
+
log({ ev: 'session-end', sid: s.sid, why, bytes: s.bytes })
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function limits(p: Principal, A: AllowEntry[] = allow) {
|
|
770
|
+
return {
|
|
771
|
+
maxConns, window: WINDOW, rateDown: p.down.rate, rateUp: p.up.rate, bytesLeft: Math.max(0, maxBytes - p.bytes),
|
|
772
|
+
allowHosts: A.length ? A.map(a => `${a.name}:${[...a.ports].join('|')}`) : null,
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
return {
|
|
777
|
+
log, stats, sessions, principals,
|
|
778
|
+
hasSession: (sid: string) => sessions.has(sid),
|
|
779
|
+
admit, refusedSession, open, closeSession, limits,
|
|
780
|
+
connect, connectSafe, refuse, dns, write, credit, clientEnd, clientAbort, fail, owe, pump, pumpAll, grantPending,
|
|
781
|
+
stop() {
|
|
782
|
+
clearInterval(selfTimer)
|
|
783
|
+
clearInterval(pruneTimer)
|
|
784
|
+
for (const s of sessions.values()) closeSession(s, 'relay stopping')
|
|
785
|
+
},
|
|
786
|
+
}
|
|
787
|
+
}
|