Merge pull request #21 from computernewb/crusttest-refactoring
merge refactor branch
This commit is contained in:
11
.editorconfig
Normal file
11
.editorconfig
Normal 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
18
.gitignore
vendored
@@ -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
3
.gitmodules
vendored
Normal 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
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
dist
|
||||
*.md
|
||||
**/package.json
|
||||
20
.prettierrc.json
Normal file
20
.prettierrc.json
Normal 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
1
.yarnrc.yml
Normal file
@@ -0,0 +1 @@
|
||||
nodeLinker: node-modules
|
||||
7
Justfile
Normal file
7
Justfile
Normal 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
|
||||
45
README.md
45
README.md
@@ -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`
|
||||
|
||||
1
collab-vm-1.2-binary-protocol
Submodule
1
collab-vm-1.2-binary-protocol
Submodule
Submodule collab-vm-1.2-binary-protocol added at cfe9acc60b
@@ -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
350
cvm-rs/Cargo.lock
generated
Normal 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
17
cvm-rs/Cargo.toml
Normal 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
84
cvm-rs/index.d.ts
vendored
Normal 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
6
cvm-rs/index.js
Normal 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
16
cvm-rs/package.json
Normal 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
193
cvm-rs/src/guac.rs
Normal 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
47
cvm-rs/src/guac_js.rs
Normal 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)
|
||||
}
|
||||
82
cvm-rs/src/jpeg_compressor.rs
Normal file
82
cvm-rs/src/jpeg_compressor.rs
Normal 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
87
cvm-rs/src/jpeg_js.rs
Normal 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(©);
|
||||
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
19
cvm-rs/src/lib.rs
Normal 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
33
cvmts/package.json
Normal 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
42
cvmts/src/AuthManager.ts
Normal 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
963
cvmts/src/CollabVMServer.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
105
cvmts/src/GeoIPDownloader.ts
Normal file
105
cvmts/src/GeoIPDownloader.ts
Normal 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
88
cvmts/src/IConfig.ts
Normal 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
71
cvmts/src/IPData.ts
Normal 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
52
cvmts/src/JPEGEncoder.ts
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
9
cvmts/src/NetworkClient.ts
Normal file
9
cvmts/src/NetworkClient.ts
Normal 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;
|
||||
}
|
||||
6
cvmts/src/NetworkServer.ts
Normal file
6
cvmts/src/NetworkServer.ts
Normal 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
36
cvmts/src/RateLimiter.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
72
cvmts/src/TCP/TCPClient.ts
Normal file
72
cvmts/src/TCP/TCPClient.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
39
cvmts/src/TCP/TCPServer.ts
Normal file
39
cvmts/src/TCP/TCPServer.ts
Normal 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
181
cvmts/src/User.ts
Normal 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
54
cvmts/src/Utilities.ts
Normal 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 += '<';
|
||||
break;
|
||||
case '>':
|
||||
output += '>';
|
||||
break;
|
||||
case '&':
|
||||
output += '&';
|
||||
break;
|
||||
case '"':
|
||||
output += '"';
|
||||
break;
|
||||
case "'":
|
||||
output += ''';
|
||||
break;
|
||||
case '/':
|
||||
output += '/';
|
||||
break;
|
||||
case '\n':
|
||||
output += ' ';
|
||||
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
13
cvmts/src/VM.ts
Normal 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
25
cvmts/src/VMDisplay.ts
Normal 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
184
cvmts/src/VNCVM/VNCVM.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
8
cvmts/src/VNCVM/VNCVMDef.ts
Normal file
8
cvmts/src/VNCVM/VNCVMDef.ts
Normal 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;
|
||||
}
|
||||
74
cvmts/src/WebSocket/WSClient.ts
Normal file
74
cvmts/src/WebSocket/WSClient.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
143
cvmts/src/WebSocket/WSServer.ts
Normal file
143
cvmts/src/WebSocket/WSServer.ts
Normal 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
94
cvmts/src/index.ts
Normal 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
7
cvmts/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": [ "src/**/*" ],
|
||||
"compilerOptions": {
|
||||
"resolveJsonModule": true,
|
||||
}
|
||||
}
|
||||
48
package.json
48
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
254
src/QEMUVM.ts
254
src/QEMUVM.ts
@@ -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);
|
||||
}
|
||||
}
|
||||
152
src/QMPClient.ts
152
src/QMPClient.ts
@@ -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}}));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
158
src/User.ts
158
src/User.ts
@@ -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,
|
||||
}
|
||||
@@ -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 += "<"
|
||||
break;
|
||||
case ">":
|
||||
output += ">"
|
||||
break;
|
||||
case "&":
|
||||
output += "&"
|
||||
break;
|
||||
case "\"":
|
||||
output += """
|
||||
break;
|
||||
case "'":
|
||||
output += "'";
|
||||
break;
|
||||
case "/":
|
||||
output += "/";
|
||||
break;
|
||||
case "\n":
|
||||
output += " ";
|
||||
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;
|
||||
}
|
||||
12
src/VM.ts
12
src/VM.ts
@@ -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>;
|
||||
}
|
||||
917
src/WSServer.ts
917
src/WSServer.ts
@@ -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};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
49
src/index.ts
49
src/index.ts
@@ -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();
|
||||
@@ -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);
|
||||
}
|
||||
110
tsconfig.json
110
tsconfig.json
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user