netplaySniff – Rev 13

Subversion Repositories:
Rev:
#!/usr/bin/env node
///////////////////////////////////////////////////////////////////////////
//    Copyright (C) 2024 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 crypto = require('crypto')
const { exec } = require("child_process")
const Inotify = require('inotify-remastered').Inotify
const inotify = new Inotify()
const sqlite = require('sqlite3')
const { Command } = require('commander')

// set up logger
const logger = createLogger({
    format: format.combine(
        format.timestamp({
            format: 'YYYYMMDDHHmmss'
        }),
        format.printf(info =>
            `${info.timestamp} ${info.level}: ${info.message}` + (info.splat !== undefined ? `${info.splat}` : " ")
        )
    ),
    transports: [
        new transports.Console({
            timestamp: true
        }),
        new transports.File(
            {
                timestamp: true,
                filename: path.join(path.dirname(fs.realpathSync(__filename)), 'log/netplaySniff.log')
            }
        )
    ]
})

const program = new Command()
program
    .name('netplaySniff')
    .description(`
  +--+
 |    | Monitor netplay traffic, sniff users and their
 | || | IP addresses and store them in a database.
  +--+
`)
    .version('1.0')

program
    .command('run')
    .option('-c, --config <path>', 'path to the configuration file', 'config.yml')
    .option('-d, --database <path>', 'the path where to store a database', 'db/players.db')
    .description('run the program as a daemon')
    .action((options) => {
        logger.info(`running as a daemon with configuration from ${options.config} and database at ${options.database}`)

        // load configuration file.
        var config = YAML.load(options.config)

        // Watch the configuration file for changes.
        inotify.addWatch({
            path: options.config,
            watch_for: Inotify.IN_MODIFY,
            callback: function (event) {
                logger.info(`Reloading configuration file config.yml`)
                config = YAML.load(options.config)
            }
        })

        const mqttClient = mqtt.connect(config.mqtt.connect)

        mqttClient.on('reconnect', () => {
            logger.info('Reconnecting to MQTT server...')
        })

        mqttClient.on('connect', () => {
            logger.info('Connected to 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 MQTT server.')
                    return
                }

                logger.info('Subscribed to MQTT server.')
            })
        })

        mqttClient.on('close', () => {
            logger.error('Disconnected from MQTT server.')
        })

        mqttClient.on('error', (error) => {
            logger.error(`MQTT ${error}`)
            console.log(error)
        })

        // set up packet capture
        logger.info(`setting up packet capture`)

        const cap = new Cap()
        const filter = `tcp and dst port ${config.netplay.port}`
        const bufSize = 10 * 1024 * 1024
        const buffer = Buffer.alloc(65535)
        var device = Cap.findDevice(config.listen);
        const linkType = cap.open(device, filter, bufSize, buffer)
        cap.setMinBytes && cap.setMinBytes(0)
        cap.on('packet', () => {
            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.src = ret.info.srcaddr
            netplay.dst = ret.info.dstaddr
            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, '')
            var shasum = crypto.createHash('sha1')
            shasum.update(`${netplay.nick}${netplay.src}`)
            netplay.hash = shasum.digest('hex')
            netplay.time = new Date().toISOString()

            logger.info(`Player ${netplay.nick} joined via IP ${netplay.src}`);

            const db = new sqlite.Database(config.db.file, sqlite.OPEN_CREATE | sqlite.OPEN_READWRITE | sqlite.OPEN_FULLMUTEX, (error) => {
                if (error) {
                    logger.error(`failed to open database: ${config.db.file}`)
                    return
                }

                db.run(`CREATE TABLE IF NOT EXISTS "players" ("hash" TEXT(40) NOT NULL PRIMARY KEY, "nick" TEXT(15) NOT NULL, "ip" TEXT NOT NULL, "date" TEXT NOT NULL)`, (error, result) => {
                    if (error) {
                        logger.error(`could not create database table: ${error}`);
                        return
                    }
                    db.run(`REPLACE INTO "players" ("hash", "nick", "ip", "date") VALUES ($hash, $nick, $ip, DateTime('now'))`, { $hash: netplay.hash, $nick: netplay.nick, $ip: netplay.src }, (error) => {
                        if (error) {
                            logger.error(`could not insert player and IP into database: ${error}`)
                            return
                        }

                        logger.info(`player added to database`)
                    })
                })
            })

            // send data to MQTT server
            const data = JSON.stringify(netplay, null, 4)
            mqttClient.publish(config.mqtt.topic, data, (error, packet) => {
                logger.info(`player data sent to MQTT broker`)
            })

            // ban by nick.
            let nickBanSet = new Set(config.bans.nicknames)
            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.src} --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
                    }
                })
            }
        })
    })

program.parse()

Generated by GNU Enscript 1.6.5.90.