add geoip country flag support

This commit is contained in:
Elijah R
2024-06-23 02:23:59 -04:00
parent 85a86327f4
commit 1a5a0cd407
9 changed files with 281 additions and 6 deletions

3
.gitignore vendored
View File

@@ -12,3 +12,6 @@ cvmts/attic
# Guac-rs # Guac-rs
cvm-rs/target cvm-rs/target
cvm-rs/index.node cvm-rs/index.node
# geolite shit
**/geoip/

View File

@@ -11,6 +11,15 @@ origin = false
# Origins to accept connections from. # Origins to accept connections from.
originAllowedDomains = ["computernewb.com"] 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] [tcp]
enabled = false enabled = false
host = "0.0.0.0" host = "0.0.0.0"

View File

@@ -13,6 +13,7 @@
"dependencies": { "dependencies": {
"@cvmts/cvm-rs": "*", "@cvmts/cvm-rs": "*",
"@cvmts/qemu": "*", "@cvmts/qemu": "*",
"@maxmind/geoip2-node": "^5.0.0",
"execa": "^8.0.1", "execa": "^8.0.1",
"mnemonist": "^0.39.5", "mnemonist": "^0.39.5",
"sharp": "^0.33.3", "sharp": "^0.33.3",

View File

@@ -14,6 +14,7 @@ import AuthManager from './AuthManager.js';
import { Size, Rect, Logger } from '@cvmts/shared'; import { Size, Rect, Logger } from '@cvmts/shared';
import { JPEGEncoder } from './JPEGEncoder.js'; import { JPEGEncoder } from './JPEGEncoder.js';
import VM from './VM.js'; import VM from './VM.js';
import { ReaderModel } from '@maxmind/geoip2-node';
// Instead of strange hacks we can just use nodejs provided // Instead of strange hacks we can just use nodejs provided
// import.meta properties, which have existed since LTS if not before // import.meta properties, which have existed since LTS if not before
@@ -81,9 +82,12 @@ export default class CollabVMServer {
// Authentication manager // Authentication manager
private auth: AuthManager | null; private auth: AuthManager | null;
// Geoip
private geoipReader: ReaderModel | null;
private logger = new Logger('CVMTS.Server'); 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.Config = config;
this.ChatHistory = new CircularBuffer<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength); this.ChatHistory = new CircularBuffer<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>(); this.TurnQueue = new Queue<User>();
@@ -127,6 +131,8 @@ export default class CollabVMServer {
// authentication manager // authentication manager
this.auth = auth; this.auth = auth;
this.geoipReader = geoipReader;
} }
public addUser(user: User) { public addUser(user: User) {
@@ -137,12 +143,20 @@ export default class CollabVMServer {
sameip[0].kick(); sameip[0].kick();
} }
this.clients.push(user); 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('msg', (msg: string) => this.onMessage(user, msg));
user.socket.on('disconnect', () => this.connectionClosed(user)); user.socket.on('disconnect', () => this.connectionClosed(user));
if (this.Config.auth.enabled) { if (this.Config.auth.enabled) {
user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint)); user.sendMsg(cvm.guacEncode('auth', this.Config.auth.apiEndpoint));
} }
user.sendMsg(this.getAdduserMsg()); user.sendMsg(this.getAdduserMsg());
if (this.Config.geoip.enabled) user.sendMsg(this.getFlagMsg());
} }
private connectionClosed(user: User) { private connectionClosed(user: User) {
@@ -196,7 +210,14 @@ export default class CollabVMServer {
await old.kick(); await old.kick();
} }
// Set username // 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 // Set rank
client.rank = res.rank; client.rank = res.rank;
if (client.rank === Rank.Admin) { 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')); client.sendMsg(cvm.guacEncode('login', '0', 'There was an internal error while authenticating. Please let a staff member know as soon as possible'));
} }
break; break;
case 'noflag': {
if (client.connectedToNode) // too late
return;
client.noFlag = true;
}
case 'list': case 'list':
client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail())); client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail()));
break; break;
@@ -652,7 +678,7 @@ export default class CollabVMServer {
return arr; 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 // This shouldn't need a ternary but it does for some reason
var hadName: boolean = client.username ? true : false; var hadName: boolean = client.username ? true : false;
var oldname: any; var oldname: any;
@@ -683,10 +709,13 @@ export default class CollabVMServer {
client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString())); client.sendMsg(cvm.guacEncode('rename', '0', status, client.username!, client.rank.toString()));
if (hadName) { if (hadName) {
this.logger.Info(`Rename ${client.IP.address} from ${oldname} to ${client.username}`); 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 { } else {
this.logger.Info(`Rename ${client.IP.address} to ${client.username}`); 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); 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 { getChatHistoryMsg(): string {
var arr: string[] = ['chat']; var arr: string[] = ['chat'];
this.ChatHistory.forEach((c) => arr.push(c.user, c.msg)); this.ChatHistory.forEach((c) => arr.push(c.user, c.msg));

View File

@@ -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<void> {
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<ReaderModel> {
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<string> {
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<void> {
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<any>).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);
}
}

View File

@@ -9,6 +9,12 @@ export default interface IConfig {
origin: boolean; origin: boolean;
originAllowedDomains: string[]; originAllowedDomains: string[];
}; };
geoip: {
enabled: boolean;
directory: string;
accountID: string;
licenseKey: string;
}
tcp: { tcp: {
enabled: boolean; enabled: boolean;
host: string; host: string;

View File

@@ -19,6 +19,9 @@ export class User {
msgsSent: number; msgsSent: number;
Config: IConfig; Config: IConfig;
IP: IPData; IP: IPData;
// Hide flag. Only takes effect if the user is logged in.
noFlag: boolean = false;
countryCode: string | null = null;
// Rate limiters // Rate limiters
ChatRateLimit: RateLimiter; ChatRateLimit: RateLimiter;
LoginRateLimit: RateLimiter; LoginRateLimit: RateLimiter;

View File

@@ -12,6 +12,7 @@ import { User } from './User.js';
import TCPServer from './TCP/TCPServer.js'; import TCPServer from './TCP/TCPServer.js';
import VM from './VM.js'; import VM from './VM.js';
import VNCVM from './VNCVM/VNCVM.js'; import VNCVM from './VNCVM/VNCVM.js';
import GeoIPDownloader from './GeoIPDownloader.js';
let logger = new Shared.Logger('CVMTS.Init'); let logger = new Shared.Logger('CVMTS.Init');
@@ -44,6 +45,11 @@ async function stop() {
} }
async function start() { 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 // Init the auth manager if enabled
let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null; let auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
switch (Config.vm.type) { switch (Config.vm.type) {
@@ -82,7 +88,7 @@ async function start() {
await VM.Start(); await VM.Start();
// Start up the server // Start up the server
var CVM = new CollabVMServer(Config, VM, auth); var CVM = new CollabVMServer(Config, VM, auth, geoipReader);
var WS = new WSServer(Config); var WS = new WSServer(Config);
WS.on('connect', (client: User) => CVM.addUser(client)); WS.on('connect', (client: User) => CVM.addUser(client));

103
yarn.lock
View File

@@ -61,6 +61,7 @@ __metadata:
dependencies: dependencies:
"@cvmts/cvm-rs": "npm:*" "@cvmts/cvm-rs": "npm:*"
"@cvmts/qemu": "npm:*" "@cvmts/qemu": "npm:*"
"@maxmind/geoip2-node": "npm:^5.0.0"
"@types/node": "npm:^20.12.5" "@types/node": "npm:^20.12.5"
"@types/ws": "npm:^8.5.5" "@types/ws": "npm:^8.5.5"
execa: "npm:^8.0.1" execa: "npm:^8.0.1"
@@ -351,6 +352,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "@mischnic/json-sourcemap@npm:^0.1.0":
version: 0.1.1 version: 0.1.1
resolution: "@mischnic/json-sourcemap@npm:0.1.1" resolution: "@mischnic/json-sourcemap@npm:0.1.1"
@@ -1648,6 +1659,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "balanced-match@npm:^1.0.0":
version: 1.0.2 version: 1.0.2
resolution: "balanced-match@npm:1.0.2" resolution: "balanced-match@npm:1.0.2"
@@ -1887,6 +1905,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "cosmiconfig@npm:^8.0.0":
version: 8.3.6 version: 8.3.6
resolution: "cosmiconfig@npm:8.3.6" resolution: "cosmiconfig@npm:8.3.6"
@@ -2206,6 +2231,20 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "fd-slicer@npm:~1.1.0":
version: 1.1.0 version: 1.1.0
resolution: "fd-slicer@npm:1.1.0" resolution: "fd-slicer@npm:1.1.0"
@@ -2500,6 +2539,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "is-arrayish@npm:^0.2.1":
version: 0.2.1 version: 0.2.1
resolution: "is-arrayish@npm:0.2.1" resolution: "is-arrayish@npm:0.2.1"
@@ -2633,6 +2682,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "json5@npm:^2.2.0, json5@npm:^2.2.1":
version: 2.2.3 version: 2.2.3
resolution: "json5@npm:2.2.3" resolution: "json5@npm:2.2.3"
@@ -2642,6 +2698,18 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "just-install@npm:^2.0.1":
version: 2.0.1 version: 2.0.1
resolution: "just-install@npm:2.0.1" resolution: "just-install@npm:2.0.1"
@@ -2832,6 +2900,16 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "mdn-data@npm:2.0.14":
version: 2.0.14 version: 2.0.14
resolution: "mdn-data@npm:2.0.14" resolution: "mdn-data@npm:2.0.14"
@@ -2965,6 +3043,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "mnemonist@npm:^0.39.5":
version: 0.39.8 version: 0.39.8
resolution: "mnemonist@npm:0.39.8" resolution: "mnemonist@npm:0.39.8"
@@ -3760,6 +3845,13 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "to-regex-range@npm:^5.0.1":
version: 5.0.1 version: 5.0.1
resolution: "to-regex-range@npm:5.0.1" resolution: "to-regex-range@npm:5.0.1"
@@ -3876,6 +3968,17 @@ __metadata:
languageName: node languageName: node
linkType: hard 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": "weak-lru-cache@npm:^1.2.2":
version: 1.2.2 version: 1.2.2
resolution: "weak-lru-cache@npm:1.2.2" resolution: "weak-lru-cache@npm:1.2.2"