About

Unfortunately, retro console binders such as retroarch do not benefit of an API that would allow programmers to hook into the platform in order to observe the inner-workings of the engine. Furthermore, retroarch is fairly tacit when it comes to debugging and even enabling the most verboose logging does not mention any details about connecting clients.

Previous attempts to pull data on connecting netplay clients have resulted in python being a requirement, that is unfortunately not compatible with closed systems such as Lakka, as well as dropping down to bash scripting and using the Linux subsystem to retrieve the connecting IP address but without the ability to additionally obtain the nickname of the connecting client.

The following document describes a mechanism to obtain both the nickname of the connecting client matched by their IP address and then broadcast that information to a Node-Red instance that will have the responsibility of storing and displaying the connecting players on the world map.

Reverse-Engineering

It becomes clear that hinging on retroarch or closed-systems such as Lakka to provide such information is unreasonable such that the best course of action seems to drop down to packet interception in order to decode incoming traffic by relying on the netplay protocol definitions.

Via the documentation, it seems that once a netplay server receives a client, the very first thing that happens is that a handshake takes place along the following lines:

  1. send connection header
  2. receive and verify connection header
  3. send nickname
  4. receive nickname

The protocol can be observed trivially using tcpdump:

tcpdump -w netplay_connect.pcap -X -i br0 tcp port 55435

where:

  • netplay_connect.pcap is a PCAP packet capture file to be analyzed later,
  • -X dumps the data in ASCII format,
  • -i br0 monitors an interface, in this case, the network bridge over which traffic to the machine running netplay takes place,
  • tcp port 55435 makes tcpdump listen on the default netplay port 55435

In order to observe the traffic, two machines are started, one retroarch instance acting as a client and another retroarch instance acting as a client while tcpdump sniffs and dumps packets whilst observing the traffic between the two retroarch instances.

Looking through the exchanged packets, very early on in the transmission, the following data is to be observed:

0000   7e 18 17 ed bc 76 38 63 bb 2c 51 b4 08 00 45 00   ~....v8c.,Q...E.
0010   00 50 06 06 40 00 40 06 6f d7 ac 10 01 51 4f 73   .P..@.@.o....QOs
0020   c7 f6 bd 39 d8 8b f5 07 6a 83 55 39 69 5b 50 18   ...9....j.U9i[P.
0030   01 00 f3 69 00 00 00 00 00 20 00 00 00 20 73 68   ...i..... ... sh
0040   65 6e 69 73 00 00 00 00 00 00 00 00 00 00 00 00   enis............
0050   00 00 00 00 00 00 00 00 00 00 00 00 00 00         ..............

where shenis is the nickname of the connecting retroarch client.

Leaving the header aside and observing the payload that includes the nickname shenis, the following bytes can be laid out in order:

00 00 00 00 00 20 00 00 00 20 73 68 65 6e 69 73 ...
            +   +             +               +
            |   |             |               |
            +---+             +---------------+
         NETPLAY_CMD_NICK           shenis

where it turns out that the sequence 0x0020 matches the netplay NICK command that sends the nickname of the connecting client. As it turns out, this 40 byte packet is well-defined within the netplay_private.h header of the retroarch netplay source code and corresponds to the NICK command.

Given the former, it seems that all that is left to do is to interactively sniff traffic from the outside world and to the machine running the netplay server while looking for a 40 byte packet that contains the 0x0020 marker that corresponds to the NICK netplay command. Once the packet is captured, the IP address of the connecting client is already known via the IP protocol and the nickname is contained within the rest of the 40 byte packet.

Engineering

The whole system will consist in a node.js script that shall be ran on the router in order to intercept TCP/Ip netplay traffic, extract the packet and send the nickname and IP address pair to an MQTT server. Node-Red and dataflow programming will be leveraged in order to subscribe to the MQTT server and receive the data sent by the node.js script in order to perform a geographic lookup of the IP address and then display the results on a rendered world map.

Netplay Sniffer

The netplay sniffer can be downloaded as a project from the Wizardry and Steamworks SVN by issuing the command:

svn co https://svn.grimore.org/netplaySniff

Once retrieved, the following command must be used in order to install all dependencies:

npm install

This will require the pcap library development files to be installed (on Debian, the libpcap-dev package should be installed before running npm install).

The next step is to configure the netplaySniff project by copying config.yml.dist to config.yml and then editing the file accordingly. The YAML file contains documentation that should provide enough guidance.

In order to test, the project can be started by making main.js executable and then running main.js.

If everything goes well, the project can be demonized on SystemD by copying the file netplaySniff.service from contrib/linux/systemd/ into /etc/systemd/system and then enabling the netplaySniff.service using systemd.

Code

Here is the code for the netplaySniff project and the configuration file config.yml.

netplaySniff

File: http://svn.grimore.org/netplaySniff/main.js -

#!/usr/bin/env node
///////////////////////////////////////////////////////////////////////////
//    Copyright (C) 2023 Wizardry and Steamworks - License: MIT          //
///////////////////////////////////////////////////////////////////////////
 
const fs = require('fs')
const path = require('path')
const { createLogger, format, transports } = require('winston')
const mqtt = require('mqtt')
const YAML = require('yamljs')
const Cap = require('cap').Cap
const decoders = require('cap').decoders
const PROTOCOL = decoders.PROTOCOL
const shortHash = require('short-hash')
const { exec } = require("child_process")
const Inotify = require('inotify-remastered').Inotify
const inotify = new Inotify()
 
// load configuration file.
let config = YAML.load('config.yml')
 
// set up logger.
const logger = createLogger({
    format: format.combine(
        format.splat(),
        format.simple()
    ),
    transports: [
        new transports.Console({
            timestamp: true
        }),
        new transports.File(
            {
                timestamp: true,
                filename: path.join(path.dirname(fs.realpathSync(__filename)), "log/netplaySniff.log")
            }
        )
    ]
})
 
// set up packet capture
const cap = new Cap()
const device = Cap.findDevice(`${config.router}`)
const filter = `tcp and dst port ${config.netplay.port} and dst host ${config.netplay.host}`
const bufSize = 10 * 1024 * 1024
const buffer = Buffer.alloc(65535)
const linkType = cap.open(device, filter, bufSize, buffer)
 
cap.setMinBytes && cap.setMinBytes(0)
 
let nickBanSet = new Set(config.bans.nicknames)
 
// Watch the configuration file for changes.
const configWatch = inotify.addWatch({
    path: 'config.yml',
    watch_for: Inotify.IN_MODIFY,
    callback: function(event) {
        logger.info(`Reloading configuration file config.yml`)
        config = YAML.load('config.yml')
        nickBanSet = new Set(config.bans.nicknames)
 
    }
})
 
const mqttClient = mqtt.connect(config.mqtt.connect)
 
mqttClient.on('reconnect', () => {
    logger.info('Reconnecting to Corrade MQTT server...')
})
 
mqttClient.on('connect', () => {
    logger.info('Connected to Corrade MQTT server.')
    // Subscribe to group message notifications with group name and password.
    mqttClient.subscribe(`${config.mqtt.topic}`, (error) => {
        if (error) {
            logger.info('Error subscribing to Corrade MQTT group messages.')
            return
        }
 
        logger.info('Subscribed to Corrade MQTT group messages.')
    })
})
 
mqttClient.on('close', () => {
    logger.error('Disconnected from Corrade MQTT server...')
})
 
mqttClient.on('error', (error) => {
    logger.error(`Error found while connecting to Corrade MQTT server ${error}`)
})
 
cap.on('packet', function(bytes, truncated) {
    let netplay = {}
 
    if(linkType !== 'ETHERNET') {
        return
    }
 
    var ret = decoders.Ethernet(buffer)
 
    if (ret.info.type !== PROTOCOL.ETHERNET.IPV4) {
        return
    }
 
    ret = decoders.IPV4(buffer, ret.offset)
    netplay.ip = ret.info.srcaddr
 
    if (ret.info.protocol !== PROTOCOL.IP.TCP) {
        return
    }
 
    var dataLength = ret.info.totallen - ret.hdrlen
 
    ret = decoders.TCP(buffer, ret.offset)
    dataLength -= ret.hdrlen
 
    var payload = buffer.subarray(ret.offset, ret.offset + dataLength)
 
    // look for the NETPLAY_CMD_NICK in "netplay_private.h" data marker.
    if(payload.indexOf('0020', 0, "hex") !== 2) {
        return
    }
 
    // remove NULL and NETPLAY_CMD_NICK
    netplay.nick = payload.toString().replace(/[\u0000\u0020]+/gi, '')
    netplay.hash = shortHash(`${netplay.nick}${netplay.ip}`)
    netplay.time = new Date().toISOString()
 
    // ban by nick.
    if(nickBanSet.has(netplay.nick)) {
        logger.info(`nick found to be banned: ${netplay.nick}`)
        exec(`/usr/sbin/iptables -t mangle -A PREROUTING -p tcp --src ${netplay.ip} --dport ${config.netplay.port} -j DROP`, (error, stdout, stderr) => {
            if (error) {
                logger.error(`Error returned while banning connecting client ${error.message}`)
                return
            }
            if (stderr) {
                logger.error(`Standard error returned ${stderr}`)
                return
            }
            if(stdout) {
                logger.info(`Standard error reported while banning ${typeof stdout}`)
                return
            }
 
            // only send notification if the connecting nick isn't banned
            const data = JSON.stringify(netplay, null, 4)
            mqttClient.publish(`${config.mqtt.topic}`, data)
        })
    }
 
})

config.yml

File: http://svn.grimore.org/netplaySniff/config.yml.dist -

###########################################################################
##    Copyright (C) 2023 Wizardry and Steamworks - License: MIT          ##
###########################################################################
 
# The local IP address of the router that this script is running on.
router: 192.168.1.1
 
# The MQTT connection string.
mqtt: 
    connect: "mqtt://iot.internal"
    topic: "arcade/netplay/clients"

netplay:
    # the netplay host ip address, ie: the machine running retroarch
    host: 192.168.1.10
    # the netplay port (default: 55435)
    port: 55435
 
# Various bans that can be placed on connecting clients.
bans:
    nicknames:
        - Cain

Node-Red

Using Node-Red the following plot is obtained by accumulating the IP and nickname tuples during an evening when a retroarch instance has been left to host a game:

The corresponding Node-Red flow is a little complicated yet should be robust enough to foresee any problems that might occur. Here is an overview of the Node-Red flow:

In short, the following steps take place, in order:

  1. Node-Red subscribes to the MQTT broker on the topic arcade/netplay/clients and listens for JSON data sent by the netplaySniffer script running on the router (purple left-most box labeled arcade/netplay/clients),
  2. the JSON data is converted to a JavaScript object and the IP address is sent through a geoip locator (IP location box),
  3. the first function after the IP geolocator sends the data to the UI world map (green box labeled worldmap) whilst the second function sends the data to a Redis server running on the local machine for storage

The netplaySniffer node.js script sends the connecting IP address and nickname by sniffing the packets exchanged during the netplay handshake but also additionally generates a Bernstein "times 33" hash, by combining the nickname and the IP address, that is conveniently short. The hash is created in order to mitigate the situation where connecting clients do not have a nickname set such that the nickname is by default set to Anonymous or the situation where two people have the same nickname but a different IP address. Furthermore, the hash itself is used as the key when storing the whole structure in Redis such that the same nickname and IP pair will always update the datapoint, whilst any variance of nickname or IP will generate a new datapoint to be plotted on the map.

On the lower side of the Node-Red flow, there are two distinct lines, the upper that can be triggered both manually and is triggered every time Node-Red restarts which is responsible for re-populating the world map in case the Node-Red machine restarts by retrieving the data from Redis, whilst the lower line can be used to delete all the stored data pertaining to the netplay clients thereby resetting both the data and the world map.

Flow Export

[{"id":"b9b835aa.311208","type":"tab","label":"Flow 3","disabled":false,"info":""},{"id":"fc1b068f.c37aa8","type":"ip-location-lite","z":"b9b835aa.311208","name":"","inputField":"payload","outputField":"payload","x":990,"y":140,"wires":[["338f7300.f2c4d4"]]},{"id":"541e17bc.8644a8","type":"ui_worldmap","z":"b9b835aa.311208","group":"6fd8fc2a.10c884","order":0,"width":0,"height":0,"name":"","lat":"","lon":"","zoom":"","layer":"OSMG","cluster":"","maxage":"3600","usermenu":"hide","layers":"hide","panit":"false","panlock":"false","zoomlock":"false","hiderightclick":"true","coords":"none","showgrid":"false","showruler":"false","allowFileDrop":"false","path":"/worldmap","overlist":"DR,CO,HM","maplist":"OSMG,OSMC,EsriC,EsriS,EsriT,EsriDG,UKOS","mapname":"","mapurl":"","mapopt":"","mapwms":false,"x":1320,"y":140,"wires":[]},{"id":"ad367324.8a3b1","type":"mqtt in","z":"b9b835aa.311208","name":"","topic":"arcade/netplay/clients","qos":"2","datatype":"auto","broker":"725ed69c.6d76a8","nl":false,"rap":true,"rh":0,"x":460,"y":140,"wires":[["3f6e050b.d83032"]]},{"id":"7a698ff7.fd1be8","type":"debug","z":"b9b835aa.311208","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":810,"y":80,"wires":[]},{"id":"3f6e050b.d83032","type":"json","z":"b9b835aa.311208","name":"","property":"payload","action":"","pretty":false,"x":650,"y":140,"wires":[["7a698ff7.fd1be8","65048b9.09466f4"]]},{"id":"338f7300.f2c4d4","type":"function","z":"b9b835aa.311208","name":"","func":"msg.payload = { \n    \"name\": `${msg.data.nick} (${msg.data.hash})`, \n    \"ip\": msg.data.ip,\n    \"lat\": msg.payload.ll[0], \n    \"lon\": msg.payload.ll[1] \n}\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1160,"y":140,"wires":[["541e17bc.8644a8","42e78.ba7d69884"]]},{"id":"65048b9.09466f4","type":"function","z":"b9b835aa.311208","name":"","func":"var info = {}\ninfo.data = msg.payload\ninfo.payload = msg.payload.ip\nmsg = info\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":820,"y":140,"wires":[["fc1b068f.c37aa8"]]},{"id":"54c9e6c6.f2fb48","type":"debug","z":"b9b835aa.311208","name":"","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","x":1490,"y":300,"wires":[]},{"id":"27a94941.e75976","type":"redis-command","z":"b9b835aa.311208","server":"a37a41f9.a98da8","command":"KEYS","name":"","topic":"","params":"[]","paramsType":"json","payloadType":"json","block":false,"x":650,"y":220,"wires":[["62991e68.02c4f8"]]},{"id":"eed69890.3e242","type":"redis-command","z":"b9b835aa.311208","server":"a37a41f9.a98da8","command":"SET","name":"","topic":"","params":"[]","paramsType":"json","payloadType":"json","block":false,"x":1480,"y":200,"wires":[["54c9e6c6.f2fb48"]]},{"id":"295ea271.cb6946","type":"inject","z":"b9b835aa.311208","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"3600","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"[\"netplay/clients *\"]","payloadType":"json","x":460,"y":220,"wires":[["27a94941.e75976"]]},{"id":"62991e68.02c4f8","type":"split","z":"b9b835aa.311208","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"payload","x":810,"y":220,"wires":[["70860790.d0ee88"]]},{"id":"42e78.ba7d69884","type":"function","z":"b9b835aa.311208","name":"","func":"var key = `netplay/clients ${msg.data.hash}`\nvar value = JSON.stringify(msg.payload)\nmsg.payload = [ key, value ]\nreturn msg;\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1320,"y":200,"wires":[["eed69890.3e242"]]},{"id":"70860790.d0ee88","type":"redis-command","z":"b9b835aa.311208","server":"a37a41f9.a98da8","command":"GET","name":"","topic":"","params":"[]","paramsType":"json","payloadType":"json","block":false,"x":970,"y":220,"wires":[["973f75d7.cc72b"]]},{"id":"973f75d7.cc72b","type":"json","z":"b9b835aa.311208","name":"","property":"payload","action":"","pretty":false,"x":1130,"y":220,"wires":[["54c9e6c6.f2fb48","541e17bc.8644a8"]]},{"id":"72b783a8.44cf84","type":"redis-command","z":"b9b835aa.311208","server":"a37a41f9.a98da8","command":"KEYS","name":"","topic":"","params":"[]","paramsType":"json","payloadType":"json","block":false,"x":650,"y":300,"wires":[["8ee43b57.29094"]]},{"id":"f3286f44.f256f8","type":"inject","z":"b9b835aa.311208","name":"","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payload":"[\"netplay/clients *\"]","payloadType":"json","x":450,"y":300,"wires":[["72b783a8.44cf84"]]},{"id":"8ee43b57.29094","type":"split","z":"b9b835aa.311208","name":"","splt":"\\n","spltType":"str","arraySplt":1,"arraySpltType":"len","stream":false,"addname":"payload","x":810,"y":300,"wires":[["bf6c835c.4434d"]]},{"id":"bf6c835c.4434d","type":"redis-command","z":"b9b835aa.311208","server":"a37a41f9.a98da8","command":"DEL","name":"","topic":"","params":"[]","paramsType":"json","payloadType":"json","block":false,"x":960,"y":300,"wires":[["54c9e6c6.f2fb48"]]},{"id":"6fd8fc2a.10c884","type":"ui_group","name":"Netplay","tab":"fd6e78a9.c88fd","order":4,"disp":true,"width":"24","collapse":false},{"id":"725ed69c.6d76a8","type":"mqtt-broker","name":"iot.internal","broker":"iot.internal","port":"1883","clientid":"","usetls":false,"compatmode":false,"keepalive":"60","cleansession":true,"birthTopic":"","birthQos":"0","birthPayload":"","closeTopic":"","closeQos":"0","closePayload":"","willTopic":"","willQos":"0","willPayload":""},{"id":"a37a41f9.a98da8","type":"redis-config","name":"Local","options":"{}","cluster":false,"optionsType":"json"},{"id":"fd6e78a9.c88fd","type":"ui_tab","name":"Arcade","icon":"dashboard","disabled":false,"hidden":false}]

Further Work

  • right now the geolocator will resolve the IP address to a set of coordinates expressed in latitude and longitude that do project onto the correct area, but it would be nicer to use a heat map instead and highlight the entire area or country due to the coordinates not really representing the location of the connecting client

retroarch/plotting_netplay_players_on_the_world_map.txt ยท Last modified: 2023/05/11 06:22 by office

Access website using Tor Access website using i2p Wizardry and Steamworks PGP Key


For the contact, copyright, license, warranty and privacy terms for the usage of this website please see the contact, license, privacy, copyright.