diff --git a/host/src/qemu/network-stack.ts b/host/src/qemu/network-stack.ts index 5fc4161..8d00981 100644 --- a/host/src/qemu/network-stack.ts +++ b/host/src/qemu/network-stack.ts @@ -194,6 +194,10 @@ type TcpSession = { pendingOutbound: Buffer; /** FIN pending until outbound payload is drained */ endPending: boolean; + /** whether the guest's FIN has been accepted */ + guestFinReceived: boolean; + /** whether our FIN has been sent (it is the last sequence number) */ + finSent: boolean; flowProtocol: TcpFlowProtocol | null; /** whether this flow can bypass protocol sniffing and run as raw tcp */ allowRawTcp: boolean; @@ -790,6 +794,8 @@ export class NetworkStack extends EventEmitter { peerWindow: window, pendingOutbound: Buffer.alloc(0), endPending: false, + guestFinReceived: false, + finSent: false, flowProtocol: null, allowRawTcp: false, pendingData: Buffer.alloc(0), @@ -832,6 +838,16 @@ export class NetworkStack extends EventEmitter { if (seqGt(ack, session.vmAck) && seqLe(ack, session.mySeq)) { session.vmAck = ack; shouldDrainOutbound = true; + // LAST-ACK: tracked apart from `state`, which handleTcpClosed overwrites + if ( + session.guestFinReceived && + session.finSent && + session.vmAck === session.mySeq + ) { + this.clearPauseState(key); + this.natTable.delete(key); + return; + } } if (session.pendingOutbound.length > 0 && window > prevPeerWindow) { shouldDrainOutbound = true; @@ -995,6 +1011,7 @@ export class NetworkStack extends EventEmitter { // finSeq === session.myAck this.callbacks.onTcpClose({ key, destroy: false }); session.myAck = wrapSeq(session.myAck + 1); + session.guestFinReceived = true; this.sendTCP( session.srcIP, @@ -1005,12 +1022,15 @@ export class NetworkStack extends EventEmitter { session.myAck, 0x10, ); - if ( - session.state === "CLOSED_BY_REMOTE" || - session.state === "FIN_WAIT" - ) { + if (session.state === "CLOSED_BY_REMOTE") { this.clearPauseState(key); this.natTable.delete(key); + } else if (session.state === "FIN_WAIT") { + // CLOSING: while our FIN is unacked, the ACK handler drops the session + if (session.finSent && session.vmAck === session.mySeq) { + this.clearPauseState(key); + this.natTable.delete(key); + } } else { session.state = "FIN_SENT"; } @@ -1468,6 +1488,7 @@ export class NetworkStack extends EventEmitter { session.mySeq = wrapSeq(session.mySeq + 1); session.state = "FIN_WAIT"; + session.finSent = true; session.endPending = false; inFlight += 1; } diff --git a/host/test/network-stack.test.ts b/host/test/network-stack.test.ts index ea0a464..f228f06 100644 --- a/host/test/network-stack.test.ts +++ b/host/test/network-stack.test.ts @@ -1917,3 +1917,262 @@ test("network-stack: outbound TCP keeps flowing when mySeq wraps past 2^32", () stack.handleTcpData({ key, data: Buffer.alloc(4 * 1024, 0x43) }); assert.equal(countTcpPayloadBytes(drainAllQemuTx(stack)), 4 * 1024); }); + +for (const remoteClosed of [false, true]) { + test(`network-stack: session is removed once the guest ACKs our FIN after closing first${remoteClosed ? " (host socket closed)" : ""}`, () => { + // A leaked session makes a later SYN on the same 4-tuple be ignored. + const gatewayMac = mac([0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xdd]); + const vmMac = mac([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); + + let key = ""; + const stack = new NetworkStack({ + gatewayMac, + vmMac, + dnsServers: ["8.8.8.8"], + callbacks: { + onUdpSend: () => {}, + onTcpConnect: (m) => { + key = m.key; + return { allowRawTcp: true }; + }, + onTcpSend: () => {}, + onTcpClose: () => {}, + onTcpPause: () => {}, + onTcpResume: () => {}, + }, + }); + + const srcIP = ip([192, 168, 127, 3]); + const dstIP = ip([198, 19, 0, 10]); + const srcPort = 40030; + const dstPort = 7000; + + stack.handleTCP( + buildTcpSegment({ srcPort, dstPort, seq: 100, ack: 0, flags: 0x02 }), + srcIP, + dstIP, + ); + stack.handleTcpConnected({ key }); + const hostSeq = (stack as any).natTable.get(key).mySeq as number; + + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 101, + ack: hostSeq, + flags: 0x11, + }), + srcIP, + dstIP, + ); + // Upstream still replies after the guest's half-close + stack.handleTcpData({ key, data: Buffer.from("bye") }); + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 102, + ack: hostSeq + 3, + flags: 0x10, + }), + srcIP, + dstIP, + ); + assert.ok((stack as any).natTable.has(key)); + + stack.handleTcpData({ key, data: Buffer.from("!") }); + stack.handleTcpEnd({ key }); + if (remoteClosed) { + stack.handleTcpClosed({ key }); + } + const fin = decodeFramesFromQemuData(drainAllQemuTx(stack)) + .map(parseEthernet) + .filter((eth) => eth.etherType === 0x0800) + .map((eth) => parseIPv4(eth.payload)) + .filter((ipOut) => ipOut.protocol === 6) + .map((ipOut) => ipOut.payload) + .find((tcp) => (tcp[13] & 0x01) !== 0); + assert.ok(fin, "expected an outbound FIN segment"); + assert.equal(fin.readUInt32BE(4), hostSeq + 4); + + // Data is acknowledged, our FIN is not yet + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 102, + ack: hostSeq + 4, + flags: 0x10, + }), + srcIP, + dstIP, + ); + assert.ok((stack as any).natTable.has(key)); + + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 102, + ack: hostSeq + 5, + flags: 0x10, + }), + srcIP, + dstIP, + ); + assert.equal((stack as any).natTable.has(key), false); + }); +} + +test("network-stack: session closed by upstream first stays until the guest's FIN", () => { + const gatewayMac = mac([0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xdd]); + const vmMac = mac([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); + + let key = ""; + const stack = new NetworkStack({ + gatewayMac, + vmMac, + dnsServers: ["8.8.8.8"], + callbacks: { + onUdpSend: () => {}, + onTcpConnect: (m) => { + key = m.key; + return { allowRawTcp: true }; + }, + onTcpSend: () => {}, + onTcpClose: () => {}, + onTcpPause: () => {}, + onTcpResume: () => {}, + }, + }); + + const srcIP = ip([192, 168, 127, 3]); + const dstIP = ip([198, 19, 0, 10]); + const srcPort = 40031; + const dstPort = 7000; + + stack.handleTCP( + buildTcpSegment({ srcPort, dstPort, seq: 100, ack: 0, flags: 0x02 }), + srcIP, + dstIP, + ); + stack.handleTcpConnected({ key }); + const hostSeq = (stack as any).natTable.get(key).mySeq as number; + stack.handleTCP( + buildTcpSegment({ srcPort, dstPort, seq: 101, ack: hostSeq, flags: 0x10 }), + srcIP, + dstIP, + ); + + stack.handleTcpEnd({ key }); + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 101, + ack: hostSeq + 1, + flags: 0x10, + }), + srcIP, + dstIP, + ); + assert.ok((stack as any).natTable.has(key)); + + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 101, + ack: hostSeq + 1, + flags: 0x11, + }), + srcIP, + dstIP, + ); + assert.equal((stack as any).natTable.has(key), false); +}); + +for (const finInOnTcpClose of [false, true]) { + test(`network-stack: simultaneous close keeps the session until the guest ACKs our FIN${finInOnTcpClose ? " (FIN sent from onTcpClose)" : ""}`, () => { + const gatewayMac = mac([0x5a, 0x94, 0xef, 0xe4, 0x0c, 0xdd]); + const vmMac = mac([0x02, 0x00, 0x00, 0x00, 0x00, 0x01]); + + let key = ""; + let stack: NetworkStack; + stack = new NetworkStack({ + gatewayMac, + vmMac, + dnsServers: ["8.8.8.8"], + callbacks: { + onUdpSend: () => {}, + onTcpConnect: (m) => { + key = m.key; + return { allowRawTcp: true }; + }, + onTcpSend: () => {}, + onTcpClose: (m) => { + if (finInOnTcpClose) { + stack.handleTcpEnd({ key: m.key }); + } + }, + onTcpPause: () => {}, + onTcpResume: () => {}, + }, + }); + + const srcIP = ip([192, 168, 127, 3]); + const dstIP = ip([198, 19, 0, 10]); + const srcPort = 40032; + const dstPort = 7000; + + stack.handleTCP( + buildTcpSegment({ srcPort, dstPort, seq: 100, ack: 0, flags: 0x02 }), + srcIP, + dstIP, + ); + stack.handleTcpConnected({ key }); + const hostSeq = (stack as any).natTable.get(key).mySeq as number; + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 101, + ack: hostSeq, + flags: 0x10, + }), + srcIP, + dstIP, + ); + + if (!finInOnTcpClose) { + stack.handleTcpEnd({ key }); + } + // The guest's FIN crosses ours and does not ACK it + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 101, + ack: hostSeq, + flags: 0x11, + }), + srcIP, + dstIP, + ); + assert.equal((stack as any).natTable.get(key)?.mySeq, hostSeq + 1); + + stack.handleTCP( + buildTcpSegment({ + srcPort, + dstPort, + seq: 102, + ack: hostSeq + 1, + flags: 0x10, + }), + srcIP, + dstIP, + ); + assert.equal((stack as any).natTable.has(key), false); + }); +}