netplaySniff – Rev 5

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 shortHash = require('short-hash')
const { exec } = require("child_process")
const Inotify = require('inotify-remastered').Inotify
const inotify = new Inotify()
const sqlite = require('sqlite3')

// load configuration file.
let config = YAML.load('config.yml')

// 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")
            }
        )
    ]
})

// 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 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)
})

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()
    
    logger.info(`Player ${netplay.nick} joined via IP ${netplay.ip}`);
    
    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" ("nick" TEXT(15) NOT NULL, "ip" TEXT NOT NULL)`, (error, result) => {
            if(error) {
                logger.error(`could not create database table: ${error}`);
                return
            }
            db.run(`INSERT INTO "players" ("nick", "ip") VALUES ($nick, $ip)`, { $nick: netplay.nick, $ip: netplay.ip }, (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.
    if(nickBanSet.has(netplay.nick)) {
        logger.info(`nick found to be banned: ${netplay.nick}`)
        exec(`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
            }
        })
    }

})