commit d0d8c5f08e2092afb8772d846286335d2dd03987 Author: Fang Lu Date: Mon Feb 21 16:31:55 2022 -0800 Initial (dev) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c20da61 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +*.ini diff --git a/README b/README new file mode 100644 index 0000000..1243a35 --- /dev/null +++ b/README @@ -0,0 +1,26 @@ +CHUNICHUNIMatch +=============== + +Remote LAN multiplayer matching by virtualizing members into the local LAN. + +*No VPN required* + +Instructions +------------ + +***Project is currently in experimentation*** + +* 配置IP别名。提权cmd运行以下指令在主网卡上新增192.168.139.11-14(如果必要,替换Ethernet为网卡名) + ``` + netsh interface ipv4 set interface interface="Ethernet" dhcpstaticipcoexistence=enabled + netsh interface ipv4 add address "Ethernet" 192.168.139.11 255.255.255.0 store=active + netsh interface ipv4 add address "Ethernet" 192.168.139.12 255.255.255.0 store=active + netsh interface ipv4 add address "Ethernet" 192.168.139.13 255.255.255.0 store=active + netsh interface ipv4 add address "Ethernet" 192.168.139.14 255.255.255.0 store=active + ``` +* 确保使用的segatools更新到至少forcebind +* 修改segatools.ini添加下面的新section以使用CCM在连接时自动生成的INI + ``` + [override] + netenv="D:\path\to\ccm\segatools-override.ini" + ``` diff --git a/ccencap.js b/ccencap.js new file mode 100644 index 0000000..76c6782 --- /dev/null +++ b/ccencap.js @@ -0,0 +1,78 @@ +var CCEncap = {}; + +const CCM_NET_SENDTCP = 0x01; +const CCM_NET_SENDUDP = 0x02; +const CCM_PARTY_CREATE = 0x30; +const CCM_PARTY_HELLO = 0x31; +const CCM_PARTY_UPDATE = 0x32; + +CCEncap.encodePartyCreate = function(req) { + var msg = Buffer.from(JSON.stringify(req)); + msg[0] = CCM_PARTY_CREATE; + return msg; +} + +CCEncap.encodePartyHello = function(req) { + var msg = Buffer.from(JSON.stringify(req)); + msg[0] = CCM_PARTY_HELLO; + return msg; +} + +CCEncap.encodePartyUpdate = function(req) { + var msg = Buffer.from(JSON.stringify(req)); + msg[0] = CCM_PARTY_UPDATE; + return msg; +} + +CCEncap.encodePacket = function(src, dst, sport, dport, type, data) { + if (type == 'tcp') { + var header = CCM_NET_SENDTCP; + } else if (type == 'udp') { + var header = CCM_NET_SENDUDP; + } else { + throw new Error('Invalid network protocol: ' + type); + } + var msg = Buffer.alloc(data.length + 7); + msg[0] = header; + msg[1] = dst; + msg.writeUInt16LE(sport, 2); + msg.writeUInt16LE(dport, 4); + msg[6] = src; + data.copy(msg, 7); + console.log(type + "_send "+src+':'+sport+'->'+dst+':'+dport); + return msg; +} + +CCEncap.decode = function(msg) { + var opcode = msg[0], data = msg; + var ret = {op: '?'}; + switch(opcode) { + case CCM_PARTY_CREATE: ret.op = 'party_create'; break; + case CCM_PARTY_HELLO: ret.op = 'party_hello'; break; + case CCM_PARTY_UPDATE: ret.op = 'party_update'; break; + case CCM_NET_SENDTCP: ret.op = 'net_tcp'; break; + case CCM_NET_SENDUDP: ret.op = 'net_udp'; break; + } + switch(opcode) { + case CCM_PARTY_CREATE: + case CCM_PARTY_HELLO: + case CCM_PARTY_UPDATE: + msg[0] = 0x7b; // '{' + Object.assign(ret, JSON.parse(msg)); + break; + case CCM_NET_SENDTCP: + case CCM_NET_SENDUDP: + ret.dst = msg[1]; + ret.src = msg[6]; + ret.sport = msg.readUInt16LE(2); + ret.dport = msg.readUInt16LE(4); + // ret.msg = msg.slice(7); + console.log(ret.op+" "+ret.src+':'+ret.sport+'->'+ret.dst+':'+ret.dport); + break; + default: + console.warn('Received unknown opcode: ' + opcode.toString(16)); + } + return ret; +} + +module.exports = CCEncap; diff --git a/cchub.js b/cchub.js new file mode 100644 index 0000000..cfa62a7 --- /dev/null +++ b/cchub.js @@ -0,0 +1,72 @@ +const ws = require('ws'); +const enc = require('./ccencap'); + +// Note: src and dst addresses in this file are numerical suffixes only. + +function CCHub(url) { + var conn = new ws.WebSocket(url); + this.ready = new Promise((resolve) => { + conn.on('open', () => { + this.conn = conn; + resolve(); + }); + }); + this.conn = this.ready; + + this.onNetForward = function(src, dst, sport, dport, type, msg) {}; + this.onPartyJoin = function(subnet, suffix) {}; + this.onPartyUpdate = function(members) {}; + + conn.on('message', (data) => { + msg = enc.decode(data); + switch (msg.op) { + case 'party_update': + this._updateParty(msg); + break; + case 'net_tcp': + case 'net_udp': + var type = msg.op.slice(4); + var payload = data.slice(7); + this.onNetForward(msg.src, msg.dst, msg.sport, msg.dport, type, payload); + break; + default: + console.warn('Unhandled packed: ', msg) + } + }); + + this.subnet = null; + this.mysuffix = 0; + this.members = []; +} + +CCHub.prototype.send = function(msg) { + if (this.conn instanceof Promise) { + this.conn.then(() => { + this.send(msg); + }); + return; + } + this.conn.send(msg); +}; + +CCHub.prototype.hello = function(req) { + this.send(enc.encodePartyHello(req)); +}; + +CCHub.prototype._updateParty = function(msg) { + if (!this.subnet) { + this.subnet = msg.subnet; + this.mysuffix = msg.suffix; + this.onPartyJoin(this.subnet, this.mysuffix); + } + var diff = msg.members.filter(suffix => this.members.indexOf(suffix) == -1); + this.members = this.members.concat(diff); + this.onPartyUpdate(diff); +}; + +CCHub.prototype.sendNetForward = function(src, dst, sport, dport, type, data) { + var msg = enc.encodePacket(src, dst, sport, dport, type, data); + this.send(msg); +}; + +module.exports = CCHub; diff --git a/ccrelay.js b/ccrelay.js new file mode 100644 index 0000000..0bc298c --- /dev/null +++ b/ccrelay.js @@ -0,0 +1,120 @@ +const net = require('net'); +const dgram = require('dgram'); + +// Note: src and dst addresses in this file are full IP strings only. + +function CCRelay(host) { + console.log("Relay started for " + host); + this.host = host; + this.sockets = {}; + this.servers = {}; + this.onReceive = function(sport, dport, type, msg) {} +} + +CCRelay.prototype.bindUDP = function(port) { + if (this.sockets['udp'+port]) { + console.warn('Attempted to bind more than once on '+this.host+':'+this.port); + return this.sockets['udp'+port]; + } + var socket = dgram.createSocket('udp4'); + socket.on('error', (e) => { + console.error('udp '+this.host+':'+port+' error', e); + socket.close(); + this.sockets['udp'+port] = null; + this.bindUDP(port); + }); + socket.on('message', (msg, rinfo) => { + this.onReceive(rinfo.port, port, 'udp', msg); + }); + var bindpromise = new Promise((resolve) => { + socket.bind({address: this.host, port: port}, () => { + this.sockets['udp'+port] = socket; + resolve(socket); + }); + }); + this.sockets['udp'+port] = bindpromise; + return bindpromise; +}; + +CCRelay.prototype.sendUDP = function(dst, sport, dport, msg) { + var socket = this.sockets['udp'+sport]; + if (!socket) { + socket = this.bindUDP(sport); + } + if (socket instanceof Promise) { + return socket.then(() => { + return this.sendUDP(dst, sport, dport, msg); + }); + } + return new Promise((resolve) => { + socket.send(msg, dport, dst, resolve); + }); +}; + +CCRelay.prototype._setupTCPSocket = function(socket, sport, dport) { + socket.on('data', (msg) => { + this.onReceive(sport, dport, 'tcp', msg); + }); + socket.on('end', () => { + this.sockets['tcp'+sport+'-'+dport] = null; + }); + socket.on('close', () => { + this.sockets['tcp'+sport+'-'+dport] = null; + }); + socket.on('error', e => { + this.sockets['tcp'+sport+'-'+dport] = null; + }); +} + +CCRelay.prototype.listenTCP = function(port) { + if (this.servers['tcp'+port]) { + console.warn('Attempted to listen more than once on '+this.host+':'+this.port); + return this.servers['tcp'+port]; + } + var server = net.createServer(); + server.on('error', (e) => { + console.error('tcpserver '+this.host+':'+port+' error', e); + server.close(); + this.servers['tcp'+port] = null; + this.listenTCP(port); + }); + server.on('connection', (socket) => { + this._setupTCPSocket(socket, socket.remotePort, port); + console.log("Adding passive TCP socket ", 'tcp'+socket.remotePort+'-'+port) + this.sockets['tcp'+socket.remotePort+'-'+port] = socket; + }); + var listenpromise = new Promise((resolve) => { + server.listen({host: this.host, port: port}, () => { + this.servers['tcp'+port] = server; + resolve(server); + }); + }); + this.servers['tcp'+port] = listenpromise; + return listenpromise; +}; + +CCRelay.prototype.sendTCP = function(dst, sport, dport, msg) { + var socket = this.sockets['tcp'+sport+'-'+dport]; + if (!socket) { + socket = new Promise((resolve) => { + console.log("Adding active TCP socket", 'tcp'+sport+'-'+dport); + var newsocket = new net.Socket(); + this._setupTCPSocket(newsocket, sport, dport); + newsocket.connect(dport, dst, () => { + this.sockets['tcp'+sport+'-'+dport] = newsocket; + resolve(newsocket); + }); + }); + this.sockets['tcp'+sport+'-'+dport] = socket; + } + if (socket instanceof Promise) { + return socket.then(() => { + return this.sendTCP(dst, sport, dport, msg); + }); + } + return new Promise((resolve) => { + socket.write(msg, null, resolve); + }); +}; + +module.exports = CCRelay; diff --git a/config.js b/config.js new file mode 100644 index 0000000..5abb1e7 --- /dev/null +++ b/config.js @@ -0,0 +1,6 @@ +module.exports = { + "server": "gcp.naominet.com", + "server_port": 61820, + "login": {}, + "write_override_ini": "segatools-override.ini" +}; diff --git a/index.js b/index.js new file mode 100644 index 0000000..31e1963 --- /dev/null +++ b/index.js @@ -0,0 +1,60 @@ +const cfg = require('./config'); + +const fs = require('fs'); + +const CCRelay = require('./ccrelay'); +const CCHub = require('./cchub'); + + +var hub = new CCHub('ws://' + cfg.server + ':' + cfg.server_port); +var relays = {}; +var suffix = 0; +var subnet = ""; + +hub.ready.then(() => { + console.log('Connecting to CCM Hub ' + cfg.server + '...'); + hub.hello(cfg.login || {}); +}); + +hub.onPartyJoin = function(mysubnet, mysuffix) { + suffix = mysuffix; + subnet = mysubnet; + console.log('Join successful. Game IP should be ' + subnet+suffix); + console.log('Please wait until all match members have joined before starting game'); + if (cfg.write_override_ini) { + var override = + '[netenv]\n' + + 'enable=1\n' + + 'forcebind=1\n' + + 'addrSuffix=' + suffix + '\n' + + '[keychip]\n' + + 'subnet=' + subnet + '0\n'; + fs.writeFileSync(cfg.write_override_ini, override); + } +} + +hub.onPartyUpdate = function(members) { + members.forEach(dst => { + console.log(subnet+dst + ' joined virtual party.'); + var relay = new CCRelay(subnet+dst); + relay.bindUDP(50200); + relay.listenTCP(50200); + relay.onReceive = function(sport, dport, type, msg) { + hub.sendNetForward(suffix, dst, sport, dport, type, msg); + } + relays[dst] = relay; + }); +} + +hub.onNetForward = function(src, dst, sport, dport, type, msg) { + var relay = relays[src]; + if (!relay) { + console.warn('Received fwd from unknown host ' + src); + return; + } + if (type == 'tcp') { + relay.sendTCP(subnet+dst, sport, dport, msg) + } else if (type == 'udp') { + relay.sendUDP(subnet+dst, sport, dport, msg); + } +}; diff --git a/netif.bat b/netif.bat new file mode 100644 index 0000000..7a0aac2 --- /dev/null +++ b/netif.bat @@ -0,0 +1,7 @@ +set NETIF="Ethernet" +netsh interface ipv4 set interface interface=%NETIF% dhcpstaticipcoexistence=enabled +netsh interface ipv4 add address %NETIF% 192.168.139.11 255.255.255.0 store=active +netsh interface ipv4 add address %NETIF% 192.168.139.12 255.255.255.0 store=active +netsh interface ipv4 add address %NETIF% 192.168.139.13 255.255.255.0 store=active +netsh interface ipv4 add address %NETIF% 192.168.139.14 255.255.255.0 store=active +pause diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a94f150 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,40 @@ +{ + "name": "chunichunimatch", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "dependencies": { + "ws": "^8.5.0" + } + }, + "node_modules/ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + }, + "dependencies": { + "ws": { + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", + "requires": {} + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..7805267 --- /dev/null +++ b/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "ws": "^8.5.0" + } +} diff --git a/server.js b/server.js new file mode 100644 index 0000000..78f1521 --- /dev/null +++ b/server.js @@ -0,0 +1,80 @@ +const cfg = require('./config'); +const ws = require('ws'); +const enc = require('./ccencap'); + +var server = new ws.WebSocketServer({ + port: cfg.server_port, + perMessageDeflate: false +}); + +var subnet = '192.168.139.'; +var occupancy = [0, 0, 0, 0]; +var suffixes = [11, 12, 13, 14]; +var clients = {}; + +function allocateClient() { + var slot = -1; + for (var i = 0; i < occupancy.length; i++) { + if (!occupancy[i]) { + slot = i; + occupancy[slot] = 1; + break; + } + } + return slot; +} + +server.on('connection', function(conn, req) { + var slot = allocateClient(); + var realip = req.socket.remoteAddress; + if (slot < 0) { + console.log('Dropping ' + realip + ' due to no party vacancy'); + conn.close(); + return; + } + var suffix = suffixes[slot]; + clients[suffix] = conn; + console.log("[Client #" + slot + "] Allocated. Welcome " + realip); + conn.on('message', function(data) { + var msg = enc.decode(data); + switch(msg.op) { + case 'party_hello': + sendMemberChange(); + break; + case 'net_tcp': + case 'net_udp': + var dstconn = clients[msg.dst]; + if (!dstconn) { + console.warn('Invalid dst: ' + msg.src + '->' + msg.dst); + } else { + dstconn.send(data); + } + break; + } + }); + conn.on('close', function() { + console.log("[Client #" + slot + "] Dropped."); + delete clients[suffix]; + occupancy[slot] = 0; + }); + conn.on('error', function(e) { + console.log("[Client #" + slot + "] Error ", e); + conn.close(); + delete clients[suffix]; + occupancy[slot] = 0; + }); +}); + +function sendMemberChange() { + // var members = suffixes.filter((suffix, slot) => occupancy[slot]); + // Pre-allocate all members on the client to prevent late joiner crash + var members = suffixes; + for (var i = 0; i < occupancy.length; i++) { + if (!occupancy[i]) continue; + clients[suffixes[i]].send(enc.encodePartyUpdate({ + subnet: subnet, + suffix: suffixes[i], + members: members.filter(suffix => suffix != suffixes[i]) + })); + } +}