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.
- package/LICENSE +21 -0
- package/README.md +184 -0
- package/bin/ufr.ts +4 -0
- package/models.json +206 -0
- package/package.json +16 -0
- package/src/cli/catalog.ts +45 -0
- package/src/cli/connect.ts +146 -0
- package/src/cli/daemon-client.ts +19 -0
- package/src/cli/index.ts +93 -0
- package/src/cli/io.ts +98 -0
- package/src/cli/keys.ts +95 -0
- package/src/cli/stats.ts +37 -0
- package/src/cli/status.ts +48 -0
- package/src/cli/stop.ts +8 -0
- package/src/cli/ufr-check.ts +43 -0
- package/src/daemon/breaker.ts +146 -0
- package/src/daemon/catalog-source.ts +85 -0
- package/src/daemon/catalog.ts +127 -0
- package/src/daemon/daemon.ts +397 -0
- package/src/daemon/fallbacks.ts +41 -0
- package/src/daemon/keypool.ts +109 -0
- package/src/daemon/log.ts +15 -0
- package/src/daemon/main.ts +18 -0
- package/src/daemon/model.ts +15 -0
- package/src/daemon/router.ts +392 -0
- package/src/daemon/server.ts +54 -0
- package/src/daemon/stats.ts +105 -0
- package/src/daemon/transport.ts +21 -0
- package/src/daemon/upstream.ts +68 -0
- package/src/daemon/vpn/fortinet.ts +466 -0
- package/src/daemon/vpn/ip.ts +176 -0
- package/src/daemon/vpn/manager.ts +258 -0
- package/src/daemon/vpn/ppp.ts +450 -0
- package/src/daemon/vpn/proxy.ts +102 -0
- package/src/daemon/vpn/stack.ts +140 -0
- package/src/daemon/vpn/tcp.ts +356 -0
- package/src/daemon/window.ts +126 -0
- package/src/plugin/connect.ts +113 -0
- package/src/plugin/ensure-daemon.ts +101 -0
- package/src/plugin/index.ts +93 -0
- package/src/plugin/model-info.ts +35 -0
- package/src/shared/config.ts +170 -0
- package/src/shared/connect.ts +100 -0
- package/src/shared/errors.ts +6 -0
- package/src/shared/fs.ts +31 -0
- package/src/shared/models-file.ts +68 -0
- package/src/shared/paths.ts +62 -0
- package/src/shared/secrets.ts +62 -0
- package/src/shared/sleep.ts +15 -0
- package/src/shared/time.ts +6 -0
- package/src/shared/version.ts +3 -0
- 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
|
+
}
|