Merge pull request #21 from computernewb/crusttest-refactoring

merge refactor branch
This commit is contained in:
Elijah R
2024-07-30 14:18:11 -04:00
committed by GitHub
61 changed files with 7618 additions and 2024 deletions

11
.editorconfig Normal file
View File

@@ -0,0 +1,11 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
indent_style = tab
indent_size = 4
# specifically for YAML
[{yml, yaml}]
indent_style = space

18
.gitignore vendored
View File

@@ -1,3 +1,17 @@
node_modules/
build/
.parcel-cache/
**/.yarn/
**/node_modules/
config.toml
# for now
cvmts/attic
/dist
**/dist/
# Guac-rs
cvm-rs/target
cvm-rs/index.node
# geolite shit
**/geoip/

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "collab-vm-1.2-binary-protocol"]
path = collab-vm-1.2-binary-protocol
url = https://github.com/computernewb/collab-vm-1.2-binary-protocol

1
.npmrc
View File

@@ -1 +0,0 @@
package-lock=false

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
dist
*.md
**/package.json

20
.prettierrc.json Normal file
View File

@@ -0,0 +1,20 @@
{
"arrowParens": "always",
"bracketSameLine": false,
"bracketSpacing": true,
"embeddedLanguageFormatting": "auto",
"htmlWhitespaceSensitivity": "css",
"insertPragma": false,
"jsxSingleQuote": true,
"printWidth": 200,
"proseWrap": "preserve",
"quoteProps": "consistent",
"requirePragma": false,
"semi": true,
"singleAttributePerLine": false,
"singleQuote": true,
"tabWidth": 4,
"trailingComma": "none",
"useTabs": true,
"vueIndentScriptAndStyle": false
}

1
.yarnrc.yml Normal file
View File

@@ -0,0 +1 @@
nodeLinker: node-modules

7
Justfile Normal file
View File

@@ -0,0 +1,7 @@
all:
yarn workspace @cvmts/cvm-rs run build
yarn workspace @cvmts/collab-vm-1.2-binary-protocol run build
yarn workspace @cvmts/cvmts run build
pkg:
yarn

View File

@@ -1,18 +1,35 @@
# CollabVM1.ts
This is a drop-in replacement for the dying CollabVM 1.2.11. Currently in beta
## Running
1. Copy config.example.toml to config.toml, and fill out fields
2. Install dependencies: `npm i`
3. Build it: `npm run build`
4. Run it: `npm run serve`
## Compatibility
## FAQ
### When I try to access the admin panel, the server crashes!
The server does not support the admin panel. Instead, there is a configuration file you can edit named config.toml.
### Why only QEMU? Why not VMWare, VirtualBox, etc.?
This server was written very quickly to replace CollabVM Server 1.2.11, and so only QEMU support exists. There are plans to support VMWare when CollabVM Server 3 releases.
### What platforms can this be run on?
If it can run a relatively new version of Node and QEMU, then you can run this. This means modern Linux distributions, modern macOS versions and Windows 10 and above.
### When the VM shuts off, instead of restarting, it freezes.
This has been fixed already, you are running a copy of the code before February 11th, 2023.
The CollabVM server will run on any Operating System that can run Node.JS and Rust. This means modern linux distributions and Windows versions.
We do not recommend or support running CollabVM Server on Windows due to very poor support for QEMU on that platform.
## Dependencies
The CollabVM server requires the following to be installed on your server:
1. Node.js (obviously)
2. QEMU (Unless you just want to use a VNC Connection as your VM)
3. A Rust toolchain (e.g: [rustup](https://rustup.rs))
4. NASM assembler
### Installing dependencies on Arch
1. Install dependencies: `sudo pacman --needed --noconfirm -Sy nodejs nasm rust`
2. Enable corepack: `sudo corepack enable`
### Installing dependencies on Debian
TODO
## Running
**TODO**: These instructions are not finished for the refactor branch.
1. Copy config.example.toml to config.toml, and fill out fields
2. Install dependencies: `yarn`
3. Build it: `yarn build`
4. Run it: `yarn serve`

View File

@@ -10,8 +10,20 @@ proxyAllowedIps = ["127.0.0.1"]
origin = false
# Origins to accept connections from.
originAllowedDomains = ["computernewb.com"]
# Maximum amount of active connections allowed from the same IP.
maxConnections = 3
[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"
port = 6014
[auth]
enabled = false
@@ -25,6 +37,9 @@ callForReset = false
vote = true
[vm]
type = "qemu"
[qemu]
qemuArgs = "qemu-system-x86_64"
vncPort = 5900
snapshots = true
@@ -34,10 +49,20 @@ snapshots = true
# Comment out qmpSockDir if you're using Windows.
qmpSockDir = "/tmp/"
[vncvm]
vncHost = "127.0.0.1"
vncPort = 5900
# startCmd = ""
# stopCmd = ""
# rebootCmd = ""
# restoreCmd = ""
[collabvm]
node = "acoolvm"
displayname = "A <b>Really</b> Cool CollabVM Instance"
motd = "welcome!"
# Maximum amount of active connections allowed from the same IP.
maxConnections = 3
# Command used to ban an IP.
# Use $IP to specify an ip and (optionally) use $NAME to specify a username
bancmd = "iptables -A INPUT -s $IP -j REJECT"

350
cvm-rs/Cargo.lock generated Normal file
View File

@@ -0,0 +1,350 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "addr2line"
version = "0.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4503c46a5c0c7844e948c9a4d6acd9f50cccb4de1c48eb9e291ea17470c678"
dependencies = [
"gimli",
]
[[package]]
name = "adler"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe"
[[package]]
name = "anyhow"
version = "1.0.86"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b3d1d046238990b9cf5bcde22a3fb3584ee5cf65fb2765f454ed428c7a0063da"
[[package]]
name = "backtrace"
version = "0.3.73"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5cc23269a4f8976d0a4d2e7109211a419fe30e8d88d677cd60b6bc79c5732e0a"
dependencies = [
"addr2line",
"cc",
"cfg-if",
"libc",
"miniz_oxide",
"object",
"rustc-demangle",
]
[[package]]
name = "cc"
version = "1.0.99"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96c51067fd44124faa7f870b4b1c969379ad32b2ba805aa959430ceaa384f695"
[[package]]
name = "cfg-if"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd"
[[package]]
name = "cmake"
version = "0.1.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31c789563b815f77f4250caee12365734369f942439b7defd71e18a48197130"
dependencies = [
"cc",
]
[[package]]
name = "cvm-rs"
version = "0.1.0"
dependencies = [
"neon",
"once_cell",
"tokio",
"turbojpeg-sys",
]
[[package]]
name = "getrandom"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7"
dependencies = [
"cfg-if",
"libc",
"wasi",
]
[[package]]
name = "gimli"
version = "0.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ecd4077b5ae9fd2e9e169b102c6c330d0605168eb0e8bf79952b256dbefffd"
[[package]]
name = "hermit-abi"
version = "0.3.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231dfb89cfffdbc30e7fc41579ed6066ad03abda9e567ccafae602b97ec5024"
[[package]]
name = "libc"
version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libloading"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c2a198fb6b0eada2a8df47933734e6d35d350665a33a3593d7164fa52c75c19"
dependencies = [
"cfg-if",
"windows-targets",
]
[[package]]
name = "memchr"
version = "2.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3"
[[package]]
name = "miniz_oxide"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08"
dependencies = [
"adler",
]
[[package]]
name = "neon"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d75440242411c87dc39847b0e33e961ec1f10326a9d8ecf9c1ea64a3b3c13dc"
dependencies = [
"getrandom",
"libloading",
"neon-macros",
"once_cell",
"semver",
"send_wrapper",
"smallvec",
]
[[package]]
name = "neon-macros"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6813fde79b646e47e7ad75f480aa80ef76a5d9599e2717407961531169ee38b"
dependencies = [
"quote",
"syn",
"syn-mid",
]
[[package]]
name = "num_cpus"
version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43"
dependencies = [
"hermit-abi",
"libc",
]
[[package]]
name = "object"
version = "0.36.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "576dfe1fc8f9df304abb159d767a29d0476f7750fbf8aa7ad07816004a207434"
dependencies = [
"memchr",
]
[[package]]
name = "once_cell"
version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92"
[[package]]
name = "pin-project-lite"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02"
[[package]]
name = "pkg-config"
version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d231b230927b5e4ad203db57bbcbee2802f6bce620b1e4a9024a07d94e2907ec"
[[package]]
name = "proc-macro2"
version = "1.0.85"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "22244ce15aa966053a896d1accb3a6e68469b97c7f33f284b99f0d576879fc23"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa76aaf39101c457836aec0ce2316dbdc3ab723cdda1c6bd4e6ad4208acaca7"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustc-demangle"
version = "0.1.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "719b953e2095829ee67db738b3bfa9fa368c94900df327b3f07fe6e794d2fe1f"
[[package]]
name = "semver"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b"
[[package]]
name = "send_wrapper"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73"
[[package]]
name = "smallvec"
version = "1.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c5e1a9a646d36c3599cd173a41282daf47c44583ad367b8e6837255952e5c67"
[[package]]
name = "syn"
version = "2.0.66"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c42f3f41a2de00b01c0aaad383c5a45241efc8b2d1eda5661812fda5f3cdcff5"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn-mid"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5dc35bb08dd1ca3dfb09dce91fd2d13294d6711c88897d9a9d60acf39bce049"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "tokio"
version = "1.38.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba4f4a02a7a80d6f274636f0aa95c7e383b912d41fe721a31f29e29698585a4a"
dependencies = [
"backtrace",
"num_cpus",
"pin-project-lite",
]
[[package]]
name = "turbojpeg-sys"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fa6daade3b979fb7454cce5ebcb9772ce7a1cf476ea27ed20ed06e13d9bc983"
dependencies = [
"anyhow",
"cmake",
"libc",
"pkg-config",
]
[[package]]
name = "unicode-ident"
version = "1.0.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b"
[[package]]
name = "wasi"
version = "0.11.0+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
[[package]]
name = "windows-targets"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f0713a46559409d202e70e28227288446bf7841d3211583a4b53e3f6d96e7eb"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7088eed71e8b8dda258ecc8bac5fb1153c5cffaf2578fc8ff5d61e23578d3263"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9985fd1504e250c615ca5f281c3f7a6da76213ebd5ccc9561496568a2752afb6"
[[package]]
name = "windows_i686_gnu"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88ba073cf16d5372720ec942a8ccbf61626074c6d4dd2e745299726ce8b89670"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87f4261229030a858f36b459e748ae97545d6f1ec60e5e0d6a3d32e0dc232ee9"
[[package]]
name = "windows_i686_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db3c2bf3d13d5b658be73463284eaf12830ac9a26a90c717b7f771dfe97487bf"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e4246f76bdeff09eb48875a0fd3e2af6aada79d409d33011886d3e1581517d9"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "852298e482cd67c356ddd9570386e2862b5673c85bd5f88df9ab6802b334c596"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bec47e5bfd1bff0eeaf6d8b485cc1074891a197ab4225d504cb7a1ab88b02bf0"

17
cvm-rs/Cargo.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "cvm-rs"
description = "Rust utility library for cvmts. Runs all the high performance code"
version = "0.1.0"
edition = "2021"
exclude = ["index.node"]
[lib]
crate-type = ["cdylib"]
[dependencies]
neon = "1"
# Required for JPEG
once_cell = "1.19.0"
tokio = { version = "1.38.0", features = [ "rt", "rt-multi-thread" ] }
turbojpeg-sys = "1.0.0"

84
cvm-rs/index.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
//
// Guacamole Codec
export function guacDecode(input: string): string[];
export function guacEncode(...items: string[]): string;
interface JpegInputArgs {
width: number,
height: number,
stride: number, // The width of your input framebuffer OR your image width (if encoding a full image)
buffer: Buffer
// TODO: Allow different formats, or export a boxed ffi object which can store a format
// (i.e: new JpegEncoder(FORMAT_xxx)).
}
/// Performs JPEG encoding.
export function jpegEncode(input: JpegInputArgs) : Promise<Buffer>;
// TODO: Version that can downscale?
/* remoting API?
js side api:
class RemotingClient extends EventEmitter {
constructor(uri: string)
Connect(): Promise<void> - connects to server.
Disconnect(): void - disconnects from a server.
get FullScreen(): Buffer - gets the full screen JPEG at a specific moment. This should only be called once
during some user-specific setup (for example: when a new user connects)
get Thumbnail(): Buffer - gets JPEG thumbnail.
KeyEvent(key: number, pressed: boolean) - sends a key event to the server.
MouseEvent(x: number, y: number, buttons: MouseButtonMask) - sends a mouse event (the button mask is semi-standardized for remoting,
the mask can be converted if not applicable for a given protocol)
// explicit property setter APIs, maybe expose the semi-internal remotingSetProperty API if required?
set JpegQuality(q: number) - sets JPEG quality
// events:
on('open', cb: () => void) - on open
//on('firstupdate', cb: (rect: RectWithJpeg) => void) - the first update of a resize is given here
// doesn't really matter
on('resize', cb: (size: Size) => void) - when the server resizes we do too.
on('update', cb: (rects: Array<RectWithJpeg>) => void) - gives screen frame update as jpeg rects
(pre-batched using existing batcher or a new invention or something)
on('close', cb: () => void) - on close
on('cursor', cb: (b: CursorBitmap) => void) - cursor bitmap changed (always rgba8888)
}
binding side API:
remotingNew("vnc://abc.def:1234") - creates a new remoting client which will use the given protocol in the URI
xxx for callbacks (they will get migrated to eventemitter or something on the JS side so it's more "idiomatic", depending on performance.
In all honesty however, remoting will take care of all the performance sensitive tasks, so it probably won't matter at all)
remotingConnect(client) -> promise<void> (throws rejection) - disconnects
remotingDisconnect(client) - disconnects
remotingGetBuffer(client) -> Buffer - gets the buffer used for the screen
remotingSetProperty(client, propertyId, propertyValue) - sets property (e.g: jpeg quality)
e.g: server uri could be set after client creation
with remotingSetProperty(boxedClient, remoting.propertyServerUri, "vnc://another-server.org::2920")
remotingGetThumbnail(client) - gets thumbnail, this is updated by remoting at about 5 fps
remotingKeyEvent(client, key, pressed) - key event
remotingMouseEvent(client, x, y, buttons) - mouse event
on the rust side a boxed client will contain an inner boxed `dyn RemotingProtocolClient` which will contain protocol specific dispatch,
upon parsing a remoting URI we will create a given client (e.g: for `vnc://` we'd make the VNC one)
*/

6
cvm-rs/index.js Normal file
View File

@@ -0,0 +1,6 @@
// *sigh*
import { createRequire } from 'module';
const require = createRequire(import.meta.url);
export let {guacDecode, guacEncode, jpegEncode} = require('./index.node');

16
cvm-rs/package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "@cvmts/cvm-rs",
"version": "0.1.0",
"packageManager": "yarn@4.1.1",
"type": "module",
"main": "index.js",
"types": "index.d.ts",
"scripts": {
"build": "cargo-cp-artifact -nc index.node -- cargo build --release --message-format=json-render-diagnostics",
"install": "yarn build",
"test": "cargo test"
},
"devDependencies": {
"cargo-cp-artifact": "^0.1"
}
}

193
cvm-rs/src/guac.rs Normal file
View File

@@ -0,0 +1,193 @@
use std::fmt;
// type of a guac message
pub type Elements = Vec<String>;
// FIXME: thiserror, please.
/// Errors during decoding
#[derive(Debug, Clone)]
pub enum DecodeError {
/// Invalid guacamole instruction format
InvalidFormat,
/// Instruction is too long for the current decode policy.
InstructionTooLong,
/// Element is too long for the current decode policy.
ElementTooLong,
/// Invalid element size.
ElementSizeInvalid,
}
pub type DecodeResult<T> = std::result::Result<T, DecodeError>;
impl fmt::Display for DecodeError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid Guacamole instruction while decoding"),
Self::InstructionTooLong => write!(f, "Instruction too long for current decode policy"),
Self::ElementTooLong => write!(f, "Element too long for current decode policy"),
Self::ElementSizeInvalid => write!(f, "Element size is invalid")
}
}
}
// this decode policy abstraction would in theory be useful,
// but idk how to do this kind of thing in rust very well
pub struct StaticDecodePolicy<const INST_SIZE: usize, const ELEM_SIZE: usize>();
impl<const INST_SIZE: usize, const ELEM_SIZE: usize> StaticDecodePolicy<INST_SIZE, ELEM_SIZE> {
fn max_instruction_size(&self) -> usize {
INST_SIZE
}
fn max_element_size(&self) -> usize {
ELEM_SIZE
}
}
/// The default decode policy.
pub type DefaultDecodePolicy = StaticDecodePolicy<12288, 4096>;
/// Encodes elements into a Guacamole instruction
pub fn encode_instruction(elements: &Elements) -> String {
let mut str = String::new();
for elem in elements.iter() {
str.push_str(&format!("{}.{},", elem.len(), elem));
}
// hacky, but whatever
str.pop();
str.push(';');
str
}
/// Decodes a Guacamole instruction to individual elements
pub fn decode_instruction(element_string: &String) -> DecodeResult<Elements> {
let policy = DefaultDecodePolicy {};
let mut vec: Elements = Vec::new();
let mut current_position: usize = 0;
// Instruction is too long. Don't even bother
if policy.max_instruction_size() < element_string.len() {
return Err(DecodeError::InstructionTooLong);
}
let chars = element_string.chars().collect::<Vec<_>>();
loop {
let mut element_size: usize = 0;
// Scan the integer value in by hand. This is mostly because
// I'm stupid, and the Rust integer parsing routines (seemingly)
// require a substring (or a slice, but, if you can generate a slice,
// you can also just scan the value in by hand.)
//
// We bound this anyways and do quite the checks, so even though it's not great,
// it should be generally fine (TM).
loop {
let c = chars[current_position];
if c >= '0' && c <= '9' {
element_size = element_size * 10 + (c as usize) - ('0' as usize);
} else {
if c == '.' {
break;
}
return Err(DecodeError::InvalidFormat);
}
current_position += 1;
}
// Eat the '.' seperating the size and the element data;
// our integer scanning ensures we only get here in the case that this is actually the '.'
// character.
current_position += 1;
// Make sure the element size doesn't overflow the decode policy
// or the size of the whole instruction.
if element_size >= policy.max_element_size() {
return Err(DecodeError::ElementTooLong);
}
if element_size >= element_string.len() {
return Err(DecodeError::ElementSizeInvalid);
}
// cutoff elements or something
if current_position + element_size > chars.len()-1 {
//println!("? {current_position} a {}", chars.len());
return Err(DecodeError::InvalidFormat);
}
let element = chars
.iter()
.skip(current_position)
.take(element_size)
.collect::<String>();
current_position += element_size;
vec.push(element);
// make sure seperator is proper
match chars[current_position] {
',' => {}
';' => break,
_ => return Err(DecodeError::InvalidFormat),
}
// eat the ','
current_position += 1;
}
Ok(vec)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_basic() {
let test = String::from("7.connect,3.vm1;");
let res = decode_instruction(&test);
assert!(res.is_ok());
assert_eq!(res.unwrap(), vec!["connect", "vm1"]);
}
#[test]
fn decode_errors() {
let test = String::from("700.connect,3.vm1;");
let res = decode_instruction(&test);
eprintln!("Error for: {}", res.clone().unwrap_err());
assert!(res.is_err())
}
// generally just test that the codec even works
// (we can decode a instruction we created)
#[test]
fn general_codec_works() {
let vec = vec![String::from("connect"), String::from("vm1")];
let test = encode_instruction(&vec);
assert_eq!(test, "7.connect,3.vm1;");
let res = decode_instruction(&test);
assert!(res.is_ok());
assert_eq!(res.unwrap(), vec);
}
}

47
cvm-rs/src/guac_js.rs Normal file
View File

@@ -0,0 +1,47 @@
use neon::prelude::*;
use crate::guac;
fn guac_decode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsArray> {
let input = cx.argument::<JsString>(0)?.value(cx);
match guac::decode_instruction(&input) {
Ok(data) => {
let array = JsArray::new(cx, data.len());
let conv = data
.iter()
.map(|v| cx.string(v))
.collect::<Vec<Handle<JsString>>>();
for (i, str) in conv.iter().enumerate() {
array.set(cx, i as u32, *str)?;
}
return Ok(array);
}
Err(e) => {
return cx.throw_error(format!("{}", e));
}
}
}
fn guac_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsString> {
let mut elements: Vec<String> = Vec::with_capacity(cx.len());
// Capture varadic arguments
for i in 0..cx.len() {
let input = cx.argument::<JsString>(i)?.value(cx);
elements.push(input);
}
Ok(cx.string(guac::encode_instruction(&elements)))
}
pub fn guac_decode(mut cx: FunctionContext) -> JsResult<JsArray> {
guac_decode_impl(&mut cx)
}
pub fn guac_encode(mut cx: FunctionContext) -> JsResult<JsString> {
guac_encode_impl(&mut cx)
}

View File

@@ -0,0 +1,82 @@
use turbojpeg_sys::*;
pub struct Image<'a> {
pub buffer: &'a [u8],
pub width: u32,
pub height: u32,
pub stride: u32,
pub format: TJPF,
}
pub struct JpegCompressor {
handle: tjhandle,
subsamp: TJSAMP,
quality: u32,
}
unsafe impl Send for JpegCompressor {}
impl JpegCompressor {
pub fn new() -> Self {
unsafe {
let init = Self {
handle: tjInitCompress(),
subsamp: TJSAMP_TJSAMP_422,
quality: 95,
};
return init;
}
}
pub fn set_quality(&mut self, quality: u32) {
self.quality = quality;
}
pub fn set_subsamp(&mut self, samp: TJSAMP) {
self.subsamp = samp;
}
pub fn compress_buffer<'a>(&self, image: &Image<'a>) -> Vec<u8> {
unsafe {
let size: usize =
tjBufSize(image.width as i32, image.height as i32, self.subsamp) as usize;
let mut vec = Vec::with_capacity(size);
vec.resize(size, 0);
let mut ptr: *mut u8 = vec.as_mut_ptr();
let mut size: u64 = 0;
let res = tjCompress2(
self.handle,
image.buffer.as_ptr(),
image.width as i32,
image.stride as i32,
image.height as i32,
image.format,
std::ptr::addr_of_mut!(ptr),
std::ptr::addr_of_mut!(size),
self.subsamp,
self.quality as i32,
(TJFLAG_NOREALLOC) as i32,
);
// TODO: Result sex so we can actually notify failure
if res == -1 {
return Vec::new();
}
// Truncate down to the size we're given back
vec.truncate(size as usize);
return vec;
}
}
}
impl Drop for JpegCompressor {
fn drop(&mut self) {
unsafe {
tjDestroy(self.handle);
}
}
}

87
cvm-rs/src/jpeg_js.rs Normal file
View File

@@ -0,0 +1,87 @@
use std::sync::{Arc, Mutex};
use neon::prelude::*;
use neon::types::buffer::TypedArray;
use once_cell::sync::OnceCell;
use tokio::runtime::Runtime;
use std::cell::RefCell;
use crate::jpeg_compressor::*;
/// Gives a static Tokio runtime. We should replace this with
/// rayon or something, but for now tokio works.
fn runtime<'a, C: Context<'a>>(cx: &mut C) -> NeonResult<&'static Runtime> {
static RUNTIME: OnceCell<Runtime> = OnceCell::new();
RUNTIME
.get_or_try_init(Runtime::new)
.or_else(|err| cx.throw_error(&err.to_string()))
}
thread_local! {
static COMPRESSOR: RefCell<JpegCompressor> = RefCell::new(JpegCompressor::new());
}
fn jpeg_encode_impl<'a>(cx: &mut FunctionContext<'a>) -> JsResult<'a, JsPromise> {
let input = cx.argument::<JsObject>(0)?;
// Get our input arguments here
let width: u64 = input.get::<JsNumber, _, _>(cx, "width")?.value(cx) as u64;
let height: u64 = input.get::<JsNumber, _, _>(cx, "height")?.value(cx) as u64;
let stride: u64 = input.get::<JsNumber, _, _>(cx, "stride")?.value(cx) as u64;
let buffer: Handle<JsBuffer> = input.get(cx, "buffer")?;
let (deferred, promise) = cx.promise();
let channel = cx.channel();
let runtime = runtime(cx)?;
let buf = buffer.as_slice(cx);
let copy: Arc<Mutex<Vec<u8>>> = Arc::new(Mutex::new(Vec::with_capacity(buf.len())));
// Copy from the node buffer to our temporary buffer
{
let mut locked = copy.lock().unwrap();
let cap = locked.capacity();
locked.resize(cap, 0);
locked.copy_from_slice(buf);
}
// Spawn off a tokio blocking pool thread that will do the work for us
runtime.spawn_blocking(move || {
let clone = Arc::clone(&copy);
let locked = clone.lock().unwrap();
let image: Image = Image {
buffer: locked.as_slice(),
width: width as u32,
height: height as u32,
stride: (stride * 4u64) as u32, // I think?
format: turbojpeg_sys::TJPF_TJPF_RGBA,
};
let vec = COMPRESSOR.with(|lazy| {
let mut b = lazy.borrow_mut();
b.set_quality(35);
b.set_subsamp(turbojpeg_sys::TJSAMP_TJSAMP_420);
b.compress_buffer(&image)
});
// Fulfill the Javascript promise with our encoded buffer
deferred.settle_with(&channel, move |mut cx| {
let mut buf = cx.buffer(vec.len())?;
let slice = buf.as_mut_slice(&mut cx);
slice.copy_from_slice(vec.as_slice());
Ok(buf)
});
});
Ok(promise)
}
pub fn jpeg_encode(mut cx: FunctionContext) -> JsResult<JsPromise> {
jpeg_encode_impl(&mut cx)
}

19
cvm-rs/src/lib.rs Normal file
View File

@@ -0,0 +1,19 @@
mod guac;
mod guac_js;
mod jpeg_compressor;
mod jpeg_js;
use neon::prelude::*;
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
// Mostly transitionary, later on API should change
cx.export_function("jpegEncode", jpeg_js::jpeg_encode)?;
cx.export_function("guacDecode", guac_js::guac_decode)?;
cx.export_function("guacEncode", guac_js::guac_encode)?;
Ok(())
}

33
cvmts/package.json Normal file
View File

@@ -0,0 +1,33 @@
{
"name": "@cvmts/cvmts",
"version": "1.0.0",
"description": "replacement for collabvm 1.2.11 because the old one :boom:",
"type": "module",
"main": "dist/index.js",
"scripts": {
"build": "tsc -outDir dist -rootDir src/",
"serve": "node dist/index.js"
},
"author": "Elijah R, modeco80",
"license": "GPL-3.0",
"dependencies": {
"@computernewb/nodejs-rfb": "^0.3.0",
"@computernewb/superqemu": "^0.1.0",
"@cvmts/cvm-rs": "*",
"@maxmind/geoip2-node": "^5.0.0",
"execa": "^8.0.1",
"mnemonist": "^0.39.5",
"msgpackr": "^1.10.2",
"pino": "^9.3.1",
"sharp": "^0.33.3",
"toml": "^3.0.0",
"ws": "^8.14.1"
},
"devDependencies": {
"@types/node": "^20.12.5",
"@types/ws": "^8.5.5",
"pino-pretty": "^11.2.1",
"prettier": "^3.2.5",
"typescript": "^5.4.4"
}
}

42
cvmts/src/AuthManager.ts Normal file
View File

@@ -0,0 +1,42 @@
import { Rank, User } from './User.js';
export default class AuthManager {
apiEndpoint: string;
secretKey: string;
constructor(apiEndpoint: string, secretKey: string) {
this.apiEndpoint = apiEndpoint;
this.secretKey = secretKey;
}
async Authenticate(token: string, user: User): Promise<JoinResponse> {
let response = await fetch(this.apiEndpoint + '/api/v1/join', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
secretKey: this.secretKey,
sessionToken: token,
ip: user.IP.address
})
});
// Make sure the fetch returned okay
if (!response.ok) throw new Error(`Failed to query auth server: ${response.statusText}`);
let json = (await response.json()) as JoinResponse;
if (!json.success) throw new Error(json.error);
return json;
}
}
interface JoinResponse {
success: boolean;
clientSuccess: boolean;
error: string | undefined;
username: string | undefined;
rank: Rank;
}

963
cvmts/src/CollabVMServer.ts Normal file
View File

@@ -0,0 +1,963 @@
import IConfig from './IConfig.js';
import * as Utilities from './Utilities.js';
import { User, Rank } from './User.js';
import * as cvm from '@cvmts/cvm-rs';
// I hate that you have to do it like this
import CircularBuffer from 'mnemonist/circular-buffer.js';
import Queue from 'mnemonist/queue.js';
import { createHash } from 'crypto';
import { VMState, QemuVM, QemuVmDefinition } from '@computernewb/superqemu';
import { IPDataManager } from './IPData.js';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import AuthManager from './AuthManager.js';
import { JPEGEncoder } from './JPEGEncoder.js';
import VM from './VM.js';
import { ReaderModel } from '@maxmind/geoip2-node';
import * as msgpack from 'msgpackr';
import { CollabVMProtocolMessage, CollabVMProtocolMessageType } from '@cvmts/collab-vm-1.2-binary-protocol';
import { Size, Rect } from './VMDisplay.js';
import pino from 'pino';
// Instead of strange hacks we can just use nodejs provided
// import.meta properties, which have existed since LTS if not before
const __dirname = import.meta.dirname;
const kCVMTSAssetsRoot = path.resolve(__dirname, '../../assets');
const kRestartTimeout = 5000;
type ChatHistory = {
user: string;
msg: string;
};
type VoteTally = {
yes: number;
no: number;
};
export default class CollabVMServer {
private Config: IConfig;
private clients: User[];
private ChatHistory: CircularBuffer<ChatHistory>;
private TurnQueue: Queue<User>;
// Time remaining on the current turn
private TurnTime: number;
// Interval to keep track of the current turn time
private TurnInterval?: NodeJS.Timeout;
// If a reset vote is in progress
private voteInProgress: boolean;
// Interval to keep track of vote resets
private voteInterval?: NodeJS.Timeout;
// How much time is left on the vote
private voteTime: number;
// How much time until another reset vote can be cast
private voteCooldown: number;
// Interval to keep track
private voteCooldownInterval?: NodeJS.Timeout;
// Completely disable turns
private turnsAllowed: boolean;
// Hide the screen
private screenHidden: boolean;
// base64 image to show when the screen is hidden
private screenHiddenImg: string;
private screenHiddenThumb: string;
// Indefinite turn
private indefiniteTurn: User | null;
private ModPerms: number;
private VM: VM;
// Authentication manager
private auth: AuthManager | null;
// Geoip
private geoipReader: ReaderModel | null;
private logger = pino({ name: 'CVMTS.Server' });
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>();
this.TurnTime = 0;
this.clients = [];
this.voteInProgress = false;
this.voteTime = 0;
this.voteCooldown = 0;
this.turnsAllowed = true;
this.screenHidden = false;
this.screenHiddenImg = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhidden.jpeg')).toString('base64');
this.screenHiddenThumb = readFileSync(path.join(kCVMTSAssetsRoot, 'screenhiddenthumb.jpeg')).toString('base64');
this.indefiniteTurn = null;
this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions);
// No size initially, since there usually won't be a display connected at all during initalization
this.OnDisplayResized({
width: 0,
height: 0
});
this.VM = vm;
// this probably should be made general at some point,
// and the VM interface allowed to return a nullable display
// but i cba
let self = this;
if (config.vm.type == 'qemu') {
(vm as QemuVM).on('statechange', (newState: VMState) => {
if (newState == VMState.Started) {
self.logger.info('VM started');
// well aware this sucks but whatever
self.VM.GetDisplay().on('resize', (size: Size) => self.OnDisplayResized(size));
self.VM.GetDisplay().on('rect', (rect: Rect) => self.OnDisplayRectangle(rect));
}
if (newState == VMState.Stopped) {
setTimeout(async () => {
self.logger.info('restarting VM');
await self.VM.Start();
}, kRestartTimeout);
}
});
}
// authentication manager
this.auth = auth;
this.geoipReader = geoipReader;
}
public addUser(user: User) {
let sameip = this.clients.filter((c) => c.IP.address === user.IP.address);
if (sameip.length >= this.Config.collabvm.maxConnections) {
// Kick the oldest client
// I think this is a better solution than just rejecting the connection
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.warn(`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) {
let clientIndex = this.clients.indexOf(user);
if (clientIndex === -1) return;
if (user.IP.vote != null) {
user.IP.vote = null;
this.sendVoteUpdate();
}
if (this.indefiniteTurn === user) this.indefiniteTurn = null;
this.clients.splice(clientIndex, 1);
this.logger.info(`Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ''}`);
if (!user.username) return;
if (this.TurnQueue.toArray().indexOf(user) !== -1) {
var hadturn = this.TurnQueue.peek() === user;
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== user));
if (hadturn) this.nextTurn();
}
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('remuser', '1', user.username!)));
}
private async onMessage(client: User, message: string) {
try {
var msgArr = cvm.guacDecode(message);
if (msgArr.length < 1) return;
switch (msgArr[0]) {
case 'login':
if (msgArr.length !== 2 || !this.Config.auth.enabled) return;
if (!client.connectedToNode) {
client.sendMsg(cvm.guacEncode('login', '0', 'You must connect to the VM before logging in.'));
return;
}
try {
let res = await this.auth!.Authenticate(msgArr[1], client);
if (res.clientSuccess) {
this.logger.info(`${client.IP.address} logged in as ${res.username}`);
client.sendMsg(cvm.guacEncode('login', '1'));
let old = this.clients.find((c) => c.username === res.username);
if (old) {
// kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that
// so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list
this.connectionClosed(old);
await old.kick();
}
// Set 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) {
client.sendMsg(cvm.guacEncode('admin', '0', '1'));
} else if (client.rank === Rank.Moderator) {
client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString()));
}
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())));
} else {
client.sendMsg(cvm.guacEncode('login', '0', res.error!));
if (res.error === 'You are banned') {
client.kick();
}
}
} catch (err) {
this.logger.error(`Error authenticating client ${client.IP.address}: ${(err as Error).message}`);
// for now?
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':
if (this.VM.GetState() == VMState.Started) {
client.sendMsg(cvm.guacEncode('list', this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail()));
}
break;
case 'connect':
if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) {
client.sendMsg(cvm.guacEncode('connect', '0'));
return;
}
client.connectedToNode = true;
client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0'));
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd));
if (this.screenHidden) {
client.sendMsg(cvm.guacEncode('size', '0', '1024', '768'));
client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg));
} else {
await this.SendFullScreenWithSize(client);
}
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
if (this.voteInProgress) this.sendVoteUpdate(client);
this.sendTurnUpdate(client);
break;
case 'view':
if (client.connectedToNode) return;
if (client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) {
// The use of connect here is intentional.
client.sendMsg(cvm.guacEncode('connect', '0'));
return;
}
switch (msgArr[2]) {
case '0':
client.viewMode = 0;
break;
case '1':
client.viewMode = 1;
break;
default:
client.sendMsg(cvm.guacEncode('connect', '0'));
return;
}
client.sendMsg(cvm.guacEncode('connect', '1', '1', this.VM.SnapshotsSupported() ? '1' : '0', '0'));
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
if (this.Config.collabvm.motd) client.sendMsg(cvm.guacEncode('chat', '', this.Config.collabvm.motd));
if (client.viewMode == 1) {
if (this.screenHidden) {
client.sendMsg(cvm.guacEncode('size', '0', '1024', '768'));
client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg));
} else {
await this.SendFullScreenWithSize(client);
}
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
}
if (this.voteInProgress) this.sendVoteUpdate(client);
this.sendTurnUpdate(client);
break;
case 'rename':
if (!client.RenameRateLimit.request()) return;
if (client.connectedToNode && client.IP.muted) return;
if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) {
client.sendMsg(cvm.guacEncode('chat', '', 'Go to your account settings to change your username.'));
return;
}
if (this.Config.auth.enabled && msgArr[1] !== undefined) {
// Don't send system message to a user without a username since it was likely an automated attempt by the webapp
if (client.username) client.sendMsg(cvm.guacEncode('chat', '', 'You need to log in to do that.'));
if (client.rank !== Rank.Unregistered) return;
this.renameUser(client, undefined);
return;
}
this.renameUser(client, msgArr[1]);
break;
case 'chat':
if (!client.username) return;
if (client.IP.muted) return;
if (msgArr.length !== 2) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) {
client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.'));
return;
}
var msg = Utilities.HTMLSanitize(msgArr[1]);
// One of the things I hated most about the old server is it completely discarded your message if it was too long
if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength);
if (msg.trim().length < 1) return;
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msg)));
this.ChatHistory.push({ user: client.username, msg: msg });
client.onMsgSent();
break;
case 'turn':
if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) {
client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.'));
return;
}
if (!client.TurnRateLimit.request()) return;
if (!client.connectedToNode) return;
if (msgArr.length > 2) return;
var takingTurn: boolean;
if (msgArr.length === 1) takingTurn = true;
else
switch (msgArr[1]) {
case '0':
if (this.indefiniteTurn === client) {
this.indefiniteTurn = null;
}
takingTurn = false;
break;
case '1':
takingTurn = true;
break;
default:
return;
break;
}
if (takingTurn) {
var currentQueue = this.TurnQueue.toArray();
// If the user is already in the turn queue, ignore the turn request.
if (currentQueue.indexOf(client) !== -1) return;
// If they're muted, also ignore the turn request.
// Send them the turn queue to prevent client glitches
if (client.IP.muted) return;
if (this.Config.collabvm.turnlimit.enabled) {
// Get the amount of users in the turn queue with the same IP as the user requesting a turn.
let turns = currentQueue.filter((user) => user.IP.address == client.IP.address);
// If it exceeds the limit set in the config, ignore the turn request.
if (turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return;
}
this.TurnQueue.enqueue(client);
if (this.TurnQueue.size === 1) this.nextTurn();
} else {
var hadturn = this.TurnQueue.peek() === client;
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((u) => u !== client));
if (hadturn) this.nextTurn();
}
this.sendTurnUpdate();
break;
case 'mouse':
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
var x = parseInt(msgArr[1]);
var y = parseInt(msgArr[2]);
var mask = parseInt(msgArr[3]);
if (x === undefined || y === undefined || mask === undefined) return;
this.VM.GetDisplay()?.MouseEvent(x, y, mask);
break;
case 'key':
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
var keysym = parseInt(msgArr[1]);
var down = parseInt(msgArr[2]);
if (keysym === undefined || (down !== 0 && down !== 1)) return;
this.VM.GetDisplay()?.KeyboardEvent(keysym, down === 1 ? true : false);
break;
case 'vote':
if (!this.VM.SnapshotsSupported()) return;
if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
if (!client.connectedToNode) return;
if (msgArr.length !== 2) return;
if (!client.VoteRateLimit.request()) return;
switch (msgArr[1]) {
case '1':
if (!this.voteInProgress) {
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) {
client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.'));
return;
}
if (this.voteCooldown !== 0) {
client.sendMsg(cvm.guacEncode('vote', '3', this.voteCooldown.toString()));
return;
}
this.startVote();
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has started a vote to reset the VM.`)));
}
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) {
client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.'));
return;
} else if (client.IP.vote !== true) {
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted yes.`)));
}
client.IP.vote = true;
break;
case '0':
if (!this.voteInProgress) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) {
client.sendMsg(cvm.guacEncode('chat', '', 'You need to login to do that.'));
return;
}
if (client.IP.vote !== false) {
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', `${client.username} has voted no.`)));
}
client.IP.vote = false;
break;
}
this.sendVoteUpdate();
break;
case 'cap': {
if (msgArr.length < 2) return;
// Capabilities can only be announced before connecting to the VM
if (client.connectedToNode) return;
var caps = [];
for (const cap of msgArr.slice(1))
switch (cap) {
case 'bin': {
if (caps.indexOf('bin') !== -1) break;
client.Capabilities.bin = true;
caps.push('bin');
break;
}
}
client.sendMsg(cvm.guacEncode('cap', ...caps));
}
case 'admin':
if (msgArr.length < 2) return;
switch (msgArr[1]) {
case '2':
// Login
if (this.Config.auth.enabled) {
client.sendMsg(cvm.guacEncode('chat', '', 'This server does not support staff passwords. Please log in to become staff.'));
return;
}
if (!client.LoginRateLimit.request() || !client.username) return;
if (msgArr.length !== 3) return;
var sha256 = createHash('sha256');
sha256.update(msgArr[2]);
var pwdHash = sha256.digest('hex');
sha256.destroy();
if (pwdHash === this.Config.collabvm.adminpass) {
client.rank = Rank.Admin;
client.sendMsg(cvm.guacEncode('admin', '0', '1'));
} else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) {
client.rank = Rank.Moderator;
client.sendMsg(cvm.guacEncode('admin', '0', '3', this.ModPerms.toString()));
} else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) {
client.rank = Rank.Turn;
client.sendMsg(cvm.guacEncode('chat', '', 'You may now take turns.'));
} else {
client.sendMsg(cvm.guacEncode('admin', '0', '0'));
return;
}
if (this.screenHidden) {
await this.SendFullScreenWithSize(client);
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
}
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('adduser', '1', client.username!, client.rank.toString())));
break;
case '5':
// QEMU Monitor
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return;
let output = await this.VM.MonitorCommand(msgArr[3]);
client.sendMsg(cvm.guacEncode('admin', '2', String(output)));
break;
case '8':
// Restore
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return;
this.VM.Reset();
break;
case '10':
// Reboot
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return;
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
await this.VM.Reboot();
break;
case '12':
// Ban
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
user.ban();
case '13':
// Force Vote
if (msgArr.length !== 3) return;
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return;
if (!this.voteInProgress) return;
switch (msgArr[2]) {
case '1':
this.endVote(true);
break;
case '0':
this.endVote(false);
break;
}
break;
case '14':
// Mute
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return;
if (msgArr.length !== 4) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
var permamute;
switch (msgArr[3]) {
case '0':
permamute = false;
break;
case '1':
permamute = true;
break;
default:
return;
}
user.mute(permamute);
break;
case '15':
// Kick
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
user.kick();
break;
case '16':
// End turn
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
if (msgArr.length !== 3) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
this.endTurn(user);
break;
case '17':
// Clear turn queue
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
this.clearTurns();
break;
case '18':
// Rename user
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return;
if (this.Config.auth.enabled) {
client.sendMsg(cvm.guacEncode('chat', '', 'Cannot rename users on a server that uses authentication.'));
}
if (msgArr.length !== 4) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
this.renameUser(user, msgArr[3]);
break;
case '19':
// Get IP
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return;
if (msgArr.length !== 3) return;
var user = this.clients.find((c) => c.username === msgArr[2]);
if (!user) return;
client.sendMsg(cvm.guacEncode('admin', '19', msgArr[2], user.IP.address));
break;
case '20':
// Steal turn
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
this.bypassTurn(client);
break;
case '21':
// XSS
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return;
if (msgArr.length !== 3) return;
switch (client.rank) {
case Rank.Admin:
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msgArr[2])));
this.ChatHistory.push({ user: client.username!, msg: msgArr[2] });
break;
case Rank.Moderator:
this.clients.filter((c) => c.rank !== Rank.Admin).forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, msgArr[2])));
this.clients.filter((c) => c.rank === Rank.Admin).forEach((c) => c.sendMsg(cvm.guacEncode('chat', client.username!, Utilities.HTMLSanitize(msgArr[2]))));
break;
}
break;
case '22':
// Toggle turns
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 3) return;
switch (msgArr[2]) {
case '0':
this.clearTurns();
this.turnsAllowed = false;
break;
case '1':
this.turnsAllowed = true;
break;
}
break;
case '23':
// Indefinite turn
if (client.rank !== Rank.Admin) return;
this.indefiniteTurn = client;
this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter((c) => c !== client)]);
this.sendTurnUpdate();
break;
case '24':
// Hide screen
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 3) return;
switch (msgArr[2]) {
case '0':
this.screenHidden = true;
this.clients
.filter((c) => c.rank == Rank.Unregistered)
.forEach((client) => {
client.sendMsg(cvm.guacEncode('size', '0', '1024', '768'));
client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', this.screenHiddenImg));
client.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
});
break;
case '1':
this.screenHidden = false;
let displaySize = this.VM.GetDisplay().Size();
let encoded = await this.MakeRectData({
x: 0,
y: 0,
width: displaySize.width,
height: displaySize.height
});
this.clients.forEach(async (client) => this.SendFullScreenWithSize(client));
break;
}
break;
case '25':
if (client.rank !== Rank.Admin || msgArr.length !== 3) return;
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', msgArr[2])));
break;
}
break;
}
} catch (err) {
// No
this.logger.error(`User ${user?.IP.address} ${user?.username ? `with username ${user?.username}` : ''} sent broken Guacamole: ${err as Error}`);
user?.kick();
}
}
getUsernameList(): string[] {
var arr: string[] = [];
this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!));
return arr;
}
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;
if (hadName) oldname = client.username;
var status = '0';
if (!newName) {
client.assignGuestName(this.getUsernameList());
} else {
newName = newName.trim();
if (hadName && newName === oldname) {
client.sendMsg(cvm.guacEncode('rename', '0', '0', client.username!, client.rank.toString()));
return;
}
if (this.getUsernameList().indexOf(newName) !== -1) {
client.assignGuestName(this.getUsernameList());
if (client.connectedToNode) {
status = '1';
}
} else if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) {
client.assignGuestName(this.getUsernameList());
status = '2';
} else if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) {
client.assignGuestName(this.getUsernameList());
status = '3';
} else client.username = newName;
}
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}`);
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}`);
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));
});
}
}
getAdduserMsg(): string {
var arr: string[] = ['adduser', this.clients.filter((c) => c.username).length.toString()];
this.clients.filter((c) => c.username).forEach((c) => arr.push(c.username!, c.rank.toString()));
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));
return cvm.guacEncode(...arr);
}
private sendTurnUpdate(client?: User) {
var turnQueueArr = this.TurnQueue.toArray();
var turntime;
if (this.indefiniteTurn === null) turntime = this.TurnTime * 1000;
else turntime = 9999999999;
var arr = ['turn', turntime.toString(), this.TurnQueue.size.toString()];
// @ts-ignore
this.TurnQueue.forEach((c) => arr.push(c.username));
var currentTurningUser = this.TurnQueue.peek();
if (client) {
client.sendMsg(cvm.guacEncode(...arr));
return;
}
this.clients
.filter((c) => c !== currentTurningUser && c.connectedToNode)
.forEach((c) => {
if (turnQueueArr.indexOf(c) !== -1) {
var time;
if (this.indefiniteTurn === null) time = this.TurnTime * 1000 + (turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000;
else time = 9999999999;
c.sendMsg(cvm.guacEncode(...arr, time.toString()));
} else {
c.sendMsg(cvm.guacEncode(...arr));
}
});
if (currentTurningUser) currentTurningUser.sendMsg(cvm.guacEncode(...arr));
}
private nextTurn() {
clearInterval(this.TurnInterval);
if (this.TurnQueue.size === 0) {
} else {
this.TurnTime = this.Config.collabvm.turnTime;
this.TurnInterval = setInterval(() => this.turnInterval(), 1000);
}
this.sendTurnUpdate();
}
clearTurns() {
clearInterval(this.TurnInterval);
this.TurnQueue.clear();
this.sendTurnUpdate();
}
bypassTurn(client: User) {
var a = this.TurnQueue.toArray().filter((c) => c !== client);
this.TurnQueue = Queue.from([client, ...a]);
this.nextTurn();
}
endTurn(client: User) {
var hasTurn = this.TurnQueue.peek() === client;
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter((c) => c !== client));
if (hasTurn) this.nextTurn();
else this.sendTurnUpdate();
}
private turnInterval() {
if (this.indefiniteTurn !== null) return;
this.TurnTime--;
if (this.TurnTime < 1) {
this.TurnQueue.dequeue();
this.nextTurn();
}
}
private async OnDisplayRectangle(rect: Rect) {
let encoded = await this.MakeRectData(rect);
let encodedb64 = encoded.toString('base64');
let bmsg: CollabVMProtocolMessage = {
type: CollabVMProtocolMessageType.rect,
rect: {
x: rect.x,
y: rect.y,
data: encoded
}
};
var encodedbin = msgpack.encode(bmsg);
this.clients
.filter((c) => c.connectedToNode || c.viewMode == 1)
.forEach((c) => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
if (c.Capabilities.bin) {
c.socket.sendBinary(encodedbin);
} else {
c.sendMsg(cvm.guacEncode('png', '0', '0', rect.x.toString(), rect.y.toString(), encodedb64));
c.sendMsg(cvm.guacEncode('sync', Date.now().toString()));
}
});
}
private OnDisplayResized(size: Size) {
this.clients
.filter((c) => c.connectedToNode || c.viewMode == 1)
.forEach((c) => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
c.sendMsg(cvm.guacEncode('size', '0', size.width.toString(), size.height.toString()));
});
}
private async SendFullScreenWithSize(client: User) {
let display = this.VM.GetDisplay();
if (display == null) return;
let displaySize = display.Size();
let encoded = await this.MakeRectData({
x: 0,
y: 0,
width: displaySize.width,
height: displaySize.height
});
client.sendMsg(cvm.guacEncode('size', '0', displaySize.width.toString(), displaySize.height.toString()));
if (client.Capabilities.bin) {
let msg: CollabVMProtocolMessage = {
type: CollabVMProtocolMessageType.rect,
rect: {
x: 0,
y: 0,
data: encoded
}
};
client.socket.sendBinary(msgpack.encode(msg));
} else {
client.sendMsg(cvm.guacEncode('png', '0', '0', '0', '0', encoded.toString('base64')));
}
}
private async MakeRectData(rect: Rect) {
let display = this.VM.GetDisplay();
// TODO: actually throw an error here
if (display == null) return Buffer.from('no');
let displaySize = display.Size();
let encoded = await JPEGEncoder.Encode(display.Buffer(), displaySize, rect);
return encoded;
}
async getThumbnail(): Promise<string> {
let display = this.VM.GetDisplay();
// oh well
if (!display.Connected()) return '';
let buf = await JPEGEncoder.EncodeThumbnail(display.Buffer(), display.Size());
return buf.toString('base64');
}
startVote() {
if (this.voteInProgress) return;
this.voteInProgress = true;
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '0')));
this.voteTime = this.Config.collabvm.voteTime;
this.voteInterval = setInterval(() => {
this.voteTime--;
if (this.voteTime < 1) {
this.endVote();
}
}, 1000);
}
endVote(result?: boolean) {
if (!this.voteInProgress) return;
this.voteInProgress = false;
clearInterval(this.voteInterval);
var count = this.getVoteCounts();
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('vote', '2')));
if (result === true || (result === undefined && count.yes >= count.no)) {
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has won.')));
this.VM.Reset();
} else {
this.clients.forEach((c) => c.sendMsg(cvm.guacEncode('chat', '', 'The vote to reset the VM has lost.')));
}
this.clients.forEach((c) => {
c.IP.vote = null;
});
this.voteCooldown = this.Config.collabvm.voteCooldown;
this.voteCooldownInterval = setInterval(() => {
this.voteCooldown--;
if (this.voteCooldown < 1) clearInterval(this.voteCooldownInterval);
}, 1000);
}
sendVoteUpdate(client?: User) {
if (!this.voteInProgress) return;
var count = this.getVoteCounts();
var msg = cvm.guacEncode('vote', '1', (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString());
if (client) client.sendMsg(msg);
else this.clients.forEach((c) => c.sendMsg(msg));
}
getVoteCounts(): VoteTally {
let yes = 0;
let no = 0;
IPDataManager.ForEachIPData((c) => {
if (c.vote === true) yes++;
if (c.vote === false) no++;
});
return { yes: yes, no: no };
}
}

View File

@@ -0,0 +1,105 @@
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';
import pino from 'pino';
export default class GeoIPDownloader {
private directory: string;
private accountID: string;
private licenseKey: string;
private logger = pino({ name: 'CVMTS.GeoIPDownloader' });
constructor(filename: string, accountID: string, licenseKey: string) {
this.directory = filename;
if (!this.directory.endsWith('/')) this.directory += '/';
this.accountID = accountID;
this.licenseKey = licenseKey;
}
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.warn('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);
}
}

88
cvmts/src/IConfig.ts Normal file
View File

@@ -0,0 +1,88 @@
import VNCVMDef from './VNCVM/VNCVMDef';
export default interface IConfig {
http: {
host: string;
port: number;
proxying: boolean;
proxyAllowedIps: string[];
origin: boolean;
originAllowedDomains: string[];
};
geoip: {
enabled: boolean;
directory: string;
accountID: string;
licenseKey: string;
}
tcp: {
enabled: boolean;
host: string;
port: number;
};
auth: {
enabled: boolean;
apiEndpoint: string;
secretKey: string;
guestPermissions: {
chat: boolean;
turn: boolean;
callForReset: boolean;
vote: boolean;
};
};
vm: {
type: 'qemu' | 'vncvm';
};
qemu: {
qemuArgs: string;
vncPort: number;
snapshots: boolean;
qmpHost: string | null;
qmpPort: number | null;
qmpSockDir: string | null;
};
vncvm: VNCVMDef;
collabvm: {
node: string;
displayname: string;
motd: string;
maxConnections: number;
bancmd: string | string[];
moderatorEnabled: boolean;
usernameblacklist: string[];
maxChatLength: number;
maxChatHistoryLength: number;
turnlimit: {
enabled: boolean;
maximum: number;
};
automute: {
enabled: boolean;
seconds: number;
messages: number;
};
tempMuteTime: number;
turnTime: number;
voteTime: number;
voteCooldown: number;
adminpass: string;
modpass: string;
turnwhitelist: boolean;
turnpass: string;
moderatorPermissions: Permissions;
};
}
export interface Permissions {
restore: boolean;
reboot: boolean;
ban: boolean;
forcevote: boolean;
mute: boolean;
kick: boolean;
bypassturn: boolean;
rename: boolean;
grabip: boolean;
xss: boolean;
}

71
cvmts/src/IPData.ts Normal file
View File

@@ -0,0 +1,71 @@
import pino from 'pino';
export class IPData {
tempMuteExpireTimeout?: NodeJS.Timeout;
muted: Boolean;
vote: boolean | null;
address: string;
refCount: number = 0;
constructor(address: string) {
this.address = address;
this.muted = false;
this.vote = null;
}
// Call when a connection is closed to "release" the ip data
Unref() {
if (this.refCount - 1 < 0) this.refCount = 0;
else this.refCount--;
}
}
export class IPDataManager {
static ipDatas = new Map<string, IPData>();
static logger = pino({ name: 'CVMTS.IPDataManager' });
static GetIPData(address: string) {
if (IPDataManager.ipDatas.has(address)) {
// Note: We already check for if it exists, so we use ! here
// because TypeScript can't exactly tell that in this case,
// only in explicit null or undefined checks
let ref = IPDataManager.ipDatas.get(address)!;
ref.refCount++;
return ref;
}
let data = new IPData(address);
data.refCount++;
IPDataManager.ipDatas.set(address, data);
return data;
}
static GetIPDataMaybe(address: string) {
if (IPDataManager.ipDatas.has(address)) {
// Note: We already check for if it exists, so we use ! here
// because TypeScript can't exactly tell that in this case,
// only in explicit null or undefined checks
let ref = IPDataManager.ipDatas.get(address)!;
ref.refCount++;
return ref;
}
return null;
}
static ForEachIPData(callback: (d: IPData) => void) {
for (let tuple of IPDataManager.ipDatas) callback(tuple[1]);
}
}
// Garbage collect unreferenced IPDatas every 15 seconds.
// Strictly speaking this will just allow the v8 GC to finally
// delete the objects, but same difference.
setInterval(() => {
for (let tuple of IPDataManager.ipDatas) {
if (tuple[1].refCount == 0) {
IPDataManager.logger.info(`Deleted IPData for IP ${tuple[0]}`);
IPDataManager.ipDatas.delete(tuple[0]);
}
}
}, 15000);

52
cvmts/src/JPEGEncoder.ts Normal file
View File

@@ -0,0 +1,52 @@
import { Size, Rect } from './VMDisplay.js';
import sharp from 'sharp';
import * as cvm from '@cvmts/cvm-rs';
// A good balance. TODO: Configurable?
let gJpegQuality = 35;
const kThumbnailSize: Size = {
width: 400,
height: 300
};
// this returns appropiate Sharp options to deal with CVMTS raw framebuffers
// (which are RGBA bitmaps, essentially. We probably should abstract that out but
// that'd mean having to introduce that to rfb and oihwekjtgferklds;./tghnredsltg;erhds)
function GetRawSharpOptions(size: Size): sharp.CreateRaw {
return {
width: size.width,
height: size.height,
channels: 4
};
}
export class JPEGEncoder {
static SetQuality(quality: number) {
gJpegQuality = quality;
}
static async Encode(canvas: Buffer, displaySize: Size, rect: Rect): Promise<Buffer> {
let offset = (rect.y * displaySize.width + rect.x) * 4;
return cvm.jpegEncode({
width: rect.width,
height: rect.height,
stride: displaySize.width,
buffer: canvas.subarray(offset)
});
}
static async EncodeThumbnail(buffer: Buffer, size: Size): Promise<Buffer> {
let { data, info } = await sharp(buffer, { raw: GetRawSharpOptions(size) })
.resize(kThumbnailSize.width, kThumbnailSize.height, { fit: 'fill' })
.raw()
.toBuffer({ resolveWithObject: true });
return cvm.jpegEncode({
width: kThumbnailSize.width,
height: kThumbnailSize.height,
stride: kThumbnailSize.width,
buffer: data
});
}
}

View File

@@ -0,0 +1,9 @@
export default interface NetworkClient {
getIP(): string;
send(msg: string): Promise<void>;
sendBinary(msg: Uint8Array): Promise<void>;
close(): void;
on(event: string, listener: (...args: any[]) => void): void;
off(event: string, listener: (...args: any[]) => void): void;
isOpen(): boolean;
}

View File

@@ -0,0 +1,6 @@
export default interface NetworkServer {
start(): void;
stop(): void;
on(event: string, listener: (...args: any[]) => void): void;
off(event: string, listener: (...args: any[]) => void): void;
}

36
cvmts/src/RateLimiter.ts Normal file
View File

@@ -0,0 +1,36 @@
import { EventEmitter } from 'events';
// Class to ratelimit a resource (chatting, logging in, etc)
export default class RateLimiter extends EventEmitter {
private limit: number;
private interval: number;
private requestCount: number;
private limiter?: NodeJS.Timeout;
private limiterSet: boolean;
constructor(limit: number, interval: number) {
super();
this.limit = limit;
this.interval = interval;
this.requestCount = 0;
this.limiterSet = false;
}
// Return value is whether or not the action should be continued
request(): boolean {
this.requestCount++;
if (this.requestCount === this.limit) {
this.emit('limit');
clearTimeout(this.limiter);
this.limiterSet = false;
this.requestCount = 0;
return false;
}
if (!this.limiterSet) {
this.limiter = setTimeout(() => {
this.limiterSet = false;
this.requestCount = 0;
}, this.interval * 1000);
this.limiterSet = true;
}
return true;
}
}

View File

@@ -0,0 +1,72 @@
import EventEmitter from 'events';
import NetworkClient from '../NetworkClient.js';
import { Socket } from 'net';
const TextHeader = 0;
const BinaryHeader = 1;
export default class TCPClient extends EventEmitter implements NetworkClient {
private socket: Socket;
private cache: string;
constructor(socket: Socket) {
super();
this.socket = socket;
this.cache = '';
this.socket.on('end', () => {
this.emit('disconnect');
});
this.socket.on('data', (data) => {
var msg = data.toString('utf-8');
if (msg[msg.length - 1] === '\n') msg = msg.slice(0, -1);
this.cache += msg;
this.readCache();
});
}
private readCache() {
for (var index = this.cache.indexOf(';'); index !== -1; index = this.cache.indexOf(';')) {
this.emit('msg', this.cache.slice(0, index + 1));
this.cache = this.cache.slice(index + 1);
}
}
getIP(): string {
return this.socket.remoteAddress!;
}
send(msg: string): Promise<void> {
return new Promise((res, rej) => {
let _msg = new Uint32Array([TextHeader, ...Buffer.from(msg, "utf-8")]);
this.socket.write(Buffer.from(_msg), (err) => {
if (err) {
rej(err);
return;
}
res();
});
});
}
sendBinary(msg: Uint8Array): Promise<void> {
return new Promise((res, rej) => {
let _msg = new Uint32Array([BinaryHeader, msg.length, ...msg]);
this.socket.write(Buffer.from(_msg), (err) => {
if (err) {
rej(err);
return;
}
res();
});
});
}
close(): void {
this.emit('disconnect');
this.socket.end();
}
isOpen(): boolean {
return this.socket.writable;
}
}

View File

@@ -0,0 +1,39 @@
import EventEmitter from 'events';
import NetworkServer from '../NetworkServer.js';
import { Server, Socket } from 'net';
import IConfig from '../IConfig.js';
import TCPClient from './TCPClient.js';
import { IPDataManager } from '../IPData.js';
import { User } from '../User.js';
import pino from 'pino';
export default class TCPServer extends EventEmitter implements NetworkServer {
listener: Server;
Config: IConfig;
logger = pino({name: 'CVMTS.TCPServer'});
clients: TCPClient[];
constructor(config: IConfig) {
super();
this.Config = config;
this.listener = new Server();
this.clients = [];
this.listener.on('connection', (socket) => this.onConnection(socket));
}
private onConnection(socket: Socket) {
this.logger.info(`New TCP connection from ${socket.remoteAddress}`);
var client = new TCPClient(socket);
this.clients.push(client);
this.emit('connect', new User(client, IPDataManager.GetIPData(client.getIP()), this.Config));
}
start(): void {
this.listener.listen(this.Config.tcp.port, this.Config.tcp.host, () => {
this.logger.info(`TCP server listening on ${this.Config.tcp.host}:${this.Config.tcp.port}`);
});
}
stop(): void {
this.listener.close();
}
}

181
cvmts/src/User.ts Normal file
View File

@@ -0,0 +1,181 @@
import * as Utilities from './Utilities.js';
import * as cvm from '@cvmts/cvm-rs';
import { IPData } from './IPData.js';
import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js';
import { execa, execaCommand, ExecaSyncError } from 'execa';
import NetworkClient from './NetworkClient.js';
import { CollabVMCapabilities } from '@cvmts/collab-vm-1.2-binary-protocol';
import pino from 'pino';
export class User {
socket: NetworkClient;
nopSendInterval: NodeJS.Timeout;
msgRecieveInterval: NodeJS.Timeout;
nopRecieveTimeout?: NodeJS.Timeout;
username?: string;
connectedToNode: boolean;
viewMode: number;
rank: Rank;
msgsSent: number;
Config: IConfig;
IP: IPData;
Capabilities: CollabVMCapabilities;
// Hide flag. Only takes effect if the user is logged in.
noFlag: boolean = false;
countryCode: string | null = null;
// Rate limiters
ChatRateLimit: RateLimiter;
LoginRateLimit: RateLimiter;
RenameRateLimit: RateLimiter;
TurnRateLimit: RateLimiter;
VoteRateLimit: RateLimiter;
private logger = pino({ name: 'CVMTS.User' });
constructor(socket: NetworkClient, ip: IPData, config: IConfig, username?: string, node?: string) {
this.IP = ip;
this.connectedToNode = false;
this.viewMode = -1;
this.Config = config;
this.socket = socket;
this.msgsSent = 0;
this.Capabilities = new CollabVMCapabilities();
this.socket.on('disconnect', () => {
// Unref the ip data for this connection
this.IP.Unref();
clearInterval(this.nopSendInterval);
clearInterval(this.msgRecieveInterval);
});
this.socket.on('msg', (e) => {
clearTimeout(this.nopRecieveTimeout);
clearInterval(this.msgRecieveInterval);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
});
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
this.sendNop();
if (username) this.username = username;
this.rank = 0;
this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds);
this.ChatRateLimit.on('limit', () => this.mute(false));
this.RenameRateLimit = new RateLimiter(3, 60);
this.RenameRateLimit.on('limit', () => this.closeConnection());
this.LoginRateLimit = new RateLimiter(4, 3);
this.LoginRateLimit.on('limit', () => this.closeConnection());
this.TurnRateLimit = new RateLimiter(5, 3);
this.TurnRateLimit.on('limit', () => this.closeConnection());
this.VoteRateLimit = new RateLimiter(3, 3);
this.VoteRateLimit.on('limit', () => this.closeConnection());
}
assignGuestName(existingUsers: string[]): string {
var username;
do {
username = 'guest' + Utilities.Randint(10000, 99999);
} while (existingUsers.indexOf(username) !== -1);
this.username = username;
return username;
}
sendNop() {
this.socket.send('3.nop;');
}
sendMsg(msg: string) {
if (!this.socket.isOpen()) return;
clearInterval(this.nopSendInterval);
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.socket.send(msg);
}
private onNoMsg() {
this.sendNop();
this.nopRecieveTimeout = setTimeout(() => {
this.closeConnection();
}, 3000);
}
closeConnection() {
this.socket.send(cvm.guacEncode('disconnect'));
this.socket.close();
}
onMsgSent() {
if (!this.Config.collabvm.automute.enabled) return;
// rate limit guest and unregistered chat messages, but not staff ones
switch (this.rank) {
case Rank.Moderator:
case Rank.Admin:
break;
default:
this.ChatRateLimit.request();
break;
}
}
mute(permanent: boolean) {
this.IP.muted = true;
this.sendMsg(cvm.guacEncode('chat', '', `You have been muted${permanent ? '' : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
}
}
unmute() {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.muted = false;
this.sendMsg(cvm.guacEncode('chat', '', 'You are no longer muted.'));
}
private banCmdArgs(arg: string): string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || '');
}
async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == 'string') {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, { stdout: process.stdout, stderr: process.stderr });
this.kick();
} else {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
this.logger.error(`Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async kick() {
this.sendMsg('10.disconnect;');
this.socket.close();
}
}
export enum Rank {
Unregistered = 0,
// After all these years
Registered = 1,
Admin = 2,
Moderator = 3,
// Giving a good gap between server only internal ranks just in case
Turn = 10
}

54
cvmts/src/Utilities.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Permissions } from './IConfig';
export function Randint(min: number, max: number) {
return Math.floor(Math.random() * (max - min) + min);
}
export function HTMLSanitize(input: string): string {
var output = '';
for (var i = 0; i < input.length; i++) {
switch (input[i]) {
case '<':
output += '&lt;';
break;
case '>':
output += '&gt;';
break;
case '&':
output += '&amp;';
break;
case '"':
output += '&quot;';
break;
case "'":
output += '&#x27;';
break;
case '/':
output += '&#x2F;';
break;
case '\n':
output += '&#13;&#10;';
break;
default:
var charcode: number = input.charCodeAt(i);
if (charcode >= 32 && charcode <= 126) output += input[i];
break;
}
}
return output;
}
export function MakeModPerms(modperms: Permissions): number {
var perms = 0;
if (modperms.restore) perms |= 1;
if (modperms.reboot) perms |= 2;
if (modperms.ban) perms |= 4;
if (modperms.forcevote) perms |= 8;
if (modperms.mute) perms |= 16;
if (modperms.kick) perms |= 32;
if (modperms.bypassturn) perms |= 64;
if (modperms.rename) perms |= 128;
if (modperms.grabip) perms |= 256;
if (modperms.xss) perms |= 512;
return perms;
}

13
cvmts/src/VM.ts Normal file
View File

@@ -0,0 +1,13 @@
import { VMState } from '@computernewb/superqemu';
import { VMDisplay } from './VMDisplay.js';
export default interface VM {
Start(): Promise<void>;
Stop(): Promise<void>;
Reboot(): Promise<void>;
Reset(): Promise<void>;
MonitorCommand(command: string): Promise<any>;
GetDisplay(): VMDisplay;
GetState(): VMState;
SnapshotsSupported(): boolean;
}

25
cvmts/src/VMDisplay.ts Normal file
View File

@@ -0,0 +1,25 @@
import EventEmitter from 'node:events';
// not great but whatever
// nodejs-rfb COULD probably export them though.
export type Size = {
width: number;
height: number;
};
export type Rect = {
x: number;
y: number;
width: number;
height: number;
};
export interface VMDisplay extends EventEmitter {
Connect(): void;
Disconnect(): void;
Connected(): boolean;
Buffer(): Buffer;
Size(): Size;
MouseEvent(x: number, y: number, buttons: number): void;
KeyboardEvent(keysym: number, pressed: boolean): void;
}

184
cvmts/src/VNCVM/VNCVM.ts Normal file
View File

@@ -0,0 +1,184 @@
import EventEmitter from 'events';
import VNCVMDef from './VNCVMDef';
import VM from '../VM';
import { Size, Rect, VMDisplay } from '../VMDisplay';
import { VncClient } from '@computernewb/nodejs-rfb';
import { BatchRects, VMState } from '@computernewb/superqemu';
import { execaCommand } from 'execa';
import pino from 'pino';
function Clamp(input: number, min: number, max: number) {
return Math.min(Math.max(input, min), max);
}
async function Sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export default class VNCVM extends EventEmitter implements VM, VMDisplay {
def: VNCVMDef;
logger;
private displayVnc = new VncClient({
debug: false,
fps: 60,
encodings: [VncClient.consts.encodings.raw, VncClient.consts.encodings.pseudoDesktopSize]
});
private vncShouldReconnect: boolean = false;
constructor(def: VNCVMDef) {
super();
this.def = def;
// TODO: Now that we're using an actual structured logger can we please
this.logger = pino({ name: `CVMTS.VNCVM/${this.def.vncHost}:${this.def.vncPort}` });
this.displayVnc.on('connectTimeout', () => {
this.Reconnect();
});
this.displayVnc.on('authError', () => {
this.Reconnect();
});
this.displayVnc.on('disconnect', () => {
this.logger.info('Disconnected');
this.Reconnect();
});
this.displayVnc.on('closed', () => {
this.Reconnect();
});
this.displayVnc.on('firstFrameUpdate', () => {
this.logger.info('Connected');
// apparently this library is this good.
// at least it's better than the two others which exist.
this.displayVnc.changeFps(60);
this.emit('connected');
this.emit('resize', { width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
//this.emit('rect', { x: 0, y: 0, width: this.displayVnc.clientWidth, height: this.displayVnc.clientHeight });
this.emit('frame');
});
this.displayVnc.on('desktopSizeChanged', (size: Size) => {
this.emit('resize', size);
});
let rects: Rect[] = [];
this.displayVnc.on('rectUpdateProcessed', (rect: Rect) => {
rects.push(rect);
});
this.displayVnc.on('frameUpdated', (fb: Buffer) => {
// use the cvmts batcher
let batched = BatchRects(this.Size(), rects);
this.emit('rect', batched);
// unbatched (watch the performace go now)
//for(let rect of rects)
// this.emit('rect', rect);
rects = [];
this.emit('frame');
});
}
async Reset(): Promise<void> {
if (this.def.restoreCmd) await execaCommand(this.def.restoreCmd, { shell: true });
else {
await this.Stop();
await Sleep(1000);
await this.Start();
}
}
private Reconnect() {
if (this.displayVnc.connected) return;
if (!this.vncShouldReconnect) return;
// TODO: this should also give up after a max tries count
// if we fail after max tries, emit a event
this.displayVnc.connect({
host: this.def.vncHost,
port: this.def.vncPort,
path: null
});
}
async Start(): Promise<void> {
this.logger.info('Connecting');
if (this.def.startCmd) await execaCommand(this.def.startCmd, { shell: true });
this.Connect();
}
async Stop(): Promise<void> {
this.logger.info('Disconnecting');
this.Disconnect();
if (this.def.stopCmd) await execaCommand(this.def.stopCmd, { shell: true });
}
async Reboot(): Promise<void> {
if (this.def.rebootCmd) await execaCommand(this.def.rebootCmd, { shell: true });
}
async MonitorCommand(command: string): Promise<any> {
// TODO: This can maybe run a specified command?
return 'This VM does not support monitor commands.';
}
GetDisplay(): VMDisplay {
return this;
}
GetState(): VMState {
// for now!
return VMState.Started;
}
SnapshotsSupported(): boolean {
return true;
}
Connect(): void {
this.vncShouldReconnect = true;
this.Reconnect();
}
Disconnect(): void {
this.vncShouldReconnect = false;
this.displayVnc.disconnect();
}
Connected(): boolean {
return this.displayVnc.connected;
}
Buffer(): Buffer {
return this.displayVnc.fb;
}
Size(): Size {
if (!this.displayVnc.connected)
return {
width: 0,
height: 0
};
return {
width: this.displayVnc.clientWidth,
height: this.displayVnc.clientHeight
};
}
MouseEvent(x: number, y: number, buttons: number): void {
if (this.displayVnc.connected) this.displayVnc.sendPointerEvent(Clamp(x, 0, this.displayVnc.clientWidth), Clamp(y, 0, this.displayVnc.clientHeight), buttons);
}
KeyboardEvent(keysym: number, pressed: boolean): void {
if (this.displayVnc.connected) this.displayVnc.sendKeyEvent(keysym, pressed);
}
}

View File

@@ -0,0 +1,8 @@
export default interface VNCVMDef {
vncHost: string;
vncPort: number;
startCmd: string | null;
stopCmd: string | null;
rebootCmd: string | null;
restoreCmd: string | null;
}

View File

@@ -0,0 +1,74 @@
import { WebSocket } from 'ws';
import NetworkClient from '../NetworkClient.js';
import EventEmitter from 'events';
export default class WSClient extends EventEmitter implements NetworkClient {
socket: WebSocket;
ip: string;
constructor(ws: WebSocket, ip: string) {
super();
this.socket = ws;
this.ip = ip;
this.socket.on('message', (buf: Buffer, isBinary: boolean) => {
// Close the user's connection if they send a non-string message
if (isBinary) {
this.close();
return;
}
this.emit('msg', buf.toString('utf-8'));
});
this.socket.on('close', () => {
this.emit('disconnect');
});
}
isOpen(): boolean {
return this.socket.readyState === WebSocket.OPEN;
}
getIP(): string {
return this.ip;
}
send(msg: string): Promise<void> {
return new Promise((res, rej) => {
if (!this.isOpen()) res();
this.socket.send(msg, (err) => {
if (err) {
rej(err);
return;
}
res();
});
});
}
sendBinary(msg: Uint8Array): Promise<void> {
return new Promise((res, rej) => {
if (!this.isOpen()) res();
this.socket.send(msg, (err) => {
if (err) {
rej(err);
return;
}
res();
});
});
}
close(): void {
if (this.isOpen()) {
// While this seems counterintutive, do note that the WebSocket protocol
// *sends* a data frame whilist closing a connection. Therefore, if the other end
// has forcibly hung up (closed) their connection, the best way to handle that
// is to just let the inner TCP socket propegate that, which `ws` will do for us.
// Otherwise, we'll try to send data to a closed client then SIGPIPE.
this.socket.close();
}
}
}

View File

@@ -0,0 +1,143 @@
import * as http from 'http';
import NetworkServer from '../NetworkServer.js';
import EventEmitter from 'events';
import { WebSocketServer, WebSocket } from 'ws';
import internal from 'stream';
import IConfig from '../IConfig.js';
import { isIP } from 'net';
import { IPDataManager } from '../IPData.js';
import WSClient from './WSClient.js';
import { User } from '../User.js';
import pino from 'pino';
export default class WSServer extends EventEmitter implements NetworkServer {
private httpServer: http.Server;
private wsServer: WebSocketServer;
private clients: WSClient[];
private Config: IConfig;
private logger = pino({ name: 'CVMTS.WSServer' });
constructor(config: IConfig) {
super();
this.Config = config;
this.clients = [];
this.httpServer = http.createServer();
this.wsServer = new WebSocketServer({ noServer: true });
this.httpServer.on('upgrade', (req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) => this.httpOnUpgrade(req, socket, head));
this.httpServer.on('request', (req, res) => {
res.writeHead(426);
res.write('This server only accepts WebSocket connections.');
res.end();
});
}
start(): void {
this.httpServer.listen(this.Config.http.port, this.Config.http.host, () => {
this.logger.info(`WebSocket server listening on ${this.Config.http.host}:${this.Config.http.port}`);
});
}
stop(): void {
this.httpServer.close();
}
private httpOnUpgrade(req: http.IncomingMessage, socket: internal.Duplex, head: Buffer) {
var killConnection = () => {
socket.write('HTTP/1.1 400 Bad Request\n\n400 Bad Request');
socket.destroy();
};
if (req.headers['sec-websocket-protocol'] !== 'guacamole') {
killConnection();
return;
}
if (this.Config.http.origin) {
// If the client is not sending an Origin header, kill the connection.
if (!req.headers.origin) {
killConnection();
return;
}
// Try to parse the Origin header sent by the client, if it fails, kill the connection.
var _uri;
var _host;
try {
_uri = new URL(req.headers.origin.toLowerCase());
_host = _uri.host;
} catch {
killConnection();
return;
}
// detect fake origin headers
if (_uri.pathname !== '/' || _uri.search !== '') {
killConnection();
return;
}
// If the domain name is not in the list of allowed origins, kill the connection.
if (!this.Config.http.originAllowedDomains.includes(_host)) {
killConnection();
return;
}
}
let ip: string;
if (this.Config.http.proxying) {
// If the requesting IP isn't allowed to proxy, kill it
if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) {
killConnection();
return;
}
// Make sure x-forwarded-for is set
if (req.headers['x-forwarded-for'] === undefined) {
killConnection();
this.logger.error('X-Forwarded-For header not set. This is most likely a misconfiguration of your reverse proxy.');
return;
}
try {
// Get the first IP from the X-Forwarded-For variable
ip = req.headers['x-forwarded-for']?.toString().replace(/\ /g, '').split(',')[0];
} catch {
// If we can't get the IP, kill the connection
this.logger.error('Invalid X-Forwarded-For header. This is most likely a misconfiguration of your reverse proxy.');
killConnection();
return;
}
// If for some reason the IP isn't defined, kill it
if (!ip) {
killConnection();
return;
}
// Make sure the IP is valid. If not, kill the connection.
if (!isIP(ip)) {
killConnection();
return;
}
} else {
if (!req.socket.remoteAddress) return;
ip = req.socket.remoteAddress;
}
this.wsServer.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this.wsServer.emit('connection', ws, req);
this.onConnection(ws, req, ip);
});
}
private onConnection(ws: WebSocket, req: http.IncomingMessage, ip: string) {
let client = new WSClient(ws, ip);
this.clients.push(client);
let user = new User(client, IPDataManager.GetIPData(ip), this.Config);
this.emit('connect', user);
ws.on('error', (e) => {
this.logger.error(`${e} (caused by connection ${ip})`);
ws.close();
});
this.logger.info(`New WebSocket connection from ${user.IP.address}`);
}
}

94
cvmts/src/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import * as toml from 'toml';
import IConfig from './IConfig.js';
import * as fs from 'fs';
import CollabVMServer from './CollabVMServer.js';
import { QemuVM, QemuVmDefinition } from '@computernewb/superqemu';
import AuthManager from './AuthManager.js';
import WSServer from './WebSocket/WSServer.js';
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';
import pino from 'pino';
let logger = pino();
logger.info('CollabVM Server starting up');
// Parse the config file
let Config: IConfig;
if (!fs.existsSync('config.toml')) {
logger.error('Fatal error: Config.toml not found. Please copy config.example.toml and fill out fields');
process.exit(1);
}
try {
var configRaw = fs.readFileSync('config.toml').toString();
Config = toml.parse(configRaw);
} catch (e) {
logger.error('Fatal error: Failed to read or parse the config file: {0}', (e as Error).message);
process.exit(1);
}
let exiting = false;
let VM: VM;
async function stop() {
if (exiting) return;
exiting = true;
await VM.Stop();
process.exit(0);
}
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) {
case 'qemu': {
// Fire up the VM
let def: QemuVmDefinition = {
id: Config.collabvm.node,
command: Config.qemu.qemuArgs,
snapshot: Config.qemu.snapshots
};
VM = new QemuVM(def);
break;
}
case 'vncvm': {
VM = new VNCVM(Config.vncvm);
break;
}
default: {
logger.error(`Invalid VM type in config: ${Config.vm.type}`);
process.exit(1);
return;
}
}
process.on('SIGINT', async () => await stop());
process.on('SIGTERM', async () => await stop());
await VM.Start();
// Start up the server
var CVM = new CollabVMServer(Config, VM, auth, geoipReader);
var WS = new WSServer(Config);
WS.on('connect', (client: User) => CVM.addUser(client));
WS.start();
if (Config.tcp.enabled) {
var TCP = new TCPServer(Config);
TCP.on('connect', (client: User) => CVM.addUser(client));
TCP.start();
}
}
start();

7
cvmts/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"include": [ "src/**/*" ],
"compilerOptions": {
"resolveJsonModule": true,
}
}

View File

@@ -1,27 +1,25 @@
{
"name": "collabvm1.ts",
"version": "1.0.0",
"description": "replacement for collabvm 1.2.11 because the old one :boom:",
"main": "build/index.js",
"scripts": {
"build": "tsc",
"serve": "node build/index.js"
},
"author": "Elijah R",
"license": "GPL-3.0",
"dependencies": {
"@types/node": "^20.6.0",
"@types/sharp": "^0.31.1",
"@types/ws": "^8.5.5",
"async-mutex": "^0.4.0",
"canvas": "^2.11.2",
"execa": "^8.0.1",
"jimp": "^0.22.10",
"mnemonist": "^0.39.5",
"rfb2": "github:elijahr2411/node-rfb2",
"toml": "^3.0.0",
"typescript": "^5.2.2",
"ws": "^8.14.1"
},
"type": "module"
"name": "cvmts-repo",
"workspaces": [
"shared",
"cvm-rs",
"cvmts",
"collab-vm-1.2-binary-protocol"
],
"devDependencies": {
"@parcel/packager-ts": "2.12.0",
"@parcel/transformer-sass": "2.12.0",
"@parcel/transformer-typescript-types": "2.12.0",
"@types/node": "^20.14.10",
"just-install": "^2.0.1",
"parcel": "^2.12.0",
"prettier": "^3.2.5",
"typescript": "^5.4.4"
},
"packageManager": "yarn@4.1.1",
"scripts": {
"build": "just",
"serve": "node cvmts/dist/index.js",
"clean": "rm -rf .parcel-cache .yarn **/node_modules **/dist cvm-rs/target cvm-rs/index.node"
}
}

View File

@@ -1,41 +0,0 @@
import { Rank, User } from "./User.js";
import log from "./log.js";
export default class AuthManager {
apiEndpoint : string;
secretKey : string;
constructor(apiEndpoint : string, secretKey : string) {
this.apiEndpoint = apiEndpoint;
this.secretKey = secretKey;
}
Authenticate(token : string, user : User) {
return new Promise<JoinResponse>(async res => {
var response = await fetch(this.apiEndpoint + "/api/v1/join", {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
secretKey: this.secretKey,
sessionToken: token,
ip: user.IP.address
})
});
var json = await response.json() as JoinResponse;
if (!json.success) {
log("FATAL", `Failed to query auth server: ${json.error}`);
process.exit(1);
}
res(json);
});
}
}
interface JoinResponse {
success : boolean;
clientSuccess : boolean;
error : string | undefined;
username : string | undefined;
rank : Rank;
}

View File

@@ -1,44 +0,0 @@
import { Mutex } from "async-mutex";
export default class Framebuffer {
fb : Buffer;
private writemutex : Mutex;
size : {height : number, width : number};
constructor() {
this.fb = Buffer.alloc(1);
this.size = {height: 0, width: 0};
this.writemutex = new Mutex();
}
setSize(w : number, h : number) {
var size = h * w * 4;
this.size.height = h;
this.size.width = w;
this.fb = Buffer.alloc(size);
}
loadDirtyRect(rect : Buffer, x : number, y : number, width : number, height : number) : Promise<void> {
if (this.fb.length < rect.length)
throw new Error("Dirty rect larger than framebuffer (did you forget to set the size?)");
return this.writemutex.runExclusive(() => {
return new Promise<void>((res, rej) => {
var byteswritten = 0;
for (var i = 0; i < height; i++) {
byteswritten += rect.copy(this.fb, 4 * ((y + i) * this.size.width + x), byteswritten, byteswritten + (width * 4));
}
res();
})
});
}
getFb() : Promise<Buffer> {
return new Promise<Buffer>(async (res, rej) => {
var v = await this.writemutex.runExclusive(() => {
return new Promise<Buffer>((reso, reje) => {
var buff = Buffer.alloc(this.fb.length);
this.fb.copy(buff);
reso(buff);
});
});
res(v);
})
}
}

View File

@@ -1,71 +0,0 @@
export default interface IConfig {
http : {
host : string;
port : number;
proxying : boolean;
proxyAllowedIps : string[];
origin : boolean;
originAllowedDomains : string[];
maxConnections: number;
};
auth : {
enabled : boolean;
apiEndpoint : string;
secretKey : string;
guestPermissions : {
chat : boolean;
turn : boolean;
callForReset : boolean;
vote : boolean;
}
}
vm : {
qemuArgs : string;
vncPort : number;
snapshots : boolean;
qmpHost : string | null;
qmpPort : number | null;
qmpSockDir : string | null;
};
collabvm : {
node : string;
displayname : string;
motd : string;
bancmd : string | string[];
moderatorEnabled : boolean;
usernameblacklist : string[];
maxChatLength : number;
maxChatHistoryLength : number;
turnlimit : {
enabled: boolean,
maximum: number;
};
automute : {
enabled: boolean;
seconds: number;
messages: number;
};
tempMuteTime : number;
turnTime : number;
voteTime : number;
voteCooldown: number;
adminpass : string;
modpass : string;
turnwhitelist : boolean;
turnpass : string;
moderatorPermissions : Permissions;
};
};
export interface Permissions {
restore : boolean;
reboot : boolean;
ban : boolean;
forcevote : boolean;
mute : boolean;
kick : boolean;
bypassturn : boolean;
rename : boolean;
grabip : boolean;
xss : boolean;
}

View File

@@ -1,12 +0,0 @@
export class IPData {
tempMuteExpireTimeout? : NodeJS.Timeout;
muted: Boolean;
vote: boolean | null;
address: string;
constructor(address: string) {
this.address = address;
this.muted = false;
this.vote = null;
}
}

View File

@@ -1,254 +0,0 @@
import IConfig from "./IConfig.js";
import * as rfb from 'rfb2';
import * as fs from 'fs';
import { ExecaChildProcess, execaCommand } from "execa";
import QMPClient from "./QMPClient.js";
import BatchRects from "./RectBatcher.js";
import { createCanvas, Canvas, CanvasRenderingContext2D, createImageData } from "canvas";
import { Mutex } from "async-mutex";
import log from "./log.js";
import VM from "./VM.js";
export default class QEMUVM extends VM {
vnc? : rfb.RfbClient;
vncPort : number;
framebuffer : Canvas;
framebufferCtx : CanvasRenderingContext2D;
qmpSock : string;
qmpType: string;
qmpClient : QMPClient;
qemuCmd : string;
qemuProcess? : ExecaChildProcess;
qmpErrorLevel : number;
vncErrorLevel : number;
processRestartErrorLevel : number;
expectedExit : boolean;
vncOpen : boolean;
vncUpdateInterval? : NodeJS.Timeout;
rects : {height:number,width:number,x:number,y:number,data:Buffer}[];
rectMutex : Mutex;
vncReconnectTimeout? : NodeJS.Timeout;
qmpReconnectTimeout? : NodeJS.Timeout;
qemuRestartTimeout? : NodeJS.Timeout;
constructor(Config : IConfig) {
super();
if (Config.vm.vncPort < 5900) {
log("FATAL", "VNC port must be 5900 or higher")
process.exit(1);
}
Config.vm.qmpSockDir == null ? this.qmpType = "tcp:" : this.qmpType = "unix:";
if(this.qmpType == "tcp:") {
this.qmpSock = `${Config.vm.qmpHost}:${Config.vm.qmpPort}`;
}else{
this.qmpSock = `${Config.vm.qmpSockDir}collab-vm-qmp-${Config.collabvm.node}.sock`;
}
this.vncPort = Config.vm.vncPort;
this.qemuCmd = `${Config.vm.qemuArgs} -no-shutdown -vnc 127.0.0.1:${this.vncPort - 5900} -qmp ${this.qmpType}${this.qmpSock},server,nowait`;
if (Config.vm.snapshots) this.qemuCmd += " -snapshot"
this.qmpErrorLevel = 0;
this.vncErrorLevel = 0;
this.vncOpen = true;
this.rects = [];
this.rectMutex = new Mutex();
this.framebuffer = createCanvas(1, 1);
this.framebufferCtx = this.framebuffer.getContext("2d");
this.processRestartErrorLevel = 0;
this.expectedExit = false;
this.qmpClient = new QMPClient(this.qmpSock, this.qmpType);
this.qmpClient.on('connected', () => this.qmpConnected());
this.qmpClient.on('close', () => this.qmpClosed());
}
Start() : Promise<void> {
return new Promise<void>(async (res, rej) => {
if (fs.existsSync(this.qmpSock))
try {
fs.unlinkSync(this.qmpSock);
} catch (e) {
log("ERROR", `Failed to delete existing socket: ${e}`);
process.exit(-1);
}
this.qemuProcess = execaCommand(this.qemuCmd);
this.qemuProcess.catch(() => false);
this.qemuProcess.stderr?.on('data', (d) => log("ERROR", `QEMU sent to stderr: ${d.toString()}`));
this.qemuProcess.once('spawn', () => {
setTimeout(async () => {
await this.qmpClient.connect();
}, 2000)
});
this.qemuProcess.once('exit', () => {
if (this.expectedExit) return;
clearTimeout(this.qmpReconnectTimeout);
clearTimeout(this.vncReconnectTimeout);
this.processRestartErrorLevel++;
if (this.processRestartErrorLevel > 4) {
log("FATAL", "QEMU failed to launch 5 times.");
process.exit(-1);
}
log("WARN", "QEMU exited unexpectedly, retrying in 3 seconds");
this.qmpClient.disconnect();
this.vnc?.end();
this.qemuRestartTimeout = setTimeout(() => this.Start(), 3000);
});
this.qemuProcess.on('error', () => false);
this.once('vncconnect', () => res());
});
}
private qmpConnected() {
this.qmpErrorLevel = 0;
this.processRestartErrorLevel = 0;
log("INFO", "QMP Connected");
setTimeout(() => this.startVNC(), 1000);
}
private startVNC() {
this.vnc = rfb.createConnection({
host: "127.0.0.1",
port: this.vncPort,
});
this.vnc.on("close", () => this.vncClosed());
this.vnc.on("connect", () => this.vncConnected());
this.vnc.on("rect", (r) => this.onVNCRect(r));
this.vnc.on("resize", (s) => this.onVNCSize(s));
}
public getSize() {
if (!this.vnc) return {height:0,width:0};
return {height: this.vnc.height, width: this.vnc.width}
}
private qmpClosed() {
if (this.expectedExit) return;
this.qmpErrorLevel++;
if (this.qmpErrorLevel > 4) {
log("FATAL", "Failed to connect to QMP after 5 attempts");
process.exit(1);
}
log("ERROR", "Failed to connect to QMP, retrying in 3 seconds.");
this.qmpReconnectTimeout = setTimeout(() => this.qmpClient.connect(), 3000);
}
private vncClosed() {
this.vncOpen = false;
if (this.expectedExit) return;
this.vncErrorLevel++;
if (this.vncErrorLevel > 4) {
log("FATAL", "Failed to connect to VNC after 5 attempts.")
process.exit(1);
}
try {
this.vnc?.end();
} catch {};
log("ERROR", "Failed to connect to VNC, retrying in 3 seconds");
this.vncReconnectTimeout = setTimeout(() => this.startVNC(), 3000);
}
private vncConnected() {
this.vncOpen = true;
this.emit('vncconnect');
log("INFO", "VNC Connected");
this.vncErrorLevel = 0;
this.onVNCSize({height: this.vnc!.height, width: this.vnc!.width});
this.vncUpdateInterval = setInterval(() => this.SendRects(), 33);
}
private onVNCRect(rect : any) {
return this.rectMutex.runExclusive(async () => {
return new Promise<void>(async (res, rej) => {
var buff = Buffer.alloc(rect.height * rect.width * 4)
var offset = 0;
for (var i = 0; i < rect.data.length; i += 4) {
buff[offset++] = rect.data[i + 2];
buff[offset++] = rect.data[i + 1];
buff[offset++] = rect.data[i];
buff[offset++] = 255;
}
var imgdata = createImageData(Uint8ClampedArray.from(buff), rect.width, rect.height);
this.framebufferCtx.putImageData(imgdata, rect.x, rect.y);
this.rects.push({
x: rect.x,
y: rect.y,
height: rect.height,
width: rect.width,
data: buff,
});
if (!this.vnc) throw new Error();
if (this.vncOpen)
this.vnc.requestUpdate(true, 0, 0, this.vnc.height, this.vnc.width);
res();
})
});
}
SendRects() {
if (!this.vnc || this.rects.length < 1) return;
return this.rectMutex.runExclusive(() => {
return new Promise<void>(async (res, rej) => {
var rect = await BatchRects(this.framebuffer, [...this.rects]);
this.rects = [];
this.emit('dirtyrect', rect.data, rect.x, rect.y);
res();
});
})
}
private onVNCSize(size : any) {
if (this.framebuffer.height !== size.height) this.framebuffer.height = size.height;
if (this.framebuffer.width !== size.width) this.framebuffer.width = size.width;
this.emit("size", {height: size.height, width: size.width});
}
Reboot() : Promise<void> {
return new Promise(async (res, rej) => {
if (this.expectedExit) {res(); return;}
res(await this.qmpClient.reboot());
});
}
async Restore() {
if (this.expectedExit) return;
await this.Stop();
this.expectedExit = false;
this.Start();
}
Stop() : Promise<void> {
return new Promise<void>(async (res, rej) => {
if (this.expectedExit) {res(); return;}
if (!this.qemuProcess) throw new Error("VM was not running");
this.expectedExit = true;
this.vncOpen = false;
this.vnc?.end();
clearInterval(this.vncUpdateInterval);
var killTimeout = setTimeout(() => {
log("WARN", "Force killing QEMU after 10 seconds of waiting for shutdown");
this.qemuProcess?.kill(9);
}, 10000);
var closep = new Promise<void>(async (reso, reje) => {
this.qemuProcess?.once('exit', () => reso());
await this.qmpClient.execute({ "execute": "quit" });
});
var qmpclosep = new Promise<void>((reso, rej) => {
this.qmpClient.once('close', () => reso());
});
await Promise.all([closep, qmpclosep]);
clearTimeout(killTimeout);
res();
})
}
public pointerEvent(x: number, y: number, mask: number) {
if (!this.vnc) throw new Error("VNC was not instantiated.");
this.vnc.pointerEvent(x, y, mask);
}
public acceptingInput(): boolean {
return this.vncOpen;
}
public keyEvent(keysym: number, down: boolean): void {
if (!this.vnc) throw new Error("VNC was not instantiated.");
this.vnc.keyEvent(keysym, down ? 1 : 0);
}
}

View File

@@ -1,152 +0,0 @@
import EventEmitter from "events";
import { Socket } from "net";
import { Mutex } from "async-mutex";
import log from "./log.js";
import { EOL } from "os";
export default class QMPClient extends EventEmitter {
socketfile : string;
sockettype: string;
socket : Socket;
connected : boolean;
sentConnected : boolean;
cmdMutex : Mutex; // So command outputs don't get mixed up
constructor(socketfile : string, sockettype: string) {
super();
this.sockettype = sockettype;
this.socketfile = socketfile;
this.socket = new Socket();
this.connected = false;
this.sentConnected = false;
this.cmdMutex = new Mutex();
}
connect() : Promise<void> {
return new Promise((res, rej) => {
if (this.connected) {res(); return;}
try {
if(this.sockettype == "tcp:") {
let _sock = this.socketfile.split(':');
this.socket.connect(parseInt(_sock[1]), _sock[0]);
}else{
this.socket.connect(this.socketfile);
}
} catch (e) {
this.onClose();
}
this.connected = true;
this.socket.on('error', () => false); // Disable throwing if QMP errors
this.socket.on('data', (data) => {
data.toString().split(EOL).forEach(instr => this.onData(instr));
});
this.socket.on('close', () => this.onClose());
this.once('connected', () => {res();});
})
}
disconnect() {
this.connected = false;
this.socket.destroy();
}
private async onData(data : string) {
let msg;
try {
msg = JSON.parse(data);
} catch {
return;
}
if (msg.QMP !== undefined) {
if (this.sentConnected)
return;
await this.execute({ execute: "qmp_capabilities" });
this.emit('connected');
this.sentConnected = true;
}
if (msg.return !== undefined && Object.keys(msg.return).length)
this.emit("qmpreturn", msg.return);
else if(msg.event !== undefined) {
switch(msg.event) {
case "STOP":
{
log("INFO", "The VM was shut down, restarting...");
this.reboot();
break;
}
case "RESET":
{
log("INFO", "QEMU reset event occured");
this.resume();
break;
};
default: break;
}
}else
// for now just return an empty string.
// This is a giant hack but avoids a deadlock
this.emit("qmpreturn", '');
}
private onClose() {
this.connected = false;
this.sentConnected = false;
if (this.socket.readyState === 'open')
this.socket.destroy();
this.cmdMutex.cancel();
this.cmdMutex.release();
this.socket = new Socket();
this.emit('close');
}
async reboot() {
if (!this.connected)
return;
await this.execute({"execute": "system_reset"});
}
async resume() {
if (!this.connected)
return;
await this.execute({"execute": "cont"});
}
async ExitQEMU() {
if (!this.connected)
return;
await this.execute({"execute": "quit"});
}
execute(args : object) {
return new Promise(async (res, rej) => {
var result:any;
try {
result = await this.cmdMutex.runExclusive(() => {
// I kinda hate having two promises but IDK how else to do it /shrug
return new Promise((reso, reje) => {
this.once('qmpreturn', (e) => {
reso(e);
});
this.socket.write(JSON.stringify(args));
});
});
} catch {
res({});
}
res(result);
});
}
runMonitorCmd(command : string) {
return new Promise(async (res, rej) => {
res(await this.execute({execute: "human-monitor-command", arguments: {"command-line": command}}));
});
}
}

View File

@@ -1,36 +0,0 @@
import { EventEmitter } from "events";
// Class to ratelimit a resource (chatting, logging in, etc)
export default class RateLimiter extends EventEmitter {
private limit : number;
private interval : number;
private requestCount : number;
private limiter? : NodeJS.Timeout;
private limiterSet : boolean;
constructor(limit : number, interval : number) {
super();
this.limit = limit;
this.interval = interval;
this.requestCount = 0;
this.limiterSet = false;
}
// Return value is whether or not the action should be continued
request() : boolean {
this.requestCount++;
if (this.requestCount === this.limit) {
this.emit('limit');
clearTimeout(this.limiter);
this.limiterSet = false;
this.requestCount = 0;
return false;
}
if (!this.limiterSet) {
this.limiter = setTimeout(() => {
this.limiterSet = false;
this.requestCount = 0;
}, this.interval * 1000);
this.limiterSet = true;
}
return true;
}
}

View File

@@ -1,28 +0,0 @@
import { Canvas, createCanvas, createImageData } from "canvas";
export default async function BatchRects(fb : Canvas, rects : {height:number,width:number,x:number,y:number,data:Buffer}[]) : Promise<{x:number,y:number,data:Canvas}> {
var mergedX = fb.width;
var mergedY = fb.height;
var mergedHeight = 0;
var mergedWidth = 0;
rects.forEach((r) => {
if (r.x < mergedX) mergedX = r.x;
if (r.y < mergedY) mergedY = r.y;
});
rects.forEach(r => {
if (((r.height + r.y) - mergedY) > mergedHeight) mergedHeight = (r.height + r.y) - mergedY;
if (((r.width + r.x) - mergedX) > mergedWidth) mergedWidth = (r.width + r.x) - mergedX;
});
var rect = createCanvas(mergedWidth, mergedHeight);
var ctx = rect.getContext("2d");
ctx.drawImage(fb, mergedX, mergedY, mergedWidth, mergedHeight, 0, 0, mergedWidth, mergedHeight);
for (const r of rects) {
var id = createImageData(Uint8ClampedArray.from(r.data), r.width, r.height);
ctx.putImageData(id, r.x - mergedX, r.y - mergedY);
}
return {
data: rect,
x: mergedX,
y: mergedY,
}
}

View File

@@ -1,158 +0,0 @@
import * as Utilities from './Utilities.js';
import * as guacutils from './guacutils.js';
import {WebSocket} from 'ws';
import {IPData} from './IPData.js';
import IConfig from './IConfig.js';
import RateLimiter from './RateLimiter.js';
import { execa, execaCommand, ExecaSyncError } from 'execa';
import log from './log.js';
export class User {
socket : WebSocket;
nopSendInterval : NodeJS.Timeout;
msgRecieveInterval : NodeJS.Timeout;
nopRecieveTimeout? : NodeJS.Timeout;
username? : string;
connectedToNode : boolean;
viewMode : number;
rank : Rank;
msgsSent : number;
Config : IConfig;
IP : IPData;
// Rate limiters
ChatRateLimit : RateLimiter;
LoginRateLimit : RateLimiter;
RenameRateLimit : RateLimiter;
TurnRateLimit : RateLimiter;
VoteRateLimit : RateLimiter;
constructor(ws : WebSocket, ip : IPData, config : IConfig, username? : string, node? : string) {
this.IP = ip;
this.connectedToNode = false;
this.viewMode = -1;
this.Config = config;
this.socket = ws;
this.msgsSent = 0;
this.socket.on('close', () => {
clearInterval(this.nopSendInterval);
});
this.socket.on('message', (e) => {
clearTimeout(this.nopRecieveTimeout);
clearInterval(this.msgRecieveInterval);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
})
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.msgRecieveInterval = setInterval(() => this.onNoMsg(), 10000);
this.sendNop();
if (username) this.username = username;
this.rank = 0;
this.ChatRateLimit = new RateLimiter(this.Config.collabvm.automute.messages, this.Config.collabvm.automute.seconds);
this.ChatRateLimit.on('limit', () => this.mute(false));
this.RenameRateLimit = new RateLimiter(3, 60);
this.RenameRateLimit.on('limit', () => this.closeConnection());
this.LoginRateLimit = new RateLimiter(4, 3);
this.LoginRateLimit.on('limit', () => this.closeConnection());
this.TurnRateLimit = new RateLimiter(5, 3);
this.TurnRateLimit.on('limit', () => this.closeConnection());
this.VoteRateLimit = new RateLimiter(3, 3);
this.VoteRateLimit.on('limit', () => this.closeConnection());
}
assignGuestName(existingUsers : string[]) : string {
var username;
do {
username = "guest" + Utilities.Randint(10000, 99999);
} while (existingUsers.indexOf(username) !== -1);
this.username = username;
return username;
}
sendNop() {
this.socket.send("3.nop;");
}
sendMsg(msg : string | Buffer) {
if (this.socket.readyState !== this.socket.OPEN) return;
clearInterval(this.nopSendInterval);
this.nopSendInterval = setInterval(() => this.sendNop(), 5000);
this.socket.send(msg);
}
private onNoMsg() {
this.sendNop();
this.nopRecieveTimeout = setTimeout(() => {
this.closeConnection();
}, 3000);
}
closeConnection() {
this.socket.send(guacutils.encode("disconnect"));
this.socket.close();
}
onMsgSent() {
if (!this.Config.collabvm.automute.enabled) return;
// rate limit guest and unregistered chat messages, but not staff ones
switch(this.rank) {
case Rank.Moderator:
case Rank.Admin:
break;
default:
this.ChatRateLimit.request();
break;
}
}
mute(permanent : boolean) {
this.IP.muted = true;
this.sendMsg(guacutils.encode("chat", "", `You have been muted${permanent ? "" : ` for ${this.Config.collabvm.tempMuteTime} seconds`}.`));
if (!permanent) {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.tempMuteExpireTimeout = setTimeout(() => this.unmute(), this.Config.collabvm.tempMuteTime * 1000);
}
}
unmute() {
clearTimeout(this.IP.tempMuteExpireTimeout);
this.IP.muted = false;
this.sendMsg(guacutils.encode("chat", "", "You are no longer muted."));
}
private banCmdArgs(arg: string) : string {
return arg.replace(/\$IP/g, this.IP.address).replace(/\$NAME/g, this.username || "");
}
async ban() {
// Prevent the user from taking turns or chatting, in case the ban command takes a while
this.IP.muted = true;
try {
if (Array.isArray(this.Config.collabvm.bancmd)) {
let args: string[] = this.Config.collabvm.bancmd.map((a: string) => this.banCmdArgs(a));
if (args.length || args[0].length) {
await execa(args.shift()!, args, {stdout: process.stdout, stderr: process.stderr});
this.kick();
} else {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
} else if (typeof this.Config.collabvm.bancmd == "string") {
let cmd: string = this.banCmdArgs(this.Config.collabvm.bancmd);
if (cmd.length) {
await execaCommand(cmd, {stdout: process.stdout, stderr: process.stderr});
this.kick();
} else {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): Empty command`);
}
}
} catch (e) {
log("ERROR", `Failed to ban ${this.IP.address} (${this.username}): ${(e as ExecaSyncError).shortMessage}`);
}
}
async kick() {
this.sendMsg("10.disconnect;");
this.socket.close();
}
}
export enum Rank {
Unregistered = 0,
// After all these years
Registered = 1,
Admin = 2,
Moderator = 3,
// Giving a good gap between server only internal ranks just in case
Turn = 10,
}

View File

@@ -1,54 +0,0 @@
import { Permissions } from "./IConfig";
export function Randint(min : number, max : number) {
return Math.floor((Math.random() * (max - min)) + min);
}
export function HTMLSanitize(input : string) : string {
var output = "";
for (var i = 0; i < input.length; i++) {
switch (input[i]) {
case "<":
output += "&lt;"
break;
case ">":
output += "&gt;"
break;
case "&":
output += "&amp;"
break;
case "\"":
output += "&quot;"
break;
case "'":
output += "&#x27;";
break;
case "/":
output += "&#x2F;";
break;
case "\n":
output += "&#13;&#10;";
break;
default:
var charcode : number = input.charCodeAt(i);
if (charcode >= 32 && charcode <= 126)
output += input[i];
break;
}
}
return output;
}
export function MakeModPerms(modperms : Permissions) : number {
var perms = 0;
if (modperms.restore) perms |= 1;
if (modperms.reboot) perms |= 2;
if (modperms.ban) perms |= 4;
if (modperms.forcevote) perms |= 8;
if (modperms.mute) perms |= 16;
if (modperms.kick) perms |= 32;
if (modperms.bypassturn) perms |= 64;
if (modperms.rename) perms |= 128;
if (modperms.grabip) perms |= 256;
if (modperms.xss) perms |= 512;
return perms;
}

View File

@@ -1,12 +0,0 @@
import { Canvas } from "canvas";
import EventEmitter from "events";
export default abstract class VM extends EventEmitter {
public abstract getSize() : {height:number;width:number;};
public abstract get framebuffer() : Canvas;
public abstract pointerEvent(x : number, y : number, mask : number) : void;
public abstract acceptingInput() : boolean;
public abstract keyEvent(keysym : number, down : boolean) : void;
public abstract Restore() : void;
public abstract Reboot() : Promise<void>;
}

View File

@@ -1,917 +0,0 @@
import {WebSocketServer, WebSocket} from 'ws';
import * as http from 'http';
import IConfig from './IConfig.js';
import internal from 'stream';
import * as Utilities from './Utilities.js';
import { User, Rank } from './User.js';
import * as guacutils from './guacutils.js';
// I hate that you have to do it like this
import CircularBuffer from 'mnemonist/circular-buffer.js';
import Queue from 'mnemonist/queue.js';
import { createHash } from 'crypto';
import { isIP } from 'net';
import QEMUVM from './QEMUVM.js';
import { Canvas, createCanvas } from 'canvas';
import { IPData } from './IPData.js';
import { readFileSync } from 'fs';
import log from './log.js';
import VM from './VM.js';
import { fileURLToPath } from 'url';
import path from 'path';
import AuthManager from './AuthManager.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default class WSServer {
private Config : IConfig;
private server : http.Server;
private socket : WebSocketServer;
private clients : User[];
private ips : IPData[];
private ChatHistory : CircularBuffer<{user:string,msg:string}>
private TurnQueue : Queue<User>;
// Time remaining on the current turn
private TurnTime : number;
// Interval to keep track of the current turn time
private TurnInterval? : NodeJS.Timeout;
// If a reset vote is in progress
private voteInProgress : boolean;
// Interval to keep track of vote resets
private voteInterval? : NodeJS.Timeout;
// How much time is left on the vote
private voteTime : number;
// How much time until another reset vote can be cast
private voteCooldown : number;
// Interval to keep track
private voteCooldownInterval? : NodeJS.Timeout;
// Completely disable turns
private turnsAllowed : boolean;
// Hide the screen
private screenHidden : boolean;
// base64 image to show when the screen is hidden
private screenHiddenImg : string;
private screenHiddenThumb : string;
// Indefinite turn
private indefiniteTurn : User | null;
private ModPerms : number;
private VM : VM;
// Authentication manager
private auth : AuthManager | null;
constructor(config : IConfig, vm : VM, auth : AuthManager | null) {
this.Config = config;
this.ChatHistory = new CircularBuffer<{user:string,msg:string}>(Array, this.Config.collabvm.maxChatHistoryLength);
this.TurnQueue = new Queue<User>();
this.TurnTime = 0;
this.clients = [];
this.ips = [];
this.voteInProgress = false;
this.voteTime = 0;
this.voteCooldown = 0;
this.turnsAllowed = true;
this.screenHidden = false;
this.screenHiddenImg = readFileSync(__dirname + "/../assets/screenhidden.jpeg").toString("base64");
this.screenHiddenThumb = readFileSync(__dirname + "/../assets/screenhiddenthumb.jpeg").toString("base64");
this.indefiniteTurn = null;
this.ModPerms = Utilities.MakeModPerms(this.Config.collabvm.moderatorPermissions);
this.server = http.createServer();
this.socket = new WebSocketServer({noServer: true});
this.server.on('upgrade', (req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) => this.httpOnUpgrade(req, socket, head));
this.server.on('request', (req, res) => {
res.writeHead(426);
res.write("This server only accepts WebSocket connections.");
res.end();
});
var initSize = vm.getSize();
this.newsize(initSize);
this.VM = vm;
this.VM.on("dirtyrect", (j, x, y) => this.newrect(j, x, y));
this.VM.on("size", (s) => this.newsize(s));
// authentication manager
this.auth = auth;
}
listen() {
this.server.listen(this.Config.http.port, this.Config.http.host);
}
private httpOnUpgrade(req : http.IncomingMessage, socket : internal.Duplex, head : Buffer) {
var killConnection = () => {
socket.write("HTTP/1.1 400 Bad Request\n\n400 Bad Request");
socket.destroy();
}
if (req.headers['sec-websocket-protocol'] !== "guacamole") {
killConnection();
return;
}
if (this.Config.http.origin) {
// If the client is not sending an Origin header, kill the connection.
if(!req.headers.origin) {
killConnection();
return;
}
// Try to parse the Origin header sent by the client, if it fails, kill the connection.
var _uri;
var _host;
try {
_uri = new URL(req.headers.origin.toLowerCase());
_host = _uri.host;
} catch {
killConnection();
return;
}
// detect fake origin headers
if (_uri.pathname !== "/" || _uri.search !== "") {
killConnection();
return;
}
// If the domain name is not in the list of allowed origins, kill the connection.
if(!this.Config.http.originAllowedDomains.includes(_host)) {
killConnection();
return;
}
}
let ip: string;
if (this.Config.http.proxying) {
// If the requesting IP isn't allowed to proxy, kill it
if (this.Config.http.proxyAllowedIps.indexOf(req.socket.remoteAddress!) === -1) {
killConnection();
return;
}
// Make sure x-forwarded-for is set
if (req.headers["x-forwarded-for"] === undefined) {
killConnection();
return;
}
try {
// Get the first IP from the X-Forwarded-For variable
ip = req.headers["x-forwarded-for"]?.toString().replace(/\ /g, "").split(",")[0];
} catch {
// If we can't get the IP, kill the connection
killConnection();
return;
}
// If for some reason the IP isn't defined, kill it
if (!ip) {
killConnection();
return;
}
// Make sure the IP is valid. If not, kill the connection.
if (!isIP(ip)) {
killConnection();
return;
}
} else {
if (!req.socket.remoteAddress) return;
ip = req.socket.remoteAddress;
}
// Get the amount of active connections coming from the requesting IP.
let connections = this.clients.filter(client => client.IP.address == ip);
// If it exceeds the limit set in the config, reject the connection with a 429.
if(connections.length + 1 > this.Config.http.maxConnections) {
socket.write("HTTP/1.1 429 Too Many Requests\n\n429 Too Many Requests");
socket.destroy();
}
this.socket.handleUpgrade(req, socket, head, (ws: WebSocket) => {
this.socket.emit('connection', ws, req);
this.onConnection(ws, req, ip);
});
}
private onConnection(ws : WebSocket, req: http.IncomingMessage, ip : string) {
var _ipdata = this.ips.filter(data => data.address == ip);
var ipdata;
if(_ipdata.length > 0) {
ipdata = _ipdata[0];
}else{
ipdata = new IPData(ip);
this.ips.push(ipdata);
}
var user = new User(ws, ipdata, this.Config);
this.clients.push(user);
ws.on('error', (e) => {
log("ERROR", `${e} (caused by connection ${ip})`);
ws.close();
});
ws.on('close', () => this.connectionClosed(user));
ws.on('message', (e) => {
var msg;
try {msg = e.toString()}
catch {
// Close the user's connection if they send a non-string message
user.closeConnection();
return;
}
this.onMessage(user, msg);
});
if (this.Config.auth.enabled) {
user.sendMsg(guacutils.encode("auth", this.Config.auth.apiEndpoint));
}
user.sendMsg(this.getAdduserMsg());
log("INFO", `Connect from ${user.IP.address}`);
};
private connectionClosed(user : User) {
if (this.clients.indexOf(user) === -1) return;
if(user.IP.vote != null) {
user.IP.vote = null;
this.sendVoteUpdate();
};
if (this.indefiniteTurn === user) this.indefiniteTurn = null;
this.clients.splice(this.clients.indexOf(user), 1);
log("INFO", `Disconnect From ${user.IP.address}${user.username ? ` with username ${user.username}` : ""}`);
if (!user.username) return;
if (this.TurnQueue.toArray().indexOf(user) !== -1) {
var hadturn = (this.TurnQueue.peek() === user);
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== user));
if (hadturn) this.nextTurn();
}
this.clients.forEach((c) => c.sendMsg(guacutils.encode("remuser", "1", user.username!)));
}
private async onMessage(client : User, message : string) {
var msgArr = guacutils.decode(message);
if (msgArr.length < 1) return;
switch (msgArr[0]) {
case "login":
if (msgArr.length !== 2 || !this.Config.auth.enabled) return;
if (!client.connectedToNode) {
client.sendMsg(guacutils.encode("login", "0", "You must connect to the VM before logging in."));
return;
}
var res = await this.auth!.Authenticate(msgArr[1], client);
if (res.clientSuccess) {
log("INFO", `${client.IP.address} logged in as ${res.username}`);
client.sendMsg(guacutils.encode("login", "1"));
var old = this.clients.find(c=>c.username === res.username);
if (old) {
// kick() doesnt wait until the user is actually removed from the list and itd be anal to make it do that
// so we call connectionClosed manually here. When it gets called on kick(), it will return because the user isn't in the list
this.connectionClosed(old);
await old.kick();
}
// Set username
this.renameUser(client, res.username);
// Set rank
client.rank = res.rank;
if (client.rank === Rank.Admin) {
client.sendMsg(guacutils.encode("admin", "0", "1"));
} else if (client.rank === Rank.Moderator) {
client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString()));
}
this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
} else {
client.sendMsg(guacutils.encode("login", "0", res.error!));
if (res.error === "You are banned") {
client.kick();
}
}
break;
case "list":
client.sendMsg(guacutils.encode("list", this.Config.collabvm.node, this.Config.collabvm.displayname, this.screenHidden ? this.screenHiddenThumb : await this.getThumbnail()));
break;
case "connect":
if (!client.username || msgArr.length !== 2 || msgArr[1] !== this.Config.collabvm.node) {
client.sendMsg(guacutils.encode("connect", "0"));
return;
}
client.connectedToNode = true;
client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0"));
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd));
if (this.screenHidden) {
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
} else {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
}
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
if (this.voteInProgress) this.sendVoteUpdate(client);
this.sendTurnUpdate(client);
break;
case "view":
if(client.connectedToNode) return;
if(client.username || msgArr.length !== 3 || msgArr[1] !== this.Config.collabvm.node) {
// The use of connect here is intentional.
client.sendMsg(guacutils.encode("connect", "0"));
return;
}
switch(msgArr[2]) {
case "0":
client.viewMode = 0;
break;
case "1":
client.viewMode = 1;
break;
default:
client.sendMsg(guacutils.encode("connect", "0"));
return;
}
client.sendMsg(guacutils.encode("connect", "1", "1", this.Config.vm.snapshots ? "1" : "0", "0"));
if (this.ChatHistory.size !== 0) client.sendMsg(this.getChatHistoryMsg());
if (this.Config.collabvm.motd) client.sendMsg(guacutils.encode("chat", "", this.Config.collabvm.motd));
if(client.viewMode == 1) {
if (this.screenHidden) {
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
} else {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
}
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
}
if (this.voteInProgress) this.sendVoteUpdate(client);
this.sendTurnUpdate(client);
break;
case "rename":
if (!client.RenameRateLimit.request()) return;
if (client.connectedToNode && client.IP.muted) return;
if (this.Config.auth.enabled && client.rank !== Rank.Unregistered) {
client.sendMsg(guacutils.encode("chat", "", "Go to your account settings to change your username."));
return;
}
if (this.Config.auth.enabled && msgArr[1] !== undefined) {
// Don't send system message to a user without a username since it was likely an automated attempt by the webapp
if (client.username) client.sendMsg(guacutils.encode("chat", "", "You need to log in to do that."));
if (client.rank !== Rank.Unregistered) return;
this.renameUser(client, undefined);
return;
}
this.renameUser(client, msgArr[1]);
break;
case "chat":
if (!client.username) return;
if (client.IP.muted) return;
if (msgArr.length !== 2) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.chat) {
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
return;
}
var msg = Utilities.HTMLSanitize(msgArr[1]);
// One of the things I hated most about the old server is it completely discarded your message if it was too long
if (msg.length > this.Config.collabvm.maxChatLength) msg = msg.substring(0, this.Config.collabvm.maxChatLength);
if (msg.trim().length < 1) return;
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msg)));
this.ChatHistory.push({user: client.username, msg: msg});
client.onMsgSent();
break;
case "turn":
if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.turn) {
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
return;
}
if (!client.TurnRateLimit.request()) return;
if (!client.connectedToNode) return;
if (msgArr.length > 2) return;
var takingTurn : boolean;
if (msgArr.length === 1) takingTurn = true;
else switch (msgArr[1]) {
case "0":
if (this.indefiniteTurn === client) {
this.indefiniteTurn = null;
}
takingTurn = false;
break;
case "1":
takingTurn = true;
break;
default:
return;
break;
}
if (takingTurn) {
var currentQueue = this.TurnQueue.toArray();
// If the user is already in the turn queue, ignore the turn request.
if (currentQueue.indexOf(client) !== -1) return;
// If they're muted, also ignore the turn request.
// Send them the turn queue to prevent client glitches
if (client.IP.muted) return;
if(this.Config.collabvm.turnlimit.enabled) {
// Get the amount of users in the turn queue with the same IP as the user requesting a turn.
let turns = currentQueue.filter(user => user.IP.address == client.IP.address);
// If it exceeds the limit set in the config, ignore the turn request.
if(turns.length + 1 > this.Config.collabvm.turnlimit.maximum) return;
}
this.TurnQueue.enqueue(client);
if (this.TurnQueue.size === 1) this.nextTurn();
} else {
var hadturn = (this.TurnQueue.peek() === client);
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(u => u !== client));
if (hadturn) this.nextTurn();
}
this.sendTurnUpdate();
break;
case "mouse":
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
if (!this.VM.acceptingInput()) return;
var x = parseInt(msgArr[1]);
var y = parseInt(msgArr[2]);
var mask = parseInt(msgArr[3]);
if (x === undefined || y === undefined || mask === undefined) return;
this.VM.pointerEvent(x, y, mask);
break;
case "key":
if (this.TurnQueue.peek() !== client && client.rank !== Rank.Admin) return;
if (!this.VM.acceptingInput()) return;
var keysym = parseInt(msgArr[1]);
var down = parseInt(msgArr[2]);
if (keysym === undefined || (down !== 0 && down !== 1)) return;
this.VM.keyEvent(keysym, down === 1 ? true : false);
break;
case "vote":
if (!this.Config.vm.snapshots) return;
if ((!this.turnsAllowed || this.Config.collabvm.turnwhitelist) && client.rank !== Rank.Admin && client.rank !== Rank.Moderator && client.rank !== Rank.Turn) return;
if (!client.connectedToNode) return;
if (msgArr.length !== 2) return;
if (!client.VoteRateLimit.request()) return;
switch (msgArr[1]) {
case "1":
if (!this.voteInProgress) {
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.callForReset) {
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
return;
}
if (this.voteCooldown !== 0) {
client.sendMsg(guacutils.encode("vote", "3", this.voteCooldown.toString()));
return;
}
this.startVote();
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has started a vote to reset the VM.`)));
}
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) {
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
return;
}
else if (client.IP.vote !== true)
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted yes.`)));
client.IP.vote = true;
break;
case "0":
if (!this.voteInProgress) return;
if (this.Config.auth.enabled && client.rank === Rank.Unregistered && !this.Config.auth.guestPermissions.vote) {
client.sendMsg(guacutils.encode("chat", "", "You need to login to do that."));
return;
}
if (client.IP.vote !== false)
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", `${client.username} has voted no.`)));
client.IP.vote = false;
break;
}
this.sendVoteUpdate();
break;
case "admin":
if (msgArr.length < 2) return;
switch (msgArr[1]) {
case "2":
// Login
if (this.Config.auth.enabled) {
client.sendMsg(guacutils.encode("chat", "", "This server does not support staff passwords. Please log in to become staff."));
return;
}
if (!client.LoginRateLimit.request() || !client.username) return;
if (msgArr.length !== 3) return;
var sha256 = createHash("sha256");
sha256.update(msgArr[2]);
var pwdHash = sha256.digest('hex');
sha256.destroy();
if (pwdHash === this.Config.collabvm.adminpass) {
client.rank = Rank.Admin;
client.sendMsg(guacutils.encode("admin", "0", "1"));
} else if (this.Config.collabvm.moderatorEnabled && pwdHash === this.Config.collabvm.modpass) {
client.rank = Rank.Moderator;
client.sendMsg(guacutils.encode("admin", "0", "3", this.ModPerms.toString()));
} else if (this.Config.collabvm.turnwhitelist && pwdHash === this.Config.collabvm.turnpass) {
client.rank = Rank.Turn;
client.sendMsg(guacutils.encode("chat", "", "You may now take turns."));
} else {
client.sendMsg(guacutils.encode("admin", "0", "0"));
return;
}
if (this.screenHidden) {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
}
this.clients.forEach((c) => c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
break;
case "5":
// QEMU Monitor
if (client.rank !== Rank.Admin) return;
if (!(this.VM instanceof QEMUVM)) {
client.sendMsg(guacutils.encode("admin", "2", "This is not a QEMU VM and therefore QEMU monitor commands cannot be run."));
return;
}
if (msgArr.length !== 4 || msgArr[2] !== this.Config.collabvm.node) return;
var output = await this.VM.qmpClient.runMonitorCmd(msgArr[3]);
client.sendMsg(guacutils.encode("admin", "2", String(output)));
break;
case "8":
// Restore
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.restore)) return;
this.VM.Restore();
break;
case "10":
// Reboot
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.reboot)) return;
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
this.VM.Reboot();
break;
case "12":
// Ban
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.ban)) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
user.ban();
case "13":
// Force Vote
if (msgArr.length !== 3) return;
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.forcevote)) return;
if (!this.voteInProgress) return;
switch (msgArr[2]) {
case "1":
this.endVote(true);
break;
case "0":
this.endVote(false);
break;
}
break;
case "14":
// Mute
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.mute)) return;
if (msgArr.length !== 4) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
var permamute;
switch (msgArr[3]) {
case "0":
permamute = false;
break;
case "1":
permamute = true;
break;
default:
return;
}
user.mute(permamute);
break;
case "15":
// Kick
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.kick)) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
user.kick();
break;
case "16":
// End turn
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
if (msgArr.length !== 3) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
this.endTurn(user);
break;
case "17":
// Clear turn queue
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
if (msgArr.length !== 3 || msgArr[2] !== this.Config.collabvm.node) return;
this.clearTurns();
break;
case "18":
// Rename user
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.rename)) return;
if (this.Config.auth.enabled) {
client.sendMsg(guacutils.encode("chat", "", "Cannot rename users on a server that uses authentication."));
}
if (msgArr.length !== 4) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
this.renameUser(user, msgArr[3]);
break;
case "19":
// Get IP
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.grabip)) return;
if (msgArr.length !== 3) return;
var user = this.clients.find(c => c.username === msgArr[2]);
if (!user) return;
client.sendMsg(guacutils.encode("admin", "19", msgArr[2], user.IP.address));
break;
case "20":
// Steal turn
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.bypassturn)) return;
this.bypassTurn(client);
break;
case "21":
// XSS
if (client.rank !== Rank.Admin && (client.rank !== Rank.Moderator || !this.Config.collabvm.moderatorPermissions.xss)) return;
if (msgArr.length !== 3) return;
switch (client.rank) {
case Rank.Admin:
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2])));
this.ChatHistory.push({user: client.username!, msg: msgArr[2]});
break;
case Rank.Moderator:
this.clients.filter(c => c.rank !== Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, msgArr[2])));
this.clients.filter(c => c.rank === Rank.Admin).forEach(c => c.sendMsg(guacutils.encode("chat", client.username!, Utilities.HTMLSanitize(msgArr[2]))));
break;
}
break;
case "22":
// Toggle turns
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 3) return;
switch (msgArr[2]) {
case "0":
this.clearTurns();
this.turnsAllowed = false;
break;
case "1":
this.turnsAllowed = true;
break;
}
break;
case "23":
// Indefinite turn
if (client.rank !== Rank.Admin) return;
this.indefiniteTurn = client;
this.TurnQueue = Queue.from([client, ...this.TurnQueue.toArray().filter(c=>c!==client)]);
this.sendTurnUpdate();
break;
case "24":
// Hide screen
if (client.rank !== Rank.Admin) return;
if (msgArr.length !== 3) return;
switch (msgArr[2]) {
case "0":
this.screenHidden = true;
this.clients.filter(c => c.rank == Rank.Unregistered).forEach(client => {
client.sendMsg(guacutils.encode("size", "0", "1024", "768"));
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", this.screenHiddenImg));
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
});
break;
case "1":
this.screenHidden = false;
this.clients.forEach(client => {
client.sendMsg(guacutils.encode("size", "0", this.VM.framebuffer.width.toString(), this.VM.framebuffer.height.toString()));
var jpg = this.VM.framebuffer.toBuffer("image/jpeg");
var jpg64 = jpg.toString("base64");
client.sendMsg(guacutils.encode("png", "0", "0", "0", "0", jpg64));
client.sendMsg(guacutils.encode("sync", Date.now().toString()));
});
break;
}
break;
case "25":
if (client.rank !== Rank.Admin || msgArr.length !== 3)
return;
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", msgArr[2])));
break;
}
break;
}
}
getUsernameList() : string[] {
var arr : string[] = [];
this.clients.filter(c => c.username).forEach((c) => arr.push(c.username!));
return arr;
}
renameUser(client : User, newName? : string) {
// This shouldn't need a ternary but it does for some reason
var hadName : boolean = client.username ? true : false;
var oldname : any;
if (hadName) oldname = client.username;
var status = "0";
if (!newName) {
client.assignGuestName(this.getUsernameList());
} else {
newName = newName.trim();
if (hadName && newName === oldname) {
client.sendMsg(guacutils.encode("rename", "0", "0", client.username!, client.rank.toString()));
return;
}
if (this.getUsernameList().indexOf(newName) !== -1) {
client.assignGuestName(this.getUsernameList());
if(client.connectedToNode) {
status = "1";
}
} else
if (!/^[a-zA-Z0-9\ \-\_\.]+$/.test(newName) || newName.length > 20 || newName.length < 3) {
client.assignGuestName(this.getUsernameList());
status = "2";
} else
if (this.Config.collabvm.usernameblacklist.indexOf(newName) !== -1) {
client.assignGuestName(this.getUsernameList());
status = "3";
} else client.username = newName;
}
client.sendMsg(guacutils.encode("rename", "0", status, client.username!, client.rank.toString()));
if (hadName) {
log("INFO", `Rename ${client.IP.address} from ${oldname} to ${client.username}`);
this.clients.forEach((c) =>
c.sendMsg(guacutils.encode("rename", "1", oldname, client.username!, client.rank.toString())));
} else {
log("INFO", `Rename ${client.IP.address} to ${client.username}`);
this.clients.forEach((c) =>
c.sendMsg(guacutils.encode("adduser", "1", client.username!, client.rank.toString())));
}
}
getAdduserMsg() : string {
var arr : string[] = ["adduser", this.clients.filter(c=>c.username).length.toString()];
this.clients.filter(c=>c.username).forEach((c) => arr.push(c.username!, c.rank.toString()));
return guacutils.encode(...arr);
}
getChatHistoryMsg() : string {
var arr : string[] = ["chat"];
this.ChatHistory.forEach(c => arr.push(c.user, c.msg));
return guacutils.encode(...arr);
}
private sendTurnUpdate(client? : User) {
var turnQueueArr = this.TurnQueue.toArray();
var turntime;
if (this.indefiniteTurn === null) turntime = (this.TurnTime * 1000);
else turntime = 9999999999;
var arr = ["turn", turntime.toString(), this.TurnQueue.size.toString()];
// @ts-ignore
this.TurnQueue.forEach((c) => arr.push(c.username));
var currentTurningUser = this.TurnQueue.peek();
if (client) {
client.sendMsg(guacutils.encode(...arr));
return;
}
this.clients.filter(c => (c !== currentTurningUser && c.connectedToNode)).forEach((c) => {
if (turnQueueArr.indexOf(c) !== -1) {
var time;
if (this.indefiniteTurn === null) time = ((this.TurnTime * 1000) + ((turnQueueArr.indexOf(c) - 1) * this.Config.collabvm.turnTime * 1000));
else time = 9999999999;
c.sendMsg(guacutils.encode(...arr, time.toString()));
} else {
c.sendMsg(guacutils.encode(...arr));
}
});
if (currentTurningUser)
currentTurningUser.sendMsg(guacutils.encode(...arr));
}
private nextTurn() {
clearInterval(this.TurnInterval);
if (this.TurnQueue.size === 0) {
} else {
this.TurnTime = this.Config.collabvm.turnTime;
this.TurnInterval = setInterval(() => this.turnInterval(), 1000);
}
this.sendTurnUpdate();
}
clearTurns() {
clearInterval(this.TurnInterval);
this.TurnQueue.clear();
this.sendTurnUpdate();
}
bypassTurn(client : User) {
var a = this.TurnQueue.toArray().filter(c => c !== client);
this.TurnQueue = Queue.from([client, ...a]);
this.nextTurn();
}
endTurn(client : User) {
var hasTurn = (this.TurnQueue.peek() === client);
this.TurnQueue = Queue.from(this.TurnQueue.toArray().filter(c => c !== client));
if (hasTurn) this.nextTurn();
else this.sendTurnUpdate();
}
private turnInterval() {
if (this.indefiniteTurn !== null) return;
this.TurnTime--;
if (this.TurnTime < 1) {
this.TurnQueue.dequeue();
this.nextTurn();
}
}
private async newrect(rect : Canvas, x : number, y : number) {
var jpg = rect.toBuffer("image/jpeg", {quality: 0.5, progressive: true, chromaSubsampling: true});
var jpg64 = jpg.toString("base64");
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
c.sendMsg(guacutils.encode("png", "0", "0", x.toString(), y.toString(), jpg64));
c.sendMsg(guacutils.encode("sync", Date.now().toString()));
});
}
private newsize(size : {height:number,width:number}) {
this.clients.filter(c => c.connectedToNode || c.viewMode == 1).forEach(c => {
if (this.screenHidden && c.rank == Rank.Unregistered) return;
c.sendMsg(guacutils.encode("size", "0", size.width.toString(), size.height.toString()))
});
}
getThumbnail() : Promise<string> {
return new Promise(async (res, rej) => {
var cnv = createCanvas(400, 300);
var ctx = cnv.getContext("2d");
ctx.drawImage(this.VM.framebuffer, 0, 0, 400, 300);
var jpg = cnv.toBuffer("image/jpeg");
res(jpg.toString("base64"));
})
}
startVote() {
if (this.voteInProgress) return;
this.voteInProgress = true;
this.clients.forEach(c => c.sendMsg(guacutils.encode("vote", "0")));
this.voteTime = this.Config.collabvm.voteTime;
this.voteInterval = setInterval(() => {
this.voteTime--;
if (this.voteTime < 1) {
this.endVote();
}
}, 1000);
}
endVote(result? : boolean) {
if (!this.voteInProgress) return;
this.voteInProgress = false;
clearInterval(this.voteInterval);
var count = this.getVoteCounts();
this.clients.forEach((c) => c.sendMsg(guacutils.encode("vote", "2")));
if (result === true || (result === undefined && count.yes >= count.no)) {
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has won.")));
this.VM.Restore();
} else {
this.clients.forEach(c => c.sendMsg(guacutils.encode("chat", "", "The vote to reset the VM has lost.")));
}
this.clients.forEach(c => {
c.IP.vote = null;
});
this.voteCooldown = this.Config.collabvm.voteCooldown;
this.voteCooldownInterval = setInterval(() => {
this.voteCooldown--;
if (this.voteCooldown < 1)
clearInterval(this.voteCooldownInterval);
}, 1000);
}
sendVoteUpdate(client? : User) {
if (!this.voteInProgress) return;
var count = this.getVoteCounts();
var msg = guacutils.encode("vote", "1", (this.voteTime * 1000).toString(), count.yes.toString(), count.no.toString());
if (client)
client.sendMsg(msg);
else
this.clients.forEach((c) => c.sendMsg(msg));
}
getVoteCounts() : {yes:number,no:number} {
var yes = 0;
var no = 0;
this.ips.forEach((c) => {
if (c.vote === true) yes++;
if (c.vote === false) no++;
});
return {yes:yes,no:no};
}
}

View File

@@ -1,43 +0,0 @@
export function decode(string : string) : string[] {
let pos = -1;
let sections = [];
for(;;) {
let len = string.indexOf('.', pos + 1);
if(len === -1)
break;
pos = parseInt(string.slice(pos + 1, len)) + len + 1;
// don't allow funky protocol length
if(pos > string.length)
return [];
sections.push(string.slice(len + 1, pos));
const sep = string.slice(pos, pos + 1);
if(sep === ',')
continue;
else if(sep === ';')
break;
else
// Invalid data.
return [];
}
return sections;
}
export function encode(...string : string[]) : string {
let command = '';
for(var i = 0; i < string.length; i++) {
let current = string[i];
command += current.toString().length + '.' + current;
command += ( i < string.length - 1 ? ',' : ';');
}
return command;
}

View File

@@ -1,49 +0,0 @@
import * as toml from 'toml';
import IConfig from './IConfig.js';
import * as fs from "fs";
import WSServer from './WSServer.js';
import QEMUVM from './QEMUVM.js';
import log from './log.js';
import AuthManager from './AuthManager.js';
log("INFO", "CollabVM Server starting up");
// Parse the config file
var Config : IConfig;
if (!fs.existsSync("config.toml")) {
log("FATAL", "Config.toml not found. Please copy config.example.toml and fill out fields")
process.exit(1);
}
try {
var configRaw = fs.readFileSync("config.toml").toString();
Config = toml.parse(configRaw);
} catch (e) {
log("FATAL", `Failed to read or parse the config file: ${e}`);
process.exit(1);
}
async function start() {
// Print a warning if qmpSockDir is set
// and the host OS is Windows, as this
// configuration will very likely not work.
if(process.platform === "win32" && Config.vm.qmpSockDir) {
log("WARN", "You appear to have the option 'qmpSockDir' enabled in the config.")
log("WARN", "This is not supported on Windows, and you will likely run into issues.");
log("WARN", "To remove this warning, use the qmpHost and qmpPort options instead.");
}
// Init the auth manager if enabled
var auth = Config.auth.enabled ? new AuthManager(Config.auth.apiEndpoint, Config.auth.secretKey) : null;
// Fire up the VM
var VM = new QEMUVM(Config);
await VM.Start();
// Start up the websocket server
var WS = new WSServer(Config, VM, auth);
WS.listen();
}
start();

View File

@@ -1,7 +0,0 @@
export default function log(loglevel : string, ...message : string[]) {
console[
(loglevel === "ERROR" || loglevel === "FATAL") ? "error" :
(loglevel === "WARN") ? "warn" :
"log"
](`[${new Date().toLocaleString()}] [${loglevel}]`, ...message);
}

View File

@@ -1,103 +1,11 @@
// This is the base tsconfig the entire cvmts project uses
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [".js", ".d.ts", ".ts", ".mjs", ".cjs", ".json"], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Node",
"types": ["node"],
"allowSyntheticDefaultImports": true,
"strict": true,
}
}

4265
yarn.lock Normal file

File diff suppressed because it is too large Load Diff