@threadedstack/wmd-web 0.0.0-stage → 0.1.13
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/wisp.ts
ADDED
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
// W59: the Wisp v2 adapter (src/net/wisp.js, docs/wisp-wmd-extensions.md) over the relay engine.
|
|
2
|
+
//
|
|
3
|
+
// Who may use it (plan §5):
|
|
4
|
+
// - token: the HMAC `t.<token>` subprotocol, as wmd-relay.v1 (wmn's bundled relay)
|
|
5
|
+
// - open: no key (the standalone relay's default); identity = the client's instance id (0xE7)
|
|
6
|
+
// plus its address group, bandwidth shared per address
|
|
7
|
+
// - keys (optional): Wisp password auth, scrypt hashes; a relay with keys refuses keyless clients
|
|
8
|
+
// Pre-auth limits: sockets in their handshake per address, a handshake deadline, failed keys
|
|
9
|
+
// throttled per address.
|
|
10
|
+
//
|
|
11
|
+
// Flow control: upload is Wisp's CONTINUE (packets), released as the bytes reach the remote and
|
|
12
|
+
// the up bucket allows (so the bucket delays CONTINUE); download is wmd-credit (0xE1) bytes when
|
|
13
|
+
// the client lists it, otherwise WebSocket backpressure only.
|
|
14
|
+
|
|
15
|
+
import { randomBytes } from 'node:crypto'
|
|
16
|
+
import { E } from '../src/net/proto.js'
|
|
17
|
+
import {
|
|
18
|
+
CLOSE, EXT, PT, STREAM, SUBPROTOCOL, ServerHandshake, answerPacket, closePacket, closeWords, continuePacket,
|
|
19
|
+
dataPacket, ext, parse, shutdownPacket,
|
|
20
|
+
} from '../src/net/wisp.js'
|
|
21
|
+
import { type AllowEntry, type Engine, type Principal, type Session, type Shared, type Stream, Window, type Wire, compileAllow, newShared } from './engine.ts'
|
|
22
|
+
import { type Governor, resolveClient } from './hosted.ts'
|
|
23
|
+
import { type OwnerKey, verifySecret } from './keys.ts'
|
|
24
|
+
import type { Cidr } from './policy.ts'
|
|
25
|
+
import { clientKey, verifyToken } from './token.ts'
|
|
26
|
+
|
|
27
|
+
export type WispOptions = {
|
|
28
|
+
enabled?: boolean
|
|
29
|
+
path?: string // URL prefix, ends with "/" (default "/wisp/")
|
|
30
|
+
open?: boolean // serve clients that bring no token and no key (the standalone default)
|
|
31
|
+
keys?: OwnerKey[] // when set, a client needs one of them (or a token)
|
|
32
|
+
prefixKeys?: boolean // Wisp v1 clients may put a key in the URL: /wisp/<id>.<secret>/
|
|
33
|
+
origins?: string[] // page origins allowed (default: any); a request without Origin is refused when set
|
|
34
|
+
checkHost?: boolean // the Host header must be a loopback name with the port (a loopback bind, open mode)
|
|
35
|
+
trustProxy?: boolean // caps per X-Forwarded-For client (the last entry, added by the trusted proxy)
|
|
36
|
+
proxy?: { trusted: Cidr[]; header: string } // hosted: only these peers are proxies; the header is walked from the right (hosted.ts resolveClient)
|
|
37
|
+
hosted?: Governor // the hosted profile's limits, lists and state (plan §15)
|
|
38
|
+
buffer?: number // CONTINUE: packets buffered per stream (32)
|
|
39
|
+
maxPayload?: number // largest WebSocket frame (1 MiB + 1 KiB)
|
|
40
|
+
maxHeldBytes?: number // upload bytes held per stream before CONTINUE is withheld (4 MiB). Never a close by itself: a client
|
|
41
|
+
// that keeps the packet rule may hold 2 x buffer packets of up to maxPayload each (the session cap bounds that)
|
|
42
|
+
maxSessionHeldBytes?: number // upload bytes held for one session, all streams (default: 2 x buffer x maxPayload, at least 64 MiB,
|
|
43
|
+
// so one stream that keeps the packet rule never reaches it); past it the stream that sent more gets 0x49
|
|
44
|
+
maxHeldTotal?: number // upload bytes held by the whole relay (512 MiB); past it, DATA from a group over its fair share gets 0x49
|
|
45
|
+
maxGroupHeldBytes?: number // upload bytes held by one address group or key, all its sessions (2 x the session cap)
|
|
46
|
+
instancesPerHour?: number // new identities per address (or key) an hour (120)
|
|
47
|
+
maxPrincipals?: number // identities remembered (10000)
|
|
48
|
+
preAuthPerAddr?: number // sockets in their handshake per address (8)
|
|
49
|
+
handshakeMs?: number // (10000)
|
|
50
|
+
streamIdleMs?: number // a stream with no bytes either way this long is closed, 0x47 (30 min)
|
|
51
|
+
version?: string // the relay's version, in the MOTD
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type Mode = 'token' | 'open' | 'key' | 'key-pending'
|
|
55
|
+
|
|
56
|
+
const REASON: Record<number, number> = {
|
|
57
|
+
[E.BLOCKED]: CLOSE.BLOCKED, [E.NOT_ALLOWED]: CLOSE.BLOCKED, [E.PORT]: CLOSE.BLOCKED, [E.REFUSED]: CLOSE.REFUSED,
|
|
58
|
+
[E.TIMEOUT]: CLOSE.TIMEOUT, [E.DNS]: CLOSE.UNREACHABLE, [E.UNREACHABLE]: CLOSE.UNREACHABLE, [E.LIMIT]: CLOSE.THROTTLED,
|
|
59
|
+
[E.BAD]: CLOSE.INVALID, [E.RESET]: CLOSE.NETWORK, [E.GONE]: CLOSE.NETWORK,
|
|
60
|
+
}
|
|
61
|
+
const reasonFor = (code: number) => REASON[code] ?? CLOSE.UNKNOWN
|
|
62
|
+
|
|
63
|
+
export const DRAIN_WORDS = 'the relay is closed for maintenance'
|
|
64
|
+
const LOOPBACK_HOST = /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/i
|
|
65
|
+
// a key id that does not exist is still checked against this, so the time does not tell
|
|
66
|
+
const DUMMY_HASH = 'scrypt:16384:8:1:AAAAAAAAAAAAAAAAAAAAAA:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
|
|
67
|
+
|
|
68
|
+
type StreamW = {
|
|
69
|
+
pending: Uint8Array[] | null // DATA that came before the remote connected
|
|
70
|
+
sizes: number[] // sizes of packets written to the remote, not yet released
|
|
71
|
+
released: number // bytes released by the engine, not yet a whole packet
|
|
72
|
+
held: number // packets received and not yet released
|
|
73
|
+
heldBytes: number
|
|
74
|
+
drained: number // packets released since the last CONTINUE
|
|
75
|
+
last: number // the last time a byte moved either way
|
|
76
|
+
idle: boolean
|
|
77
|
+
shutPending: boolean // SHUTDOWN before the remote connected
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
type SessionW = {
|
|
81
|
+
engine: Engine
|
|
82
|
+
v2: boolean
|
|
83
|
+
confirm: boolean
|
|
84
|
+
credit: boolean
|
|
85
|
+
halfclose: boolean
|
|
86
|
+
dns: boolean
|
|
87
|
+
buffer: number
|
|
88
|
+
idleTimer: ReturnType<typeof setInterval> | null
|
|
89
|
+
unknownLogged: boolean
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function createWisp(engine: Engine, o: { secret?: string; port: () => number; maxConns?: number; rateDown?: number; rateUp?: number; blockedPorts?: number[]; wisp: WispOptions }) {
|
|
93
|
+
const w = o.wisp
|
|
94
|
+
const path = w.path ?? '/wisp/'
|
|
95
|
+
const keys = w.keys ?? []
|
|
96
|
+
const open = !!w.open && !keys.length
|
|
97
|
+
const buffer = w.buffer ?? 32
|
|
98
|
+
const maxPayload = w.maxPayload ?? (1 << 20) + 1024
|
|
99
|
+
const maxHeld = w.maxHeldBytes ?? 4 << 20
|
|
100
|
+
const maxSessHeld = w.maxSessionHeldBytes ?? Math.max(64 << 20, 2 * buffer * maxPayload)
|
|
101
|
+
const maxGroupHeld = w.maxGroupHeldBytes ?? 2 * maxSessHeld
|
|
102
|
+
const maxHeldAll = w.maxHeldTotal ?? 512 << 20
|
|
103
|
+
// The relay's and each group's held bytes at the last count, plus what came since (they decrease only at the next
|
|
104
|
+
// count, so they never go negative and only overestimate). When the relay is over its total, only a group holding
|
|
105
|
+
// more than its fair share (the total over the groups holding bytes) is refused: one party cannot fill the relay
|
|
106
|
+
// and break everyone else's uploads.
|
|
107
|
+
let heldSwept = 0
|
|
108
|
+
let groupHeld = new Map<string, number>()
|
|
109
|
+
const heldCount = setInterval(() => {
|
|
110
|
+
let n = 0
|
|
111
|
+
const g = new Map<string, number>()
|
|
112
|
+
for (const s of engine.sessions.values()) {
|
|
113
|
+
let k = 0
|
|
114
|
+
for (const st of s.streams.values()) k += st.w?.heldBytes || 0
|
|
115
|
+
if (k) g.set(s.p.grp, (g.get(s.p.grp) || 0) + k)
|
|
116
|
+
n += k
|
|
117
|
+
}
|
|
118
|
+
heldSwept = n
|
|
119
|
+
groupHeld = g
|
|
120
|
+
}, 1000)
|
|
121
|
+
const handshakeMs = w.handshakeMs ?? 10_000
|
|
122
|
+
const streamIdleMs = w.streamIdleMs ?? 30 * 60_000
|
|
123
|
+
const preAuth = w.preAuthPerAddr ?? 8
|
|
124
|
+
const maxPrincipals = w.maxPrincipals ?? 10_000
|
|
125
|
+
const instancesPerHour = w.instancesPerHour ?? 120
|
|
126
|
+
const inHandshake = new Map<string, number>()
|
|
127
|
+
const failed = new Map<string, { fails: number; until: number; at: number }>()
|
|
128
|
+
const newIds = new Map<string, Window>() // per address or key: new identities this hour
|
|
129
|
+
const shared = new Map<string, Shared>() // per address or key: bandwidth
|
|
130
|
+
const sweep = setInterval(() => {
|
|
131
|
+
const t = Date.now()
|
|
132
|
+
for (const [k, f] of failed) if (Math.max(f.until, f.at) + 10 * 60_000 < t) failed.delete(k)
|
|
133
|
+
for (const [k, win] of newIds) if (!win.times.length || performance.now() - win.times[win.times.length - 1] > win.ms) newIds.delete(k)
|
|
134
|
+
const groups = new Set<string>()
|
|
135
|
+
for (const p of engine.principals.values()) groups.add(p.grp)
|
|
136
|
+
for (const k of shared.keys()) if (!groups.has(k)) shared.delete(k)
|
|
137
|
+
}, 60_000)
|
|
138
|
+
const log = engine.log
|
|
139
|
+
const stats = engine.stats
|
|
140
|
+
|
|
141
|
+
const motd = () =>
|
|
142
|
+
w.hosted
|
|
143
|
+
? `wmd hosted relay${w.version ? ` ${w.version}` : ''}: the operator sees destination names, DNS names and plain-http content (TLS traffic is not visible); ` +
|
|
144
|
+
`abuse contact ${w.hosted.contact}; ${w.hosted.limitsLine()}; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}`
|
|
145
|
+
:
|
|
146
|
+
`wmd-relay${w.version ? ` ${w.version}` : ''}: up to ${o.maxConns ?? 64} streams per guest; ${o.rateDown ?? 16 << 20} B/s down and ` +
|
|
147
|
+
`${o.rateUp ?? 4 << 20} B/s up; blocked ports: ${(o.blockedPorts ?? [25]).join(', ') || 'none'}; private, loopback and metadata addresses are refused`
|
|
148
|
+
|
|
149
|
+
function serverExts(mode: Mode) {
|
|
150
|
+
const e = [ext.confirm(), ext.motd(motd()), ext.wmdCredit(0), ext.wmdHalfclose(), ext.wmdDns(), ext.wmdInstance()]
|
|
151
|
+
if (mode === 'key-pending') e.push(ext.passwordServer(true))
|
|
152
|
+
return e
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The client's address as text (the peer, or behind trusted proxies the client the proxy saw), or a refusal. */
|
|
156
|
+
function clientIp(req: Request, srv: any): string | Response {
|
|
157
|
+
const peer = srv.requestIP(req)?.address || 'unknown'
|
|
158
|
+
if (w.proxy) {
|
|
159
|
+
const r = resolveClient(peer, req.headers.get(w.proxy.header), w.proxy.trusted, w.proxy.header)
|
|
160
|
+
if ('error' in r) return refuse(400, `${r.error}\n`, 'proxy header', { peer })
|
|
161
|
+
return r.ip
|
|
162
|
+
}
|
|
163
|
+
if (w.trustProxy) {
|
|
164
|
+
const xff = (req.headers.get('x-forwarded-for') || '').split(',').map(x => x.trim()).filter(Boolean)
|
|
165
|
+
if (xff.length) return xff[xff.length - 1]
|
|
166
|
+
}
|
|
167
|
+
return peer
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** A refusal body; on a hosted relay it always names the abuse contact. */
|
|
171
|
+
const reply = (body: string, status: number) => new Response(w.hosted ? body + w.hosted.contactLine : body, { status })
|
|
172
|
+
|
|
173
|
+
function refuse(status: number, body: string, why: string, fields: Record<string, unknown>) {
|
|
174
|
+
engine.refusedSession(why, { proto: 'wisp', ...fields })
|
|
175
|
+
return reply(body, status)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function fetch(req: Request, srv: any, url: URL): Promise<Response | undefined> {
|
|
179
|
+
const ip = clientIp(req, srv)
|
|
180
|
+
if (typeof ip !== 'string') return ip
|
|
181
|
+
const addr = clientKey(ip)
|
|
182
|
+
const origin = req.headers.get('origin') || ''
|
|
183
|
+
// before any table or the log sees it: no real page origin is this long
|
|
184
|
+
if (origin.length > 256) return refuse(400, 'the Origin header is longer than 256 characters\n', 'origin too long', { addr })
|
|
185
|
+
if (w.hosted) {
|
|
186
|
+
const denied = w.hosted.deniedClient(ip, origin)
|
|
187
|
+
if (denied) return refuse(403, `${denied}\n`, 'denied', { addr, origin })
|
|
188
|
+
const closed = w.hosted.notAccepting()
|
|
189
|
+
if (closed) return refuse(503, `${closed}\n`, 'not accepting', { addr })
|
|
190
|
+
}
|
|
191
|
+
const t = failed.get(addr)
|
|
192
|
+
if (t && t.until > Date.now()) return refuse(429, 'too many wrong keys from this address; wait\n', 'key throttle', { addr }) // set only after ADDR_FAILS
|
|
193
|
+
if (open && w.checkHost && !LOOPBACK_HOST.test(req.headers.get('host') || '')) {
|
|
194
|
+
return refuse(403, 'this relay answers only to localhost, 127.0.0.1 or [::1] (a check against DNS rebinding)\n', 'host not loopback', { addr, host: req.headers.get('host') })
|
|
195
|
+
}
|
|
196
|
+
if (w.origins?.length && !w.origins.includes(origin)) return refuse(403, 'origin not allowed\n', 'origin not allowed', { addr, origin })
|
|
197
|
+
const header = req.headers.get('sec-websocket-protocol')
|
|
198
|
+
const protos = (header || '').split(',').map(x => x.trim()).filter(Boolean)
|
|
199
|
+
if (protos.includes('wmd-relay.v1') && !protos.includes(SUBPROTOCOL)) {
|
|
200
|
+
return reply(`this is the Wisp endpoint (${path}); wmd-relay.v1 is at /relay\n`, 400)
|
|
201
|
+
}
|
|
202
|
+
const v2 = header !== null // the spec: v2 only when the header is there
|
|
203
|
+
const rest = url.pathname.slice(path.length)
|
|
204
|
+
let mode: Mode
|
|
205
|
+
let key: OwnerKey | null = null
|
|
206
|
+
let sid = randomBytes(9).toString('base64url')
|
|
207
|
+
let uid = ''
|
|
208
|
+
let grp = ''
|
|
209
|
+
let exp = Infinity
|
|
210
|
+
let p: Principal | null = null
|
|
211
|
+
const tok = protos.find(x => x.startsWith('t.'))
|
|
212
|
+
if (tok && o.secret) {
|
|
213
|
+
const info = verifyToken(o.secret, tok.slice(2))
|
|
214
|
+
if (typeof info === 'string') return refuse(401, `${info}\n`, info, { origin })
|
|
215
|
+
if (engine.hasSession(info.sid)) {
|
|
216
|
+
stats.refusedSessions++
|
|
217
|
+
return reply('session already connected\n', 409)
|
|
218
|
+
}
|
|
219
|
+
mode = 'token'
|
|
220
|
+
sid = info.sid
|
|
221
|
+
uid = info.uid || info.sid
|
|
222
|
+
grp = info.grp || uid
|
|
223
|
+
exp = info.exp
|
|
224
|
+
const a = engine.admit(uid, grp, exp, { proto: 'wisp' })
|
|
225
|
+
if (!a.ok) return reply(a.body, a.status)
|
|
226
|
+
p = a.p
|
|
227
|
+
} else if (rest) {
|
|
228
|
+
// a key in the URL, for Wisp v1 clients (off unless the owner enables it)
|
|
229
|
+
const m = rest.match(/^([^./]{1,64})\.([^/]{1,256})\/$/)
|
|
230
|
+
if (!w.prefixKeys || !m) return reply('not found\n', 404)
|
|
231
|
+
if (keyThrottled(addr, m[1])) return refuse(429, 'too many wrong secrets for this key from this address; wait\n', 'key throttle', { addr, key: m[1] })
|
|
232
|
+
// a check costs a scrypt: at most preAuth of them at once per address, so a burst cannot test many secrets before
|
|
233
|
+
// the throttle (below) is set
|
|
234
|
+
const busy = inHandshake.get(addr) || 0
|
|
235
|
+
if (busy >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
|
|
236
|
+
inHandshake.set(addr, busy + 1)
|
|
237
|
+
const k = keys.find(x => x.id === m[1])
|
|
238
|
+
let ok = false
|
|
239
|
+
try {
|
|
240
|
+
ok = await verifySecret(m[2], k?.hash ?? DUMMY_HASH)
|
|
241
|
+
} finally {
|
|
242
|
+
release({ pre: true, addr })
|
|
243
|
+
}
|
|
244
|
+
if (!ok || !k) {
|
|
245
|
+
noteFailure(addr, m[1])
|
|
246
|
+
return refuse(401, 'wrong key\n', 'wrong key', { addr, key: m[1] })
|
|
247
|
+
}
|
|
248
|
+
failed.delete(`${addr}|${m[1]}`)
|
|
249
|
+
if (k.origins?.length && !k.origins.includes(origin)) return refuse(403, 'origin not allowed for this key\n', 'origin not allowed for key', { addr, key: k.id, origin })
|
|
250
|
+
mode = 'key'
|
|
251
|
+
key = k
|
|
252
|
+
} else if (keys.length) {
|
|
253
|
+
if (!v2) return refuse(401, `this relay needs a key; a Wisp v1 client can put it in the URL (${path}<id>.<secret>/) when the owner allows it\n`, 'no key (v1)', { addr })
|
|
254
|
+
mode = 'key-pending'
|
|
255
|
+
} else if (open) {
|
|
256
|
+
mode = 'open'
|
|
257
|
+
} else {
|
|
258
|
+
return refuse(401, 'no token\n', 'no token', { origin })
|
|
259
|
+
}
|
|
260
|
+
const n = inHandshake.get(addr) || 0
|
|
261
|
+
if (n >= preAuth) return refuse(429, 'too many connections opening from this address; wait\n', 'pre-auth limit', { addr })
|
|
262
|
+
inHandshake.set(addr, n + 1)
|
|
263
|
+
const headers: Record<string, string> = {}
|
|
264
|
+
if (v2 && protos.length) headers['Sec-WebSocket-Protocol'] = protos.includes(SUBPROTOCOL) ? SUBPROTOCOL : protos[0]
|
|
265
|
+
const data = { kind: 'wisp', v2, mode, addr, ip, sid, uid, grp, exp, p, key, origin, hs: null as any, hsTimer: null as any, pre: true }
|
|
266
|
+
const ok = srv.upgrade(req, Object.keys(headers).length ? { data, headers } : { data }) // Bun refuses an empty headers object
|
|
267
|
+
if (!ok) {
|
|
268
|
+
release(data)
|
|
269
|
+
return reply('upgrade failed\n', 400)
|
|
270
|
+
}
|
|
271
|
+
return undefined
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Wrong keys are throttled per (address, key id), so one wrong guess does not lock out the right key from the same
|
|
275
|
+
// address (a NAT, a proxy); the whole address is throttled only after ADDR_FAILS wrong keys (guessing ids).
|
|
276
|
+
const ADDR_FAILS = 20
|
|
277
|
+
const KEY_FREE_FAILS = 3
|
|
278
|
+
function noteFailure(addr: string, id: string) {
|
|
279
|
+
const now = Date.now()
|
|
280
|
+
const f = failed.get(`${addr}|${id}`) || { fails: 0, until: 0, at: 0 }
|
|
281
|
+
f.fails++
|
|
282
|
+
f.at = now
|
|
283
|
+
// a typo or two goes through at once; from the fourth wrong secret on, 1 s doubling to 60 s
|
|
284
|
+
if (f.fails > KEY_FREE_FAILS) f.until = now + Math.min(60_000, 1000 * 2 ** (f.fails - KEY_FREE_FAILS - 1))
|
|
285
|
+
failed.set(`${addr}|${id}`, f)
|
|
286
|
+
const a = failed.get(addr) || { fails: 0, until: 0, at: 0 }
|
|
287
|
+
a.fails++
|
|
288
|
+
a.at = now
|
|
289
|
+
if (a.fails >= ADDR_FAILS) a.until = now + Math.min(60_000, 1000 * 2 ** (a.fails - ADDR_FAILS))
|
|
290
|
+
failed.set(addr, a)
|
|
291
|
+
}
|
|
292
|
+
const keyThrottled = (addr: string, id: string) => (failed.get(`${addr}|${id}`)?.until ?? 0) > Date.now()
|
|
293
|
+
|
|
294
|
+
/** The socket left its handshake (done or gone): it no longer counts against the pre-auth cap. */
|
|
295
|
+
function release(d: any) {
|
|
296
|
+
if (!d.pre) return
|
|
297
|
+
d.pre = false
|
|
298
|
+
const n = (inHandshake.get(d.addr) || 1) - 1
|
|
299
|
+
if (n > 0) inHandshake.set(d.addr, n)
|
|
300
|
+
else inHandshake.delete(d.addr)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function sendAll(ws: any, pkts: Uint8Array[]) {
|
|
304
|
+
for (const b of pkts) ws.send(b)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function open_(ws: any) {
|
|
308
|
+
const d = ws.data
|
|
309
|
+
d.hs = new ServerHandshake({ v2: d.v2, exts: serverExts(d.mode), buffer })
|
|
310
|
+
const r = d.hs.start()
|
|
311
|
+
sendAll(ws, r.send)
|
|
312
|
+
if (r.done) return finish(ws, null)
|
|
313
|
+
d.hsTimer = setTimeout(() => {
|
|
314
|
+
if (d.session) return
|
|
315
|
+
log({ ev: 'session-refused', proto: 'wisp', why: 'handshake timeout', addr: d.addr })
|
|
316
|
+
ws.close(1008, 'handshake timeout')
|
|
317
|
+
}, handshakeMs)
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function rejectHs(ws: any, reason: number, why: string) {
|
|
321
|
+
const d = ws.data
|
|
322
|
+
log({ ev: 'session-refused', proto: 'wisp', why, addr: d.addr })
|
|
323
|
+
stats.refusedSessions++
|
|
324
|
+
sendAll(ws, d.hs.reject(reason))
|
|
325
|
+
ws.close(1008, closeWords(reason).slice(0, 120))
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
async function finish(ws: any, client: any) {
|
|
329
|
+
const d = ws.data
|
|
330
|
+
if (d.hsTimer) clearTimeout(d.hsTimer)
|
|
331
|
+
let share: Shared | undefined
|
|
332
|
+
let allow: AllowEntry[] | undefined
|
|
333
|
+
let fresh = false
|
|
334
|
+
const inst = client?.instance ? Buffer.from(client.instance).toString('hex') : randomBytes(8).toString('hex')
|
|
335
|
+
if (d.mode === 'key-pending') {
|
|
336
|
+
const pw = client?.password
|
|
337
|
+
if (pw && keyThrottled(d.addr, pw.user)) return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `key throttle ${String(pw.user).slice(0, 64)}`)
|
|
338
|
+
const k = pw ? keys.find(x => x.id === pw.user) : undefined
|
|
339
|
+
const ok = pw ? await verifySecret(pw.pass, k?.hash ?? DUMMY_HASH) : false
|
|
340
|
+
if (ws.readyState !== 1) return
|
|
341
|
+
if (!ok || !k) {
|
|
342
|
+
noteFailure(d.addr, pw ? String(pw.user) : '')
|
|
343
|
+
return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `wrong key${pw ? ` ${String(pw.user).slice(0, 64)}` : ''}`)
|
|
344
|
+
}
|
|
345
|
+
failed.delete(`${d.addr}|${pw.user}`)
|
|
346
|
+
if (k.origins?.length && !k.origins.includes(d.origin)) return rejectHs(ws, CLOSE.AUTH_BAD_PASSWORD, `origin not allowed for key ${k.id}`)
|
|
347
|
+
d.key = k
|
|
348
|
+
d.mode = 'key'
|
|
349
|
+
}
|
|
350
|
+
if (d.mode === 'key') {
|
|
351
|
+
const k: OwnerKey = d.key
|
|
352
|
+
d.grp = `key:${k.id}`
|
|
353
|
+
d.uid = `key:${k.id}/${inst}`
|
|
354
|
+
share = sharedFor(d.grp, k)
|
|
355
|
+
if (k.allowHosts?.length) allow = compileAllow(k.allowHosts)
|
|
356
|
+
} else if (d.mode === 'open') {
|
|
357
|
+
d.grp = d.addr
|
|
358
|
+
d.uid = `${d.addr}/${inst}`
|
|
359
|
+
share = sharedFor(d.grp)
|
|
360
|
+
}
|
|
361
|
+
if (d.mode !== 'token') {
|
|
362
|
+
fresh = !engine.principals.has(d.uid)
|
|
363
|
+
if (fresh && engine.principals.size >= maxPrincipals) {
|
|
364
|
+
// least recently admitted identity with no live session goes first (plan 5: LRU); refuse only when all are live
|
|
365
|
+
for (const [k, q] of engine.principals) if (!q.live) { engine.principals.delete(k); break }
|
|
366
|
+
if (engine.principals.size >= maxPrincipals) return rejectHs(ws, CLOSE.THROTTLED, 'too many identities remembered')
|
|
367
|
+
}
|
|
368
|
+
if (fresh) {
|
|
369
|
+
let win = newIds.get(d.grp)
|
|
370
|
+
if (!win) newIds.set(d.grp, (win = new Window(instancesPerHour, 60 * 60_000)))
|
|
371
|
+
if (!win.hit()) return rejectHs(ws, CLOSE.THROTTLED, `more than ${instancesPerHour} new instances this hour from ${d.grp}`)
|
|
372
|
+
}
|
|
373
|
+
const a = engine.admit(d.uid, d.grp, Date.now(), { proto: 'wisp' }, share)
|
|
374
|
+
if (!a.ok) return rejectHs(ws, CLOSE.THROTTLED, a.body.trim())
|
|
375
|
+
d.p = a.p
|
|
376
|
+
}
|
|
377
|
+
let adm: ReturnType<Governor['admitSession']> | null = null
|
|
378
|
+
if (w.hosted) {
|
|
379
|
+
const closed = w.hosted.notAccepting()
|
|
380
|
+
if (closed) return rejectHs(ws, CLOSE.THROTTLED, closed)
|
|
381
|
+
adm = w.hosted.admitSession({ ip: d.ip, grp: d.grp, origin: d.origin, fresh, end: words => ws.close(4011, words.slice(0, 120)) })
|
|
382
|
+
if (!adm.ok) return rejectHs(ws, CLOSE.THROTTLED, adm.why)
|
|
383
|
+
d.rel = adm.adm.release
|
|
384
|
+
}
|
|
385
|
+
const common: Set<number> = client?.common ?? new Set()
|
|
386
|
+
const window = common.has(EXT.WMD_CREDIT) ? client.window : Infinity
|
|
387
|
+
const s = engine.open(ws, d.sid, d.p, d.exp, wire, window, { proto: 'wisp', mode: d.mode, v: d.v2 ? 2 : 1, inst: client?.instance ? inst : undefined, addr: d.addr, ...(w.hosted ? { origin: d.origin } : {}) })
|
|
388
|
+
release(d)
|
|
389
|
+
if (!s) return d.rel?.()
|
|
390
|
+
if (adm?.ok) s.hx = adm.adm
|
|
391
|
+
s.allow = allow
|
|
392
|
+
const sw: SessionW = {
|
|
393
|
+
engine, v2: d.v2, confirm: common.has(EXT.CONFIRM), credit: common.has(EXT.WMD_CREDIT), halfclose: common.has(EXT.WMD_HALFCLOSE),
|
|
394
|
+
dns: common.has(EXT.WMD_DNS), buffer, idleTimer: null, unknownLogged: false,
|
|
395
|
+
}
|
|
396
|
+
s.w = sw
|
|
397
|
+
d.session = s
|
|
398
|
+
if (streamIdleMs > 0) sw.idleTimer = setInterval(() => idleSweep(s), Math.min(60_000, Math.max(20, streamIdleMs / 4)))
|
|
399
|
+
if (d.v2) sendAll(ws, d.hs.accept())
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function sharedFor(k: string, key?: OwnerKey): Shared {
|
|
403
|
+
let sh = shared.get(k)
|
|
404
|
+
if (!sh) shared.set(k, (sh = newShared({ rateDown: key?.rateDown ?? o.rateDown, rateUp: key?.rateUp ?? o.rateUp })))
|
|
405
|
+
return sh
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function idleSweep(s: Session) {
|
|
409
|
+
const t = performance.now()
|
|
410
|
+
for (const st of [...s.streams.values()]) {
|
|
411
|
+
const x = sw(st)
|
|
412
|
+
if (st.connected && t - x.last > streamIdleMs) {
|
|
413
|
+
x.idle = true
|
|
414
|
+
engine.fail(s, st, E.TIMEOUT, `no bytes for ${Math.round(streamIdleMs / 1000)} s`)
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function sw(st: Stream): StreamW {
|
|
420
|
+
return (st.w ||= { pending: null, sizes: [], released: 0, held: 0, heldBytes: 0, drained: 0, last: performance.now(), idle: false, shutPending: false })
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function send(s: Session, b: Uint8Array) {
|
|
424
|
+
if (!s.closed) s.ws.send(b)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Room in the stream's buffer, for a CONTINUE. */
|
|
428
|
+
const room = (s: Session, x: StreamW) => Math.max(0, (s.w as SessionW).buffer - x.held)
|
|
429
|
+
|
|
430
|
+
const wire: Wire = {
|
|
431
|
+
connected(s, st) {
|
|
432
|
+
const x = sw(st)
|
|
433
|
+
x.last = performance.now()
|
|
434
|
+
if ((s.w as SessionW).confirm) send(s, continuePacket(st.id, room(s, x)))
|
|
435
|
+
const q = x.pending
|
|
436
|
+
x.pending = null
|
|
437
|
+
if (q) for (const b of q) writeUp(s, st, x, b)
|
|
438
|
+
if (x.shutPending && !st.closed) engine.clientEnd(s, st.id)
|
|
439
|
+
},
|
|
440
|
+
refused: (s, id, code) => send(s, closePacket(id, reasonFor(code))),
|
|
441
|
+
data(s, st, bytes) {
|
|
442
|
+
sw(st).last = performance.now()
|
|
443
|
+
send(s, dataPacket(st.id, bytes))
|
|
444
|
+
},
|
|
445
|
+
end(s, st) {
|
|
446
|
+
if ((s.w as SessionW).halfclose) send(s, shutdownPacket(st.id))
|
|
447
|
+
else engine.clientEnd(s, st.id) // no half-close: the stream is over once the remote's bytes are all sent
|
|
448
|
+
},
|
|
449
|
+
abort: (s, st, code) => send(s, closePacket(st.id, st.w?.idle ? CLOSE.DATA_TIMEOUT : reasonFor(code))),
|
|
450
|
+
grant(s, st, n) {
|
|
451
|
+
const x = sw(st)
|
|
452
|
+
x.released += n
|
|
453
|
+
while (x.sizes.length && x.released >= x.sizes[0]) {
|
|
454
|
+
const k = x.sizes.shift()!
|
|
455
|
+
x.released -= k
|
|
456
|
+
x.held--
|
|
457
|
+
x.heldBytes -= k
|
|
458
|
+
x.drained++
|
|
459
|
+
// released bytes leave the relay's and the group's counts at once (not at the next recount), so a fast honest
|
|
460
|
+
// uploader whose bytes are sent as they arrive never meets a cap that its real held bytes are far from
|
|
461
|
+
heldSwept = Math.max(0, heldSwept - k)
|
|
462
|
+
const gh = (groupHeld.get(s.p.grp) || 0) - k
|
|
463
|
+
if (gh > 0) groupHeld.set(s.p.grp, gh)
|
|
464
|
+
else groupHeld.delete(s.p.grp)
|
|
465
|
+
}
|
|
466
|
+
maybeContinue(s, st, x)
|
|
467
|
+
},
|
|
468
|
+
dnsReply: (s, id, rcode, ttl, addrs, why) => send(s, answerPacket(id, rcode, ttl, addrs, why)),
|
|
469
|
+
done: (s, st) => send(s, closePacket(st.id, CLOSE.VOLUNTARY)),
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function maybeContinue(s: Session, st: Stream, x: StreamW) {
|
|
473
|
+
const b = (s.w as SessionW).buffer
|
|
474
|
+
if (x.drained >= Math.max(1, b >> 1) && x.heldBytes <= maxHeld && !st.closed) {
|
|
475
|
+
x.drained = 0
|
|
476
|
+
send(s, continuePacket(st.id, room(s, x)))
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function writeUp(s: Session, st: Stream, x: StreamW, b: Uint8Array) {
|
|
481
|
+
if (b.length === 0) {
|
|
482
|
+
// nothing to write: released at once (a zero-byte write would never be credited)
|
|
483
|
+
x.held--
|
|
484
|
+
x.drained++
|
|
485
|
+
return maybeContinue(s, st, x)
|
|
486
|
+
}
|
|
487
|
+
x.sizes.push(b.length)
|
|
488
|
+
engine.write(s, st, b)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function message(ws: any, raw: any) {
|
|
492
|
+
const d = ws.data
|
|
493
|
+
if (typeof raw === 'string') return ws.close(1003, 'binary packets only')
|
|
494
|
+
const u8: Uint8Array = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
|
495
|
+
if (u8.length > maxPayload) return ws.close(1009, 'packet too large')
|
|
496
|
+
const m: any = parse(u8)
|
|
497
|
+
if (!d.session) {
|
|
498
|
+
if (!d.hs || d.hs.state !== 'wait-info') return // a packet while the key is being checked: dropped, the client waits for CONTINUE
|
|
499
|
+
const r = d.hs.step(m)
|
|
500
|
+
if (r.error) {
|
|
501
|
+
log({ ev: 'session-refused', proto: 'wisp', why: `handshake: ${r.error}`, addr: d.addr })
|
|
502
|
+
stats.refusedSessions++
|
|
503
|
+
sendAll(ws, r.send)
|
|
504
|
+
return ws.close(1008, 'handshake failed')
|
|
505
|
+
}
|
|
506
|
+
if (r.client) finish(ws, r.client).catch(e => {
|
|
507
|
+
log({ ev: 'error', proto: 'wisp', error: String(e) })
|
|
508
|
+
ws.close(1011, 'relay error')
|
|
509
|
+
})
|
|
510
|
+
return
|
|
511
|
+
}
|
|
512
|
+
const s: Session = d.session
|
|
513
|
+
const x = s.w as SessionW
|
|
514
|
+
if (m.error) return ws.close(1002, `malformed packet: ${m.error}`.slice(0, 120))
|
|
515
|
+
switch (m.type) {
|
|
516
|
+
case PT.CONNECT: {
|
|
517
|
+
const to = `${m.host}:${m.port}`
|
|
518
|
+
if (m.id === 0) {
|
|
519
|
+
log({ ev: 'refused', sid: s.sid, id: 0, to, code: 'bad', why: 'stream id 0 is reserved' })
|
|
520
|
+
return ws.close(1002, 'stream id 0 is reserved for the handshake')
|
|
521
|
+
}
|
|
522
|
+
if (m.streamType !== STREAM.TCP) return engine.refuse(s, m.id, E.BAD, m.streamType === STREAM.UDP ? 'UDP streams are not supported' : `stream type ${m.streamType}`, to)
|
|
523
|
+
const dup = s.streams.get(m.id)
|
|
524
|
+
if (dup) return engine.fail(s, dup, E.BAD, 'stream id in use') // the client must not keep believing in a stream the server dropped (or the reverse)
|
|
525
|
+
engine.connectSafe(s, m.id, m.host, m.port)
|
|
526
|
+
return
|
|
527
|
+
}
|
|
528
|
+
case PT.DATA: {
|
|
529
|
+
const st = s.streams.get(m.id)
|
|
530
|
+
if (!st || st.closed) return // gone: its CLOSE is on the way
|
|
531
|
+
const sx = sw(st)
|
|
532
|
+
if (st.clientEnded || st.w?.shutPending) return engine.fail(s, st, E.BAD, 'data after SHUTDOWN')
|
|
533
|
+
sx.held++
|
|
534
|
+
sx.heldBytes += m.payload.length
|
|
535
|
+
sx.last = performance.now()
|
|
536
|
+
if (sx.held > 2 * x.buffer) return engine.fail(s, st, E.LIMIT, `more than ${2 * x.buffer} packets past the CONTINUE window`)
|
|
537
|
+
let tot = 0
|
|
538
|
+
for (const t of s.streams.values()) tot += t.w?.heldBytes || 0
|
|
539
|
+
if (tot > maxSessHeld) return engine.fail(s, st, E.LIMIT, `more than ${maxSessHeld} bytes held for this session`)
|
|
540
|
+
heldSwept += m.payload.length
|
|
541
|
+
const gh = (groupHeld.get(s.p.grp) || 0) + m.payload.length
|
|
542
|
+
groupHeld.set(s.p.grp, gh)
|
|
543
|
+
if (gh > maxGroupHeld) return engine.fail(s, st, E.LIMIT, `more than ${maxGroupHeld} bytes held for this address or key`)
|
|
544
|
+
if (heldSwept > maxHeldAll && gh > maxHeldAll / Math.max(1, groupHeld.size)) {
|
|
545
|
+
return engine.fail(s, st, E.LIMIT, 'the relay is holding too much unsent data; try again shortly')
|
|
546
|
+
}
|
|
547
|
+
if (!st.connected || !st.sock) {
|
|
548
|
+
;(sx.pending ||= []).push(m.payload.slice())
|
|
549
|
+
return
|
|
550
|
+
}
|
|
551
|
+
writeUp(s, st, sx, m.payload)
|
|
552
|
+
return
|
|
553
|
+
}
|
|
554
|
+
case PT.CLOSE:
|
|
555
|
+
engine.clientAbort(s, m.id)
|
|
556
|
+
return
|
|
557
|
+
case PT.SHUTDOWN: {
|
|
558
|
+
if (!x.halfclose) return unknown(s, m.type)
|
|
559
|
+
const st = s.streams.get(m.id)
|
|
560
|
+
if (!st) return
|
|
561
|
+
if (!st.connected) sw(st).shutPending = true
|
|
562
|
+
else engine.clientEnd(s, m.id)
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
case PT.CREDIT:
|
|
566
|
+
if (!x.credit) return unknown(s, m.type)
|
|
567
|
+
engine.credit(s, m.id, m.n)
|
|
568
|
+
return
|
|
569
|
+
case PT.LOOKUP:
|
|
570
|
+
if (!x.dns) return unknown(s, m.type)
|
|
571
|
+
engine.dns(s, m.id, m.name, m.qtype).catch(e => log({ ev: 'error', sid: s.sid, error: String(e) }))
|
|
572
|
+
return
|
|
573
|
+
default:
|
|
574
|
+
return unknown(s, m.type)
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function unknown(s: Session, type: number) {
|
|
579
|
+
const x = s.w as SessionW
|
|
580
|
+
if (x.unknownLogged) return
|
|
581
|
+
x.unknownLogged = true
|
|
582
|
+
log({ ev: 'ignored', sid: s.sid, why: `packet type 0x${type.toString(16)} not used on this session` })
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function close(ws: any, code: number, reason: string) {
|
|
586
|
+
const d = ws.data
|
|
587
|
+
if (d.hsTimer) clearTimeout(d.hsTimer)
|
|
588
|
+
release(d)
|
|
589
|
+
d.rel?.()
|
|
590
|
+
const s: Session | undefined = d.session
|
|
591
|
+
if (!s) return
|
|
592
|
+
const x = s.w as SessionW
|
|
593
|
+
if (x?.idleTimer) clearInterval(x.idleTimer)
|
|
594
|
+
engine.closeSession(s, `websocket closed ${code} ${reason || ''}`.trim())
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return {
|
|
598
|
+
path,
|
|
599
|
+
maxPayload,
|
|
600
|
+
/** Maintenance: every stream gets CLOSE 0x01 (the spec has no maintenance code), then the socket closes 1001 in words. */
|
|
601
|
+
drain(ws: any) {
|
|
602
|
+
const s: Session | undefined = ws.data.session
|
|
603
|
+
if (s && !s.closed) for (const st of s.streams.values()) ws.send(closePacket(st.id, CLOSE.UNKNOWN))
|
|
604
|
+
ws.close(1001, DRAIN_WORDS)
|
|
605
|
+
},
|
|
606
|
+
fetch,
|
|
607
|
+
open: open_,
|
|
608
|
+
message,
|
|
609
|
+
close,
|
|
610
|
+
stop: () => {
|
|
611
|
+
clearInterval(sweep)
|
|
612
|
+
clearInterval(heldCount)
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
}
|