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,258 @@
1
+ /**
2
+ * The built-in VPN's lifecycle: decides whether UFR is reachable directly,
3
+ * brings the Fortinet tunnel up when not, and exposes a Transport the daemon
4
+ * routes its UFR calls through. Nothing here touches the system's routing —
5
+ * the tunnel lives entirely inside this process (userspace TCP stack).
6
+ */
7
+
8
+ import { type Ipv4, ipv4Parse } from "./ip"
9
+ import { TcpStack } from "./stack"
10
+ import { startProxy, type ProxyHandle } from "./proxy"
11
+ import {
12
+ authenticate,
13
+ fetchTunnelConfig,
14
+ openTunnel,
15
+ VpnChallengeError,
16
+ type TunnelHandle,
17
+ } from "./fortinet"
18
+ import type { Transport } from "../transport"
19
+ import { isVpnPage } from "../../shared/vpn"
20
+
21
+ export type VpnState = {
22
+ mode: "off" | "connecting" | "up" | "failed"
23
+ detail: string
24
+ since: number
25
+ innerIp?: string
26
+ proxyPort?: number
27
+ reconnectAttempt?: number
28
+ }
29
+
30
+ const DIRECT_CHECK_CACHE_MS = 60_000
31
+ const ESTABLISH_TIMEOUT_MS = 30_000
32
+ const RECONNECT_BASE_MS = 5_000
33
+ const RECONNECT_MAX_MS = 10 * 60_000
34
+
35
+ type FetchFn = (url: string, init?: RequestInit) => Promise<Response>
36
+
37
+ export class VpnManager {
38
+ private state: VpnState
39
+ private tunnel: TunnelHandle | null = null
40
+ private stack: TcpStack | null = null
41
+ private proxy: ProxyHandle | null = null
42
+ private remoteIp: Ipv4 | null = null
43
+ private connecting: Promise<"direct" | "vpn" | "failed"> | null = null
44
+ private directCheckedAt = 0
45
+ private directOk = false
46
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null
47
+ private reconnectAttempt = 0
48
+ private stopped = false
49
+
50
+ constructor(
51
+ private readonly o: {
52
+ gateway: string // https://fortivpn.uni-freiburg.de
53
+ upstreamHost: string // openwebui.uni-freiburg.de
54
+ baseUrl: string // https://openwebui.uni-freiburg.de/api — for the direct check
55
+ credentials: { user: string; pass: string } | null
56
+ /** "always" skips the direct check: every UFR call goes through the tunnel. */
57
+ mode?: "auto" | "always"
58
+ log: (msg: string) => void
59
+ /** Outgoing direct-leg fetch (injectable for tests). */
60
+ fetch?: FetchFn
61
+ },
62
+ private readonly now: () => number = Date.now,
63
+ ) {
64
+ this.state = { mode: "off", detail: "not checked", since: now() }
65
+ }
66
+
67
+ get status(): VpnState {
68
+ return { ...this.state }
69
+ }
70
+
71
+ /** UFR transport: through the tunnel when it is up, direct otherwise. */
72
+ transport(): Transport {
73
+ const f = this.o.fetch ?? fetch
74
+ return {
75
+ name: "vpn",
76
+ fetch: async (url, init) => {
77
+ const path = await this.ensurePath()
78
+ if (path === "vpn" && this.proxy) {
79
+ return await fetch(url, { ...init, proxy: `http://127.0.0.1:${this.proxy.port}` } as RequestInit)
80
+ }
81
+ return await f(url, init)
82
+ },
83
+ }
84
+ }
85
+
86
+ /**
87
+ * Make sure a working path to UFR exists. Cached direct checks avoid
88
+ * hammering UFR with probes; the tunnel reconnects with backoff when it dies.
89
+ */
90
+ async ensurePath(): Promise<"direct" | "vpn" | "failed"> {
91
+ if (this.stopped) return "failed"
92
+ if (this.state.mode === "up" && this.proxy) return "vpn"
93
+ const cacheFresh = this.now() - this.directCheckedAt < DIRECT_CHECK_CACHE_MS
94
+ if (this.o.mode !== "always" && cacheFresh && this.directOk && this.state.mode !== "connecting") return "direct"
95
+ if (this.connecting) return await this.connecting
96
+ this.connecting = this.connect()
97
+ try {
98
+ return await this.connecting
99
+ } finally {
100
+ this.connecting = null
101
+ }
102
+ }
103
+
104
+ private async connect(): Promise<"direct" | "vpn" | "failed"> {
105
+ // 1. is UFR reachable directly? ("always" skips this: tunnel unconditionally)
106
+ if (this.o.mode !== "always") {
107
+ const direct = await this.checkDirect()
108
+ if (direct === "direct") {
109
+ this.setState("off", "UFR reachable directly — tunnel not needed")
110
+ return "direct"
111
+ }
112
+ }
113
+ // 2. tunnel needed — do we have a login?
114
+ if (!this.o.credentials) {
115
+ this.setState("failed", "off campus and no uni login stored — run `ufr login add <user>`")
116
+ return "failed"
117
+ }
118
+ // 3. bring the tunnel up
119
+ try {
120
+ await this.open()
121
+ return "vpn"
122
+ } catch (e) {
123
+ const msg = (e as Error).message
124
+ this.setState("failed", msg)
125
+ this.scheduleReconnect(msg)
126
+ return "failed"
127
+ }
128
+ }
129
+
130
+ private async checkDirect(): Promise<"direct" | "tunnel-needed"> {
131
+ try {
132
+ const res = await (this.o.fetch ?? fetch)(`${this.o.baseUrl}/models`, {
133
+ headers: { "User-Agent": "opencode-ufr" },
134
+ signal: AbortSignal.timeout(6_000),
135
+ redirect: "manual",
136
+ })
137
+ const type = res.headers.get("content-type") ?? ""
138
+ this.directCheckedAt = this.now()
139
+ if (type.includes("application/json")) {
140
+ this.directOk = true
141
+ return "direct"
142
+ }
143
+ if (type.includes("text/html")) {
144
+ const html = await res.text().catch(() => "")
145
+ this.directOk = false
146
+ return isVpnPage(html) ? "tunnel-needed" : "tunnel-needed"
147
+ }
148
+ this.directOk = false
149
+ return "tunnel-needed"
150
+ } catch {
151
+ this.directCheckedAt = this.now()
152
+ this.directOk = false
153
+ return "tunnel-needed" // unreachable: try the tunnel before giving up
154
+ }
155
+ }
156
+
157
+ private async open(): Promise<void> {
158
+ const creds = this.o.credentials!
159
+ this.setState("connecting", "logging in to the uni VPN")
160
+ const { cookie } = await authenticate({
161
+ gateway: this.o.gateway,
162
+ user: creds.user,
163
+ pass: creds.pass,
164
+ log: this.o.log,
165
+ })
166
+ if (this.remoteIp === null) this.remoteIp = await resolveIp(this.o.upstreamHost)
167
+
168
+ this.setState("connecting", "negotiating PPP")
169
+ let sendIp: (datagram: Uint8Array) => void = () => {}
170
+ let stackRef: TcpStack | null = null
171
+ const tunnel = await openTunnel({
172
+ gateway: this.o.gateway,
173
+ cookie,
174
+ log: this.o.log,
175
+ onPpp: (ppp) => {
176
+ sendIp = (datagram) => ppp.sendIp(datagram)
177
+ },
178
+ onEstablished: (cfg) => {
179
+ if (!stackRef) {
180
+ stackRef = new TcpStack({ local: ipv4Parse(cfg.innerIp), sink: (pkt) => sendIp(pkt), opts: { mss: Math.min(cfg.mru - 40, 1360) } }, this.o.log)
181
+ this.stack = stackRef
182
+ this.proxy = startProxy({ stack: stackRef, remoteIp: this.remoteIp!, log: this.o.log })
183
+ this.o.log(`proxy listening on 127.0.0.1:${this.proxy.port}`)
184
+ } else {
185
+ stackRef.setLocal(ipv4Parse(cfg.innerIp))
186
+ }
187
+ this.reconnectAttempt = 0
188
+ this.setState("up", `tunnel up (inner ip ${cfg.innerIp})`, cfg.innerIp, this.proxy?.port)
189
+ },
190
+ onDead: (reason) => {
191
+ this.teardown()
192
+ if (this.stopped) return
193
+ this.setState("failed", `tunnel died: ${reason}`)
194
+ this.scheduleReconnect(reason)
195
+ },
196
+ onIp: (datagram) => stackRef?.receive(datagram),
197
+ })
198
+ this.tunnel = tunnel
199
+
200
+ // wait for the XML phase + PPP/ICCP negotiation to finish
201
+ const deadline = Date.now() + ESTABLISH_TIMEOUT_MS
202
+ while (this.state.mode !== "up") {
203
+ if (Date.now() > deadline) {
204
+ await tunnel.close()
205
+ throw new Error("the tunnel did not come up within 30 s")
206
+ }
207
+ if (this.state.mode === "failed") throw new Error(this.state.detail)
208
+ await Bun.sleep(100)
209
+ }
210
+ }
211
+
212
+ private teardown(): void {
213
+ this.tunnel = null
214
+ this.stack?.closeAll()
215
+ this.stack = null
216
+ this.proxy?.stop()
217
+ this.proxy = null
218
+ }
219
+
220
+ private scheduleReconnect(reason: string): void {
221
+ if (this.reconnectTimer || this.stopped) return
222
+ const delay = Math.min(RECONNECT_BASE_MS * 2 ** this.reconnectAttempt, RECONNECT_MAX_MS)
223
+ this.reconnectAttempt++
224
+ this.o.log(`vpn reconnect in ${Math.round(delay / 1000)} s (${reason})`)
225
+ this.reconnectTimer = setTimeout(() => {
226
+ this.reconnectTimer = null
227
+ if (this.stopped) return
228
+ void this.ensurePath()
229
+ }, delay)
230
+ }
231
+
232
+ private setState(mode: VpnState["mode"], detail: string, innerIp?: string, proxyPort?: number): void {
233
+ this.state = {
234
+ mode,
235
+ detail,
236
+ since: this.state.mode === mode ? this.state.since : this.now(),
237
+ innerIp: innerIp ?? this.state.innerIp,
238
+ proxyPort: proxyPort ?? this.state.proxyPort,
239
+ reconnectAttempt: this.reconnectAttempt || undefined,
240
+ }
241
+ }
242
+
243
+ /** Shut everything down (daemon idle shutdown). */
244
+ async stop(): Promise<void> {
245
+ this.stopped = true
246
+ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); this.reconnectTimer = null }
247
+ const t = this.tunnel
248
+ this.teardown()
249
+ if (t) await t.close()
250
+ this.setState("off", "stopped")
251
+ }
252
+ }
253
+
254
+ async function resolveIp(host: string): Promise<Ipv4> {
255
+ const { lookup } = await import("node:dns/promises")
256
+ const res = await lookup(host, { family: 4 })
257
+ return ipv4Parse(res.address)
258
+ }
@@ -0,0 +1,450 @@
1
+ /**
2
+ * PPP over the Fortinet SSL-VPN tunnel (RFC 1661 framing, no HDLC), following
3
+ * docs/fortinet-protocol.md.
4
+ *
5
+ * The client only needs: LCP (MRU + magic, no auth — the SVPNCOOKIE did that),
6
+ * IPCP (solicit inner IPv4 + DNS), Echo keepalives (DPD) and IP data frames.
7
+ * Everything else is rejected or ignored defensively.
8
+ */
9
+
10
+ export const PPP_IP = 0x0021
11
+ export const PPP_LCP = 0xc021
12
+ export const PPP_IPCP = 0x8021
13
+ export const PPP_CCP = 0x80fd
14
+
15
+ export const CONFREQ = 1
16
+ export const CONFACK = 2
17
+ export const CONFNAK = 3
18
+ export const CONFREJ = 4
19
+ export const TERMREQ = 5
20
+ export const TERMACK = 6
21
+ export const PROTREJ = 8
22
+ export const ECHOREQ = 9
23
+ export const ECHOREP = 10
24
+
25
+ // LCP option tags
26
+ const LCP_MRU = 1
27
+ const LCP_MAGIC = 5
28
+ // IPCP option tags
29
+ const IPCP_IPADDR = 3
30
+ const IPCP_DNS1 = 129
31
+ const IPCP_DNS2 = 131
32
+
33
+ const RETRANSMIT_MS = 3_000
34
+ const TERM_WAIT_MS = 1_000
35
+
36
+ export type PppConfig = { innerIp: string; dns: string[]; mru: number }
37
+
38
+ export type PppEvents = {
39
+ /** PPP frame outbound (the tunnel layer wraps it). */
40
+ sendPpp(frame: Uint8Array): void
41
+ /** IPv4 datagram received from the tunnel (protocol 0x0021). */
42
+ onIp(datagram: Uint8Array): void
43
+ /** Negotiation finished; the stack can dial. */
44
+ onEstablished(cfg: PppConfig): void
45
+ /** The link died (DPD timeout, TERMREQ, or framing error). */
46
+ onDead(reason: string): void
47
+ }
48
+
49
+ export type PppOptions = {
50
+ mru?: number // what we announce and assume as link MTU
51
+ dpdS?: number // DPD interval in seconds (from the XML config)
52
+ log?: (msg: string) => void
53
+ }
54
+
55
+ type Tlv = { tag: number; data: Uint8Array }
56
+
57
+ const u16 = (a: Uint8Array, i: number) => (a[i]! << 8) | a[i + 1]!
58
+
59
+ function tlvBytes(tag: number, data: Uint8Array): Uint8Array {
60
+ const out = new Uint8Array(2 + data.length)
61
+ out[0] = tag
62
+ out[1] = 2 + data.length
63
+ out.set(data, 2)
64
+ return out
65
+ }
66
+
67
+ function parseTlvs(data: Uint8Array): Tlv[] {
68
+ const out: Tlv[] = []
69
+ let i = 0
70
+ while (i + 2 <= data.length) {
71
+ const len = data[i + 1]!
72
+ if (len < 2 || i + len > data.length) break
73
+ out.push({ tag: data[i]!, data: data.slice(i + 2, i + len) })
74
+ i += len
75
+ }
76
+ return out
77
+ }
78
+
79
+ /** Builds a complete PPP frame: FF 03 | protocol | payload. */
80
+ export function pppFrame(proto: number, payload: Uint8Array): Uint8Array {
81
+ const out = new Uint8Array(4 + payload.length)
82
+ out[0] = 0xff
83
+ out[1] = 0x03
84
+ out[2] = proto >> 8
85
+ out[3] = proto & 0xff
86
+ out.set(payload, 4)
87
+ return out
88
+ }
89
+
90
+ /** Splits a PPP frame into protocol + payload; null if malformed. */
91
+ export function parsePpp(frame: Uint8Array): { proto: number; payload: Uint8Array } | null {
92
+ let i = 0
93
+ if (frame.length >= 2 && frame[0] === 0xff && frame[1] === 0x03) i = 2
94
+ if (frame.length < i + 2) return null
95
+ // protocol field: 2 bytes when the first byte is even (PFC never negotiated, but parse defensively)
96
+ let proto: number
97
+ if (frame[i]! & 1) {
98
+ proto = frame[i]!
99
+ i += 1
100
+ } else {
101
+ proto = u16(frame, i)
102
+ i += 2
103
+ }
104
+ return { proto, payload: frame.slice(i) }
105
+ }
106
+
107
+ function ncpPacket(code: number, id: number, options: Uint8Array): Uint8Array {
108
+ const len = 4 + options.length
109
+ const out = new Uint8Array(len)
110
+ out[0] = code
111
+ out[1] = id
112
+ out[2] = len >> 8
113
+ out[3] = len & 0xff
114
+ out.set(options, 4)
115
+ return out
116
+ }
117
+
118
+ export class PppSession {
119
+ private mru: number
120
+ private dpdMs: number
121
+ private magic = (Math.floor(Math.random() * 0xffffffff) | 1) >>> 0
122
+ private nextId = 1
123
+ private peerMagic: number | null = null
124
+ private lcpAckSent = false
125
+ private lcpAckReceived = false
126
+ private ipcpAckSent = false
127
+ private ipcpAckReceived = false
128
+ private innerIp = ""
129
+ private dns: string[] = []
130
+ private ourConfReq: Uint8Array | null = null // pending LCP CONFREQ (for retransmit)
131
+ private ourConfReqId = 0
132
+ private ipcpReq: Uint8Array | null = null
133
+ private ipcpReqId = 0
134
+ private retransmitTimer: ReturnType<typeof setTimeout> | null = null
135
+ private dpdTimer: ReturnType<typeof setInterval> | null = null
136
+ private lastReceivedAt = Date.now()
137
+ private echoOutstanding = false
138
+ private termWait: ReturnType<typeof setTimeout> | null = null
139
+ private dead = false
140
+ private log: (msg: string) => void
141
+
142
+ constructor(private readonly ev: PppEvents, o: PppOptions = {}) {
143
+ this.mru = o.mru ?? 1400
144
+ this.dpdMs = (o.dpdS ?? 10) * 1000
145
+ this.log = o.log ?? (() => {})
146
+ }
147
+
148
+ get established(): boolean {
149
+ return this.lcpAckSent && this.lcpAckReceived && this.ipcpAckSent && this.ipcpAckReceived
150
+ }
151
+
152
+ get config(): PppConfig {
153
+ return { innerIp: this.innerIp, dns: [...this.dns], mru: this.mru }
154
+ }
155
+
156
+ /** Kick off negotiation: LCP CONFREQ (MRU + magic). */
157
+ start(): void {
158
+ this.sendLcpConfReq()
159
+ this.startDpd()
160
+ }
161
+
162
+ private sendLcpConfReq(): void {
163
+ const options = concat([tlvBytes(LCP_MRU, u16be(this.mru)), tlvBytes(LCP_MAGIC, u32be(this.magic))])
164
+ this.ourConfReq = ncpPacket(CONFREQ, this.nextId, options)
165
+ this.ourConfReqId = this.nextId
166
+ this.nextId = (this.nextId + 1) & 0xff || 1
167
+ this.ev.sendPpp(pppFrame(PPP_LCP, this.ourConfReq))
168
+ this.armRetransmit()
169
+ }
170
+
171
+ /** IPCP CONFREQ: zeros to solicit before the NAK, assigned values after it. */
172
+ private buildIpcpOptions(): Uint8Array {
173
+ const ip = this.innerIp ? parseIpv4(this.innerIp) : new Uint8Array(4)
174
+ const opts = [tlvBytes(IPCP_IPADDR, ip)]
175
+ if (this.dns.length > 0) {
176
+ for (let i = 0; i < Math.min(2, this.dns.length); i++) {
177
+ opts.push(tlvBytes(i === 0 ? IPCP_DNS1 : IPCP_DNS2, parseIpv4(this.dns[i]!)))
178
+ }
179
+ } else {
180
+ opts.push(tlvBytes(IPCP_DNS1, new Uint8Array(4)), tlvBytes(IPCP_DNS2, new Uint8Array(4)))
181
+ }
182
+ return concat(opts)
183
+ }
184
+
185
+ private sendIpcpConfReq(): void {
186
+ if (!this.lcpOpen() || this.ipcpAckReceived) return
187
+ const options = this.buildIpcpOptions()
188
+ this.ipcpReq = ncpPacket(CONFREQ, this.nextId, options)
189
+ this.ipcpReqId = this.nextId
190
+ this.nextId = (this.nextId + 1) & 0xff || 1
191
+ this.ev.sendPpp(pppFrame(PPP_IPCP, this.ipcpReq))
192
+ this.armRetransmit()
193
+ }
194
+
195
+ private armRetransmit(): void {
196
+ if (this.retransmitTimer) clearTimeout(this.retransmitTimer)
197
+ this.retransmitTimer = setTimeout(() => {
198
+ this.retransmitTimer = null
199
+ if (this.dead) return
200
+ if (this.ourConfReq && !this.lcpAckReceived) {
201
+ this.log("lcp confreq timed out — retransmitting with a fresh id")
202
+ this.sendLcpConfReq() // new id per spec (ppp.c:891-901)
203
+ } else if (this.ipcpReq && !this.ipcpAckReceived) {
204
+ this.log("ipcp confreq timed out — retransmitting with a fresh id")
205
+ this.sendIpcpConfReq()
206
+ }
207
+ }, RETRANSMIT_MS)
208
+ }
209
+
210
+ private startDpd(): void {
211
+ if (this.dpdTimer) clearInterval(this.dpdTimer)
212
+ this.lastReceivedAt = Date.now()
213
+ this.echoOutstanding = false
214
+ this.dpdTimer = setInterval(() => {
215
+ if (this.dead) return
216
+ const silentMs = Date.now() - this.lastReceivedAt
217
+ if (silentMs >= 2 * this.dpdMs) {
218
+ this.kill("dead peer: no data for " + Math.round(silentMs / 1000) + "s")
219
+ return
220
+ }
221
+ if (silentMs >= this.dpdMs && !this.echoOutstanding) {
222
+ this.echoOutstanding = true
223
+ const payload = u32be(this.magic)
224
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(ECHOREQ, this.nextId, payload)))
225
+ this.nextId = (this.nextId + 1) & 0xff || 1
226
+ }
227
+ }, Math.min(this.dpdMs / 2, 5_000))
228
+ }
229
+
230
+ /** The tunnel layer calls this for every inbound PPP frame AND for DPD feeding. */
231
+ receive(frame: Uint8Array): void {
232
+ if (this.dead) return
233
+ this.lastReceivedAt = Date.now()
234
+ this.echoOutstanding = false
235
+ const p = parsePpp(frame)
236
+ if (!p) return this.kill("malformed PPP frame")
237
+ switch (p.proto) {
238
+ case PPP_LCP: return this.receiveLcp(p.payload)
239
+ case PPP_IPCP: return this.receiveIpcp(p.payload)
240
+ case PPP_IP: return this.ev.onIp(p.payload)
241
+ case PPP_CCP: return this.sendProtRej(PPP_CCP) // no compression, per spec quirk 13
242
+ default: return // unknown protocol: ignore (IP6CP etc.)
243
+ }
244
+ }
245
+
246
+ private receiveLcp(payload: Uint8Array): void {
247
+ if (payload.length < 4) return this.kill("short LCP packet")
248
+ const code = payload[0]!
249
+ const id = payload[1]!
250
+ const body = payload.slice(4)
251
+ switch (code) {
252
+ case CONFACK:
253
+ if (this.ourConfReq && id === this.ourConfReqId) {
254
+ this.lcpAckReceived = true
255
+ this.ourConfReq = null
256
+ if (this.retransmitTimer) { clearTimeout(this.retransmitTimer); this.retransmitTimer = null }
257
+ this.log("lcp opened (our confreq acked)")
258
+ this.maybeEstablish()
259
+ this.sendIpcpConfReq()
260
+ }
261
+ return
262
+ case CONFREQ: return this.answerServerConfReq(id, body)
263
+ case CONFNAK: return this.nakLcp(id, body)
264
+ case ECHOREQ: {
265
+ if (!this.lcpOpen()) return
266
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(ECHOREP, id, u32be(this.magic))))
267
+ return
268
+ }
269
+ case ECHOREP: return // nothing to do; DPD fed above
270
+ case TERMREQ: {
271
+ const reason = new TextDecoder().decode(body).trim()
272
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(TERMACK, id, new Uint8Array(0))))
273
+ return this.kill(`server terminated the link${reason ? `: ${reason}` : ""}`)
274
+ }
275
+ case TERMACK:
276
+ if (this.termWait) { clearTimeout(this.termWait); this.termWait = null }
277
+ return this.kill("link terminated")
278
+ default: return // CODEREJ/DISCREQ etc.: ignore
279
+ }
280
+ }
281
+
282
+ private lcpOpen(): boolean {
283
+ return this.lcpAckSent && this.lcpAckReceived
284
+ }
285
+
286
+ private answerServerConfReq(id: number, body: Uint8Array): void {
287
+ const tlvs = parseTlvs(body)
288
+ const accept: Uint8Array[] = []
289
+ const reject: Tlv[] = []
290
+ let serverMru: number | null = null
291
+ for (const t of tlvs) {
292
+ if (t.tag === LCP_MRU && t.data.length === 2) {
293
+ serverMru = u16(t.data, 0)
294
+ accept.push(tlvBytes(LCP_MRU, t.data))
295
+ } else if (t.tag === LCP_MAGIC && t.data.length === 4) {
296
+ this.peerMagic = u32(t.data, 0)
297
+ accept.push(tlvBytes(LCP_MAGIC, t.data))
298
+ } else {
299
+ reject.push(t) // ASYNCMAP and anything else: Fortinet gets a CONFREJ (spec §4.3)
300
+ }
301
+ }
302
+ if (reject.length > 0) {
303
+ const options = concat(reject.map((t) => [new Uint8Array([t.tag, t.data.length + 2]), t.data]).flat())
304
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(CONFREJ, id, options)))
305
+ } else {
306
+ this.lcpAckSent = true
307
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(CONFACK, id, body)))
308
+ if (serverMru && serverMru !== this.mru) {
309
+ // adopt the server's final word on the link MTU (spec: accept after one coax)
310
+ this.mru = serverMru
311
+ }
312
+ this.maybeEstablish()
313
+ this.sendIpcpConfReq()
314
+ }
315
+ }
316
+
317
+ private nakLcp(id: number, body: Uint8Array): void {
318
+ // one-shot coax: server nak'd our MRU — accept its suggestion verbatim
319
+ const tlvs = parseTlvs(body)
320
+ const mru = tlvs.find((t) => t.tag === LCP_MRU && t.data.length === 2)
321
+ if (mru) {
322
+ this.mru = u16(mru.data, 0)
323
+ this.log(`lcp mru coerced to ${this.mru}`)
324
+ this.ourConfReq = null
325
+ this.sendLcpConfReq()
326
+ }
327
+ }
328
+
329
+ private receiveIpcp(payload: Uint8Array): void {
330
+ if (payload.length < 4) return
331
+ const code = payload[0]!
332
+ const id = payload[1]!
333
+ const body = payload.slice(4)
334
+ switch (code) {
335
+ case CONFACK:
336
+ if (this.ipcpReq && id === this.ipcpReqId) {
337
+ this.ipcpAckReceived = true
338
+ this.ipcpReq = null
339
+ if (this.retransmitTimer) { clearTimeout(this.retransmitTimer); this.retransmitTimer = null }
340
+ this.log(`ipcp opened (inner ip ${this.innerIp})`)
341
+ this.maybeEstablish()
342
+ }
343
+ return
344
+ case CONFNAK: {
345
+ // the server hands out the real values here
346
+ const tlvs = parseTlvs(body)
347
+ const ip = tlvs.find((t) => t.tag === IPCP_IPADDR && t.data.length === 4)
348
+ if (ip) this.innerIp = ipv4Str(ip.data)
349
+ for (const t of tlvs) {
350
+ if ((t.tag === IPCP_DNS1 || t.tag === IPCP_DNS2) && t.data.length === 4) {
351
+ const d = ipv4Str(t.data)
352
+ if (!this.dns.includes(d)) this.dns.push(d)
353
+ }
354
+ }
355
+ this.log(`ipcp nak: inner ip ${this.innerIp}, dns ${this.dns.join(", ")}`)
356
+ // re-request with the assigned values (spec §4.5: REQ → NAK → REQ → ACK)
357
+ this.sendIpcpConfReq()
358
+ return
359
+ }
360
+ case CONFREQ: {
361
+ // server-side IPCP request: ack it verbatim (we requested nothing special)
362
+ this.ipcpAckSent = true
363
+ this.ev.sendPpp(pppFrame(PPP_IPCP, ncpPacket(CONFACK, id, body)))
364
+ this.maybeEstablish()
365
+ return
366
+ }
367
+ case TERMREQ: {
368
+ const reason = new TextDecoder().decode(body).trim()
369
+ this.ev.sendPpp(pppFrame(PPP_IPCP, ncpPacket(TERMACK, id, new Uint8Array(0))))
370
+ return this.kill(`server terminated IPCP${reason ? `: ${reason}` : ""}`)
371
+ }
372
+ default: return
373
+ }
374
+ }
375
+
376
+ private maybeEstablish(): void {
377
+ if (this.established) {
378
+ this.log(`ppp established: inner ip ${this.innerIp}, dns ${this.dns.join(", ") || "none"}, mru ${this.mru}`)
379
+ this.ev.onEstablished(this.config)
380
+ }
381
+ }
382
+
383
+ private sendProtRej(proto: number): void {
384
+ const info = new Uint8Array(4)
385
+ info[0] = proto >> 8
386
+ info[1] = proto & 0xff
387
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(PROTREJ, this.nextId, info)))
388
+ this.nextId = (this.nextId + 1) & 0xff || 1
389
+ }
390
+
391
+ /** Send an IPv4 datagram into the tunnel (protocol 0x0021). */
392
+ sendIp(datagram: Uint8Array): void {
393
+ if (this.dead) throw new Error("ppp link is dead")
394
+ this.ev.sendPpp(pppFrame(PPP_IP, datagram))
395
+ }
396
+
397
+ /** Clean teardown: TERMREQ, wait ≤1 s for TERMACK, then signal dead. */
398
+ close(): void {
399
+ if (this.dead) return
400
+ this.ev.sendPpp(pppFrame(PPP_LCP, ncpPacket(TERMREQ, this.nextId, new Uint8Array(0))))
401
+ this.nextId = (this.nextId + 1) & 0xff || 1
402
+ this.termWait = setTimeout(() => this.kill("closed"), TERM_WAIT_MS)
403
+ }
404
+
405
+ private kill(reason: string): void {
406
+ if (this.dead) return
407
+ this.dead = true
408
+ if (this.retransmitTimer) { clearTimeout(this.retransmitTimer); this.retransmitTimer = null }
409
+ if (this.dpdTimer) { clearInterval(this.dpdTimer); this.dpdTimer = null }
410
+ if (this.termWait) { clearTimeout(this.termWait); this.termWait = null }
411
+ this.log(`ppp dead: ${reason}`)
412
+ this.ev.onDead(reason)
413
+ }
414
+
415
+ destroy(): void {
416
+ this.dead = true
417
+ if (this.retransmitTimer) { clearTimeout(this.retransmitTimer); this.retransmitTimer = null }
418
+ if (this.dpdTimer) { clearInterval(this.dpdTimer); this.dpdTimer = null }
419
+ if (this.termWait) { clearTimeout(this.termWait); this.termWait = null }
420
+ }
421
+ }
422
+
423
+ // -- small helpers ---------------------------------------------------------
424
+
425
+ const u32 = (a: Uint8Array, i: number) => ((a[i]! << 24) | (a[i + 1]! << 16) | (a[i + 2]! << 8) | a[i + 3]!) >>> 0
426
+
427
+ function u16be(n: number): Uint8Array {
428
+ return new Uint8Array([(n >> 8) & 0xff, n & 0xff])
429
+ }
430
+
431
+ function u32be(n: number): Uint8Array {
432
+ return new Uint8Array([(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff])
433
+ }
434
+
435
+ function concat(parts: Uint8Array[]): Uint8Array {
436
+ const total = parts.reduce((n, p) => n + p.length, 0)
437
+ const out = new Uint8Array(total)
438
+ let off = 0
439
+ for (const p of parts) { out.set(p, off); off += p.length }
440
+ return out
441
+ }
442
+
443
+ function ipv4Str(b: Uint8Array): string {
444
+ return `${b[0]}.${b[1]}.${b[2]}.${b[3]}`
445
+ }
446
+
447
+ function parseIpv4(s: string): Uint8Array {
448
+ const parts = s.split(".").map(Number)
449
+ return new Uint8Array(parts.map((n) => n & 0xff))
450
+ }