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

View File

@@ -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<ChatHistory>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>();
@@ -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));

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;
originAllowedDomains: string[];
};
geoip: {
enabled: boolean;
directory: string;
accountID: string;
licenseKey: string;
}
tcp: {
enabled: boolean;
host: string;

View File

@@ -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;

View File

@@ -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));