opencode-ufr 0.2.0

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.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +184 -0
  3. package/bin/ufr.ts +4 -0
  4. package/models.json +206 -0
  5. package/package.json +16 -0
  6. package/src/cli/catalog.ts +45 -0
  7. package/src/cli/connect.ts +146 -0
  8. package/src/cli/daemon-client.ts +19 -0
  9. package/src/cli/index.ts +93 -0
  10. package/src/cli/io.ts +98 -0
  11. package/src/cli/keys.ts +95 -0
  12. package/src/cli/stats.ts +37 -0
  13. package/src/cli/status.ts +48 -0
  14. package/src/cli/stop.ts +8 -0
  15. package/src/cli/ufr-check.ts +43 -0
  16. package/src/daemon/breaker.ts +146 -0
  17. package/src/daemon/catalog-source.ts +85 -0
  18. package/src/daemon/catalog.ts +127 -0
  19. package/src/daemon/daemon.ts +397 -0
  20. package/src/daemon/fallbacks.ts +41 -0
  21. package/src/daemon/keypool.ts +109 -0
  22. package/src/daemon/log.ts +15 -0
  23. package/src/daemon/main.ts +18 -0
  24. package/src/daemon/model.ts +15 -0
  25. package/src/daemon/router.ts +392 -0
  26. package/src/daemon/server.ts +54 -0
  27. package/src/daemon/stats.ts +105 -0
  28. package/src/daemon/transport.ts +21 -0
  29. package/src/daemon/upstream.ts +68 -0
  30. package/src/daemon/vpn/fortinet.ts +466 -0
  31. package/src/daemon/vpn/ip.ts +176 -0
  32. package/src/daemon/vpn/manager.ts +258 -0
  33. package/src/daemon/vpn/ppp.ts +450 -0
  34. package/src/daemon/vpn/proxy.ts +102 -0
  35. package/src/daemon/vpn/stack.ts +140 -0
  36. package/src/daemon/vpn/tcp.ts +356 -0
  37. package/src/daemon/window.ts +126 -0
  38. package/src/plugin/connect.ts +113 -0
  39. package/src/plugin/ensure-daemon.ts +101 -0
  40. package/src/plugin/index.ts +93 -0
  41. package/src/plugin/model-info.ts +35 -0
  42. package/src/shared/config.ts +170 -0
  43. package/src/shared/connect.ts +100 -0
  44. package/src/shared/errors.ts +6 -0
  45. package/src/shared/fs.ts +31 -0
  46. package/src/shared/models-file.ts +68 -0
  47. package/src/shared/paths.ts +62 -0
  48. package/src/shared/secrets.ts +62 -0
  49. package/src/shared/sleep.ts +15 -0
  50. package/src/shared/time.ts +6 -0
  51. package/src/shared/version.ts +3 -0
  52. package/src/shared/vpn.ts +8 -0
@@ -0,0 +1,466 @@
1
+ /**
2
+ * Fortinet SSL-VPN tunnel layer per docs/fortinet-protocol.md:
3
+ * - 0x5050 ("PP") frame framing over the TLS stream (concat + split safe)
4
+ * - HTTP auth → SVPNCOOKIE (tokeninfo-style 2FA supported; HTML-form 2FA is not)
5
+ * - tunnel config XML parsing
6
+ * - the tunnel itself: GET /remote/sslvpn-tunnel, then raw PPP frames on TLS
7
+ */
8
+
9
+ import tls from "node:tls"
10
+
11
+ import { PppSession, type PppConfig, type PppOptions } from "./ppp"
12
+
13
+ // -- framing -----------------------------------------------------------------
14
+
15
+ export const FRAME_MAGIC = 0x5050
16
+ const MAX_FRAME = 16384 + 64 // servers may exceed the MTU; spec says use ≥16 KiB
17
+
18
+ /** Splits the TLS byte stream into complete tunnel frames (stateful). */
19
+ export class FrameReader {
20
+ private buf = new Uint8Array(MAX_FRAME * 2)
21
+ private len = 0
22
+
23
+ /** Feed bytes; returns every complete frame contained. */
24
+ push(chunk: Uint8Array): Uint8Array[] {
25
+ if (this.len + chunk.length > this.buf.length) {
26
+ // pathological server: grow
27
+ const grown = new Uint8Array(Math.max(this.buf.length * 2, this.len + chunk.length))
28
+ grown.set(this.buf.subarray(0, this.len))
29
+ this.buf = grown
30
+ }
31
+ this.buf.set(chunk, this.len)
32
+ this.len += chunk.length
33
+ const out: Uint8Array[] = []
34
+ let pos = 0
35
+ for (;;) {
36
+ if (this.len - pos < 6) break
37
+ const total = (this.buf[pos]! << 8) | this.buf[pos + 1]!
38
+ const magic = (this.buf[pos + 2]! << 8) | this.buf[pos + 3]!
39
+ const pppLen = (this.buf[pos + 4]! << 8) | this.buf[pos + 5]!
40
+ if (magic !== FRAME_MAGIC || total - 6 !== pppLen || total < 7) {
41
+ throw new Error(`broken tunnel frame: magic 0x${magic.toString(16)} total ${total} ppp ${pppLen}`)
42
+ }
43
+ if (this.len - pos < total) break // frame split across TLS records: buffer
44
+ out.push(this.buf.slice(pos + 6, pos + total))
45
+ pos += total
46
+ }
47
+ if (pos > 0) {
48
+ this.buf.copyWithin(0, pos, this.len)
49
+ this.len -= pos
50
+ }
51
+ return out
52
+ }
53
+ }
54
+
55
+ /** Wraps one PPP frame into the 6-byte tunnel header. */
56
+ export function wrapFrame(ppp: Uint8Array): Uint8Array {
57
+ const out = new Uint8Array(6 + ppp.length)
58
+ const total = 6 + ppp.length
59
+ out[0] = total >> 8
60
+ out[1] = total & 0xff
61
+ out[2] = FRAME_MAGIC >> 8
62
+ out[3] = FRAME_MAGIC & 0xff
63
+ out[4] = ppp.length >> 8
64
+ out[5] = ppp.length & 0xff
65
+ out.set(ppp, 6)
66
+ return out
67
+ }
68
+
69
+ // -- auth --------------------------------------------------------------------
70
+
71
+ export const FORTI_UA = "Mozilla/5.0 SV1"
72
+
73
+ export class VpnAuthError extends Error {}
74
+ export class VpnChallengeError extends Error {
75
+ constructor(readonly kind: "credentials" | "2fa-html-form" | "unsupported", message: string) {
76
+ super(message)
77
+ }
78
+ }
79
+
80
+ export type AuthOutcome = { cookie: string; realm: string }
81
+
82
+ function formEncode(fields: Record<string, string>): string {
83
+ return Object.entries(fields).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join("&")
84
+ }
85
+
86
+ function cookieFrom(res: Response): string | null {
87
+ const candidates = [...(res.headers.getSetCookie?.() ?? []), ...(res.headers.get("set-cookie") ? [res.headers.get("set-cookie")!] : [])]
88
+ for (const c of candidates) {
89
+ const eq = c.indexOf("=")
90
+ if (!c.startsWith("SVPNCOOKIE") || eq < 0) continue
91
+ const value = c.slice(eq + 1).split(";", 1)[0]?.trim() ?? ""
92
+ // "SVPNCOOKIE=" with an empty value is a deletion, not a session
93
+ if (value === "") continue
94
+ return `SVPNCOOKIE=${value}`
95
+ }
96
+ return null
97
+ }
98
+
99
+ /** All cookies a response sets, as a "name=value; …" header (SVPNCOOKIE deletions skipped). */
100
+ function cookiesFrom(res: Response, existing = ""): string {
101
+ const jar = new Map<string, string>()
102
+ for (const pair of existing.split(";")) {
103
+ const [k, v] = pair.split("=", 2)
104
+ if (k?.trim()) jar.set(k.trim(), v ?? "")
105
+ }
106
+ for (const c of res.headers.getSetCookie?.() ?? []) {
107
+ const [pair] = c.split(";")
108
+ const eq = pair?.indexOf("=") ?? -1
109
+ if (eq <= 0) continue
110
+ const name = pair!.slice(0, eq).trim()
111
+ const value = pair!.slice(eq + 1).trim()
112
+ if (value === "") jar.delete(name) // deletion
113
+ else jar.set(name, value)
114
+ }
115
+ return [...jar.entries()].map(([k, v]) => `${k}=${v}`).join("; ")
116
+ }
117
+
118
+ /**
119
+ * Full login: GET / (JS redirect + realm), POST /remote/logincheck, optional
120
+ * tokeninfo 2FA round (code via on2fa). Returns the SVPNCOOKIE cookie pair.
121
+ */
122
+ export async function authenticate(o: {
123
+ gateway: string // https://fortivpn.uni-freiburg.de
124
+ user: string
125
+ pass: string
126
+ fetch?: typeof fetch
127
+ on2fa?: (challenge: { kind: string; message: string }) => Promise<string> // returns the OTP ("" = FTM push)
128
+ log?: (msg: string) => void
129
+ }): Promise<AuthOutcome> {
130
+ const f = o.fetch ?? fetch
131
+ const log = o.log ?? (() => {})
132
+ const base = o.gateway.replace(/\/$/, "")
133
+
134
+ // 1. GET / — follow redirects manually (fetch drops intermediate Set-Cookies),
135
+ // keep the cookie jar: FortiOS binds the logincheck to the login-page session.
136
+ let realm = ""
137
+ let jar = ""
138
+ let url = `${base}/`
139
+ for (let hops = 0; hops < 5; hops++) {
140
+ const res = await f(url, { headers: { "User-Agent": FORTI_UA, ...(jar ? { Cookie: jar } : {}) }, redirect: "manual" })
141
+ jar = cookiesFrom(res, jar)
142
+ const body = await res.text().catch(() => "")
143
+ if (res.status >= 300 && res.status < 400) {
144
+ const loc = res.headers.get("location")
145
+ if (!loc) break
146
+ url = new URL(loc, url).toString()
147
+ continue
148
+ }
149
+ const js = /top\.location\s*=\s*"([^"]+)"/.exec(body)?.[1]
150
+ ?? /window\.location\s*=\s*"([^"]+)"/.exec(body)?.[1]
151
+ if (js) {
152
+ url = new URL(js, url).toString()
153
+ continue
154
+ }
155
+ // landed on the login form (or any page): look for the realm
156
+ const realmMatch = /[?&]realm=([^&]+)/.exec(url) ?? /[?&]realm=([^&"']+)/.exec(body)
157
+ if (realmMatch) realm = decodeURIComponent(realmMatch[1]!)
158
+ break
159
+ }
160
+ log(realm ? `login page (realm "${realm}")` : "login page")
161
+
162
+ // 2. POST /remote/logincheck (ajax=1 → ret= body; 405 = bad credentials)
163
+ const post = (body: Record<string, string>) =>
164
+ f(`${base}/remote/logincheck`, {
165
+ method: "POST",
166
+ headers: {
167
+ "User-Agent": FORTI_UA,
168
+ "Content-Type": "application/x-www-form-urlencoded",
169
+ ...(jar ? { Cookie: jar } : {}),
170
+ },
171
+ body: formEncode(body),
172
+ redirect: "manual",
173
+ })
174
+
175
+ let res = await post({ username: o.user, credential: o.pass, realm, ajax: "1", just_logged_in: "1" })
176
+ jar = cookiesFrom(res, jar)
177
+ let cookie = cookieFrom(res)
178
+ if (cookie) return { cookie, realm }
179
+
180
+ // 3. no cookie: ret=/tokeninfo challenge (2FA), or failure
181
+ const body = await res.text().catch(() => "")
182
+ const fields = new Map<string, string>()
183
+ for (const m of body.matchAll(/([a-zA-Z_]+)=([^,&\r\n]*)/g)) fields.set(m[1]!, m[2]!)
184
+ const ret = fields.get("ret")
185
+
186
+ if (res.status === 405 || ret === "0") {
187
+ throw new VpnChallengeError("credentials", "the uni VPN rejected the login (wrong user or password)")
188
+ }
189
+ if (fields.has("tokeninfo") || (ret !== undefined && ret !== "1")) {
190
+ const kind = fields.get("tokeninfo") ?? "token"
191
+ const message = fields.get("chal_msg") ?? "one-time code required"
192
+ if (!o.on2fa) throw new VpnChallengeError("2fa-html-form", `2FA challenge (${kind}) but no code prompt configured`)
193
+ const code = await o.on2fa({ kind, message })
194
+ const second: Record<string, string> = { username: o.user, realm }
195
+ if (code === "" && kind === "ftm_push") {
196
+ second.ftmpush = "1" // FortiToken Mobile push instead of a typed code
197
+ } else {
198
+ second.code = code
199
+ second.code2 = ""
200
+ }
201
+ for (const k of ["reqid", "polid", "grp", "portal", "peer"]) {
202
+ if (fields.has(k)) second[k] = fields.get(k)!
203
+ }
204
+ if (fields.has("magic")) second.magic = fields.get("magic")! // must be last
205
+ res = await post(second)
206
+ cookie = cookieFrom(res)
207
+ if (cookie) return { cookie, realm }
208
+ const retry = await res.text().catch(() => "")
209
+ if (res.status === 405 || /ret=0/.test(retry)) {
210
+ throw new VpnChallengeError("credentials", "the uni VPN rejected the one-time code")
211
+ }
212
+ throw new VpnChallengeError("unsupported", `2FA did not conclude (HTTP ${res.status})`)
213
+ }
214
+ throw new VpnChallengeError("unsupported", `unexpected login response (HTTP ${res.status})${body ? `: ${body.slice(0, 120)}` : ""}`)
215
+ }
216
+
217
+ // -- tunnel config XML ---------------------------------------------------------
218
+
219
+ export type TunnelConfig = {
220
+ innerIp: string
221
+ dns: string[]
222
+ dpdS: number
223
+ idleTimeoutS: number
224
+ authTimeoutS: number
225
+ routes: { ip: string; mask: string }[]
226
+ }
227
+
228
+ function attr(xml: string, tag: string, attribute: string): string | null {
229
+ const re = new RegExp(`<${tag}[^>]*\\b${attribute}=["']([^"']*)["']`, "i")
230
+ return re.exec(xml)?.[1] ?? null
231
+ }
232
+
233
+ export function parseTunnelConfig(xml: string): TunnelConfig {
234
+ if (!/<sslvpn-tunnel/i.test(xml)) {
235
+ throw new VpnAuthError("tunnel config: response is not a sslvpn-tunnel document (session dead?)")
236
+ }
237
+ const dns = [...xml.matchAll(/<dns[^>]*\bip=["']([^"']*)["']/gi)].map((m) => m[1]!)
238
+ const routes = [...xml.matchAll(/<addr[^>]*\bip=["']([^"']*)["'][^>]*\bmask=["']([^"']*)["']/gi)].map((m) => ({ ip: m[1]!, mask: m[2]! }))
239
+ return {
240
+ innerIp: attr(xml, "assigned-addr", "ipv4") ?? "",
241
+ dns,
242
+ dpdS: Number(attr(xml, "dtls-config", "heartbeat-interval") ?? "10"),
243
+ idleTimeoutS: Number(attr(xml, "idle-timeout", "val") ?? "0"),
244
+ authTimeoutS: Number(attr(xml, "auth-timeout", "val") ?? "0"),
245
+ routes,
246
+ }
247
+ }
248
+
249
+ export async function fetchTunnelConfig(o: {
250
+ gateway: string
251
+ cookie: string
252
+ fetch?: typeof fetch
253
+ }): Promise<TunnelConfig> {
254
+ const f = o.fetch ?? fetch
255
+ const res = await f(`${o.gateway.replace(/\/$/, "")}/remote/fortisslvpn_xml?dual_stack=1`, {
256
+ headers: { "User-Agent": FORTI_UA, Cookie: o.cookie },
257
+ redirect: "manual",
258
+ })
259
+ const text = await res.text().catch(() => "")
260
+ if (res.status !== 200 || /\/remote\/login/.test(res.headers.get("location") ?? "")) {
261
+ throw new VpnAuthError("tunnel config fetch failed — session invalid (HTTP " + res.status + ")")
262
+ }
263
+ return parseTunnelConfig(text)
264
+ }
265
+
266
+ // -- the tunnel ----------------------------------------------------------------
267
+
268
+ export type TunnelHandle = {
269
+ readonly config: TunnelConfig
270
+ readonly ppp: PppSession
271
+ /** Clean teardown: PPP TERMREQ, close TLS, logout. */
272
+ close(): Promise<void>
273
+ }
274
+
275
+ export async function openTunnel(o: {
276
+ gateway: string
277
+ cookie: string
278
+ /** Called as soon as the PPP session exists (before negotiation starts). */
279
+ onPpp?: (ppp: PppSession) => void
280
+ onEstablished: (cfg: PppConfig) => void
281
+ onDead: (reason: string) => void
282
+ onIp: (datagram: Uint8Array) => void
283
+ pppOptions?: PppOptions
284
+ log?: (msg: string) => void
285
+ }): Promise<TunnelHandle> {
286
+ const log = o.log ?? (() => {})
287
+ const url = new URL(o.gateway)
288
+ const host = url.hostname
289
+ const port = Number(url.port) || 443
290
+
291
+ let socket: import("node:tls").TLSSocket | null = null
292
+ let reader = new FrameReader()
293
+ let closed = false
294
+ let config: TunnelConfig | null = null
295
+ let ppp: PppSession | null = null
296
+
297
+ // -- raw HTTP response reader over the tunnel socket (XML phase) ------------
298
+ let httpBuf = Buffer.alloc(0)
299
+ let phase: "xml" | "tunnel" = "xml"
300
+
301
+ const fail = (msg: string): void => {
302
+ if (closed) return
303
+ closed = true
304
+ ppp?.destroy()
305
+ socket?.end()
306
+ log(`tunnel failed: ${msg}`)
307
+ o.onDead(msg)
308
+ }
309
+
310
+ const handleXmlData = (chunk: Uint8Array): void => {
311
+ httpBuf = Buffer.concat([httpBuf, Buffer.from(chunk)])
312
+ const headEnd = httpBuf.indexOf("\r\n\r\n")
313
+ if (headEnd === -1) return
314
+ const head = httpBuf.subarray(0, headEnd).toString()
315
+ const status = Number(/^HTTP\/[\d.]+ (\d+)/.exec(head)?.[1] ?? 0)
316
+ const bodyStart = headEnd + 4
317
+ const chunked = /transfer-encoding:\s*chunked/i.test(head)
318
+ let body: Buffer | null = null
319
+ let rest: Buffer | null = null
320
+ if (chunked) {
321
+ // dechunk incrementally; complete when the terminal chunk arrives
322
+ const bodyBuf = httpBuf.subarray(bodyStart)
323
+ let out = Buffer.alloc(0)
324
+ let pos = 0
325
+ for (;;) {
326
+ const nl = bodyBuf.indexOf("\r\n", pos)
327
+ if (nl === -1) return // wait for more
328
+ const size = parseInt(bodyBuf.subarray(pos, nl).toString(), 16)
329
+ if (Number.isNaN(size)) return fail("broken chunked body in tunnel config response")
330
+ if (size === 0) { rest = bodyBuf.subarray(pos + 5); break } // skip "0\r\n\r\n"
331
+ if (bodyBuf.length < pos + nl + 2 + size + 2) return // wait for more
332
+ out = Buffer.concat([out, bodyBuf.subarray(nl + 2, nl + 2 + size)])
333
+ pos = nl + 2 + size + 2
334
+ }
335
+ body = out
336
+ } else {
337
+ const cl = Number(/content-length:\s*(\d+)/i.exec(head)?.[1] ?? 0)
338
+ if (httpBuf.length < bodyStart + cl) return // wait for more
339
+ body = httpBuf.subarray(bodyStart, bodyStart + cl)
340
+ rest = httpBuf.subarray(bodyStart + cl)
341
+ }
342
+ if (status !== 200) return fail(`tunnel config request failed (HTTP ${status}) — session invalid?`)
343
+ if (status === 200 && /\/remote\/login/i.test(head)) return fail("tunnel config redirected to login — session invalid")
344
+ config = parseTunnelConfig((body as Buffer).toString())
345
+ log(`tunnel config: inner ip ${config.innerIp}, dns ${config.dns.join(", ") || "none"}, dpd ${config.dpdS}s`)
346
+ // switch into tunnel phase: leftover bytes belong to the tunnel response
347
+ httpBuf = Buffer.from(rest as Buffer)
348
+ phase = "tunnel"
349
+ socket?.write(
350
+ `GET /remote/sslvpn-tunnel HTTP/1.1\r\n` +
351
+ `Host: ${host}${port === 443 ? "" : `:${port}`}\r\n` +
352
+ `User-Agent: ${FORTI_UA}\r\n` +
353
+ `Cookie: ${o.cookie}\r\n` +
354
+ `\r\n`,
355
+ )
356
+ startPpp()
357
+ }
358
+
359
+ const startPpp = (): void => {
360
+ ppp = new PppSession(
361
+ {
362
+ sendPpp: (frame) => {
363
+ if (socket && !closed) socket.write(wrapFrame(frame))
364
+ },
365
+ onIp: o.onIp,
366
+ onEstablished: o.onEstablished,
367
+ onDead: (reason) => {
368
+ if (!closed) {
369
+ closed = true
370
+ socket?.end()
371
+ o.onDead(reason)
372
+ }
373
+ },
374
+ },
375
+ { dpdS: config?.dpdS ?? 10, ...o.pppOptions, log },
376
+ )
377
+ o.onPpp?.(ppp)
378
+ log("tunnel requested — waiting for PPP negotiation")
379
+ ppp.start()
380
+ if (httpBuf.length > 0) {
381
+ // bytes that arrived between the XML response and the tunnel request
382
+ feedTunnel(httpBuf)
383
+ httpBuf = Buffer.alloc(0)
384
+ }
385
+ }
386
+
387
+ const feedTunnel = (chunk: Uint8Array): void => {
388
+ if (!sawTunnelBytes) {
389
+ sawTunnelBytes = true
390
+ // Success = silence (frames begin); an HTTP response here means refusal.
391
+ if (chunk.length >= 5 && new TextDecoder().decode(chunk.slice(0, 5)) === "HTTP/") {
392
+ const status = /^HTTP\/[\d.]+ (\d+)/.exec(new TextDecoder().decode(chunk.slice(0, 32)))?.[1]
393
+ fail(`the gateway refused tunnel mode (HTTP ${status ?? "?"}) — tunnel not allowed for this account`)
394
+ return
395
+ }
396
+ }
397
+ try {
398
+ for (const frame of reader.push(chunk)) ppp?.receive(frame)
399
+ } catch (e) {
400
+ fail(`tunnel framing error: ${(e as Error).message}`)
401
+ }
402
+ }
403
+ let sawTunnelBytes = false
404
+
405
+ socket = tls.connect({
406
+ host,
407
+ port,
408
+ servername: host,
409
+ ALPNProtocols: ["http/1.1"], // the tunnel speaks HTTP/1.1 then switches to PPP frames
410
+ rejectUnauthorized: true,
411
+ })
412
+ socket.on("data", (chunk: Buffer) => {
413
+ if (closed) return
414
+ if (phase === "xml") return handleXmlData(new Uint8Array(chunk))
415
+ feedTunnel(new Uint8Array(chunk))
416
+ })
417
+ socket.on("error", (err: Error) => {
418
+ if (!closed) {
419
+ closed = true
420
+ ppp?.destroy()
421
+ o.onDead(`tunnel socket error: ${err.message}`)
422
+ }
423
+ })
424
+ socket.on("close", () => {
425
+ if (!closed) {
426
+ closed = true
427
+ ppp?.destroy()
428
+ o.onDead("tunnel closed by server")
429
+ }
430
+ })
431
+
432
+ socket.write(
433
+ `GET /remote/fortisslvpn_xml?dual_stack=1 HTTP/1.1\r\n` +
434
+ `Host: ${host}${port === 443 ? "" : `:${port}`}\r\n` +
435
+ `User-Agent: ${FORTI_UA}\r\n` +
436
+ `Cookie: ${o.cookie}\r\n` +
437
+ `\r\n`,
438
+ )
439
+ // The manager waits for onEstablished; the PPP negotiation happens
440
+ // on this one connection, exactly like openconnect's.
441
+
442
+ const close = async (): Promise<void> => {
443
+ if (closed) return
444
+ ppp?.close() // TERMREQ; onDead fires ≤1 s later (or immediately on TERMACK)
445
+ await Bun.sleep(50)
446
+ closed = true
447
+ socket?.end()
448
+ try {
449
+ await fetch(`${o.gateway.replace(/\/$/, "")}/remote/logout`, {
450
+ headers: { "User-Agent": FORTI_UA, Cookie: o.cookie },
451
+ redirect: "manual",
452
+ })
453
+ } catch { /* best effort */ }
454
+ }
455
+
456
+ // config is filled in by the XML phase; expose a getter
457
+ return {
458
+ get config(): TunnelConfig {
459
+ return config ?? { innerIp: "", dns: [], dpdS: 10, idleTimeoutS: 0, authTimeoutS: 0, routes: [] }
460
+ },
461
+ get ppp(): PppSession {
462
+ return ppp!
463
+ },
464
+ close,
465
+ }
466
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Minimal IPv4 layer for the userspace VPN stack.
3
+ *
4
+ * The stack only ever makes outbound TCP connections to a single known host
5
+ * (openwebui.uni-freiburg.de) through a point-to-point tunnel, so this layer
6
+ * deliberately supports just what TCP over that tunnel needs: build IPv4
7
+ * datagrams for TCP segments, parse inbound ones, and hand TCP payloads to the
8
+ * connection state machine. No fragmentation, no options we do not set, no ICMP.
9
+ */
10
+
11
+ export const IPPROTO_TCP = 6
12
+
13
+ export type Ipv4 = number // address as a Uint32, e.g. 0x0a0101
14
+
15
+ export function ipv4ToString(ip: Ipv4): string {
16
+ return [ip >>> 24, (ip >>> 16) & 0xff, (ip >>> 8) & 0xff, ip & 0xff].join(".")
17
+ }
18
+
19
+ export function ipv4Parse(s: string): Ipv4 {
20
+ const parts = s.split(".")
21
+ if (parts.length !== 4) throw new Error(`not an IPv4 address: ${s}`)
22
+ let ip = 0
23
+ for (const p of parts) {
24
+ const n = Number(p)
25
+ if (!Number.isInteger(n) || n < 0 || n > 255) throw new Error(`not an IPv4 address: ${s}`)
26
+ ip = (ip << 8) | n
27
+ }
28
+ return ip >>> 0
29
+ }
30
+
31
+ const u16 = (a: Uint8Array, i: number) => (a[i]! << 8) | a[i + 1]!
32
+ const u32 = (a: Uint8Array, i: number) => ((a[i]! << 24) | (a[i + 1]! << 16) | (a[i + 2]! << 8) | a[i + 3]!) >>> 0
33
+
34
+ /** Header checksum as defined by RFC 768/791: ones' complement of the ones' complement sum. */
35
+ export function checksum(data: Uint8Array, start = 0, end = data.length): number {
36
+ let sum = 0
37
+ for (let i = start; i + 1 < end; i += 2) sum += u16(data, i)
38
+ if (end - start > 0 && (end - start) % 2 === 1) sum += data[end - 1]! << 8
39
+ while (sum > 0xffff) sum = (sum & 0xffff) + (sum >> 16)
40
+ return (~sum) & 0xffff
41
+ }
42
+
43
+ export type TcpHeader = {
44
+ srcPort: number
45
+ dstPort: number
46
+ seq: number
47
+ ack: number
48
+ dataOffset: number // bytes from TCP header start to payload
49
+ flags: number
50
+ window: number
51
+ }
52
+
53
+ export const FIN = 0x01
54
+ export const SYN = 0x02
55
+ export const RST = 0x04
56
+ export const PSH = 0x08
57
+ export const ACK = 0x10
58
+
59
+ export const FLAG_NAMES: [number, string][] = [
60
+ [SYN, "SYN"], [ACK, "ACK"], [FIN, "FIN"], [RST, "RST"], [PSH, "PSH"],
61
+ ]
62
+ export function flagsToString(f: number): string {
63
+ return FLAG_NAMES.filter(([bit]) => f & bit).map(([, name]) => name).join("|") || "0"
64
+ }
65
+
66
+ const TCP_HEADER_LEN = 20
67
+
68
+ export type TcpSegment = { header: TcpHeader; payload: Uint8Array; options?: Uint8Array }
69
+
70
+ /**
71
+ * Builds a full IPv4+TCP datagram. `segment.header.dataOffset` says how many
72
+ * header bytes to write (20 for a bare header, more for options); the payload
73
+ * length must fit in what the dataOffset promises.
74
+ */
75
+ export function buildDatagram(o: {
76
+ src: Ipv4
77
+ dst: Ipv4
78
+ segment: TcpSegment
79
+ ttl?: number
80
+ ident?: number
81
+ }): Uint8Array {
82
+ const { segment } = o
83
+ const total = 20 + segment.header.dataOffset + segment.payload.length
84
+ const pkt = new Uint8Array(total)
85
+ const v = pkt
86
+ v[0] = 0x40 | (20 >> 2) // version 4, IHL 5 (no IP options)
87
+ v[1] = 0 // DSCP/ECN
88
+ v[2] = total >> 8
89
+ v[3] = total & 0xff
90
+ const ident = (o.ident ?? 0) & 0xffff
91
+ v[4] = ident >> 8
92
+ v[5] = ident & 0xff
93
+ v[6] = 0x40 // don't fragment
94
+ v[7] = 0 // fragment offset 0
95
+ v[8] = o.ttl ?? 64
96
+ v[9] = IPPROTO_TCP
97
+ // source/destination
98
+ v[12] = o.src >>> 24; v[13] = (o.src >>> 16) & 0xff; v[14] = (o.src >>> 8) & 0xff; v[15] = o.src & 0xff
99
+ v[16] = o.dst >>> 24; v[17] = (o.dst >>> 16) & 0xff; v[18] = (o.dst >>> 8) & 0xff; v[19] = o.dst & 0xff
100
+ // header checksum over the 20 IP header bytes with the checksum field zero
101
+ v[10] = 0
102
+ v[11] = 0
103
+ const csum = checksum(v, 0, 20)
104
+ v[10] = csum >> 8
105
+ v[11] = csum & 0xff
106
+ // TCP header
107
+ const t = segment.header
108
+ const off = 20
109
+ v[off] = t.srcPort >> 8
110
+ v[off + 1] = t.srcPort & 0xff
111
+ v[off + 2] = t.dstPort >> 8
112
+ v[off + 3] = t.dstPort & 0xff
113
+ v[off + 4] = (t.seq >>> 24) & 0xff; v[off + 5] = (t.seq >>> 16) & 0xff; v[off + 6] = (t.seq >>> 8) & 0xff; v[off + 7] = t.seq & 0xff
114
+ v[off + 8] = (t.ack >>> 24) & 0xff; v[off + 9] = (t.ack >>> 16) & 0xff; v[off + 10] = (t.ack >>> 8) & 0xff; v[off + 11] = t.ack & 0xff
115
+ v[off + 12] = t.dataOffset << 2
116
+ v[off + 13] = t.flags
117
+ v[off + 14] = t.window >> 8
118
+ v[off + 15] = t.window & 0xff
119
+ // checksum + urgent pointer (we never send urgent data)
120
+ v[off + 16] = 0
121
+ v[off + 17] = 0
122
+ v[off + 18] = 0
123
+ v[off + 19] = 0
124
+ // TCP options live inside the header region (dataOffset = 20 + options.length)
125
+ if (segment.options && segment.options.length > 0) {
126
+ if (segment.header.dataOffset !== 20 + segment.options.length) {
127
+ throw new Error(`dataOffset ${segment.header.dataOffset} does not match options length ${segment.options.length}`)
128
+ }
129
+ v.set(segment.options, off + 20)
130
+ }
131
+ v.set(segment.payload, off + segment.header.dataOffset)
132
+ // TCP pseudo-header checksum (RFC 9293) over header (incl. options) + payload
133
+ const pseudo = new Uint8Array(12 + segment.header.dataOffset + segment.payload.length)
134
+ pseudo[0] = o.src >>> 24; pseudo[1] = (o.src >>> 16) & 0xff; pseudo[2] = (o.src >>> 8) & 0xff; pseudo[3] = o.src & 0xff
135
+ pseudo[4] = o.dst >>> 24; pseudo[5] = (o.dst >>> 16) & 0xff; pseudo[6] = (o.dst >>> 8) & 0xff; pseudo[7] = o.dst & 0xff
136
+ pseudo[8] = 0
137
+ pseudo[9] = IPPROTO_TCP
138
+ pseudo[10] = (segment.header.dataOffset + segment.payload.length) >> 8
139
+ pseudo[11] = (segment.header.dataOffset + segment.payload.length) & 0xff
140
+ pseudo.set(v.subarray(off, off + segment.header.dataOffset + segment.payload.length), 12)
141
+ const tcsum = checksum(pseudo)
142
+ v[off + 16] = tcsum >> 8
143
+ v[off + 17] = tcsum & 0xff
144
+ return pkt
145
+ }
146
+
147
+ /** Parses an inbound IPv4 datagram; returns null for anything that is not a TCP segment for us. */
148
+ export function parseDatagram(pkt: Uint8Array): { src: Ipv4; dst: Ipv4; segment: TcpSegment } | null {
149
+ if (pkt.length < 40) return null
150
+ if (pkt[0]! >> 4 !== 4) return null
151
+ const ihl = (pkt[0]! & 0x0f) * 4
152
+ if (ihl < 20 || pkt.length < ihl + 20) return null
153
+ if (pkt[9] !== IPPROTO_TCP) return null
154
+ const src = u32(pkt, 12)
155
+ const dst = u32(pkt, 16)
156
+ const off = ihl
157
+ const dataOffset = (pkt[off + 12]! >> 4) * 4
158
+ if (dataOffset < 20 || pkt.length < off + dataOffset) return null
159
+ return {
160
+ src,
161
+ dst,
162
+ segment: {
163
+ header: {
164
+ srcPort: u16(pkt, off),
165
+ dstPort: u16(pkt, off + 2),
166
+ seq: u32(pkt, off + 4),
167
+ ack: u32(pkt, off + 8),
168
+ dataOffset,
169
+ flags: pkt[off + 13]!,
170
+ window: u16(pkt, off + 14),
171
+ },
172
+ payload: pkt.subarray(off + dataOffset),
173
+ options: dataOffset > 20 ? pkt.subarray(off + 20, off + dataOffset) : undefined,
174
+ },
175
+ }
176
+ }