From 1a5a0cd4071262f7019b89168d46783f76ce8c5d Mon Sep 17 00:00:00 2001 From: Elijah R Date: Sun, 23 Jun 2024 02:23:59 -0400 Subject: [PATCH] add geoip country flag support --- .gitignore | 3 + config.example.toml | 9 +++ cvmts/package.json | 1 + cvmts/src/CollabVMServer.ts | 47 +++++++++++++-- cvmts/src/GeoIPDownloader.ts | 107 +++++++++++++++++++++++++++++++++++ cvmts/src/IConfig.ts | 6 ++ cvmts/src/User.ts | 3 + cvmts/src/index.ts | 8 ++- yarn.lock | 103 +++++++++++++++++++++++++++++++++ 9 files changed, 281 insertions(+), 6 deletions(-) create mode 100644 cvmts/src/GeoIPDownloader.ts diff --git a/.gitignore b/.gitignore index 8c9ed6f..6fbbf6a 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ cvmts/attic # Guac-rs cvm-rs/target cvm-rs/index.node + +# geolite shit +**/geoip/ \ No newline at end of file diff --git a/config.example.toml b/config.example.toml index 4129359..e7f4f6c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -11,6 +11,15 @@ origin = false # Origins to accept connections from. originAllowedDomains = ["computernewb.com"] +[geoip] +# Enables support for showing country flags next to usernames. +enabled = false +# Directory to store and load GeoIP databases from. +directory = "geoip/" +# MaxMind license key and account ID (https://www.maxmind.com/en/accounts/current/license-key) +accountID = "" +licenseKey = "" + [tcp] enabled = false host = "0.0.0.0" diff --git a/cvmts/package.json b/cvmts/package.json index 496ede2..773823f 100644 --- a/cvmts/package.json +++ b/cvmts/package.json @@ -13,6 +13,7 @@ "dependencies": { "@cvmts/cvm-rs": "*", "@cvmts/qemu": "*", + "@maxmind/geoip2-node": "^5.0.0", "execa": "^8.0.1", "mnemonist": "^0.39.5", "sharp": "^0.33.3", diff --git a/cvmts/src/CollabVMServer.ts b/cvmts/src/CollabVMServer.ts index 39e3f10..538507a 100644 --- a/cvmts/src/CollabVMServer.ts +++ b/cvmts/src/CollabVMServer.ts @@ -14,6 +14,7 @@ import AuthManager from './AuthManager.js'; import { Size, Rect, Logger } from '@cvmts/shared'; import { JPEGEncoder } from './JPEGEncoder.js'; import VM from './VM.js'; +import { ReaderModel } from '@maxmind/geoip2-node'; // Instead of strange hacks we can just use nodejs provided // import.meta properties, which have existed since LTS if not before @@ -81,9 +82,12 @@ export default class CollabVMServer { // Authentication manager private auth: AuthManager | null; + // Geoip + private geoipReader: ReaderModel | null; + private logger = new Logger('CVMTS.Server'); - constructor(config: IConfig, vm: VM, auth: AuthManager | null) { + constructor(config: IConfig, vm: VM, auth: AuthManager | null, geoipReader: ReaderModel | null) { this.Config = config; this.ChatHistory = new CircularBuffer(Array, this.Config.collabvm.maxChatHistoryLength); this.TurnQueue = new Queue(); @@ -127,6 +131,8 @@ export default class CollabVMServer { // authentication manager this.auth = auth; + + this.geoipReader = geoipReader; } public addUser(user: User) { @@ -137,12 +143,20 @@ export default class CollabVMServer { sameip[0].kick(); } this.clients.push(user); + if (this.Config.geoip.enabled) { + try { + user.countryCode = this.geoipReader!.country(user.IP.address).country!.isoCode; + } catch (error) { + this.logger.Warning(`Failed to get country code for ${user.IP.address}: ${(error as Error).message}`); + } + } user.socket.on('msg', (msg: string) => this.onMessage(user, msg)); user.socket.on('disconnect', () => this.connectionClosed(user)); if (this.Config.auth.enabled) { user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint)); } user.sendMsg(this.getAdduserMsg()); + if (this.Config.geoip.enabled) user.sendMsg(this.getFlagMsg()); } private connectionClosed(user: User) { @@ -196,7 +210,14 @@ export default class CollabVMServer { await old.kick(); } // Set username - this.renameUser(client, res.username); + if (client.countryCode !== null && client.noFlag) { + // privacy + for (let cl of this.clients.filter(c => c !== client)) { + cl.sendMsg(cvm.guacEncode('remuser', '1', client.username!)); + } + this.renameUser(client, res.username, false); + } + else this.renameUser(client, res.username, true); // Set rank client.rank = res.rank; if (client.rank === Rank.Admin) { @@ -217,6 +238,11 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible')); } break; + case 'noflag': { + if (client.connectedToNode) // too late + return; + client.noFlag = true; + } case 'list': client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); break; @@ -652,7 +678,7 @@ export default class CollabVMServer { return arr; } - renameUser(client: User, newName?: string) { + renameUser(client: User, newName?: string, announce: boolean = true) { // This shouldn't need a ternary but it does for some reason var hadName: boolean = client.username ? true : false; var oldname: any; @@ -683,10 +709,13 @@ export default class CollabVMServer { client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); if (hadName) { this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); + if (announce) this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('rename', '1', oldname, client.username!, client.rank.toString()))); } else { this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); - this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString()))); + if (announce) this.clients.forEach((c) => { + c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())); + if (client.countryCode !== null) c.sendMsg(cvm.guacEncode('flag', client.username!, client.countryCode)); + }); } } @@ -697,6 +726,14 @@ export default class CollabVMServer { return cvm.guacEncode(...arr); } + getFlagMsg() : string { + var arr = ['flag']; + for (let c of this.clients.filter(cl => cl.countryCode !== null && cl.username && (!cl.noFlag || cl.rank === Rank.Unregistered))) { + arr.push(c.username!, c.countryCode!); + } + return cvm.guacEncode(...arr); + } + getChatHistoryMsg(): string { var arr: string[] = ['chat']; this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); diff --git a/cvmts/src/GeoIPDownloader.ts b/cvmts/src/GeoIPDownloader.ts new file mode 100644 index 0000000..ef297cf --- /dev/null +++ b/cvmts/src/GeoIPDownloader.ts @@ -0,0 +1,107 @@ +import { Logger } from '@cvmts/shared'; +import { Reader, ReaderModel } from '@maxmind/geoip2-node'; +import * as fs from 'fs/promises'; +import * as path from 'node:path'; +import { Readable } from 'node:stream'; +import { ReadableStream } from 'node:stream/web'; +import { finished } from 'node:stream/promises'; +import { execa } from 'execa'; + +export default class GeoIPDownloader { + private directory: string; + private accountID: string; + private licenseKey: string; + private logger: Logger + constructor(filename: string, accountID: string, licenseKey: string) { + this.directory = filename; + if (!this.directory.endsWith('/')) this.directory += '/'; + this.accountID = accountID; + this.licenseKey = licenseKey; + this.logger = new Logger('CVMTS.GeoIPDownloader'); + } + + private genAuthHeader(): string { + return `Basic ${Buffer.from(`${this.accountID}:${this.licenseKey}`).toString('base64')}`; + } + + private async ensureDirectoryExists(): Promise { + let stat; + try { + stat = await fs.stat(this.directory); + } + catch (e) { + var error = e as NodeJS.ErrnoException; + if (error.code === 'ENOTDIR') { + this.logger.Warning('File exists at GeoIP directory path, unlinking...'); + await fs.unlink(this.directory.substring(0, this.directory.length - 1)); + } else if (error.code !== 'ENOENT') { + this.logger.Error('Failed to access GeoIP directory: {0}', error.message); + process.exit(1); + } + this.logger.Info('Creating GeoIP directory: {0}', this.directory); + await fs.mkdir(this.directory, { recursive: true }); + return; + } + } + + async getGeoIPReader(): Promise { + await this.ensureDirectoryExists(); + let dbpath = path.join(this.directory, (await this.getLatestVersion()).replace('.tar.gz', ''), 'GeoLite2-Country.mmdb'); + try { + await fs.access(dbpath, fs.constants.F_OK | fs.constants.R_OK); + this.logger.Info('Loading cached GeoIP database: {0}', dbpath); + } catch (ex) { + var error = ex as NodeJS.ErrnoException; + if (error.code === 'ENOENT') { + await this.downloadLatestDatabase(); + } else { + this.logger.Error('Failed to access GeoIP database: {0}', error.message); + process.exit(1); + } + } + return await Reader.open(dbpath); + } + + async getLatestVersion(): Promise { + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + method: "HEAD", + headers: { + "Authorization": this.genAuthHeader() + } + }); + let disposition = res.headers.get('Content-Disposition'); + if (!disposition) { + this.logger.Error('Failed to get latest version of GeoIP database: No Content-Disposition header'); + process.exit(1); + } + let filename = disposition.match(/filename=(.*)$/); + if (!filename) { + this.logger.Error('Failed to get latest version of GeoIP database: Could not parse version from Content-Disposition header'); + process.exit(1); + } + return filename[1]; + } + + async downloadLatestDatabase(): Promise { + let filename = await this.getLatestVersion(); + this.logger.Info('Downloading latest GeoIP database: {0}', filename); + let dbpath = path.join(this.directory, filename); + let file = await fs.open(dbpath, fs.constants.O_CREAT | fs.constants.O_TRUNC | fs.constants.O_WRONLY); + let stream = file.createWriteStream(); + let res = await fetch('https://download.maxmind.com/geoip/databases/GeoLite2-Country/download?suffix=tar.gz', { + redirect: 'follow', + headers: { + "Authorization": this.genAuthHeader() + } + }); + await finished(Readable.fromWeb(res.body as ReadableStream).pipe(stream)); + await file.close(); + this.logger.Info('Finished downloading latest GeoIP database: {0}', filename); + this.logger.Info('Extracting GeoIP database: {0}', filename); + // yeah whatever + await execa('tar', ['xzf', filename], {cwd: this.directory}); + this.logger.Info('Unlinking GeoIP tarball'); + await fs.unlink(dbpath); + } +} \ No newline at end of file diff --git a/cvmts/src/IConfig.ts b/cvmts/src/IConfig.ts index c049ce6..6b72adc 100644 --- a/cvmts/src/IConfig.ts +++ b/cvmts/src/IConfig.ts @@ -9,6 +9,12 @@ export default interface IConfig { origin: boolean; originAllowedDomains: string[]; }; + geoip: { + enabled: boolean; + directory: string; + accountID: string; + licenseKey: string; + } tcp: { enabled: boolean; host: string; diff --git a/cvmts/src/User.ts b/cvmts/src/User.ts index 4b2b9d3..6f5bc2b 100644 --- a/cvmts/src/User.ts +++ b/cvmts/src/User.ts @@ -19,6 +19,9 @@ export class User { msgsSent: number; Config: IConfig; IP: IPData; + // Hide flag. Only takes effect if the user is logged in. + noFlag: boolean = false; + countryCode: string | null = null; // Rate limiters ChatRateLimit: RateLimiter; LoginRateLimit: RateLimiter; diff --git a/cvmts/src/index.ts b/cvmts/src/index.ts index fa85223..4aefa80 100644 --- a/cvmts/src/index.ts +++ b/cvmts/src/index.ts @@ -12,6 +12,7 @@ import { User } from './User.js'; import TCPServer from './TCP/TCPServer.js'; import VM from './VM.js'; import VNCVM from './VNCVM/VNCVM.js'; +import GeoIPDownloader from './GeoIPDownloader.js'; let logger = new Shared.Logger('CVMTS.Init'); @@ -44,6 +45,11 @@ async function stop() { } async function start() { + let geoipReader = null; + if (Config.geoip.enabled) { + let downloader = new GeoIPDownloader(Config.geoip.directory, Config.geoip.accountID, Config.geoip.licenseKey); + geoipReader = await downloader.getGeoIPReader(); + } // Init the auth manager if enabled let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; switch (Config.vm.type) { @@ -82,7 +88,7 @@ async function start() { await VM.Start(); // Start up the server - var CVM = new CollabVMServer(Config, VM, auth); + var CVM = new CollabVMServer(Config, VM, auth, geoipReader); var WS = new WSServer(Config); WS.on('connect', (client: User) => CVM.addUser(client)); diff --git a/yarn.lock b/yarn.lock index f0a0c55..1181e92 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,6 +61,7 @@ __metadata: dependencies: "@cvmts/cvm-rs": "npm:*" "@cvmts/qemu": "npm:*" + "@maxmind/geoip2-node": "npm:^5.0.0" "@types/node": "npm:^20.12.5" "@types/ws": "npm:^8.5.5" execa: "npm:^8.0.1" @@ -351,6 +352,16 @@ __metadata: languageName: node linkType: hard +"@maxmind/geoip2-node@npm:^5.0.0": + version: 5.0.0 + resolution: "@maxmind/geoip2-node@npm:5.0.0" + dependencies: + ip6addr: "npm:^0.2.5" + maxmind: "npm:^4.2.0" + checksum: 10c0/10f6c936b45632210210750b839578c610a3ceba06aff5db2a3d9da68b51b986caa7e700c78ab2ea02524b3793e4f21daee7ecfde1dc242241291e43833b7087 + languageName: node + linkType: hard + "@mischnic/json-sourcemap@npm:^0.1.0": version: 0.1.1 resolution: "@mischnic/json-sourcemap@npm:0.1.1" @@ -1648,6 +1659,13 @@ __metadata: languageName: node linkType: hard +"assert-plus@npm:1.0.0, assert-plus@npm:^1.0.0": + version: 1.0.0 + resolution: "assert-plus@npm:1.0.0" + checksum: 10c0/b194b9d50c3a8f872ee85ab110784911e696a4d49f7ee6fc5fb63216dedbefd2c55999c70cb2eaeb4cf4a0e0338b44e9ace3627117b5bf0d42460e9132f21b91 + languageName: node + linkType: hard + "balanced-match@npm:^1.0.0": version: 1.0.2 resolution: "balanced-match@npm:1.0.2" @@ -1887,6 +1905,13 @@ __metadata: languageName: node linkType: hard +"core-util-is@npm:1.0.2": + version: 1.0.2 + resolution: "core-util-is@npm:1.0.2" + checksum: 10c0/980a37a93956d0de8a828ce508f9b9e3317039d68922ca79995421944146700e4aaf490a6dbfebcb1c5292a7184600c7710b957d724be1e37b8254c6bc0fe246 + languageName: node + linkType: hard + "cosmiconfig@npm:^8.0.0": version: 8.3.6 resolution: "cosmiconfig@npm:8.3.6" @@ -2206,6 +2231,20 @@ __metadata: languageName: node linkType: hard +"extsprintf@npm:1.3.0": + version: 1.3.0 + resolution: "extsprintf@npm:1.3.0" + checksum: 10c0/f75114a8388f0cbce68e277b6495dc3930db4dde1611072e4a140c24e204affd77320d004b947a132e9a3b97b8253017b2b62dce661975fb0adced707abf1ab5 + languageName: node + linkType: hard + +"extsprintf@npm:^1.2.0": + version: 1.4.1 + resolution: "extsprintf@npm:1.4.1" + checksum: 10c0/e10e2769985d0e9b6c7199b053a9957589d02e84de42832c295798cb422a025e6d4a92e0259c1fb4d07090f5bfde6b55fd9f880ac5855bd61d775f8ab75a7ab0 + languageName: node + linkType: hard + "fd-slicer@npm:~1.1.0": version: 1.1.0 resolution: "fd-slicer@npm:1.1.0" @@ -2500,6 +2539,16 @@ __metadata: languageName: node linkType: hard +"ip6addr@npm:^0.2.5": + version: 0.2.5 + resolution: "ip6addr@npm:0.2.5" + dependencies: + assert-plus: "npm:^1.0.0" + jsprim: "npm:^2.0.2" + checksum: 10c0/aaa16f844d57d2c8afca375dabb42a62e6990ea044e397bf50e18bea8b445ae0978df6fae5898c898edfd6b58cc3d3c557f405a34792739be912cd303563a916 + languageName: node + linkType: hard + "is-arrayish@npm:^0.2.1": version: 0.2.1 resolution: "is-arrayish@npm:0.2.1" @@ -2633,6 +2682,13 @@ __metadata: languageName: node linkType: hard +"json-schema@npm:0.4.0": + version: 0.4.0 + resolution: "json-schema@npm:0.4.0" + checksum: 10c0/d4a637ec1d83544857c1c163232f3da46912e971d5bf054ba44fdb88f07d8d359a462b4aec46f2745efbc57053365608d88bc1d7b1729f7b4fc3369765639ed3 + languageName: node + linkType: hard + "json5@npm:^2.2.0, json5@npm:^2.2.1": version: 2.2.3 resolution: "json5@npm:2.2.3" @@ -2642,6 +2698,18 @@ __metadata: languageName: node linkType: hard +"jsprim@npm:^2.0.2": + version: 2.0.2 + resolution: "jsprim@npm:2.0.2" + dependencies: + assert-plus: "npm:1.0.0" + extsprintf: "npm:1.3.0" + json-schema: "npm:0.4.0" + verror: "npm:1.10.0" + checksum: 10c0/677be2d41df536c92c6d0114a492ef197084018cfbb1a3e10b1fa1aad889564b2e3a7baa6af7949cc2d73678f42368b0be165a26bd4e4de6883a30dd6a24e98d + languageName: node + linkType: hard + "just-install@npm:^2.0.1": version: 2.0.1 resolution: "just-install@npm:2.0.1" @@ -2832,6 +2900,16 @@ __metadata: languageName: node linkType: hard +"maxmind@npm:^4.2.0": + version: 4.3.20 + resolution: "maxmind@npm:4.3.20" + dependencies: + mmdb-lib: "npm:2.1.1" + tiny-lru: "npm:11.2.6" + checksum: 10c0/f21b366f7c2bf7f6853eeea52478e53dd1052ad75f6f45c270258d5ff023c5f4a85c577d6b1ebdb0a8734073e24df5ed66375cdac0c3159a8f8ae30c6535149d + languageName: node + linkType: hard + "mdn-data@npm:2.0.14": version: 2.0.14 resolution: "mdn-data@npm:2.0.14" @@ -2965,6 +3043,13 @@ __metadata: languageName: node linkType: hard +"mmdb-lib@npm:2.1.1": + version: 2.1.1 + resolution: "mmdb-lib@npm:2.1.1" + checksum: 10c0/675817303af64c21be02e9550ce885b6ffcc6fbbeae7959a189493ccf68c6b7bac74afa00376fd7a421ff2acd8f74f44fc7fd25aeed0675fc21dbc1a9d5df9f9 + languageName: node + linkType: hard + "mnemonist@npm:^0.39.5": version: 0.39.8 resolution: "mnemonist@npm:0.39.8" @@ -3760,6 +3845,13 @@ __metadata: languageName: node linkType: hard +"tiny-lru@npm:11.2.6": + version: 11.2.6 + resolution: "tiny-lru@npm:11.2.6" + checksum: 10c0/d59b2047edae1b4b79708070463ed27ddb1daa64563b74eedaa571e555c47f8de3a7cc19171f47dc46c01f1b7283d9afd2c682dddb4832552ed747d52cd297a6 + languageName: node + linkType: hard + "to-regex-range@npm:^5.0.1": version: 5.0.1 resolution: "to-regex-range@npm:5.0.1" @@ -3876,6 +3968,17 @@ __metadata: languageName: node linkType: hard +"verror@npm:1.10.0": + version: 1.10.0 + resolution: "verror@npm:1.10.0" + dependencies: + assert-plus: "npm:^1.0.0" + core-util-is: "npm:1.0.2" + extsprintf: "npm:^1.2.0" + checksum: 10c0/37ccdf8542b5863c525128908ac80f2b476eed36a32cb944de930ca1e2e78584cc435c4b9b4c68d0fc13a47b45ff364b4be43aa74f8804f9050140f660fb660d + languageName: node + linkType: hard + "weak-lru-cache@npm:^1.2.2": version: 1.2.2 resolution: "weak-lru-cache@npm:1.2.2"