diff --git a/README.md b/README.md index a6e54bf..f72d881 100644 --- a/README.md +++ b/README.md @@ -683,6 +683,62 @@ Outbound datagrams follow the same format as inbound datagrams: an object with ` Unlike Tun and Tap interfaces which are also connectionless, UDP sockets do not require you to lock the stream before receiving data - simply calling `stack.openUdp()` will begin listening for datagrams. +## ICMP API + +The ICMP API allows you to ping hosts over the virtual network stack. + +### `createPingSession()` + +To ping a host, first create a ping session using `createPingSession()`: + +```ts +const pingSession = await stack.createPingSession({ + host: '192.168.1.2', +}); + +const reply = await pingSession.ping({ + payload: new TextEncoder().encode('Hello, world!'), +}); + +console.log(reply.roundTripTime); +console.log(new TextDecoder().decode(reply.payload)); + +await pingSession.close(); +``` + +`createPingSession()` accepts a `host` and returns a `Promise`. The `host` can be an IP address or hostname. If it's a hostname, the stack will attempt to resolve the IP using the [embedded DNS resolver](#embedded-resolver). + +Each ping session has a stable ICMP identifier and an automatically incrementing sequence number. Each call to `pingSession.ping()` sends an ICMP echo request and returns a `Promise` that resolves when the matching echo reply is received. The sequence number is incremented with each call to `ping()` while the identifier remains constant over a session. + +```ts +type PingSessionOptions = { + host: string; + timeout?: number; +}; + +type PingProbeOptions = { + timeout?: number; + payload?: Uint8Array; +}; + +interface PingSession { + readonly host: string; + readonly identifier: number; + ping(options?: PingProbeOptions): Promise; + close(): Promise; +} + +type PingReply = { + host: string; + identifier: number; + sequenceNumber: number; + payload: Uint8Array; + roundTripTime: number; +}; +``` + +If no `timeout` is provided, `pingSession.ping()` waits up to 1000ms for a reply. If no `payload` is provided, the stack sends a 56-byte incrementing pattern. To close the session, call `close()`. + ## DNS DNS is supported in two ways: @@ -696,7 +752,7 @@ If you wish to resolve external hostnames, you will need a way to route packets ### Embedded resolver -Each `NetworkStack` has an embedded DNS resolver that can lookup an IP address by hostname when using the TCP and UDP APIs. For example: +Each `NetworkStack` has an embedded DNS resolver that can lookup an IP address by hostname when using the TCP, UDP, and ICMP APIs. For example: ```ts const connection = await stack.connectTcp({ @@ -836,7 +892,6 @@ _Background:_ Vite optimizes dependencies during development to improve build ti ## Future plans -- [ ] ICMP (ping) API - [ ] mDNS API - [ ] Hosts file - [ ] Experimental Wireguard interface diff --git a/packages/tcpip/src/bindings/icmp.ts b/packages/tcpip/src/bindings/icmp.ts new file mode 100644 index 0000000..f8b6c19 --- /dev/null +++ b/packages/tcpip/src/bindings/icmp.ts @@ -0,0 +1,310 @@ +import type { DnsClient } from '@tcpip/dns'; +import { + ICMP_ECHO_HEADER_LENGTH, + IPV4_HEADER_LENGTH, + parseIPv4Address, + serializeIPv4Address, +} from '@tcpip/wire'; +import { LwipError } from '../lwip/errors.js'; +import type { + PingProbeOptions, + PingReply, + PingSession, + PingSessionOptions, +} from '../types.js'; +import { Hooks, nextMicrotask } from '../util.js'; +import { Bindings } from './base.js'; +import type { Pointer } from './types.js'; + +type IcmpSocketHandle = Pointer; + +type PingSessionOuterHooks = { + send(sequenceNumber: number, options?: PingProbeOptions): Promise; + close(): void; +}; + +// biome-ignore lint/complexity/noBannedTypes: intentionally empty hook type +type PingSessionInnerHooks = {}; + +const pingSessionHooks = new Hooks< + PingSession, + PingSessionOuterHooks, + PingSessionInnerHooks +>(); + +type PendingPing = { + host: string; + identifier: number; + sequenceNumber: number; + payload: Uint8Array; + startedAt: number; + timeoutId: ReturnType; + resolve(reply: PingReply): void; + reject(error: Error): void; +}; + +export type IcmpImports = { + receive_icmp_echo_reply( + handle: IcmpSocketHandle, + hostPtr: number, + identifier: number, + sequenceNumber: number, + payloadPtr: number, + length: number + ): number; +}; + +export type IcmpExports = { + open_icmp_socket(): IcmpSocketHandle; + close_icmp_socket(handle: IcmpSocketHandle): void; + send_icmp_echo_request( + handle: IcmpSocketHandle, + host: Pointer, + identifier: number, + sequenceNumber: number, + payload: Pointer, + length: number + ): number; +}; + +const DEFAULT_TIMEOUT = 1000; +const DEFAULT_PAYLOAD = Uint8Array.from({ length: 56 }, (_, index) => index); +const MAX_IPV4_PACKET_LENGTH = 65535; +const MAX_ICMP_ECHO_PAYLOAD_LENGTH = + MAX_IPV4_PACKET_LENGTH - IPV4_HEADER_LENGTH - ICMP_ECHO_HEADER_LENGTH; + +export class IcmpBindings extends Bindings { + #dnsClient: DnsClient; + #handle?: IcmpSocketHandle; + #pendingPings = new Map(); + + constructor(dnsClient: DnsClient) { + super(); + this.#dnsClient = dnsClient; + } + + imports = { + receive_icmp_echo_reply: ( + _handle: IcmpSocketHandle, + hostPtr: number, + identifier: number, + sequenceNumber: number, + payloadPtr: number, + length: number + ) => { + const host = parseIPv4Address(this.copyFromMemory(hostPtr, 4)); + const payload = this.copyFromMemory(payloadPtr, length); + const key = this.#getPendingKey(host, identifier, sequenceNumber); + const pendingPing = this.#pendingPings.get(key); + + if (!pendingPing || !this.#payloadEquals(payload, pendingPing.payload)) { + return 0; + } + + this.#pendingPings.delete(key); + clearTimeout(pendingPing.timeoutId); + + const reply = { + host, + identifier, + sequenceNumber, + payload, + roundTripTime: Date.now() - pendingPing.startedAt, + }; + + nextMicrotask().then(() => pendingPing.resolve(reply)); + return 1; + }, + }; + + async createPingSession(options: PingSessionOptions) { + const host = await this.#resolveHost(options.host); + const identifier = this.#createIdentifier(); + const defaultTimeout = options.timeout ?? DEFAULT_TIMEOUT; + + this.#getHandle(); + + const pingSession = new VirtualPingSession({ + host, + identifier, + timeout: defaultTimeout, + }); + + pingSessionHooks.setOuter(pingSession, { + send: async (sequenceNumber, options = {}) => { + const payload = options.payload ?? DEFAULT_PAYLOAD; + const timeout = options.timeout ?? defaultTimeout; + + this.#validatePayload(payload); + + const key = this.#getPendingKey(host, identifier, sequenceNumber); + if (this.#pendingPings.has(key)) { + throw new Error( + 'icmp ping identifier and sequence number are in use' + ); + } + + return await new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + this.#pendingPings.delete(key); + reject(new Error(`icmp ping timed out: ${host}`)); + }, timeout); + + this.#pendingPings.set(key, { + host, + identifier, + sequenceNumber, + payload, + startedAt: Date.now(), + timeoutId, + resolve, + reject, + }); + + try { + using hostPtr = this.copyToMemory(serializeIPv4Address(host)); + using payloadPtr = this.copyToMemory(payload); + + const result = this.exports.send_icmp_echo_request( + this.#getHandle(), + hostPtr, + identifier, + sequenceNumber, + payloadPtr, + payload.length + ); + + if (result !== LwipError.ERR_OK) { + throw new Error(`failed to send icmp echo request: ${result}`); + } + } catch (error) { + clearTimeout(timeoutId); + this.#pendingPings.delete(key); + reject(error instanceof Error ? error : new Error(String(error))); + } + }); + }, + close: () => { + for (const [key, pendingPing] of this.#pendingPings) { + if ( + pendingPing.host === host && + pendingPing.identifier === identifier + ) { + clearTimeout(pendingPing.timeoutId); + pendingPing.reject(new Error('icmp ping session closed')); + this.#pendingPings.delete(key); + } + } + }, + }); + + pingSessionHooks.setInner(pingSession, {}); + + return pingSession; + } + + #getHandle() { + if (!this.#handle) { + const handle = this.exports.open_icmp_socket(); + + if (Number(handle) === 0) { + throw new Error('failed to open icmp socket'); + } + + this.#handle = handle; + } + + return this.#handle; + } + + async #resolveHost(host: string) { + try { + serializeIPv4Address(host); + return host; + } catch (e) { + return await this.#dnsClient.lookup(host); + } + } + + #createIdentifier() { + const buffer = new Uint16Array(1); + crypto.getRandomValues(buffer); + return buffer[0]!; + } + + #getPendingKey(host: string, identifier: number, sequenceNumber: number) { + return `${host}:${identifier}:${sequenceNumber}`; + } + + #validateUint16(value: number, name: string) { + if (!Number.isInteger(value) || value < 0 || value > 0xffff) { + throw new Error(`${name} must be an integer between 0 and 65535`); + } + } + + #validatePayload(payload: Uint8Array) { + if (payload.length > MAX_ICMP_ECHO_PAYLOAD_LENGTH) { + throw new Error('icmp echo payload exceeds maximum IPv4 packet size'); + } + } + + #payloadEquals(a: Uint8Array, b: Uint8Array) { + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) { + return false; + } + } + + return true; + } +} + +type VirtualPingSessionOptions = { + host: string; + identifier: number; + timeout: number; +}; + +export class VirtualPingSession implements PingSession { + #closed = false; + #sequenceNumber = 0; + + readonly host: string; + readonly identifier: number; + readonly timeout: number; + + constructor(options: VirtualPingSessionOptions) { + this.host = options.host; + this.identifier = options.identifier; + this.timeout = options.timeout; + } + + async ping(options?: PingProbeOptions) { + if (this.#closed) { + throw new Error('icmp ping session closed'); + } + + return await pingSessionHooks + .getOuter(this) + .send(this.#nextSequenceNumber(), options); + } + + async close() { + if (this.#closed) { + return; + } + + this.#closed = true; + pingSessionHooks.getOuter(this).close(); + } + + #nextSequenceNumber() { + const sequenceNumber = this.#sequenceNumber; + this.#sequenceNumber = (this.#sequenceNumber + 1) & 0xffff; + return sequenceNumber; + } +} diff --git a/packages/tcpip/src/bindings/types.ts b/packages/tcpip/src/bindings/types.ts index a7cb45a..5a491f8 100644 --- a/packages/tcpip/src/bindings/types.ts +++ b/packages/tcpip/src/bindings/types.ts @@ -1,5 +1,6 @@ import type { UniquePointer } from '../util.js'; import type { BridgeExports } from './bridge-interface.js'; +import type { IcmpExports } from './icmp.js'; import type { LoopbackExports } from './loopback-interface.js'; import type { TapExports } from './tap-interface.js'; import type { TcpExports } from './tcp.js'; @@ -38,7 +39,8 @@ export type WasmExports = WasiExports & TapExports & BridgeExports & TcpExports & - UdpExports; + UdpExports & + IcmpExports; export type WasmInstance = { exports: WasmExports; diff --git a/packages/tcpip/src/index.ts b/packages/tcpip/src/index.ts index 6880c25..446fc85 100644 --- a/packages/tcpip/src/index.ts +++ b/packages/tcpip/src/index.ts @@ -7,6 +7,10 @@ export type { LoopbackInterfaceOptions, NetworkInterface, NetworkStack, + PingProbeOptions, + PingReply, + PingSession, + PingSessionOptions, TapInterface, TapInterfaceOptions, TcpConnection, diff --git a/packages/tcpip/src/stack.test.ts b/packages/tcpip/src/stack.test.ts index 606f9de..fad0103 100644 --- a/packages/tcpip/src/stack.test.ts +++ b/packages/tcpip/src/stack.test.ts @@ -1622,6 +1622,140 @@ describe('dns', () => { expect(received.value).toStrictEqual(data); }); + + test('can resolve a hostname during ping session creation', async () => { + const stack = await createStack(); + const { serve } = await createDns(stack); + + await serve({ + request: async ({ name, type }) => { + if (name === 'example.com' && type === 'A') { + return { + type, + ip: '127.0.0.1', + ttl: 300, + }; + } + }, + }); + + const payload = new TextEncoder().encode('dns ping'); + const pingSession = await stack.createPingSession({ + host: 'example.com', + }); + + const reply = await pingSession.ping({ payload }); + + expect(reply.host).toBe('127.0.0.1'); + expect(reply.sequenceNumber).toBe(0); + expect(reply.payload).toStrictEqual(payload); + + await pingSession.close(); + }); +}); + +describe('icmp', () => { + test('ping session can ping loopback interface', async () => { + const stack = await createStack(); + const payload = new TextEncoder().encode('loopback ping'); + const pingSession = await stack.createPingSession({ + host: '127.0.0.1', + }); + + const reply = await pingSession.ping({ payload }); + + expect(reply.host).toBe('127.0.0.1'); + expect(reply.identifier).toBe(pingSession.identifier); + expect(reply.sequenceNumber).toBe(0); + expect(reply.payload).toStrictEqual(payload); + expect(reply.roundTripTime).toBeGreaterThanOrEqual(0); + + await pingSession.close(); + }); + + test('ping session uses default payload', async () => { + const stack = await createStack(); + const pingSession = await stack.createPingSession({ + host: '127.0.0.1', + }); + + const reply = await pingSession.ping(); + + expect(reply.payload).toStrictEqual( + Uint8Array.from({ length: 56 }, (_, index) => index) + ); + + await pingSession.close(); + }); + + test('ping session rejects after close', async () => { + const stack = await createStack(); + const pingSession = await stack.createPingSession({ + host: '127.0.0.1', + }); + + await pingSession.close(); + + await expect(pingSession.ping()).rejects.toThrowError( + 'icmp ping session closed' + ); + }); + + test('ping session can ping another stack', async () => { + const stack1 = await createStack(); + const stack2 = await createStack(); + + const tun1 = await stack1.createTunInterface({ + ip: '192.168.1.1/24', + }); + + const tun2 = await stack2.createTunInterface({ + ip: '192.168.1.2/24', + }); + + tun1.readable.pipeTo(tun2.writable); + tun2.readable.pipeTo(tun1.writable); + + const payload = new TextEncoder().encode('tcpip.js ping'); + const pingSession = await stack1.createPingSession({ + host: '192.168.1.2', + }); + + const firstReply = await pingSession.ping({ payload }); + const secondReply = await pingSession.ping({ payload }); + + expect(firstReply.host).toBe('192.168.1.2'); + expect(firstReply.identifier).toBe(pingSession.identifier); + expect(firstReply.sequenceNumber).toBe(0); + expect(firstReply.payload).toStrictEqual(payload); + expect(firstReply.roundTripTime).toBeGreaterThanOrEqual(0); + expect(secondReply.host).toBe('192.168.1.2'); + expect(secondReply.identifier).toBe(pingSession.identifier); + expect(secondReply.sequenceNumber).toBe(1); + expect(secondReply.payload).toStrictEqual(payload); + expect(secondReply.roundTripTime).toBeGreaterThanOrEqual(0); + + await pingSession.close(); + }); + + test('ping session rejects when the host does not reply', async () => { + const stack = await createStack(); + + await stack.createTunInterface({ + ip: '192.168.1.1/24', + }); + + const pingSession = await stack.createPingSession({ + host: '192.168.1.2', + timeout: 10, + }); + + await expect(pingSession.ping()).rejects.toThrowError( + 'icmp ping timed out: 192.168.1.2' + ); + + await pingSession.close(); + }); }); async function nextValue(iterable: Iterable | AsyncIterable) { diff --git a/packages/tcpip/src/stack.ts b/packages/tcpip/src/stack.ts index a6a3505..05ccac1 100644 --- a/packages/tcpip/src/stack.ts +++ b/packages/tcpip/src/stack.ts @@ -1,6 +1,7 @@ import { ConsoleStdout, File, OpenFile, WASI } from '@bjorn3/browser_wasi_shim'; import { DnsClient, type NameServer } from '@tcpip/dns'; import { BridgeBindings } from './bindings/bridge-interface.js'; +import { IcmpBindings } from './bindings/icmp.js'; import { LoopbackBindings } from './bindings/loopback-interface.js'; import { TapBindings } from './bindings/tap-interface.js'; import { TcpBindings } from './bindings/tcp.js'; @@ -14,6 +15,7 @@ import type { LoopbackInterfaceOptions, NetworkInterface, NetworkStack, + PingSessionOptions, TapInterface, TapInterfaceOptions, TcpConnectionOptions, @@ -58,6 +60,7 @@ export class VirtualNetworkStack implements NetworkStack { #bridgeBindings: BridgeBindings; #tcpBindings: TcpBindings; #udpBindings: UdpBindings; + #icmpBindings: IcmpBindings; ready: Promise; get interfaces() { @@ -81,6 +84,7 @@ export class VirtualNetworkStack implements NetworkStack { this.#bridgeBindings = new BridgeBindings(); this.#tcpBindings = new TcpBindings(this.#dnsClient); this.#udpBindings = new UdpBindings(this.#dnsClient); + this.#icmpBindings = new IcmpBindings(this.#dnsClient); // Initialize the stack this.ready = this.#init(); @@ -125,6 +129,7 @@ export class VirtualNetworkStack implements NetworkStack { ...this.#bridgeBindings.imports, ...this.#tcpBindings.imports, ...this.#udpBindings.imports, + ...this.#icmpBindings.imports, }, }); @@ -136,6 +141,7 @@ export class VirtualNetworkStack implements NetworkStack { this.#bridgeBindings.register(wasmInstance.exports); this.#tcpBindings.register(wasmInstance.exports); this.#udpBindings.register(wasmInstance.exports); + this.#icmpBindings.register(wasmInstance.exports); // Our WASM binary is a WASI reactor module (ie. a lib), // so we call `initialize()` instead of `start()`. @@ -227,4 +233,12 @@ export class VirtualNetworkStack implements NetworkStack { await this.ready; return this.#udpBindings.open(options); } + + /** + * Creates an ICMP ping session for sending echo requests to a host. + */ + async createPingSession(options: PingSessionOptions) { + await this.ready; + return this.#icmpBindings.createPingSession(options); + } } diff --git a/packages/tcpip/src/types.ts b/packages/tcpip/src/types.ts index b73e434..eda560a 100644 --- a/packages/tcpip/src/types.ts +++ b/packages/tcpip/src/types.ts @@ -50,6 +50,31 @@ export type TcpConnection = { [Symbol.asyncIterator](): AsyncIterator; }; +export type PingSessionOptions = { + host: string; + timeout?: number; +}; + +export type PingProbeOptions = { + timeout?: number; + payload?: Uint8Array; +}; + +export type PingReply = { + host: string; + identifier: number; + sequenceNumber: number; + payload: Uint8Array; + roundTripTime: number; +}; + +export type PingSession = { + readonly host: string; + readonly identifier: number; + ping(options?: PingProbeOptions): Promise; + close(): Promise; +}; + export type TcpListener = { [Symbol.asyncIterator](): AsyncIterableIterator; }; @@ -143,4 +168,8 @@ export type NetworkStack = { * If no local port is provided, the socket will bind to a random port. */ openUdp(options?: UdpSocketOptions): Promise; + /** + * Creates an ICMP ping session for sending echo requests to a host. + */ + createPingSession(options: PingSessionOptions): Promise; }; diff --git a/packages/tcpip/wasm/Filelists.mk b/packages/tcpip/wasm/Filelists.mk index 9bb1dfe..aaebd67 100644 --- a/packages/tcpip/wasm/Filelists.mk +++ b/packages/tcpip/wasm/Filelists.mk @@ -7,4 +7,5 @@ SRC_FILES= \ $(SRC_DIR)/bridge_interface.c \ $(SRC_DIR)/tcp.c \ $(SRC_DIR)/udp.c \ - $(SRC_DIR)/arch.c \ No newline at end of file + $(SRC_DIR)/icmp.c \ + $(SRC_DIR)/arch.c diff --git a/packages/tcpip/wasm/icmp.c b/packages/tcpip/wasm/icmp.c new file mode 100644 index 0000000..df3d6b0 --- /dev/null +++ b/packages/tcpip/wasm/icmp.c @@ -0,0 +1,115 @@ +#include "lwip/inet_chksum.h" +#include "lwip/ip4.h" +#include "lwip/pbuf.h" +#include "lwip/prot/icmp.h" +#include "lwip/raw.h" + +#include +#include + +#include "macros.h" + +extern uint8_t receive_icmp_echo_reply(struct raw_pcb *socket, const uint8_t *addr, uint16_t identifier, uint16_t sequence_number, const uint8_t *payload, uint16_t length); + +static uint8_t recv_icmp_callback(void *arg, struct raw_pcb *socket, struct pbuf *p, const ip_addr_t *addr) { + LWIP_UNUSED_ARG(arg); + + if (!IP_IS_V4(addr)) { + return 0; + } + + if (p->tot_len < IP_HLEN + sizeof(struct icmp_echo_hdr)) { + return 0; + } + + struct ip_hdr iphdr; + pbuf_copy_partial(p, &iphdr, sizeof(iphdr), 0); + uint16_t ip_header_length = IPH_HL_BYTES(&iphdr); + + if (p->tot_len < ip_header_length + sizeof(struct icmp_echo_hdr)) { + return 0; + } + + struct icmp_echo_hdr echo; + pbuf_copy_partial(p, &echo, sizeof(echo), ip_header_length); + + if (echo.type != ICMP_ER || echo.code != 0) { + return 0; + } + + uint16_t payload_length = p->tot_len - ip_header_length - sizeof(struct icmp_echo_hdr); + uint8_t *payload = NULL; + + if (payload_length > 0) { + payload = malloc(payload_length); + if (payload == NULL) { + return 0; + } + + pbuf_copy_partial(p, payload, payload_length, ip_header_length + sizeof(struct icmp_echo_hdr)); + } + + uint8_t eaten = receive_icmp_echo_reply( + socket, + (const uint8_t *)&ip_2_ip4(addr)->addr, + lwip_ntohs(echo.id), + lwip_ntohs(echo.seqno), + payload, + payload_length + ); + + free(payload); + + if (eaten) { + pbuf_free(p); + return 1; + } + + return 0; +} + +EXPORT("open_icmp_socket") +struct raw_pcb *open_icmp_socket() { + struct raw_pcb *socket = raw_new(IP_PROTO_ICMP); + + if (socket == NULL) { + return NULL; + } + + raw_recv(socket, recv_icmp_callback, NULL); + return socket; +} + +EXPORT("close_icmp_socket") +void close_icmp_socket(struct raw_pcb *socket) { + raw_remove(socket); +} + +EXPORT("send_icmp_echo_request") +err_t send_icmp_echo_request(struct raw_pcb *socket, const uint8_t *addr, uint16_t identifier, uint16_t sequence_number, uint8_t *payload, uint16_t length) { + ip4_addr_t ipaddr; + IP4_ADDR(&ipaddr, addr[0], addr[1], addr[2], addr[3]); + + struct pbuf *p = pbuf_alloc(PBUF_IP, sizeof(struct icmp_echo_hdr) + length, PBUF_RAM); + if (p == NULL) { + return ERR_MEM; + } + + struct icmp_echo_hdr *echo = (struct icmp_echo_hdr *)p->payload; + echo->type = ICMP_ECHO; + echo->code = 0; + echo->chksum = 0; + echo->id = lwip_htons(identifier); + echo->seqno = lwip_htons(sequence_number); + + if (length > 0) { + uint8_t *echo_payload = ((uint8_t *)p->payload) + sizeof(struct icmp_echo_hdr); + MEMCPY(echo_payload, payload, length); + } + + echo->chksum = inet_chksum(p->payload, p->len); + + err_t code = raw_sendto(socket, p, &ipaddr); + pbuf_free(p); + return code; +} diff --git a/packages/tcpip/wasm/include/lwipopts.h b/packages/tcpip/wasm/include/lwipopts.h index 735cbdb..f756c7f 100644 --- a/packages/tcpip/wasm/include/lwipopts.h +++ b/packages/tcpip/wasm/include/lwipopts.h @@ -24,7 +24,7 @@ #define MEMP_NUM_PBUF (2 * MEMP_NUM_TCP_SEG) // Number of pbufs in the pool // Application layer options -#define LWIP_RAW 0 // Disable application layer sending raw packets +#define LWIP_RAW 1 // Enable raw protocol control blocks for ICMP ping // Loopback options #define LWIP_NETIF_LOOPBACK 1 // Enable loopback logic (applies to every interface) @@ -83,4 +83,4 @@ #define TCP_OUTPUT_DEBUG LWIP_DBG_ON // Enable debugging for TCP input #define UDP_DEBUG LWIP_DBG_ON // Enable debugging for UDP -#endif /* LWIPOPTS_H */ \ No newline at end of file +#endif /* LWIPOPTS_H */ diff --git a/packages/v86/src/network-adapter.test.ts b/packages/v86/src/network-adapter.test.ts index a15c4fb..0e43d6b 100644 --- a/packages/v86/src/network-adapter.test.ts +++ b/packages/v86/src/network-adapter.test.ts @@ -6,6 +6,61 @@ import { describe, expect, it } from 'vitest'; import { createVm, nextValue } from '../test/util.js'; describe('network adapter', () => { + it('should ping a VM from the host stack', async () => { + const networkStack = await createStack(); + const tapInterface = await networkStack.createTapInterface({ + ip: '192.168.1.1/24', + }); + + const { emulator, net } = await createVm({ + ip: '192.168.1.2/24', + }); + + connectStreams(tapInterface, net); + + const payload = new TextEncoder().encode('tcpip.js ping'); + const pingSession = await networkStack.createPingSession({ + host: '192.168.1.2', + }); + + const firstReply = await pingSession.ping({ payload }); + const secondReply = await pingSession.ping({ payload }); + + expect(firstReply.host).toBe('192.168.1.2'); + expect(firstReply.identifier).toBe(pingSession.identifier); + expect(firstReply.sequenceNumber).toBe(0); + expect(firstReply.payload).toStrictEqual(payload); + expect(firstReply.roundTripTime).toBeGreaterThanOrEqual(0); + expect(secondReply.host).toBe('192.168.1.2'); + expect(secondReply.identifier).toBe(pingSession.identifier); + expect(secondReply.sequenceNumber).toBe(1); + expect(secondReply.payload).toStrictEqual(payload); + expect(secondReply.roundTripTime).toBeGreaterThanOrEqual(0); + + await pingSession.close(); + await emulator.destroy(); + }); + + it('should ping the host stack from a VM', async () => { + const networkStack = await createStack(); + const tapInterface = await networkStack.createTapInterface({ + ip: '192.168.1.1/24', + }); + + const { emulator, net, executeCommand } = await createVm({ + ip: '192.168.1.2/24', + }); + + connectStreams(tapInterface, net); + + const pingOutput = await executeCommand('ping -c 1 192.168.1.1'); + + expect(pingOutput).toContain('1 packets transmitted, 1 packets received'); + expect(pingOutput).toContain('0% packet loss'); + + await emulator.destroy(); + }); + it('should assign an IP address and DNS server to a VM with DHCP', async () => { const networkStack = await createStack(); const tapInterface = await networkStack.createTapInterface({ @@ -84,7 +139,7 @@ describe('network adapter', () => { // Listen for incoming TCP connections in the VM await executeCommand( - 'echo "5000 stream tcp nowait nobody /bin/echo Hello" | inetd -f - &' + 'echo "5000 stream tcp nowait nobody /bin/busybox echo Hello" | inetd -f - &' ); // Wait for inetd to start @@ -100,7 +155,7 @@ describe('network adapter', () => { const message = await nextValue(connection.readable); const textDecoder = new TextDecoder(); - expect(textDecoder.decode(message)).toBe('Hello'); + expect(textDecoder.decode(message)).toBe('Hello\n'); await connection.close(); await emulator.destroy(); diff --git a/packages/wire/src/icmp.ts b/packages/wire/src/icmp.ts index ee3a87e..49ae196 100644 --- a/packages/wire/src/icmp.ts +++ b/packages/wire/src/icmp.ts @@ -1,5 +1,7 @@ import { calculateChecksum } from './util.js'; +export const ICMP_ECHO_HEADER_LENGTH = 8; + export type IcmpMessage = { type: string; code?: string; @@ -39,7 +41,7 @@ export function parseIcmpMessage(data: Uint8Array): IcmpMessage { * Serializes an ICMP message from an `ICMPMessage` object. */ export function serializeIcmpMessage(message: IcmpMessage): Uint8Array { - const data = new Uint8Array(8 + message.payload.length); + const data = new Uint8Array(ICMP_ECHO_HEADER_LENGTH + message.payload.length); const dataView = new DataView(data.buffer, data.byteOffset, data.byteLength); dataView.setUint8(0, serializeIcmpType(message.type));