diff --git a/Makefile b/Makefile index 3cb82fb5c7..fe0ccb9441 100644 --- a/Makefile +++ b/Makefile @@ -87,7 +87,7 @@ CORE_FILES=const.js config.js io.js main.js lib.js buffer.js ide.js pci.js flopp LIB_FILES=9p.js filesystem.js jor1k.js marshall.js utf8.js BROWSER_FILES=screen.js keyboard.js mouse.js speaker.js serial.js \ network.js starter.js worker_bus.js dummy_screen.js \ - fake_network.js fetch_network.js print_stats.js filestorage.js + fake_network.js wisp_network.js fetch_network.js print_stats.js filestorage.js RUST_FILES=$(shell find src/rust/ -name '*.rs') \ src/rust/gen/interpreter.rs src/rust/gen/interpreter0f.rs \ @@ -306,6 +306,7 @@ devices-test: all-debug ./tests/devices/virtio_9p.js ./tests/devices/virtio_console.js ./tests/devices/fetch_network.js + ./tests/devices/wisp_network.js rust-test: $(RUST_FILES) env RUSTFLAGS="-D warnings" RUST_BACKTRACE=full RUST_TEST_THREADS=1 cargo test -- --nocapture diff --git a/src/browser/fake_network.js b/src/browser/fake_network.js index 33d99505e0..39ea9ba393 100644 --- a/src/browser/fake_network.js +++ b/src/browser/fake_network.js @@ -225,33 +225,7 @@ function handle_fake_networking(data, adapter) { } if(packet.arp && packet.arp.oper === 1 && packet.arp.ptype === ETHERTYPE_IPV4) { - let packet_subnet = iptolong(packet.arp.tpa) & 0xFFFFFF00; - let router_subnet = iptolong(adapter.router_ip) & 0xFFFFFF00; - - if(!adapter.masquerade) { - if(packet_subnet !== router_subnet) { - return; - } - } - - if(packet_subnet === router_subnet) { - // Ignore the DHCP client area - if(packet.arp.tpa[3] > 99) return; - } - - // Reply to ARP Whohas - let reply = {}; - reply.eth = { ethertype: ETHERTYPE_ARP, src: adapter.router_mac, dest: packet.eth.src }; - reply.arp = { - htype: 1, - ptype: ETHERTYPE_IPV4, - oper: 2, - sha: adapter.router_mac, - spa: packet.arp.tpa, - tha: packet.eth.src, - tpa: packet.arp.spa - }; - adapter.receive(make_packet(reply)); + arp_whohas(packet, adapter); } if(packet.dns) { @@ -264,20 +238,7 @@ function handle_fake_networking(data, adapter) { // ICMP Ping if(packet.icmp && packet.icmp.type === 8) { - let reply = {}; - reply.eth = { ethertype: ETHERTYPE_IPV4, src: adapter.router_mac, dest: packet.eth.src }; - reply.ipv4 = { - proto: IPV4_PROTO_ICMP, - src: adapter.router_ip, - dest: packet.ipv4.src, - }; - reply.icmp = { - type: 0, - code: packet.icmp.code, - data: packet.icmp.data - }; - adapter.receive(make_packet(reply)); - return; + handle_fake_ping(packet, adapter); } if(packet.dhcp) { @@ -285,20 +246,7 @@ function handle_fake_networking(data, adapter) { } if(packet.udp && packet.udp.dport === 8) { - // UDP Echo Server - let reply = {}; - reply.eth = { ethertype: ETHERTYPE_IPV4, src: adapter.router_mac, dest: packet.eth.src }; - reply.ipv4 = { - proto: IPV4_PROTO_UDP, - src: packet.ipv4.dest, - dest: packet.ipv4.src, - }; - reply.udp = { - sport: packet.udp.dport, - dport: packet.udp.sport, - data: new TextEncoder().encode(packet.udp.data_s) - }; - adapter.receive(make_packet(reply)); + handle_udp_echo(packet, adapter); } } @@ -528,6 +476,7 @@ function parse_udp(data, o) { dport: view.getUint16(2), len: view.getUint16(4), checksum: view.getUint16(6), + data: data.subarray(8), data_s: new TextDecoder().decode(data.subarray(8)) }; @@ -1133,3 +1082,67 @@ TCPConnection.prototype.pump = function() { this.net.receive(make_packet(reply)); } }; + + +function arp_whohas(packet, adapter) { + let packet_subnet = iptolong(packet.arp.tpa) & 0xFFFFFF00; + let router_subnet = iptolong(adapter.router_ip) & 0xFFFFFF00; + + if(!adapter.masquerade) { + if(packet_subnet !== router_subnet) { + return; + } + } + + if(packet_subnet === router_subnet) { + // Ignore the DHCP client area + if(packet.arp.tpa[3] > 99) return; + } + + // Reply to ARP Whohas + let reply = {}; + reply.eth = { ethertype: ETHERTYPE_ARP, src: adapter.router_mac, dest: packet.eth.src }; + reply.arp = { + htype: 1, + ptype: ETHERTYPE_IPV4, + oper: 2, + sha: adapter.router_mac, + spa: packet.arp.tpa, + tha: packet.eth.src, + tpa: packet.arp.spa + }; + adapter.receive(make_packet(reply)); +} + +function handle_fake_ping(packet, adapter) { + let reply = {}; + reply.eth = { ethertype: ETHERTYPE_IPV4, src: adapter.router_mac, dest: packet.eth.src }; + reply.ipv4 = { + proto: IPV4_PROTO_ICMP, + src: adapter.router_ip, + dest: packet.ipv4.src, + }; + reply.icmp = { + type: 0, + code: packet.icmp.code, + data: packet.icmp.data + }; + adapter.receive(make_packet(reply)); +} + +function handle_udp_echo(packet, adapter) { + // UDP Echo Server + let reply = {}; + reply.eth = { ethertype: ETHERTYPE_IPV4, src: adapter.router_mac, dest: packet.eth.src }; + reply.ipv4 = { + proto: IPV4_PROTO_UDP, + src: packet.ipv4.dest, + dest: packet.ipv4.src, + }; + reply.udp = { + sport: packet.udp.dport, + dport: packet.udp.sport, + data: new TextEncoder().encode(packet.udp.data_s) + }; + adapter.receive(make_packet(reply)); +} diff --git a/src/browser/starter.js b/src/browser/starter.js index ae1c5425ba..f0ec027d6b 100644 --- a/src/browser/starter.js +++ b/src/browser/starter.js @@ -297,6 +297,9 @@ V86.prototype.continue_init = async function(emulator, options) { this.network_adapter = new FetchNetworkAdapter(this.bus); } + else if(options.network_relay_url.startsWith("wisp://") || options.network_relay_url.startsWith("wisps://")) { + this.network_adapter = new WispNetworkAdapter(options.network_relay_url, this.bus, options); + } else { this.network_adapter = new NetworkAdapter(options.network_relay_url, this.bus); diff --git a/src/browser/wisp_network.js b/src/browser/wisp_network.js new file mode 100644 index 0000000000..faf0f5fc8c --- /dev/null +++ b/src/browser/wisp_network.js @@ -0,0 +1,302 @@ +"use strict"; + +const DEFAULT_DOH_SERVER = "cloudflare-dns.com"; + +/** + * @constructor + * + * @param {BusConnector} bus + * @param {*=} config + */ +function WispNetworkAdapter(wisp_url, bus, config) +{ + + this.register_ws(wisp_url); + this.last_stream = 1; + this.connections = {0: {congestion: 0}}; + this.congested_buffer = []; + + config = config || {}; + this.bus = bus; + this.id = config.id || 0; + this.router_mac = new Uint8Array((config.router_mac || "52:54:0:1:2:3").split(":").map(function(x) { return parseInt(x, 16); })); + this.router_ip = new Uint8Array((config.router_ip || "192.168.86.1").split(".").map(function(x) { return parseInt(x, 10); })); + this.vm_ip = new Uint8Array((config.vm_ip || "192.168.86.100").split(".").map(function(x) { return parseInt(x, 10); })); + this.masquerade = config.masquerade === undefined || !!config.masquerade; + this.vm_mac = new Uint8Array(6); + this.doh_server = config.doh_server || DEFAULT_DOH_SERVER; + this.tcp_conn = {}; + + this.bus.register("net" + this.id + "-mac", function(mac) { + this.vm_mac = new Uint8Array(mac.split(":").map(function(x) { return parseInt(x, 16); })); + }, this); + this.bus.register("net" + this.id + "-send", function(data) + { + this.send(data); + }, this); +} + +WispNetworkAdapter.prototype.register_ws = function(wisp_url) { + this.wispws = new WebSocket(wisp_url.replace("wisp://", "ws://").replace("wisps://", "wss://")); + this.wispws.binaryType = "arraybuffer"; + this.wispws.onmessage = (event) => { + this.process_incoming_wisp_frame(new Uint8Array(event.data)); + }; + this.wispws.onclose = () => { + setTimeout(() => { + this.register_ws(wisp_url); + }, 10000); // wait 10s before reconnecting + }; +}; + +WispNetworkAdapter.prototype.send_packet = function(data, type, stream_id) { + if(this.connections[stream_id].congestion > 0) { + if(type === "DATA") { + this.connections[stream_id].congestion--; + } + this.wispws.send(data); + } else { + this.connections[stream_id].congested = true; + this.congested_buffer.push({data: data, type: type}); + } +}; + +WispNetworkAdapter.prototype.process_incoming_wisp_frame = function(frame) { + const view = new DataView(frame.buffer); + const stream_id = view.getUint32(1, true); + switch(frame[0]) { + case 1: // CONNECT + // The server should never send this actually + dbg_log("Server sent client-only packet CONNECT", LOG_NET); + break; + case 2: // DATA + if(this.connections[stream_id]) + this.connections[stream_id].data_callback(frame.slice(5)); + else + throw new Error("Got a DATA packet but stream not registered. ID: " + stream_id); + break; + case 3: // CONTINUE + if(this.connections[stream_id]) { + this.connections[stream_id].congestion = view.getUint32(5, true); + } + + if(this.connections[stream_id].congested) { + for(const packet of this.congested_buffer) { + this.send_packet(packet.data, packet.type, stream_id); + } + this.connections[stream_id].congested = false; + } + break; + case 4: // CLOSE + if(this.connections[stream_id]) + this.connections[stream_id].close_callback(view.getUint8(5)); + delete this.connections[stream_id]; + break; + case 5: // PROTOEXT + dbg_log("got a wisp V2 upgrade request, ignoring", LOG_NET); + // Not responding, this is wisp v1 client not wisp v2; + break; + default: + dbg_log("Wisp server returned unknown packet: " + frame[0], LOG_NET); + } +}; + + +// FrameObj will be the following +// FrameObj.stream_id (number) +// +// FrameObj.type -- CONNECT +// FrameObj.hostname (string) +// FrameObj.port (number) +// FrameObj.data_callback (function (Uint8Array)) +// FrameObj.close_callback (function (number)) OPTIONAL +// +// +// FrameObj.type -- DATA +// FrameObj.data (Uint8Array) +// +// FrameObj.type -- CLOSE +// FrameObj.reason (number) +// +// + +WispNetworkAdapter.prototype.send_wisp_frame = function(frame_obj) { + let full_packet; + let view; + switch(frame_obj.type) { + case "CONNECT": + const hostname_buffer = new TextEncoder().encode(frame_obj.hostname); + full_packet = new Uint8Array(5 + 1 + 2 + hostname_buffer.length); + view = new DataView(full_packet.buffer); + view.setUint8(0, 0x01); // TYPE + view.setUint32(1, frame_obj.stream_id, true); // Stream ID + view.setUint8(5, 0x01); // TCP + view.setUint16(6, frame_obj.port, true); // PORT + full_packet.set(hostname_buffer, 8); // hostname + + // Setting callbacks + this.connections[frame_obj.stream_id] = { + data_callback: frame_obj.data_callback, + close_callback: frame_obj.close_callback, + congestion: this.connections[0].congestion + }; + break; + case "DATA": + full_packet = new Uint8Array(5 + frame_obj.data.length); + view = new DataView(full_packet.buffer); + view.setUint8(0, 0x02); // TYPE + view.setUint32(1, frame_obj.stream_id, true); // Stream ID + full_packet.set(frame_obj.data, 5); // Actual data + break; + case "CLOSE": + full_packet = new Uint8Array(5 + 1); + view = new DataView(full_packet.buffer); + view.setUint8(0, 0x04); // TYPE + view.setUint32(1, frame_obj.stream_id, true); // Stream ID + view.setUint8(5, frame_obj.reason); // Packet size + break; + default: + dbg_log("Client tried to send unknown packet: " + frame_obj.type, LOG_NET); + + } + this.send_packet(full_packet, frame_obj.type, frame_obj.stream_id); +}; + +WispNetworkAdapter.prototype.destroy = function() +{ + if(this.wispws) { + this.wispws.onmessage = null; + this.wispws.onclose = null; + this.wispws.close(); + this.wispws = null; + } +}; + +/** + * @param {Uint8Array} data + */ +WispNetworkAdapter.prototype.send = function(data) +{ + let packet = {}; + parse_eth(data, packet); + + if(packet.tcp) { + let reply = {}; + reply.eth = { ethertype: ETHERTYPE_IPV4, src: this.router_mac, dest: packet.eth.src }; + reply.ipv4 = { + proto: IPV4_PROTO_TCP, + src: packet.ipv4.dest, + dest: packet.ipv4.src + }; + + let tuple = [ + packet.ipv4.src.join("."), + packet.tcp.sport, + packet.ipv4.dest.join("."), + packet.tcp.dport + ].join(":"); + + if(packet.tcp.syn) { + if(this.tcp_conn[tuple]) { + dbg_log("SYN to already opened port", LOG_FETCH); + } + const tcp_conn = new TCPConnection(); + + tcp_conn.state = TCP_STATE_SYN_RECEIVED; + tcp_conn.net = this; + tcp_conn.tuple = tuple; + tcp_conn.stream_id = this.last_stream++; + this.tcp_conn[tuple] = tcp_conn; + + tcp_conn.on_data = (data) => { + if(data.length !== 0) { + this.send_wisp_frame({ + type: "DATA", + stream_id: tcp_conn.stream_id, + data: data + }); + } + }; + + this.send_wisp_frame({ + type: "CONNECT", + stream_id: tcp_conn.stream_id, + hostname: packet.ipv4.dest.join("."), + port: packet.tcp.dport, + data_callback: (data) => { + tcp_conn.write(data); + }, + close_callback: (data) => { + tcp_conn.close(); + } + }); + + tcp_conn.accept(packet); + return; + } + + if(!this.tcp_conn[tuple]) { + dbg_log(`I dont know about ${tuple}, so restting`, LOG_FETCH); + let bop = packet.tcp.ackn; + if(packet.tcp.fin || packet.tcp.syn) bop += 1; + reply.tcp = { + sport: packet.tcp.dport, + dport: packet.tcp.sport, + seq: bop, + ackn: packet.tcp.seq + (packet.tcp.syn ? 1: 0), + winsize: packet.tcp.winsize, + rst: true, + ack: packet.tcp.syn + }; + this.receive(make_packet(reply)); + return; + } + + this.tcp_conn[tuple].process(packet); + } + + if(packet.arp && packet.arp.oper === 1 && packet.arp.ptype === ETHERTYPE_IPV4) { + arp_whohas(packet, this); + } + + if(packet.dns) { + // TODO: remove when this wisp client supports udp + (async () => { + let reply = {}; + reply.eth = { ethertype: ETHERTYPE_IPV4, src: this.router_mac, dest: packet.eth.src }; + reply.ipv4 = { + proto: IPV4_PROTO_UDP, + src: this.router_ip, + dest: packet.ipv4.src, + }; + reply.udp = { sport: 53, dport: packet.udp.sport }; + const result = await ((await fetch(`https://${this.doh_server}/dns-query`, {method: "POST", headers: [["content-type", "application/dns-message"]], body: packet.udp.data})).arrayBuffer()); + reply.udp.data = new Uint8Array(result); + this.receive(make_packet(reply)); + })(); + } + + if(packet.ntp) { + // TODO: remove when this wisp client supports udp + handle_fake_ntp(packet, this); + return; + } + + if(packet.dhcp) { + handle_fake_dhcp(packet, this); + return; + } + + if(packet.udp && packet.udp.dport === 8) { + // TODO: remove when this wisp client supports udp + handle_udp_echo(packet, this); + } +}; + +/** + * @param {Uint8Array} data + */ +WispNetworkAdapter.prototype.receive = function(data) +{ + this.bus.send("net" + this.id + "-receive", new Uint8Array(data)); +}; diff --git a/tests/devices/wisp_network.js b/tests/devices/wisp_network.js new file mode 100755 index 0000000000..e0f4d90a23 --- /dev/null +++ b/tests/devices/wisp_network.js @@ -0,0 +1,312 @@ +#!/usr/bin/env -S node --experimental-websocket +"use strict"; + +process.on("unhandledRejection", exn => { throw exn; }); + +const TEST_RELEASE_BUILD = +process.env.TEST_RELEASE_BUILD; + +const V86 = require(`../../build/${TEST_RELEASE_BUILD ? "libv86" : "libv86-debug"}.js`).V86; + +const assert = require("assert").strict; +const SHOW_LOGS = false; +const STOP_ON_FIRST_FAILURE = false; + +function log_pass(msg, ...args) +{ + console.log(`\x1b[92m[+] ${msg}\x1b[0m`, ...args); +} + +function log_warn(msg, ...args) +{ + console.error(`\x1b[93m[!] ${msg}\x1b[0m`, ...args); +} + +function log_fail(msg, ...args) +{ + console.error(`\x1b[91m[-] ${msg}\x1b[0m`, ...args); +} + +const tests = +[ + { + name: "DHCP", + timeout: 60, + start: () => + { + emulator.serial0_send("udhcpc\n"); + emulator.serial0_send("echo -e done\\\\tudhcpc\n"); + }, + end_trigger: "done\tudhcpc", + end: (capture) => + { + assert(/lease of 192.168.86.100 obtained/.test(capture), "lease of 192.168.86.100 obtained"); + }, + }, + { + name: "ifconfig", + timeout: 60, + start: () => + { + emulator.serial0_send("ifconfig\n"); + emulator.serial0_send("echo -e done\\\\tifconfig\n"); + }, + end_trigger: "done\tifconfig", + end: (capture) => + { + assert(/192.168.86.100/.test(capture), "192.168.86.100"); + }, + }, + { + name: "route", + timeout: 60, + start: () => + { + emulator.serial0_send("ip route\n"); + emulator.serial0_send("echo -e done\\\\troute\n"); + }, + end_trigger: "done\troute", + end: (capture) => + { + assert(/192.168.86.1/.test(capture), "192.168.86.100"); + }, + }, + //{ + // name: "arp -a", + // timeout: 60, + // start: () => + // { + // emulator.serial0_send("arp -a\n"); + // emulator.serial0_send("echo -e done\\\\tarp\n"); + // }, + // end_trigger: "done\tarp", + // end: (capture) => + // { + // assert(/.192.168.86.1. at 52:54:00:01:02:03 \[ether\] {2}on eth0/.test(capture), "(192.168.86.1) at 52:54:00:01:02:03 [ether] on eth0"); + // }, + //}, + { + name: "Curl example.org", + timeout: 60, + allow_failure: true, + start: () => + { + emulator.serial0_send("wget -T 10 -O - example.org\n"); + emulator.serial0_send("echo -e done\\\\texample.org\n"); + }, + end_trigger: "done\texample.org", + end: (capture) => + { + assert(/This domain is for use in illustrative examples in documents/.test(capture), "got example.org text"); + }, + }, + +]; + +let test_num = 0; +let test_timeout = 0; +const failed_tests = []; + +const emulator = new V86({ + bios: { url: __dirname + "/../../bios/seabios.bin" }, + vga_bios: { url: __dirname + "/../../bios/vgabios.bin" }, + cdrom: { url: __dirname + "/../../images/linux4.iso" }, + autostart: true, + memory_size: 64 * 1024 * 1024, + disable_jit: +process.env.DISABLE_JIT, + network_relay_url: "wisps://wisp.mercurywork.shop/", + log_level: SHOW_LOGS ? 0x400000 : 0, +}); + +emulator.add_listener("emulator-ready", function () { + +}); + +let ran_command = false; +let line = ""; +let capturing = false; +let capture = ""; +let next_trigger; +let next_trigger_handler; + +function start_timeout() +{ + if(tests[test_num].timeout) + { + test_timeout = setTimeout(() => + { + log_fail("Test #%d (%s) took longer than %s sec. Timing out and terminating.", test_num, tests[test_num].name, tests[test_num].timeout); + process.exit(1); + }, tests[test_num].timeout * 1000); + } +} + +function begin() +{ + start_timeout(); + + console.log("\nPreparing test #%d: %s", test_num, tests[test_num].name); + start_test(); +} + +function start_test() +{ + console.log("Starting test #%d: %s", test_num, tests[test_num].name); + + capture = ""; + + tests[test_num].start(); + + if(tests[test_num].capture_trigger) + { + next_trigger = tests[test_num].capture_trigger; + next_trigger_handler = start_capture; + } + else + { + next_trigger = tests[test_num].end_trigger; + next_trigger_handler = end_test; + } + start_capture(); +} + +function start_capture() +{ + console.log("Capturing..."); + capture = ""; + capturing = true; + + next_trigger = tests[test_num].end_trigger; + next_trigger_handler = end_test; +} + +function end_test() +{ + capturing = false; + + if(tests[test_num].timeout) + { + clearTimeout(test_timeout); + } + + let test_has_failed = false; + + try { + tests[test_num].end(capture); + } catch(e) { + console.log(e); + test_has_failed = true; + } + + if(!test_has_failed) + { + log_pass("Test #%d passed: %s", test_num, tests[test_num].name); + } + else + { + if(tests[test_num].allow_failure) + { + log_warn("Test #%d failed: %s (failure allowed)", test_num, tests[test_num].name); + } + else + { + log_fail("Test #%d failed: %s", test_num, tests[test_num].name); + + if(STOP_ON_FIRST_FAILURE) + { + finish_tests(); + } + } + test_has_failed = false; + } + + test_num++; + + if(test_num < tests.length) + { + begin(); + } + else + { + finish_tests(); + } +} + +function finish_tests() +{ + emulator.stop(); + emulator.destroy(); + + console.log("\nTests finished."); + if(failed_tests.length === 0) + { + console.log("All tests passed"); + } + else + { + let unallowed_failure = false; + + console.error("Failed %d out of %d tests:", failed_tests.length, tests.length); + for(const num of failed_tests) + { + if(tests[num].allow_failure) + { + log_warn("#%d %s (failure allowed)", num, tests[num].name); + } + else + { + unallowed_failure = true; + log_fail("#%d %s", num, tests[num].name); + } + } + if(unallowed_failure) + { + process.exit(1); + } + } +} + +emulator.bus.register("emulator-started", function() +{ + console.error("Booting now, please stand by"); +}); + +emulator.add_listener("serial0-output-byte", function(byte) +{ + const chr = String.fromCharCode(byte); + if(chr < " " && chr !== "\n" && chr !== "\t" || chr > "~") + { + return; + } + + let new_line = ""; + let is_new_line = false; + if(chr === "\n") + { + is_new_line = true; + new_line = line; + line = ""; + } + else + { + line += chr; + } + + if(!ran_command && line.endsWith("~% ")) + { + ran_command = true; + begin(); + } + else if(new_line === next_trigger) + { + next_trigger_handler(); + } + else if(is_new_line && capturing) + { + capture += new_line + "\n"; + console.log(" Captured: %s", new_line); + } + else if(is_new_line) + { + console.log(" Serial: %s", new_line); + } +});