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