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,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
|
+
}
|