netplaySniff – Blame information for rev 13
?pathlinks?
Rev | Author | Line No. | Line |
---|---|---|---|
1 | office | 1 | #!/usr/bin/env node |
2 | /////////////////////////////////////////////////////////////////////////// |
||
2 | office | 3 | // Copyright (C) 2024 Wizardry and Steamworks - License: MIT // |
1 | office | 4 | /////////////////////////////////////////////////////////////////////////// |
5 | |||
6 | const fs = require('fs') |
||
7 | const path = require('path') |
||
8 | const { createLogger, format, transports } = require('winston') |
||
9 | const mqtt = require('mqtt') |
||
10 | const YAML = require('yamljs') |
||
11 | const Cap = require('cap').Cap |
||
12 | const decoders = require('cap').decoders |
||
13 | const PROTOCOL = decoders.PROTOCOL |
||
12 | office | 14 | const crypto = require('crypto') |
1 | office | 15 | const { exec } = require("child_process") |
16 | const Inotify = require('inotify-remastered').Inotify |
||
17 | const inotify = new Inotify() |
||
5 | office | 18 | const sqlite = require('sqlite3') |
6 | office | 19 | const { Command } = require('commander') |
1 | office | 20 | |
6 | office | 21 | // set up logger |
1 | office | 22 | const logger = createLogger({ |
23 | format: format.combine( |
||
2 | office | 24 | format.timestamp({ |
25 | format: 'YYYYMMDDHHmmss' |
||
26 | }), |
||
6 | office | 27 | format.printf(info => |
28 | `${info.timestamp} ${info.level}: ${info.message}` + (info.splat !== undefined ? `${info.splat}` : " ") |
||
2 | office | 29 | ) |
1 | office | 30 | ), |
31 | transports: [ |
||
32 | new transports.Console({ |
||
33 | timestamp: true |
||
34 | }), |
||
35 | new transports.File( |
||
36 | { |
||
37 | timestamp: true, |
||
10 | office | 38 | filename: path.join(path.dirname(fs.realpathSync(__filename)), 'log/netplaySniff.log') |
1 | office | 39 | } |
40 | ) |
||
41 | ] |
||
42 | }) |
||
43 | |||
6 | office | 44 | const program = new Command() |
45 | program |
||
46 | .name('netplaySniff') |
||
47 | .description(` |
||
48 | +--+ |
||
49 | | | Monitor netplay traffic, sniff users and their |
||
50 | | || | IP addresses and store them in a database. |
||
51 | +--+ |
||
52 | `) |
||
53 | .version('1.0') |
||
1 | office | 54 | |
6 | office | 55 | program |
56 | .command('run') |
||
57 | .option('-c, --config <path>', 'path to the configuration file', 'config.yml') |
||
58 | .option('-d, --database <path>', 'the path where to store a database', 'db/players.db') |
||
59 | .description('run the program as a daemon') |
||
60 | .action((options) => { |
||
10 | office | 61 | logger.info(`running as a daemon with configuration from ${options.config} and database at ${options.database}`) |
1 | office | 62 | |
10 | office | 63 | // load configuration file. |
64 | var config = YAML.load(options.config) |
||
1 | office | 65 | |
6 | office | 66 | // Watch the configuration file for changes. |
10 | office | 67 | inotify.addWatch({ |
6 | office | 68 | path: options.config, |
69 | watch_for: Inotify.IN_MODIFY, |
||
70 | callback: function (event) { |
||
71 | logger.info(`Reloading configuration file config.yml`) |
||
72 | config = YAML.load(options.config) |
||
73 | } |
||
74 | }) |
||
1 | office | 75 | |
6 | office | 76 | const mqttClient = mqtt.connect(config.mqtt.connect) |
1 | office | 77 | |
6 | office | 78 | mqttClient.on('reconnect', () => { |
79 | logger.info('Reconnecting to MQTT server...') |
||
80 | }) |
||
1 | office | 81 | |
6 | office | 82 | mqttClient.on('connect', () => { |
83 | logger.info('Connected to MQTT server.') |
||
84 | // Subscribe to group message notifications with group name and password. |
||
85 | mqttClient.subscribe(`${config.mqtt.topic}`, (error) => { |
||
86 | if (error) { |
||
87 | logger.info('Error subscribing to MQTT server.') |
||
88 | return |
||
89 | } |
||
1 | office | 90 | |
6 | office | 91 | logger.info('Subscribed to MQTT server.') |
92 | }) |
||
93 | }) |
||
1 | office | 94 | |
6 | office | 95 | mqttClient.on('close', () => { |
96 | logger.error('Disconnected from MQTT server.') |
||
97 | }) |
||
1 | office | 98 | |
6 | office | 99 | mqttClient.on('error', (error) => { |
100 | logger.error(`MQTT ${error}`) |
||
101 | console.log(error) |
||
102 | }) |
||
1 | office | 103 | |
6 | office | 104 | // set up packet capture |
10 | office | 105 | logger.info(`setting up packet capture`) |
106 | |||
6 | office | 107 | const cap = new Cap() |
10 | office | 108 | const filter = `tcp and dst port ${config.netplay.port}` |
6 | office | 109 | const bufSize = 10 * 1024 * 1024 |
110 | const buffer = Buffer.alloc(65535) |
||
12 | office | 111 | var device = Cap.findDevice(config.listen); |
11 | office | 112 | const linkType = cap.open(device, filter, bufSize, buffer) |
6 | office | 113 | cap.setMinBytes && cap.setMinBytes(0) |
10 | office | 114 | cap.on('packet', () => { |
115 | let netplay = {} |
||
116 | if (linkType !== 'ETHERNET') { |
||
117 | return |
||
118 | } |
||
1 | office | 119 | |
10 | office | 120 | var ret = decoders.Ethernet(buffer) |
121 | if (ret.info.type !== PROTOCOL.ETHERNET.IPV4) { |
||
122 | return |
||
123 | } |
||
1 | office | 124 | |
10 | office | 125 | ret = decoders.IPV4(buffer, ret.offset) |
12 | office | 126 | netplay.src = ret.info.srcaddr |
127 | netplay.dst = ret.info.dstaddr |
||
10 | office | 128 | if (ret.info.protocol !== PROTOCOL.IP.TCP) { |
129 | return |
||
130 | } |
||
1 | office | 131 | |
10 | office | 132 | var dataLength = ret.info.totallen - ret.hdrlen |
1 | office | 133 | |
10 | office | 134 | ret = decoders.TCP(buffer, ret.offset) |
135 | dataLength -= ret.hdrlen |
||
1 | office | 136 | |
10 | office | 137 | var payload = buffer.subarray(ret.offset, ret.offset + dataLength) |
138 | // look for the NETPLAY_CMD_NICK in "netplay_private.h" data marker. |
||
139 | if (payload.indexOf('0020', 0, "hex") !== 2) { |
||
140 | return |
||
141 | } |
||
1 | office | 142 | |
10 | office | 143 | // remove NULL and NETPLAY_CMD_NICK |
144 | netplay.nick = payload.toString().replace(/[\u0000\u0020]+/gi, '') |
||
12 | office | 145 | var shasum = crypto.createHash('sha1') |
146 | shasum.update(`${netplay.nick}${netplay.src}`) |
||
147 | netplay.hash = shasum.digest('hex') |
||
10 | office | 148 | netplay.time = new Date().toISOString() |
1 | office | 149 | |
12 | office | 150 | logger.info(`Player ${netplay.nick} joined via IP ${netplay.src}`); |
1 | office | 151 | |
10 | office | 152 | const db = new sqlite.Database(config.db.file, sqlite.OPEN_CREATE | sqlite.OPEN_READWRITE | sqlite.OPEN_FULLMUTEX, (error) => { |
6 | office | 153 | if (error) { |
10 | office | 154 | logger.error(`failed to open database: ${config.db.file}`) |
2 | office | 155 | return |
156 | } |
||
10 | office | 157 | |
13 | office | 158 | 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) => { |
6 | office | 159 | if (error) { |
10 | office | 160 | logger.error(`could not create database table: ${error}`); |
6 | office | 161 | return |
162 | } |
||
13 | office | 163 | 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) => { |
10 | office | 164 | if (error) { |
165 | logger.error(`could not insert player and IP into database: ${error}`) |
||
166 | return |
||
167 | } |
||
6 | office | 168 | |
10 | office | 169 | logger.info(`player added to database`) |
170 | }) |
||
6 | office | 171 | }) |
2 | office | 172 | }) |
173 | |||
10 | office | 174 | // send data to MQTT server |
175 | const data = JSON.stringify(netplay, null, 4) |
||
12 | office | 176 | mqttClient.publish(config.mqtt.topic, data, (error, packet) => { |
10 | office | 177 | logger.info(`player data sent to MQTT broker`) |
178 | }) |
||
179 | |||
180 | // ban by nick. |
||
181 | let nickBanSet = new Set(config.bans.nicknames) |
||
182 | if (nickBanSet.has(netplay.nick)) { |
||
183 | logger.info(`nick found to be banned: ${netplay.nick}`) |
||
13 | office | 184 | exec(`/usr/sbin/iptables -t mangle -A PREROUTING -p tcp --src ${netplay.src} --dport ${config.netplay.port} -j DROP`, (error, stdout, stderr) => { |
10 | office | 185 | if (error) { |
186 | logger.error(`Error returned while banning connecting client ${error.message}`) |
||
187 | return |
||
188 | } |
||
189 | if (stderr) { |
||
190 | logger.error(`Standard error returned ${stderr}`) |
||
191 | return |
||
192 | } |
||
193 | if (stdout) { |
||
194 | logger.info(`Standard error reported while banning ${typeof stdout}`) |
||
195 | return |
||
196 | } |
||
197 | }) |
||
198 | } |
||
6 | office | 199 | }) |
5 | office | 200 | }) |
2 | office | 201 | |
6 | office | 202 | program.parse() |