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,102 @@
1
+ /**
2
+ * A localhost HTTP CONNECT proxy that carries TCP connections through the
3
+ * userspace tunnel. The daemon's upstream fetches use Bun's fetch `proxy`
4
+ * option (verified: absolute-form requests to the proxy) — TLS stays
5
+ * end-to-end, the proxy just moves bytes.
6
+ */
7
+
8
+ import { type Ipv4 } from "./ip"
9
+ import { TcpStack } from "./stack"
10
+
11
+ type ConnState = {
12
+ buf: Uint8Array[]
13
+ stream: ReturnType<TcpStack["dial"]> | null
14
+ }
15
+
16
+ export type ProxyHandle = {
17
+ port: number
18
+ stop(): void
19
+ }
20
+
21
+ export function startProxy(o: {
22
+ stack: TcpStack
23
+ remoteIp: Ipv4
24
+ allowedHostSuffix?: string // default: uni-freiburg.de — the tunnel carries nothing else
25
+ allowedPorts?: number[]
26
+ log?: (msg: string) => void
27
+ }): ProxyHandle {
28
+ const log = o.log ?? (() => {})
29
+ const allowedPorts = o.allowedPorts ?? [443]
30
+ const suffix = (o.allowedHostSuffix ?? "uni-freiburg.de").toLowerCase()
31
+ const conns = new Map<object, ConnState>()
32
+
33
+ const server = Bun.listen({
34
+ hostname: "127.0.0.1",
35
+ port: 0,
36
+ socket: {
37
+ open(socket) {
38
+ conns.set(socket, { buf: [], stream: null })
39
+ },
40
+ data(socket, chunk) {
41
+ const state = conns.get(socket)
42
+ if (!state) return
43
+ if (state.stream) {
44
+ // tunnel established: raw bytes flow through the userspace TCP stack
45
+ state.stream.write(chunk)
46
+ return
47
+ }
48
+ state.buf.push(chunk)
49
+ const head = Buffer.concat(state.buf.map((c) => Buffer.from(c)))
50
+ const end = head.indexOf("\r\n\r\n")
51
+ if (end === -1) return
52
+ const request = head.subarray(0, end).toString()
53
+ const m = /^CONNECT\s+(\S+):(\d+)\s+HTTP\/1\.[01]$/i.exec(request.split("\r\n")[0] ?? "")
54
+ if (!m) {
55
+ socket.write("HTTP/1.1 400 Bad Request\r\n\r\n")
56
+ socket.end()
57
+ return
58
+ }
59
+ const port = Number(m[2])
60
+ if (!allowedPorts.includes(port) || !m[1]!.toLowerCase().endsWith(suffix)) {
61
+ // the tunnel only carries traffic to the uni host
62
+ log(`proxy: refusing CONNECT ${m[1]}:${port}`)
63
+ socket.write("HTTP/1.1 403 Forbidden\r\n\r\n")
64
+ socket.end()
65
+ return
66
+ }
67
+ socket.write("HTTP/1.1 200 Connection established\r\n\r\n")
68
+ try {
69
+ const stream = o.stack.dial(o.remoteIp, port, {
70
+ data: (d) => socket.write(d),
71
+ close: (hadError) => {
72
+ log(`proxy: tunnel side closed${hadError ? " (error)" : ""}`)
73
+ socket.end()
74
+ },
75
+ })
76
+ state.stream = stream
77
+ log(`proxy: CONNECT ${m[1]}:${port} dialed through the tunnel`)
78
+ } catch (e) {
79
+ log(`proxy: dial failed: ${(e as Error).message}`)
80
+ socket.write("HTTP/1.1 502 Bad Gateway\r\n\r\n")
81
+ socket.end()
82
+ }
83
+ },
84
+ close(socket) {
85
+ const state = conns.get(socket)
86
+ conns.delete(socket)
87
+ state?.stream?.end()
88
+ },
89
+ error(socket, err) {
90
+ const state = conns.get(socket)
91
+ conns.delete(socket)
92
+ state?.stream?.destroy()
93
+ log(`proxy: socket error: ${err.message}`)
94
+ },
95
+ },
96
+ })
97
+
98
+ return {
99
+ port: server.port,
100
+ stop: () => server.stop(true),
101
+ }
102
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * The userspace TCP/IP stack: owns the inner IP the tunnel was assigned,
3
+ * demultiplexes inbound IPv4/TCP datagrams to connections and dials out.
4
+ *
5
+ * One stack per tunnel. Only outbound connections to a single remote host are
6
+ * needed (the gateway daemon's upstream calls), so the connection table is
7
+ * keyed by (remotePort, localPort).
8
+ */
9
+
10
+ import { type Ipv4, buildDatagram, parseDatagram } from "./ip"
11
+ import { TcpConn, freshLocalPort, type TcpOptions } from "./tcp"
12
+
13
+ export type PacketSink = (pkt: Uint8Array) => void
14
+
15
+ export type StreamHandlers = {
16
+ open?(): void
17
+ data(chunk: Uint8Array): void
18
+ drain?(): void
19
+ close(hadError: boolean): void
20
+ }
21
+
22
+ export type VpnStream = {
23
+ /** Queue bytes; returns the number of bytes still buffered (0 = drained). */
24
+ write(data: Uint8Array): number
25
+ /** Graceful close after buffered data drains. */
26
+ end(): void
27
+ /** Abort. */
28
+ destroy(): void
29
+ }
30
+
31
+ export class TcpStack {
32
+ private local: Ipv4
33
+ private conns = new Map<string, TcpConn>()
34
+
35
+ constructor(
36
+ o: { local: Ipv4; sink: PacketSink; opts?: TcpOptions },
37
+ private readonly log: (msg: string) => void = () => {},
38
+ ) {
39
+ this.local = o.local
40
+ this.opts = o.opts ?? {}
41
+ // Deferring the sink to a microtask makes the wire asynchronous: a send
42
+ // during dial()/flush() can never re-enter the same connection mid-call
43
+ // (the tunnel round trip would otherwise run inside sendSegment).
44
+ this.sink = (pkt) => queueMicrotask(() => o.sink(pkt))
45
+ }
46
+
47
+ private opts: TcpOptions
48
+ private sink: PacketSink
49
+
50
+ /** Called once IPCP has assigned the tunnel's inner address. */
51
+ setLocal(ip: Ipv4): void {
52
+ this.local = ip
53
+ }
54
+
55
+ get innerIp(): Ipv4 {
56
+ return this.local
57
+ }
58
+
59
+ get openCount(): number {
60
+ return this.conns.size
61
+ }
62
+
63
+ /** Outbound connection. Returns a stream handle; handlers fire as the connection progresses. */
64
+ dial(remote: Ipv4, remotePort: number, h: StreamHandlers): VpnStream {
65
+ if (this.local === 0) throw new Error("tcp stack has no inner address yet (tunnel not established)")
66
+ let localPort = freshLocalPort()
67
+ for (let i = 0; this.conns.has(`${remotePort}:${localPort}`) && i < 100; i++) localPort = freshLocalPort()
68
+ const key = `${remotePort}:${localPort}`
69
+ let conn: TcpConn
70
+ let closed = false
71
+ conn = new TcpConn(
72
+ { local: this.local, remote, localPort, remotePort },
73
+ (pkt) => this.sink(pkt),
74
+ {
75
+ onOpen: () => h.open?.(),
76
+ onData: (_c, data) => h.data(data),
77
+ onDrain: () => h.drain?.(),
78
+ onClose: (c, err) => {
79
+ closed = true
80
+ this.conns.delete(key)
81
+ h.close(err)
82
+ this.log(`tcp ${key} closed${err ? " (error)" : ""}`)
83
+ },
84
+ },
85
+ this.opts,
86
+ )
87
+ this.conns.set(key, conn)
88
+ this.log(`tcp dial ${key} from ${this.local}`)
89
+ conn.dial()
90
+ return {
91
+ write: (data) => {
92
+ if (closed) return 0
93
+ try {
94
+ return conn.write(data)
95
+ } catch {
96
+ return 0
97
+ }
98
+ },
99
+ end: () => {
100
+ try { conn.fin() } catch { /* already closing */ }
101
+ },
102
+ destroy: () => conn.reset(),
103
+ }
104
+ }
105
+
106
+ /** Feed one datagram received from the tunnel. */
107
+ receive(pkt: Uint8Array): void {
108
+ const parsed = parseDatagram(pkt)
109
+ if (!parsed) return
110
+ if (parsed.dst !== this.local) return
111
+ const conn = this.conns.get(`${parsed.segment.header.srcPort}:${parsed.segment.header.dstPort}`)
112
+ if (!conn) {
113
+ // no listener: answer RST unless the segment is itself a RST or a bare ACK
114
+ const f = parsed.segment.header.flags
115
+ if (!(f & 0x04) && !(f & 0x10 && parsed.segment.payload.length === 0)) {
116
+ const seg = {
117
+ header: {
118
+ srcPort: parsed.segment.header.dstPort,
119
+ dstPort: parsed.segment.header.srcPort,
120
+ seq: 0,
121
+ ack: parsed.segment.header.seq,
122
+ dataOffset: 20,
123
+ flags: 0x04, // RST
124
+ window: 0,
125
+ },
126
+ payload: new Uint8Array(0),
127
+ }
128
+ this.sink(buildDatagram({ src: this.local, dst: parsed.src, segment: seg }))
129
+ }
130
+ return
131
+ }
132
+ conn.receive(parsed.segment)
133
+ }
134
+
135
+ /** Tear everything down (tunnel is going away). */
136
+ closeAll(): void {
137
+ for (const c of this.conns.values()) c.reset()
138
+ this.conns.clear()
139
+ }
140
+ }
@@ -0,0 +1,356 @@
1
+ /**
2
+ * A single userspace TCP connection: the client side of the three-way
3
+ * handshake, data transfer, teardown — plus retransmission.
4
+ *
5
+ * Simplifications (fine for HTTPS through a tunnel to one known server):
6
+ * - we only ever dial out, never accept
7
+ * - inbound segments are processed in order; out-of-order data triggers a
8
+ * duplicate ACK so the peer retransmits (no reorder buffer)
9
+ * - one retransmission timer per connection, exponential backoff, RST after
10
+ * too many failed retransmits
11
+ * - the receive window is generous and inbound data is handed to the
12
+ * application immediately (TLS sockets drain fast; no app backpressure)
13
+ */
14
+
15
+ import {
16
+ ACK, FIN, PSH, RST, SYN,
17
+ type Ipv4,
18
+ type TcpSegment,
19
+ buildDatagram,
20
+ } from "./ip"
21
+
22
+ export type TcpOptions = {
23
+ mss?: number // max segment size we announce (fits the tunnel MTU)
24
+ rtoMs?: number // initial retransmission timeout
25
+ maxRetransmits?: number // give up (RST) after this many consecutive retransmits
26
+ }
27
+
28
+ export type ConnectionState =
29
+ | "closed"
30
+ | "syn-sent"
31
+ | "established"
32
+ | "fin-wait-1"
33
+ | "fin-wait-2"
34
+ | "time-wait"
35
+ | "close-wait"
36
+ | "last-ack"
37
+
38
+ export type DialInfo = { local: Ipv4; remote: Ipv4; localPort: number; remotePort: number }
39
+
40
+ /** What the stack hands to the application on this connection. */
41
+ export type TcpEvents = {
42
+ onOpen?: (c: TcpConn) => void
43
+ onData?: (c: TcpConn, data: Uint8Array) => void
44
+ onDrain?: (c: TcpConn) => void
45
+ onClose?: (c: TcpConn, hadError: boolean) => void
46
+ }
47
+
48
+ const DEFAULT_MSS = 1360 // fits a 1400-byte tunnel MTU with IP+TCP headers
49
+ const DEFAULT_RTO_MS = 500
50
+ const DEFAULT_MAX_RETRANSMITS = 6
51
+ const RECV_WINDOW = 65535
52
+
53
+ export function freshLocalPort(): number {
54
+ return 32768 + Math.floor(Math.random() * 20000)
55
+ }
56
+
57
+ type QueueEntry = { seq: number; len: number; bytes: Uint8Array } // len counts SYN/FIN as 1
58
+
59
+ export class TcpConn {
60
+ state: ConnectionState = "closed"
61
+ readonly localPort: number
62
+
63
+ private seq: number // next byte we will send (SND.NXT)
64
+ private acked: number // last byte the peer acknowledged (SND.UNA)
65
+ private rcvNext = 0 // next byte we expect from the peer (RCV.NXT)
66
+ private peerWindow = 0
67
+ private peerMss = 536
68
+ private outQueue: QueueEntry[] = []
69
+ private earlyQueue: Uint8Array[] = [] // application data written while syn-sent
70
+ private finQueued = false
71
+ private finSent = false
72
+ private closeFired = false
73
+ private peerFinSeen = false
74
+ private retransmits = 0
75
+ private rto: number
76
+ private timer: ReturnType<typeof setTimeout> | null = null
77
+ private errored = false
78
+ private dead = false
79
+
80
+ constructor(
81
+ readonly info: DialInfo,
82
+ private readonly sendPkt: (pkt: Uint8Array) => void,
83
+ private readonly events: TcpEvents,
84
+ private readonly opts: TcpOptions,
85
+ ) {
86
+ this.localPort = info.localPort
87
+ const isn = (Math.floor(Math.random() * 0x7fffffff) | 1) >>> 0
88
+ this.seq = isn
89
+ this.acked = isn
90
+ this.rto = opts.rtoMs ?? DEFAULT_RTO_MS
91
+ }
92
+
93
+ // -- outbound ------------------------------------------------------------
94
+
95
+ /** Start the connection (active open). */
96
+ dial(): void {
97
+ if (this.state !== "closed") throw new Error(`dial: connection is ${this.state}`)
98
+ this.state = "syn-sent"
99
+ const opts = new Uint8Array(4) // MSS option (kind 2, len 4)
100
+ opts[0] = 2; opts[1] = 4
101
+ opts[2] = (this.opts.mss ?? DEFAULT_MSS) >> 8
102
+ opts[3] = (this.opts.mss ?? DEFAULT_MSS) & 0xff
103
+ this.sendSegment({ flags: SYN }, new Uint8Array(0), 1, opts)
104
+ this.seq = (this.seq + 1) >>> 0
105
+ this.armRto()
106
+ }
107
+
108
+ /** Queue application data (sliced to fit the tunnel MTU and the peer's MSS). Returns buffered bytes now queued.
109
+ * Data written before the handshake completes is buffered and sent once established. */
110
+ write(data: Uint8Array): number {
111
+ if (this.state === "syn-sent") {
112
+ this.earlyQueue.push(data)
113
+ return this.earlyQueue.reduce((n, d) => n + d.length, 0)
114
+ }
115
+ if (this.state !== "established" && this.state !== "close-wait") throw new Error(`write: connection is ${this.state}`)
116
+ if (this.finQueued) throw new Error("write after fin")
117
+ for (const early of this.earlyQueue.splice(0)) this.writeEstablished(early)
118
+ return this.writeEstablished(data)
119
+ }
120
+
121
+ private writeEstablished(data: Uint8Array): number {
122
+ const segSize = Math.min(this.peerMss, this.opts.mss ?? DEFAULT_MSS)
123
+ for (let i = 0; i < data.length; i += segSize) {
124
+ this.outQueue.push({ seq: this.seq, len: 0, bytes: data.slice(i, i + segSize) })
125
+ this.seq = (this.seq + Math.min(segSize, data.length - i)) >>> 0
126
+ }
127
+ this.flush()
128
+ return this.buffered
129
+ }
130
+
131
+ /** Graceful close: FIN after queued data drains. */
132
+ fin(): void {
133
+ if (this.dead || this.finQueued) return
134
+ this.finQueued = true
135
+ this.flush()
136
+ }
137
+
138
+ /** Abort: RST and drop everything. */
139
+ reset(): void {
140
+ if (this.dead) return
141
+ if (this.state !== "syn-sent" || this.rcvNext !== 0) {
142
+ this.sendSegment({ flags: RST, ack: this.rcvNext }, new Uint8Array(0), 0)
143
+ }
144
+ this.finish()
145
+ }
146
+
147
+ get buffered(): number {
148
+ return this.outQueue.reduce((n, e) => n + e.bytes.length, 0)
149
+ }
150
+
151
+ // -- inbound -------------------------------------------------------------
152
+
153
+ /** Feed one inbound segment (already parsed, addressed to this connection). */
154
+ receive(seg: TcpSegment): void {
155
+ if (this.dead) return
156
+ const f = seg.header.flags
157
+
158
+ if (f & RST) {
159
+ this.errored = true
160
+ this.finish()
161
+ return
162
+ }
163
+
164
+ if (this.state === "syn-sent") {
165
+ if (!(f & SYN)) return
166
+ this.rcvNext = (seg.header.seq + 1) >>> 0
167
+ this.peerWindow = seg.header.window || 1
168
+ this.readMss(seg)
169
+ if (f & ACK) {
170
+ this.processAck(seg.header.ack, seg.header.window) // acks our SYN: drops it from the queue
171
+ this.state = "established"
172
+ this.events.onOpen?.(this)
173
+ this.sendSegment({ flags: ACK }, new Uint8Array(0), 0)
174
+ // application data may have been written while we were syn-sent
175
+ if (this.earlyQueue.length > 0) {
176
+ for (const early of this.earlyQueue.splice(0)) this.writeEstablished(early)
177
+ } else {
178
+ this.flush()
179
+ }
180
+ } else {
181
+ // simultaneous open — reply SYN-ACK
182
+ const opts = new Uint8Array(4)
183
+ opts[0] = 2; opts[1] = 4
184
+ opts[2] = (this.opts.mss ?? DEFAULT_MSS) >> 8
185
+ opts[3] = (this.opts.mss ?? DEFAULT_MSS) & 0xff
186
+ this.sendSegment({ flags: SYN | ACK }, new Uint8Array(0), 1, opts)
187
+ }
188
+ return
189
+ }
190
+
191
+ if (f & SYN) return // stray retransmitted SYN after handshake
192
+ if (f & ACK) this.processAck(seg.header.ack, seg.header.window)
193
+
194
+ // drop data we have already seen (retransmissions while our ACK was lost)
195
+ const segEnd = (seg.header.seq + seg.payload.length + (f & FIN ? 1 : 0)) >>> 0
196
+ if (segEnd <= this.rcvNext) return
197
+ if (seg.header.seq !== this.rcvNext) {
198
+ // gap: duplicate-ACK so the peer retransmits what we are missing
199
+ this.sendSegment({ flags: ACK }, new Uint8Array(0), 0)
200
+ return
201
+ }
202
+
203
+ if (seg.payload.length > 0) {
204
+ this.rcvNext = (this.rcvNext + seg.payload.length) >>> 0
205
+ this.events.onData?.(this, seg.payload)
206
+ this.sendSegment({ flags: ACK }, new Uint8Array(0), 0) // ack the data
207
+ }
208
+
209
+ if (f & FIN) {
210
+ this.rcvNext = (this.rcvNext + 1) >>> 0
211
+ this.peerFinSeen = true
212
+ this.sendSegment({ flags: ACK }, new Uint8Array(0), 0)
213
+ if (this.state === "established" || this.state === "fin-wait-2") {
214
+ const wasEstablished = this.state === "established"
215
+ this.state = "close-wait"
216
+ if (!wasEstablished) this.finish() // our FIN was already acked: done
217
+ else this.fireClose(false) // peer closed first; app may still write, then fin()
218
+ }
219
+ return
220
+ }
221
+
222
+ if (this.finQueued && !this.outQueue.some((e) => e.len > 0) && this.unacked === 0) this.maybeSendFin()
223
+ this.flush()
224
+ }
225
+
226
+ // -- internals -----------------------------------------------------------
227
+
228
+ private sendSegment(partial: Partial<TcpSegment["header"]>, payload: Uint8Array, queueLen: number, options?: Uint8Array): void {
229
+ const header = {
230
+ srcPort: this.info.localPort,
231
+ dstPort: this.info.remotePort,
232
+ seq: this.seq,
233
+ ack: this.rcvNext,
234
+ dataOffset: options ? 20 + options.length : 20,
235
+ flags: 0,
236
+ window: RECV_WINDOW,
237
+ ...partial,
238
+ }
239
+ const seg: TcpSegment = { header, payload, options }
240
+ const bytes = buildDatagram({ src: this.info.local, dst: this.info.remote, segment: seg })
241
+ this.sendPkt(bytes)
242
+ if (queueLen > 0) this.outQueue.push({ seq: this.seq, len: queueLen, bytes })
243
+ }
244
+
245
+ private flush(): void {
246
+ let inFlight = this.unacked
247
+ let sent = false
248
+ while (this.outQueue.length > 0) {
249
+ const e = this.outQueue[0]!
250
+ if (e.len > 0) break // SYN/FIN marker: only retransmits move it
251
+ const window = Math.max(this.peerWindow, 1)
252
+ if (inFlight + e.bytes.length > window) break
253
+ const header = {
254
+ srcPort: this.info.localPort,
255
+ dstPort: this.info.remotePort,
256
+ seq: e.seq,
257
+ ack: this.rcvNext,
258
+ dataOffset: 20,
259
+ flags: ACK | PSH,
260
+ window: RECV_WINDOW,
261
+ }
262
+ this.sendPkt(buildDatagram({ src: this.info.local, dst: this.info.remote, segment: { header, payload: e.bytes } }))
263
+ inFlight += e.bytes.length
264
+ sent = true
265
+ this.outQueue.shift()
266
+ }
267
+ if (sent) this.armRto()
268
+ if (this.finQueued && this.buffered === 0) this.maybeSendFin()
269
+ if (this.buffered === 0) this.events.onDrain?.(this)
270
+ }
271
+
272
+ private maybeSendFin(): void {
273
+ if (this.finSent || this.outQueue.some((e) => e.len > 0)) return // FIN already in flight
274
+ this.finSent = true
275
+ this.sendSegment({ flags: FIN | ACK }, new Uint8Array(0), 1)
276
+ this.seq = (this.seq + 1) >>> 0
277
+ if (this.state === "established") this.state = "fin-wait-1"
278
+ else if (this.state === "close-wait") this.state = "last-ack"
279
+ this.armRto()
280
+ }
281
+
282
+ private readMss(seg: TcpSegment): void {
283
+ const opts = seg.options
284
+ if (!opts) return
285
+ let i = 0
286
+ while (i < opts.length) {
287
+ const kind = opts[i]!
288
+ if (kind === 0) break // end of options
289
+ const len = opts[i + 1] ?? 0
290
+ if (len < 2 || i + len > opts.length) break
291
+ if (kind === 2 && len === 4) this.peerMss = Math.max((opts[i + 2]! << 8) | opts[i + 3]!, 216)
292
+ i += len
293
+ }
294
+ }
295
+
296
+ private processAck(ack: number, window: number): void {
297
+ if (window > 0) this.peerWindow = window
298
+ const unacked = this.unacked
299
+ const newly = (ack - this.acked) >>> 0
300
+ if (newly === 0 || newly > unacked) return // stale or covering more than we sent
301
+ this.acked = ack
302
+ this.retransmits = 0
303
+ this.rto = this.opts.rtoMs ?? DEFAULT_RTO_MS
304
+ // drop fully acknowledged queue entries
305
+ while (this.outQueue.length > 0) {
306
+ const e = this.outQueue[0]!
307
+ if ((e.seq + e.len) >>> 0 > ack || (e.len === 0 && e.seq + e.bytes.length > ack)) break
308
+ this.outQueue.shift()
309
+ }
310
+ if (this.outQueue.length === 0 && this.unacked === 0) {
311
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
312
+ }
313
+ if (this.state === "fin-wait-1" && this.unacked === 0) this.state = "fin-wait-2"
314
+ if (this.state === "last-ack" && this.unacked === 0) { this.finish(); return }
315
+ this.flush()
316
+ }
317
+
318
+ get unacked(): number {
319
+ return (this.seq - this.acked) >>> 0
320
+ }
321
+
322
+ private armRto(): void {
323
+ if (this.timer) clearTimeout(this.timer)
324
+ this.timer = setTimeout(() => this.onRto(), this.rto)
325
+ this.timer.unref?.()
326
+ }
327
+
328
+ private onRto(): void {
329
+ this.timer = null
330
+ if (this.dead || this.unacked === 0) return
331
+ this.retransmits++
332
+ if (this.retransmits >= (this.opts.maxRetransmits ?? DEFAULT_MAX_RETRANSMITS)) {
333
+ this.errored = true
334
+ this.finish()
335
+ return
336
+ }
337
+ this.rto = Math.min(this.rto * 2, 5_000)
338
+ const first = this.outQueue[0]!
339
+ this.sendPkt(first.bytes) // go-back-N: resend oldest unacked segment
340
+ this.armRto()
341
+ }
342
+
343
+ private fireClose(hadError: boolean): void {
344
+ if (this.closeFired) return
345
+ this.closeFired = true
346
+ this.events.onClose?.(this, hadError)
347
+ }
348
+
349
+ private finish(): void {
350
+ if (this.dead) return
351
+ this.dead = true
352
+ if (this.timer) { clearTimeout(this.timer); this.timer = null }
353
+ this.state = "closed"
354
+ this.fireClose(this.errored)
355
+ }
356
+ }