diff --git a/ConnectProxy.js b/ConnectProxy.js new file mode 100644 index 0000000..6242950 --- /dev/null +++ b/ConnectProxy.js @@ -0,0 +1,117 @@ +'use strict'; + +import assert from 'assert'; +import { sanitize } from './util.js'; +import { DIRECT_PROXY } from './settings.js'; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); +const CONNECTION_ESTABLISHED_REPLY = textEncoder.encode('HTTP/1.0 200 Connection Established\r\nProxy-agent: Apache/2.4.41 (Ubuntu)\r\n\r\n'); + +// Not sure what this does, but it seems irrelevant. +const GEOIP_RESPONSE = `HTTP/1.1 200 OK +Server: nginx/1.24.0 +Date: %NOW% +Content-Type: application/json +Content-Length: 19 +Connection: keep-alive +Cache-Control: max-age=604800, private +Access-Control-Allow-Origin: * + +{"continent":"NA"} +`; + +const LIST_RESPONSE = `HTTP/1.1 200 OK +Server: nginx/1.24.0 +Date: %NOW% +Content-Type: application/json +Content-Length: %LENGTH% +Last-Modified: %NOW% +Connection: keep-alive +Access-Control-Allow-Origin: * + +%PAYLOAD% +`; + +const PAYLOAD = { + 'total': { 'servers': DIRECT_PROXY.length, 'clients': 0 }, + 'total_max': { 'server': DIRECT_PROXY.length, 'clients': 0 }, + 'list': DIRECT_PROXY.map(([vip, ip, port]) => { + return { + 'address': vip, + 'ip': vip, + 'port': port, + 'proto_min': 37, + 'proto_max': 42, + }}), +}; + +// Fake a CONNECT proxy to simulate servers.minetest.net response +export class ConnectProxy { + constructor(client) { + this.client = client; + this.firstLine = true; + this.conn = null; + } + + forward(data) { + if (this.firstLine) { + this.firstLine = false; + this.handle_handshake(data); + return; + } + if (!(data instanceof ArrayBuffer)) { + throw new Error("ConnectProxy received non-binary messages"); + } + data = textDecoder.decode(data); + assert(data.endsWith('\r\n\r\n')); + let lines = data.split('\r\n'); + assert(lines.length >= 1); + let tokens = lines[0].split(' '); + assert(tokens[0] == 'GET'); + let url = sanitize(tokens[1]); + const now = (new Date()).toUTCString(); + let response; + if (url.startsWith('/geoip')) { + response = GEOIP_RESPONSE.replace(/%NOW%/g, now); + } else if (url.startsWith('/list')) { + const payload = JSON.stringify(PAYLOAD); + response = LIST_RESPONSE.replace(/%NOW%/g, now).replace('%LENGTH%', payload.length + 1).replace('%PAYLOAD%', payload); + this.client.log("Sending virtual server list") + } else { + this.client.log(`Invalid GET request for ${url}`); + this.client.close(); + return; + } + this.client.send(textEncoder.encode(response)); + } + + handle_handshake(data) { + // The CONNECT line and it's headers could be split among several packets. + // In a real server, this would aggregate data until it sees \r\n\r\n + // But minetest-wasm always sends it as one packet, so just assume that. + data = textDecoder.decode(data); + assert(data.endsWith('\r\n\r\n')); + let lines = data.split('\r\n'); + assert(lines.length >= 1); + let tokens = lines[0].split(' '); + assert.strictEqual(tokens.length, 3); + assert.strictEqual(tokens[0], 'CONNECT'); + assert.strictEqual(tokens[2], 'HTTP/1.1'); + let host_port = tokens[1].split(':'); + assert.strictEqual(host_port.length, 2); + let host = host_port[0]; + let port = parseInt(host_port[1]); + if (host != 'servers.minetest.net' || port != 80) { + this.client.log(`Ignoring request to proxy to ${host}:${port}`); + this.client.close(); + return; + } + this.client.log('Connected for server list'); + this.client.send(CONNECTION_ESTABLISHED_REPLY); + } + + close() { + this.client.close(); + } +} diff --git a/UDPProxy.js b/UDPProxy.js new file mode 100644 index 0000000..3689dfb --- /dev/null +++ b/UDPProxy.js @@ -0,0 +1,75 @@ +'use strict'; + +import dgram from 'dgram'; + +export class UDPProxy { + constructor(client, ip, port) { + const socket = dgram.createSocket('udp4'); + this.client = client; + this.socket = socket; + this.ip = ip; + this.port = port; + this.sendok = false; + this.sendqueue = []; + socket.on('listening', this.handle_listening.bind(this)); + socket.on('error', this.handle_error.bind(this)); + socket.on('message', this.handle_message.bind(this)); + socket.bind(); + } + + forward(data) { + // This creates a view of the ArrayBuffer + data = new Uint8Array(data); + if (data.byteLength < 4 || + data[0] != 0x4f || + data[1] != 0x45 || + data[2] != 0x74 || + data[3] != 0x03) { + throw new Error('Client sent packet with invalid protocol.'); + } + + if (this.sendok) { + // data must be a typed array here + this.socket.send(data, this.port, this.ip); + } else { + this.sendqueue.push(data); + } + } + + handle_listening() { + const sourcePort = this.socket.address().port; + this.log(`Bound ${sourcePort} -> ${this.ip}:${this.port}`); + this.sendok = true; + if (this.sendqueue.length > 0) { + for (const data of this.sendqueue) { + this.socket.send(data, this.port, this.ip); + } + this.sendqueue = []; + } + } + + handle_error(err) { + this.log("Socket error: " + err); + this.close(); + } + + handle_message(msg, rinfo) { + if (rinfo.address != this.ip || rinfo.port != this.port) { + this.log("Ignoring unsolicited packet from " + rinfo.address + " port " + rinfo.port); + return; + } + this.client.send(msg); + } + + log(msg) { + this.client.log(msg); + } + + close() { + if (this.socket) { + this.socket.close(); + this.socket = null; + } + this.client.close(); + } +} diff --git a/client.js b/client.js index d31102c..82735ab 100644 --- a/client.js +++ b/client.js @@ -1,8 +1,14 @@ 'use strict'; +import assert from 'assert'; +import { isIPv4 } from 'net'; +import { format } from 'util'; // node.js built-in + +import { DIRECT_PROXY } from './settings.js'; +import { ConnectProxy } from './ConnectProxy.js'; +import { UDPProxy } from './UDPProxy.js'; import { extract_ip_chain, sanitize } from './util.js'; import { vpn_make, vpn_connect} from './vpn.js'; -import { format } from 'util'; // node.js built-in const textDecoder = new TextDecoder(); let lastlog = null; @@ -99,6 +105,20 @@ export class Client { return; } response = 'BIND OK'; + } else if (command == 'PROXY') { + assert(tokens[2] == 'TCP' || tokens[2] == 'UDP'); + const isUDP = (tokens[2] == 'UDP'); + const ip = sanitize(tokens[3]); + const port = parseInt(sanitize(tokens[4])); + assert(isIPv4(ip)); + assert(port >= 1 && port < 65536); + this.target = route(this, isUDP, ip, port); + if (!this.target) { + this.log(`Proxy to udp=${isUDP}, ip=${ip}, port=${port} rejected`); + response = 'PROXY FAIL'; + } else { + response = 'PROXY OK'; + } } else { this.log('Unhandled command: ', data); this.close(); @@ -108,3 +128,17 @@ export class Client { } } + +const PROXY_MAP = new Map(DIRECT_PROXY.map(([vip,ip,port]) => [vip, [ip, port]])); + +function route(client, isUDP, ip, port) { + if (!isUDP && ip == '10.0.0.1' && port == 8080) { + return new ConnectProxy(client); + } + if (isUDP && PROXY_MAP.has(ip)) { + let [real_ip, real_port] = PROXY_MAP.get(ip); + return new UDPProxy(client, real_ip, real_port); + } + + return null; +} diff --git a/main.js b/main.js index a499dcd..557f253 100644 --- a/main.js +++ b/main.js @@ -1,7 +1,6 @@ 'use strict'; -const PROXY_PORT = 8888; - +import { PROXY_PORT } from './settings.js'; import { WebSocketServer } from 'ws'; import { Client } from './client.js'; diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..b06a841 --- /dev/null +++ b/settings.js @@ -0,0 +1,15 @@ + +export const PROXY_PORT = 8888; + +// [virtual_ip, real_ip, real_port] +// +// The virtual IP is the one that minetest-wasm sees. +// The virtual port is the same as the real port. +// +export const DIRECT_PROXY = [ + // This allows clients to connect to a server running on the proxy itself. + ['192.168.0.1', '127.0.0.1', 30000], + + // This would allow clients to connect to 1.2.3.4, port 40000 + //['192.168.0.2', '1.2.3.4', 40000], +];