add geoip country flag support
This commit is contained in:
@@ -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));
|
||||
|
||||
107
cvmts/src/GeoIPDownloader.ts
Normal file
107
cvmts/src/GeoIPDownloader.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user