about as ready as its gonna fucking get
This commit is contained in:
commit
574be6faee
399
.gitignore
vendored
Normal file
399
.gitignore
vendored
Normal file
|
@ -0,0 +1,399 @@
|
||||||
|
## Ignore Visual Studio temporary files, build results, and
|
||||||
|
## files generated by popular Visual Studio add-ons.
|
||||||
|
##
|
||||||
|
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||||
|
|
||||||
|
# User-specific files
|
||||||
|
*.rsuser
|
||||||
|
*.suo
|
||||||
|
*.user
|
||||||
|
*.userosscache
|
||||||
|
*.sln.docstates
|
||||||
|
|
||||||
|
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||||
|
*.userprefs
|
||||||
|
|
||||||
|
# Mono auto generated files
|
||||||
|
mono_crash.*
|
||||||
|
|
||||||
|
# Build results
|
||||||
|
[Dd]ebug/
|
||||||
|
[Dd]ebugPublic/
|
||||||
|
[Rr]elease/
|
||||||
|
[Rr]eleases/
|
||||||
|
x64/
|
||||||
|
x86/
|
||||||
|
[Ww][Ii][Nn]32/
|
||||||
|
[Aa][Rr][Mm]/
|
||||||
|
[Aa][Rr][Mm]64/
|
||||||
|
bld/
|
||||||
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
[Ll]og/
|
||||||
|
[Ll]ogs/
|
||||||
|
|
||||||
|
# Visual Studio 2015/2017 cache/options directory
|
||||||
|
.vs/
|
||||||
|
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||||
|
#wwwroot/
|
||||||
|
|
||||||
|
# Visual Studio 2017 auto generated files
|
||||||
|
Generated\ Files/
|
||||||
|
|
||||||
|
# MSTest test Results
|
||||||
|
[Tt]est[Rr]esult*/
|
||||||
|
[Bb]uild[Ll]og.*
|
||||||
|
|
||||||
|
# NUnit
|
||||||
|
*.VisualState.xml
|
||||||
|
TestResult.xml
|
||||||
|
nunit-*.xml
|
||||||
|
|
||||||
|
# Build Results of an ATL Project
|
||||||
|
[Dd]ebugPS/
|
||||||
|
[Rr]eleasePS/
|
||||||
|
dlldata.c
|
||||||
|
|
||||||
|
# Benchmark Results
|
||||||
|
BenchmarkDotNet.Artifacts/
|
||||||
|
|
||||||
|
# .NET Core
|
||||||
|
project.lock.json
|
||||||
|
project.fragment.lock.json
|
||||||
|
artifacts/
|
||||||
|
|
||||||
|
# ASP.NET Scaffolding
|
||||||
|
ScaffoldingReadMe.txt
|
||||||
|
|
||||||
|
# StyleCop
|
||||||
|
StyleCopReport.xml
|
||||||
|
|
||||||
|
# Files built by Visual Studio
|
||||||
|
*_i.c
|
||||||
|
*_p.c
|
||||||
|
*_h.h
|
||||||
|
*.ilk
|
||||||
|
*.meta
|
||||||
|
*.obj
|
||||||
|
*.iobj
|
||||||
|
*.pch
|
||||||
|
*.pdb
|
||||||
|
*.ipdb
|
||||||
|
*.pgc
|
||||||
|
*.pgd
|
||||||
|
*.rsp
|
||||||
|
*.sbr
|
||||||
|
*.tlb
|
||||||
|
*.tli
|
||||||
|
*.tlh
|
||||||
|
*.tmp
|
||||||
|
*.tmp_proj
|
||||||
|
*_wpftmp.csproj
|
||||||
|
*.log
|
||||||
|
*.tlog
|
||||||
|
*.vspscc
|
||||||
|
*.vssscc
|
||||||
|
.builds
|
||||||
|
*.pidb
|
||||||
|
*.svclog
|
||||||
|
*.scc
|
||||||
|
|
||||||
|
# Chutzpah Test files
|
||||||
|
_Chutzpah*
|
||||||
|
|
||||||
|
# Visual C++ cache files
|
||||||
|
ipch/
|
||||||
|
*.aps
|
||||||
|
*.ncb
|
||||||
|
*.opendb
|
||||||
|
*.opensdf
|
||||||
|
*.sdf
|
||||||
|
*.cachefile
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
|
||||||
|
# Visual Studio profiler
|
||||||
|
*.psess
|
||||||
|
*.vsp
|
||||||
|
*.vspx
|
||||||
|
*.sap
|
||||||
|
|
||||||
|
# Visual Studio Trace Files
|
||||||
|
*.e2e
|
||||||
|
|
||||||
|
# TFS 2012 Local Workspace
|
||||||
|
$tf/
|
||||||
|
|
||||||
|
# Guidance Automation Toolkit
|
||||||
|
*.gpState
|
||||||
|
|
||||||
|
# ReSharper is a .NET coding add-in
|
||||||
|
_ReSharper*/
|
||||||
|
*.[Rr]e[Ss]harper
|
||||||
|
*.DotSettings.user
|
||||||
|
|
||||||
|
# TeamCity is a build add-in
|
||||||
|
_TeamCity*
|
||||||
|
|
||||||
|
# DotCover is a Code Coverage Tool
|
||||||
|
*.dotCover
|
||||||
|
|
||||||
|
# AxoCover is a Code Coverage Tool
|
||||||
|
.axoCover/*
|
||||||
|
!.axoCover/settings.json
|
||||||
|
|
||||||
|
# Coverlet is a free, cross platform Code Coverage Tool
|
||||||
|
coverage*.json
|
||||||
|
coverage*.xml
|
||||||
|
coverage*.info
|
||||||
|
|
||||||
|
# Visual Studio code coverage results
|
||||||
|
*.coverage
|
||||||
|
*.coveragexml
|
||||||
|
|
||||||
|
# NCrunch
|
||||||
|
_NCrunch_*
|
||||||
|
.*crunch*.local.xml
|
||||||
|
nCrunchTemp_*
|
||||||
|
|
||||||
|
# MightyMoose
|
||||||
|
*.mm.*
|
||||||
|
AutoTest.Net/
|
||||||
|
|
||||||
|
# Web workbench (sass)
|
||||||
|
.sass-cache/
|
||||||
|
|
||||||
|
# Installshield output folder
|
||||||
|
[Ee]xpress/
|
||||||
|
|
||||||
|
# DocProject is a documentation generator add-in
|
||||||
|
DocProject/buildhelp/
|
||||||
|
DocProject/Help/*.HxT
|
||||||
|
DocProject/Help/*.HxC
|
||||||
|
DocProject/Help/*.hhc
|
||||||
|
DocProject/Help/*.hhk
|
||||||
|
DocProject/Help/*.hhp
|
||||||
|
DocProject/Help/Html2
|
||||||
|
DocProject/Help/html
|
||||||
|
|
||||||
|
# Click-Once directory
|
||||||
|
publish/
|
||||||
|
|
||||||
|
# Publish Web Output
|
||||||
|
*.[Pp]ublish.xml
|
||||||
|
*.azurePubxml
|
||||||
|
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||||
|
# but database connection strings (with potential passwords) will be unencrypted
|
||||||
|
*.pubxml
|
||||||
|
*.publishproj
|
||||||
|
|
||||||
|
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||||
|
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||||
|
# in these scripts will be unencrypted
|
||||||
|
PublishScripts/
|
||||||
|
|
||||||
|
# NuGet Packages
|
||||||
|
*.nupkg
|
||||||
|
# NuGet Symbol Packages
|
||||||
|
*.snupkg
|
||||||
|
# The packages folder can be ignored because of Package Restore
|
||||||
|
**/[Pp]ackages/*
|
||||||
|
# except build/, which is used as an MSBuild target.
|
||||||
|
!**/[Pp]ackages/build/
|
||||||
|
# Uncomment if necessary however generally it will be regenerated when needed
|
||||||
|
#!**/[Pp]ackages/repositories.config
|
||||||
|
# NuGet v3's project.json files produces more ignorable files
|
||||||
|
*.nuget.props
|
||||||
|
*.nuget.targets
|
||||||
|
|
||||||
|
# Microsoft Azure Build Output
|
||||||
|
csx/
|
||||||
|
*.build.csdef
|
||||||
|
|
||||||
|
# Microsoft Azure Emulator
|
||||||
|
ecf/
|
||||||
|
rcf/
|
||||||
|
|
||||||
|
# Windows Store app package directories and files
|
||||||
|
AppPackages/
|
||||||
|
BundleArtifacts/
|
||||||
|
Package.StoreAssociation.xml
|
||||||
|
_pkginfo.txt
|
||||||
|
*.appx
|
||||||
|
*.appxbundle
|
||||||
|
*.appxupload
|
||||||
|
|
||||||
|
# Visual Studio cache files
|
||||||
|
# files ending in .cache can be ignored
|
||||||
|
*.[Cc]ache
|
||||||
|
# but keep track of directories ending in .cache
|
||||||
|
!?*.[Cc]ache/
|
||||||
|
|
||||||
|
# Others
|
||||||
|
ClientBin/
|
||||||
|
~$*
|
||||||
|
*~
|
||||||
|
*.dbmdl
|
||||||
|
*.dbproj.schemaview
|
||||||
|
*.jfm
|
||||||
|
*.pfx
|
||||||
|
*.publishsettings
|
||||||
|
orleans.codegen.cs
|
||||||
|
|
||||||
|
# Including strong name files can present a security risk
|
||||||
|
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||||
|
#*.snk
|
||||||
|
|
||||||
|
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||||
|
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||||
|
#bower_components/
|
||||||
|
|
||||||
|
# RIA/Silverlight projects
|
||||||
|
Generated_Code/
|
||||||
|
|
||||||
|
# Backup & report files from converting an old project file
|
||||||
|
# to a newer Visual Studio version. Backup files are not needed,
|
||||||
|
# because we have git ;-)
|
||||||
|
_UpgradeReport_Files/
|
||||||
|
Backup*/
|
||||||
|
UpgradeLog*.XML
|
||||||
|
UpgradeLog*.htm
|
||||||
|
ServiceFabricBackup/
|
||||||
|
*.rptproj.bak
|
||||||
|
|
||||||
|
# SQL Server files
|
||||||
|
*.mdf
|
||||||
|
*.ldf
|
||||||
|
*.ndf
|
||||||
|
|
||||||
|
# Business Intelligence projects
|
||||||
|
*.rdl.data
|
||||||
|
*.bim.layout
|
||||||
|
*.bim_*.settings
|
||||||
|
*.rptproj.rsuser
|
||||||
|
*- [Bb]ackup.rdl
|
||||||
|
*- [Bb]ackup ([0-9]).rdl
|
||||||
|
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||||
|
|
||||||
|
# Microsoft Fakes
|
||||||
|
FakesAssemblies/
|
||||||
|
|
||||||
|
# GhostDoc plugin setting file
|
||||||
|
*.GhostDoc.xml
|
||||||
|
|
||||||
|
# Node.js Tools for Visual Studio
|
||||||
|
.ntvs_analysis.dat
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Visual Studio 6 build log
|
||||||
|
*.plg
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace options file
|
||||||
|
*.opt
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||||
|
*.vbw
|
||||||
|
|
||||||
|
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||||
|
*.vbp
|
||||||
|
|
||||||
|
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||||
|
*.dsw
|
||||||
|
*.dsp
|
||||||
|
|
||||||
|
# Visual Studio 6 technical files
|
||||||
|
*.ncb
|
||||||
|
*.aps
|
||||||
|
|
||||||
|
# Visual Studio LightSwitch build output
|
||||||
|
**/*.HTMLClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/GeneratedArtifacts
|
||||||
|
**/*.DesktopClient/ModelManifest.xml
|
||||||
|
**/*.Server/GeneratedArtifacts
|
||||||
|
**/*.Server/ModelManifest.xml
|
||||||
|
_Pvt_Extensions
|
||||||
|
|
||||||
|
# Paket dependency manager
|
||||||
|
.paket/paket.exe
|
||||||
|
paket-files/
|
||||||
|
|
||||||
|
# FAKE - F# Make
|
||||||
|
.fake/
|
||||||
|
|
||||||
|
# CodeRush personal settings
|
||||||
|
.cr/personal
|
||||||
|
|
||||||
|
# Python Tools for Visual Studio (PTVS)
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
|
||||||
|
# Cake - Uncomment if you are using it
|
||||||
|
# tools/**
|
||||||
|
# !tools/packages.config
|
||||||
|
|
||||||
|
# Tabs Studio
|
||||||
|
*.tss
|
||||||
|
|
||||||
|
# Telerik's JustMock configuration file
|
||||||
|
*.jmconfig
|
||||||
|
|
||||||
|
# BizTalk build output
|
||||||
|
*.btp.cs
|
||||||
|
*.btm.cs
|
||||||
|
*.odx.cs
|
||||||
|
*.xsd.cs
|
||||||
|
|
||||||
|
# OpenCover UI analysis results
|
||||||
|
OpenCover/
|
||||||
|
|
||||||
|
# Azure Stream Analytics local run output
|
||||||
|
ASALocalRun/
|
||||||
|
|
||||||
|
# MSBuild Binary and Structured Log
|
||||||
|
*.binlog
|
||||||
|
|
||||||
|
# NVidia Nsight GPU debugger configuration file
|
||||||
|
*.nvuser
|
||||||
|
|
||||||
|
# MFractors (Xamarin productivity tool) working folder
|
||||||
|
.mfractor/
|
||||||
|
|
||||||
|
# Local History for Visual Studio
|
||||||
|
.localhistory/
|
||||||
|
|
||||||
|
# Visual Studio History (VSHistory) files
|
||||||
|
.vshistory/
|
||||||
|
|
||||||
|
# BeatPulse healthcheck temp database
|
||||||
|
healthchecksdb
|
||||||
|
|
||||||
|
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||||
|
MigrationBackup/
|
||||||
|
|
||||||
|
# Ionide (cross platform F# VS Code tools) working folder
|
||||||
|
.ionide/
|
||||||
|
|
||||||
|
# Fody - auto-generated XML schema
|
||||||
|
FodyWeavers.xsd
|
||||||
|
|
||||||
|
# VS Code files for those working on multiple tools
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
# Local History for Visual Studio Code
|
||||||
|
.history/
|
||||||
|
|
||||||
|
# Windows Installer files from build outputs
|
||||||
|
*.cab
|
||||||
|
*.msi
|
||||||
|
*.msix
|
||||||
|
*.msm
|
||||||
|
*.msp
|
||||||
|
|
||||||
|
# JetBrains Rider
|
||||||
|
*.sln.iml
|
||||||
|
.idea/
|
6
.gitmodules
vendored
Normal file
6
.gitmodules
vendored
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
[submodule "MarcusW.VncClient"]
|
||||||
|
path = MarcusW.VncClient
|
||||||
|
url = https://github.com/elijahr2411/MarcusW.VncClient
|
||||||
|
[submodule "QMPSharp"]
|
||||||
|
path = QMPSharp
|
||||||
|
url = https://git.computernewb.com/Elijah/QMPSharp.git
|
1
MarcusW.VncClient
Submodule
1
MarcusW.VncClient
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 71fa923ce24a49ac48111915852c5953f5363373
|
1
QMPSharp
Submodule
1
QMPSharp
Submodule
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit f0de933380c0c6488306d04b340f9a1599a4d217
|
28
collab-vm-server-1.3.sln
Normal file
28
collab-vm-server-1.3.sln
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
|
||||||
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "collab-vm-server-1.3", "collab-vm-server-1.3\collab-vm-server-1.3.csproj", "{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarcusW.VncClient", "MarcusW.VncClient\src\MarcusW.VncClient\MarcusW.VncClient.csproj", "{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QMPSharp", "QMPSharp\QMPSharp\QMPSharp.csproj", "{7DA24013-9AD2-4D59-8EE4-AF73C5516603}"
|
||||||
|
EndProject
|
||||||
|
Global
|
||||||
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
|
Debug|Any CPU = Debug|Any CPU
|
||||||
|
Release|Any CPU = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
|
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
EndGlobalSection
|
||||||
|
EndGlobal
|
BIN
collab-vm-server-1.3/Assets/screenhidden.jpeg
Normal file
BIN
collab-vm-server-1.3/Assets/screenhidden.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 65 KiB |
BIN
collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg
Normal file
BIN
collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg
Normal file
Binary file not shown.
After Width: | Height: | Size: 21 KiB |
7
collab-vm-server-1.3/ChatMessage.cs
Normal file
7
collab-vm-server-1.3/ChatMessage.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class ChatMessage
|
||||||
|
{
|
||||||
|
public string Username { get; set; }
|
||||||
|
public string Message { get; set; }
|
||||||
|
}
|
43
collab-vm-server-1.3/Cooldown.cs
Normal file
43
collab-vm-server-1.3/Cooldown.cs
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class Cooldown
|
||||||
|
{
|
||||||
|
private uint _interval, _timeRemaining;
|
||||||
|
private Timer _timer;
|
||||||
|
|
||||||
|
public bool IsReady => _timeRemaining == 0;
|
||||||
|
public uint TimeRemaining => _timeRemaining;
|
||||||
|
|
||||||
|
public Cooldown(uint interval)
|
||||||
|
{
|
||||||
|
_interval = interval;
|
||||||
|
_timeRemaining = 0;
|
||||||
|
_timer = new(1000);
|
||||||
|
_timer.AutoReset = true;
|
||||||
|
_timer.Elapsed += (_, _) => Tick();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Run()
|
||||||
|
{
|
||||||
|
if (_timeRemaining == 0)
|
||||||
|
{
|
||||||
|
_timeRemaining = _interval;
|
||||||
|
_timer.Start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Tick()
|
||||||
|
{
|
||||||
|
_timeRemaining--;
|
||||||
|
if (_timeRemaining == 0)
|
||||||
|
{
|
||||||
|
_timer.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
74
collab-vm-server-1.3/Database.cs
Normal file
74
collab-vm-server-1.3/Database.cs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using MySqlConnector;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class Database
|
||||||
|
{
|
||||||
|
private MySqlConnection db;
|
||||||
|
private readonly MySQLConfig config;
|
||||||
|
private bool connected = false;
|
||||||
|
public Database(MySQLConfig config)
|
||||||
|
{
|
||||||
|
this.config = config;
|
||||||
|
var connstr = new MySqlConnectionStringBuilder();
|
||||||
|
connstr.Server = config.Host;
|
||||||
|
connstr.UserID = config.Username;
|
||||||
|
connstr.Password = config.Password;
|
||||||
|
connstr.Database = config.Database;
|
||||||
|
db = new MySqlConnection(connstr.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OpenAsync()
|
||||||
|
{
|
||||||
|
await db.OpenAsync();
|
||||||
|
await InitTables();
|
||||||
|
connected = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task InitTables()
|
||||||
|
{
|
||||||
|
await using var cmd = db.CreateCommand();
|
||||||
|
cmd.CommandText +=
|
||||||
|
"CREATE TABLE IF NOT EXISTS bans (ip TEXT NOT NULL UNIQUE, reason TEXT, date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); ";
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddBan(string ip, string reason)
|
||||||
|
{
|
||||||
|
if (!connected) throw new InvalidOperationException("Database is not connected");
|
||||||
|
await using var cmd = db.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO bans (ip, reason) VALUES (@ip, @reason)";
|
||||||
|
cmd.Parameters.AddWithValue("@ip", ip);
|
||||||
|
cmd.Parameters.AddWithValue("@reason", reason);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddBan(string ip)
|
||||||
|
{
|
||||||
|
if (!connected) throw new InvalidOperationException("Database is not connected");
|
||||||
|
await using var cmd = db.CreateCommand();
|
||||||
|
cmd.CommandText = "INSERT INTO bans (ip) VALUES (@ip)";
|
||||||
|
cmd.Parameters.AddWithValue("@ip", ip);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task RemoveBan(string ip)
|
||||||
|
{
|
||||||
|
if (!connected) throw new InvalidOperationException("Database is not connected");
|
||||||
|
await using var cmd = db.CreateCommand();
|
||||||
|
cmd.CommandText = "DELETE FROM bans WHERE ip = @ip";
|
||||||
|
cmd.Parameters.AddWithValue("@ip", ip);
|
||||||
|
await cmd.ExecuteNonQueryAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> IsBanned(string ip)
|
||||||
|
{
|
||||||
|
if (!connected) throw new InvalidOperationException("Database is not connected");
|
||||||
|
await using var cmd = db.CreateCommand();
|
||||||
|
cmd.CommandText = "SELECT COUNT(*) FROM bans WHERE ip = @ip";
|
||||||
|
cmd.Parameters.AddWithValue("@ip", ip);
|
||||||
|
var count = await cmd.ExecuteScalarAsync();
|
||||||
|
return (long)count! > 0;
|
||||||
|
}
|
||||||
|
}
|
17
collab-vm-server-1.3/DirtyRectData.cs
Normal file
17
collab-vm-server-1.3/DirtyRectData.cs
Normal file
|
@ -0,0 +1,17 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class DirtyRect
|
||||||
|
{
|
||||||
|
public int X { get; set; }
|
||||||
|
public int Y { get; set; }
|
||||||
|
public int Width { get; set; }
|
||||||
|
public int Height { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class DirtyRectData : DirtyRect
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// The data of the dirty rect represented in RGBA32 format.
|
||||||
|
/// </summary>
|
||||||
|
public byte[] Data { get; set; }
|
||||||
|
}
|
65
collab-vm-server-1.3/Display/RectBatcher.cs
Normal file
65
collab-vm-server-1.3/Display/RectBatcher.cs
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
using System.Timers;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.Display;
|
||||||
|
|
||||||
|
// This class is in charge of batching dirty rects into a single rect.
|
||||||
|
// This is needed because certain VNC servers (like QEMU) send a lot of dirty rects, which clogs up the socket.
|
||||||
|
// By default, this class will batch collected rects every 33ms (30 FPS).
|
||||||
|
public class RectBatcher
|
||||||
|
{
|
||||||
|
private const int BatchInterval = 33;
|
||||||
|
private const int InitialBufferSize = 1024 * 768 * 4;
|
||||||
|
private byte[] mergeBuffer;
|
||||||
|
public event EventHandler<DirtyRectData> Rect;
|
||||||
|
private List<DirtyRect> Rects;
|
||||||
|
private Timer timer;
|
||||||
|
private Func<int, int, int, int, byte[], byte[]> GrabRect;
|
||||||
|
|
||||||
|
public void AddRect(DirtyRect rect)
|
||||||
|
{
|
||||||
|
Rects.Add(rect);
|
||||||
|
}
|
||||||
|
|
||||||
|
public RectBatcher(Func<int, int, int, int, byte[], byte[]> getRect)
|
||||||
|
{
|
||||||
|
mergeBuffer = new byte[InitialBufferSize];
|
||||||
|
this.GrabRect = getRect;
|
||||||
|
Rects = new();
|
||||||
|
timer = new(BatchInterval);
|
||||||
|
timer.Elapsed += (_, _) => TimerOnElapsed();
|
||||||
|
timer.AutoReset = true;
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task TimerOnElapsed()
|
||||||
|
{
|
||||||
|
if (Rects.Count == 0) return;
|
||||||
|
var rects = Rects;
|
||||||
|
Rects = new();
|
||||||
|
int mergedX = 0, mergedY = 0, mergedWidth = 0, mergedHeight = 0;
|
||||||
|
foreach (var rect in rects)
|
||||||
|
{
|
||||||
|
if (rect.X < mergedX) mergedX = rect.X;
|
||||||
|
if (rect.Y < mergedY) mergedY = rect.Y;
|
||||||
|
if (rect.X + rect.Width > mergedX + mergedWidth) mergedWidth = rect.X + rect.Width;
|
||||||
|
if (rect.Y + rect.Height > mergedY + mergedHeight) mergedHeight = rect.Y + rect.Height;
|
||||||
|
}
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Batching {Rects.Count} rects into one {mergedWidth}x{mergedHeight} rect at {mergedX},{mergedY}");
|
||||||
|
// Create a rect from data already in the framebuffer
|
||||||
|
if (mergeBuffer.Length < mergedWidth * mergedHeight * 4)
|
||||||
|
{
|
||||||
|
mergeBuffer = new byte[mergedWidth * mergedHeight * 4];
|
||||||
|
}
|
||||||
|
var data = GrabRect(mergedX, mergedY, mergedWidth, mergedHeight, mergeBuffer);
|
||||||
|
// Fire the event
|
||||||
|
Rect.Invoke(this, new DirtyRectData
|
||||||
|
{
|
||||||
|
Data = mergeBuffer,
|
||||||
|
Width = mergedWidth,
|
||||||
|
Height = mergedHeight,
|
||||||
|
X = mergedX,
|
||||||
|
Y = mergedY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
61
collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs
Normal file
61
collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs
Normal file
|
@ -0,0 +1,61 @@
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
|
||||||
|
public class CollabVMLogger : ILogger
|
||||||
|
{
|
||||||
|
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
|
||||||
|
{
|
||||||
|
return NullScope.Instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
|
||||||
|
{
|
||||||
|
LogLevel level = logLevel switch
|
||||||
|
{
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Trace => LogLevel.DEBUG,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Debug => LogLevel.DEBUG,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Information => LogLevel.INFO,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Warning => LogLevel.WARN,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Error => LogLevel.ERROR,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.Critical => LogLevel.FATAL,
|
||||||
|
Microsoft.Extensions.Logging.LogLevel.None => LogLevel.FATAL,
|
||||||
|
_ => throw new ArgumentException("Invalid log level")
|
||||||
|
};
|
||||||
|
Utilities.Log(level, "VNC: " + formatter(state, exception));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CollabVMLoggerProvider : ILoggerProvider
|
||||||
|
{
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
public ILogger CreateLogger(string categoryName)
|
||||||
|
{
|
||||||
|
return new CollabVMLogger();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an empty logging scope without any logic.
|
||||||
|
/// </summary>
|
||||||
|
public class NullScope : IDisposable
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the default instance of the <see cref="NullScope"/>.
|
||||||
|
/// </summary>
|
||||||
|
public static NullScope Instance { get; } = new NullScope();
|
||||||
|
|
||||||
|
private NullScope() { }
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
public void Dispose() { }
|
||||||
|
}
|
16
collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs
Normal file
16
collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
using MarcusW.VncClient;
|
||||||
|
using MarcusW.VncClient.Protocol.SecurityTypes;
|
||||||
|
using MarcusW.VncClient.Security;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
|
||||||
|
public class VNCAuthenticationHandler : IAuthenticationHandler
|
||||||
|
{
|
||||||
|
public Task<TInput> ProvideAuthenticationInputAsync<TInput>(RfbConnection connection, ISecurityType securityType,
|
||||||
|
IAuthenticationInputRequest<TInput> request) where TInput : class, IAuthenticationInput
|
||||||
|
{
|
||||||
|
// For now, we only support passwordless authentication
|
||||||
|
// MAYBE TODO: Implement password authentication
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
103
collab-vm-server-1.3/Display/VNC/VNCDisplay.cs
Normal file
103
collab-vm-server-1.3/Display/VNC/VNCDisplay.cs
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using CollabVM.Server.Display;
|
||||||
|
using MarcusW.VncClient;
|
||||||
|
using MarcusW.VncClient.Protocol.EncodingTypes;
|
||||||
|
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes;
|
||||||
|
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes.Frame;
|
||||||
|
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes.Pseudo;
|
||||||
|
using MarcusW.VncClient.Protocol.Implementation.MessageTypes.Outgoing;
|
||||||
|
using MarcusW.VncClient.Protocol.Implementation.Services.Transports;
|
||||||
|
using MarcusW.VncClient.Rendering;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
|
||||||
|
public class VNCDisplay
|
||||||
|
{
|
||||||
|
public event EventHandler<DirtyRectData> Rect;
|
||||||
|
public event EventHandler<Size> SizeChanged;
|
||||||
|
private string host;
|
||||||
|
private int port;
|
||||||
|
private VncClient vnc;
|
||||||
|
private RfbConnection? rfb;
|
||||||
|
private RectBatcher batcher;
|
||||||
|
VNCRenderTarget renderTarget;
|
||||||
|
|
||||||
|
public VNCDisplay(string host, int port)
|
||||||
|
{
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
var loggerFactory = new LoggerFactory();
|
||||||
|
loggerFactory.AddProvider(new CollabVMLoggerProvider());
|
||||||
|
this.vnc = new VncClient(loggerFactory);
|
||||||
|
this.renderTarget = new VNCRenderTarget();
|
||||||
|
this.renderTarget.Updated += RenderTargetOnUpdated;
|
||||||
|
this.renderTarget.SizeChanged += (_, s) => SizeChanged.Invoke(this, s);
|
||||||
|
this.batcher = new(renderTarget.GetRectangle);
|
||||||
|
this.batcher.Rect += BatcherOnRect;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BatcherOnRect(object? sender, DirtyRectData e)
|
||||||
|
{
|
||||||
|
this.Rect.Invoke(this, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void RenderTargetOnUpdated(object? sender, Rectangle e)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"New {e.Size.Width}x{e.Size.Height} rect at {e.Position.X},{e.Position.Y}");
|
||||||
|
batcher.AddRect(new DirtyRect
|
||||||
|
{
|
||||||
|
Width = e.Size.Width,
|
||||||
|
Height = e.Size.Height,
|
||||||
|
X = e.Position.X,
|
||||||
|
Y = e.Position.Y
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Connect()
|
||||||
|
{
|
||||||
|
if (this.rfb != null) return;
|
||||||
|
this.rfb = await this.vnc.ConnectAsync(new()
|
||||||
|
{
|
||||||
|
TransportParameters = new TcpTransportParameters()
|
||||||
|
{
|
||||||
|
Host = this.host,
|
||||||
|
Port = this.port
|
||||||
|
},
|
||||||
|
RenderFlags = RenderFlags.UpdateByRectangle,
|
||||||
|
InitialRenderTarget = this.renderTarget,
|
||||||
|
AuthenticationHandler = new VNCAuthenticationHandler(),
|
||||||
|
EncodingTypes = new EncodingTypes[] {
|
||||||
|
EncodingTypes.RawEncodingType,
|
||||||
|
EncodingTypes.CopyRectEncodingType,
|
||||||
|
EncodingTypes.ContinuousUpdatesEncodingType,
|
||||||
|
EncodingTypes.ExtendedDesktopSizeEncodingType,
|
||||||
|
EncodingTypes.DesktopSizeEncodingType,
|
||||||
|
EncodingTypes.LastRectEncodingType
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Disconnect()
|
||||||
|
{
|
||||||
|
if (this.rfb == null) return;
|
||||||
|
await this.rfb.CloseAsync();
|
||||||
|
this.rfb = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] GetFramebufferData() => renderTarget.GetFramebufferData();
|
||||||
|
|
||||||
|
public async Task SendKeysym(int keysym, bool down)
|
||||||
|
{
|
||||||
|
if (rfb == null) return;
|
||||||
|
await rfb.SendMessageAsync(new KeyEventMessage(down, (KeySymbol)keysym));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMouse(int x, int y, int mask)
|
||||||
|
{
|
||||||
|
if (rfb == null) return;
|
||||||
|
await rfb.SendMessageAsync(new PointerEventMessage(new Position(x, y), (MouseButtons)mask));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
35
collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs
Normal file
35
collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
using MarcusW.VncClient;
|
||||||
|
using MarcusW.VncClient.Rendering;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
|
||||||
|
public class VNCFramebufferReference : IFramebufferRectangleReference
|
||||||
|
{
|
||||||
|
public event EventHandler<Rectangle> Updated;
|
||||||
|
private SemaphoreSlim fbLock;
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
fbLock.Release();
|
||||||
|
}
|
||||||
|
public IntPtr Address { get; }
|
||||||
|
public Size Size { get; }
|
||||||
|
public PixelFormat Format { get; }
|
||||||
|
public double HorizontalDpi { get; }
|
||||||
|
public double VerticalDpi { get; }
|
||||||
|
|
||||||
|
public VNCFramebufferReference(IntPtr fb, int width, int height, SemaphoreSlim fbLock)
|
||||||
|
{
|
||||||
|
Address = fb;
|
||||||
|
Size = new Size(width, height);
|
||||||
|
Format = new PixelFormat("RGBA32", 32, 32, true, true, true, 255, 255, 255, 255, 24, 16, 8, 0);
|
||||||
|
HorizontalDpi = 96;
|
||||||
|
VerticalDpi = 96;
|
||||||
|
this.fbLock = fbLock;
|
||||||
|
fbLock.Wait();
|
||||||
|
}
|
||||||
|
public void UpdateRectangle(Rectangle rect)
|
||||||
|
{
|
||||||
|
Updated.Invoke(this, rect);
|
||||||
|
}
|
||||||
|
}
|
74
collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs
Normal file
74
collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
using System.Collections.Immutable;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using MarcusW.VncClient;
|
||||||
|
using MarcusW.VncClient.Rendering;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
|
||||||
|
public class VNCRenderTarget : IRenderTarget
|
||||||
|
{
|
||||||
|
private IntPtr framebuffer;
|
||||||
|
private int width;
|
||||||
|
private int height;
|
||||||
|
private SemaphoreSlim fbLock = new(1, 1);
|
||||||
|
public event EventHandler<Rectangle> Updated;
|
||||||
|
public event EventHandler<Size> SizeChanged;
|
||||||
|
|
||||||
|
public VNCRenderTarget()
|
||||||
|
{
|
||||||
|
framebuffer = IntPtr.Zero;
|
||||||
|
width = 0;
|
||||||
|
height = 0;
|
||||||
|
}
|
||||||
|
public IFramebufferReference GrabFramebufferReference(Size size, IImmutableSet<Screen> layout)
|
||||||
|
{
|
||||||
|
if (framebuffer == IntPtr.Zero || width != size.Width || height != size.Height)
|
||||||
|
{
|
||||||
|
if (framebuffer != IntPtr.Zero)
|
||||||
|
{
|
||||||
|
Marshal.FreeHGlobal(framebuffer);
|
||||||
|
}
|
||||||
|
framebuffer = Marshal.AllocHGlobal(size.Width * size.Height * 4);
|
||||||
|
width = size.Width;
|
||||||
|
height = size.Height;
|
||||||
|
SizeChanged.Invoke(this, size);
|
||||||
|
}
|
||||||
|
var refer = new VNCFramebufferReference(framebuffer, size.Width, size.Height, fbLock);
|
||||||
|
refer.Updated += (_, rect) =>
|
||||||
|
{
|
||||||
|
Updated.Invoke(this, rect);
|
||||||
|
};
|
||||||
|
return refer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] GetFramebufferData()
|
||||||
|
{
|
||||||
|
byte[] data = new byte[width * height * 4];
|
||||||
|
Marshal.Copy(framebuffer, data, 0, data.Length);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public byte[] GetRectangle(int x, int y, int rwidth, int rheight, byte[]? buffer = null)
|
||||||
|
{
|
||||||
|
fbLock.Wait();
|
||||||
|
byte[] data;
|
||||||
|
if (buffer != null)
|
||||||
|
{
|
||||||
|
if (buffer.Length < rwidth * rheight * 4)
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Buffer is too small", nameof(buffer));
|
||||||
|
}
|
||||||
|
data = buffer;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
data = new byte[rwidth * rheight * 4];
|
||||||
|
}
|
||||||
|
for (int i = 0; i < rheight; i++)
|
||||||
|
{
|
||||||
|
Marshal.Copy(framebuffer + ((y + i) * this.width + x) * 4, data, i * rwidth * 4, rwidth * 4);
|
||||||
|
}
|
||||||
|
fbLock.Release();
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
}
|
46
collab-vm-server-1.3/Guacutils.cs
Normal file
46
collab-vm-server-1.3/Guacutils.cs
Normal file
|
@ -0,0 +1,46 @@
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Utilities for converting lists of strings to and from Guacamole format
|
||||||
|
/// </summary>
|
||||||
|
public static class Guacutils {
|
||||||
|
/// <summary>
|
||||||
|
/// Encode an array of strings to guacamole format
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="msgArr">List of strings to be encoded</param>
|
||||||
|
/// <returns>A guacamole string array containing the provided strings</returns>
|
||||||
|
public static string Encode(params string[] msgArr)
|
||||||
|
{
|
||||||
|
var res = new StringBuilder();
|
||||||
|
int i = 0;
|
||||||
|
foreach (string s in msgArr) {
|
||||||
|
res.Append(s.Length.ToString());
|
||||||
|
res.Append(".");
|
||||||
|
res.Append(s);
|
||||||
|
if (i == msgArr.Length - 1) res.Append(";");
|
||||||
|
else res.Append(",");
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return res.ToString();
|
||||||
|
}
|
||||||
|
/// <summary>
|
||||||
|
/// Decode a guacamole string array
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="msg">String containing a guacamole array</param>
|
||||||
|
/// <returns>An array of strings</returns>
|
||||||
|
public static string[] Decode(string msg) {
|
||||||
|
List<string> outArr = new List<string>();
|
||||||
|
int pos = 0;
|
||||||
|
while (pos < msg.Length - 1) {
|
||||||
|
int dotpos = msg.IndexOf('.', pos + 1);
|
||||||
|
string lenstr = msg.Substring(pos, dotpos - pos);
|
||||||
|
int len = int.Parse(lenstr);
|
||||||
|
string str = msg.Substring(dotpos + 1, len);
|
||||||
|
outArr.Add(str);
|
||||||
|
pos = dotpos + len + 2;
|
||||||
|
}
|
||||||
|
return outArr.ToArray();
|
||||||
|
}
|
||||||
|
}
|
140
collab-vm-server-1.3/HTTPServer.cs
Normal file
140
collab-vm-server-1.3/HTTPServer.cs
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
using System.Net;
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
using Microsoft.AspNetCore.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class HTTPServer
|
||||||
|
{
|
||||||
|
// this class does all the asp.net stuff to make a simple websocket server
|
||||||
|
// TODO MAYBE: Switch to using standalone kestrel stuff instead of asp.net?
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private bool shutdown = false;
|
||||||
|
private readonly HTTPConfig config;
|
||||||
|
private readonly WebApplication app;
|
||||||
|
private readonly List<User> users = new();
|
||||||
|
|
||||||
|
public HTTPServer(HTTPConfig config)
|
||||||
|
{
|
||||||
|
this.config = config;
|
||||||
|
WebApplicationBuilder builder = WebApplication.CreateBuilder();
|
||||||
|
IPAddress ip;
|
||||||
|
// TODO: Move to a dedicated config validation method
|
||||||
|
if (!IPAddress.TryParse(config.Host, out ip))
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.FATAL, "Invalid IP address in config file");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
if (config.Port < 1 || config.Port > 65535)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.FATAL, "Invalid port in config file");
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
builder.WebHost.UseKestrel(k =>
|
||||||
|
{
|
||||||
|
k.Listen(ip, config.Port);
|
||||||
|
});
|
||||||
|
#if DEBUG
|
||||||
|
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug);
|
||||||
|
#else
|
||||||
|
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning);
|
||||||
|
#endif
|
||||||
|
this.app = builder.Build();
|
||||||
|
this.app.UseWebSockets();
|
||||||
|
this.app.Lifetime.ApplicationStarted.Register(this.OnServerStarted);
|
||||||
|
this.app.Lifetime.ApplicationStopping.Register(this.OnServerStopping);
|
||||||
|
this.app.MapGet("/", HandleRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void OnServerStopping()
|
||||||
|
{
|
||||||
|
shutdown = true;
|
||||||
|
foreach (User user in this.users)
|
||||||
|
await user.Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task HandleRequest(HttpContext context)
|
||||||
|
{
|
||||||
|
if (!context.WebSockets.IsWebSocketRequest)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 426;
|
||||||
|
await context.Response.WriteAsync("This endpoint only accepts websocket connections");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.OriginCheck && !config.AllowedOrigins!.Contains(context.Request.Headers.Origin[0]))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
await context.Response.WriteAsync("Origin not allowed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.WebSockets.WebSocketRequestedProtocols.Contains("guacamole"))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 426;
|
||||||
|
await context.Response.WriteAsync("Invalid websocket protocol");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
IPAddress ip;
|
||||||
|
if (Program.Config.HTTP.ReverseProxy)
|
||||||
|
{
|
||||||
|
if (!Program.Config.HTTP.ProxyAllowedIPs!.Contains(context.Connection.RemoteIpAddress!.ToString()))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
await context.Response.WriteAsync("You are not allowed to connect to this server");
|
||||||
|
Utilities.Log(LogLevel.WARN,
|
||||||
|
$"An IP address not allowed to proxy connections ({context.Connection.RemoteIpAddress.ToString()}) attempted to connect. This mean your server port is exposed to the internet.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (context.Request.Headers["X-Forwarded-For"].Count == 0)
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
await context.Response.WriteAsync("Missing X-Forwarded-For header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!IPAddress.TryParse(context.Request.Headers["X-Forwarded-For"][0], out ip))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 400;
|
||||||
|
await context.Response.WriteAsync("Invalid X-Forwarded-For header");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else ip = context.Connection.RemoteIpAddress!;
|
||||||
|
if (Program.Config.Bans.UseInternalBlacklist && await Program.Database!.IsBanned(ip.ToString()))
|
||||||
|
{
|
||||||
|
context.Response.ContentType = "text/plain";
|
||||||
|
context.Response.StatusCode = 403;
|
||||||
|
await context.Response.WriteAsync("You are banned from this server");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var socket = await context.WebSockets.AcceptWebSocketAsync("guacamole");
|
||||||
|
var socketFinishedTcs = new TaskCompletionSource<object?>();
|
||||||
|
User user = new User(socket, ip);
|
||||||
|
this.users.Add(user);
|
||||||
|
user.Disconnected += (_, _) =>
|
||||||
|
{
|
||||||
|
socketFinishedTcs.TrySetResult(null);
|
||||||
|
if (!shutdown) users.Remove(user);
|
||||||
|
};
|
||||||
|
// keep the middleware alive until the socket is closed
|
||||||
|
await socketFinishedTcs.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnServerStarted()
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.INFO, $"HTTP Server Listening on port {this.config.Port}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task Run()
|
||||||
|
{
|
||||||
|
return this.app.RunAsync();
|
||||||
|
}
|
||||||
|
}
|
121
collab-vm-server-1.3/IConfig.cs
Normal file
121
collab-vm-server-1.3/IConfig.cs
Normal file
|
@ -0,0 +1,121 @@
|
||||||
|
namespace CollabVM.Server.Config;
|
||||||
|
|
||||||
|
public class IConfig
|
||||||
|
{
|
||||||
|
public HTTPConfig HTTP { get; set; }
|
||||||
|
public TurnConfig Turns { get; set; }
|
||||||
|
public VoteConfig Votes { get; set; }
|
||||||
|
public ChatConfig Chat { get; set; }
|
||||||
|
public StaffConfig Staff { get; set; }
|
||||||
|
public BanConfig Bans { get; set; }
|
||||||
|
public LimitsConfig Limits { get; set; }
|
||||||
|
public MySQLConfig? MySQL { get; set; }
|
||||||
|
public Permissions ModPermissions { get; set; }
|
||||||
|
public VMConfig[] VMs { get; set; }
|
||||||
|
|
||||||
|
public void Validate()
|
||||||
|
{
|
||||||
|
if (HTTP == null) throw new Exception("HTTP is a required section in the config file");
|
||||||
|
if (VMs == null) throw new Exception("VMs is a required section in the config file");
|
||||||
|
if (Turns == null) throw new Exception("Turns is a required section in the config file");
|
||||||
|
if (Votes == null) throw new Exception("Votes is a required section in the config file");
|
||||||
|
if (Chat == null) throw new Exception("Chat is a required section in the config file");
|
||||||
|
if (Staff == null) throw new Exception("Staff is a required section in the config file");
|
||||||
|
if (Staff.ModeratorEnabled && ModPermissions == null) throw new Exception("ModPermissions is a required section in the config file");
|
||||||
|
if (Bans == null) throw new Exception("Bans is a required section in the config file");
|
||||||
|
if (Limits == null) throw new Exception("Limits is a required section in the config file");
|
||||||
|
if (Bans.UseInternalBlacklist && MySQL == null) throw new Exception("MySQL is a required section in the config file if UseInternalBlacklist is true");
|
||||||
|
// TODO: Expand this to check sub-sections, probably have each section implement its own Validate() method
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class HTTPConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; }
|
||||||
|
public int Port { get; set; }
|
||||||
|
public bool ReverseProxy { get; set; }
|
||||||
|
public string[]? ProxyAllowedIPs { get; set; }
|
||||||
|
public bool OriginCheck { get; set; }
|
||||||
|
public string[]? AllowedOrigins { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VMConfig
|
||||||
|
{
|
||||||
|
public string ID { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public string MOTD { get; set; }
|
||||||
|
public bool TurnsAllowed { get; set; }
|
||||||
|
public string TurnPasswordHash { get; set; }
|
||||||
|
// Controllers
|
||||||
|
public VNCVMConfig? VNC { get; set; }
|
||||||
|
public QEMUVMConfig? QEMU { get; set; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public class StaffConfig
|
||||||
|
{
|
||||||
|
public string AdminPasswordHash { get; set; }
|
||||||
|
public bool ModeratorEnabled { get; set; }
|
||||||
|
public string ModPasswordHash { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class TurnConfig
|
||||||
|
{
|
||||||
|
public uint TurnTime { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ChatConfig
|
||||||
|
{
|
||||||
|
public uint MaxMessageLength { get; set; }
|
||||||
|
public uint ChatHistoryLength { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VoteConfig
|
||||||
|
{
|
||||||
|
public uint VoteTime { get; set; }
|
||||||
|
public uint VoteCooldown { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BanConfig
|
||||||
|
{
|
||||||
|
public bool UseInternalBlacklist { get; set; }
|
||||||
|
public string? RunCommand { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitsConfig
|
||||||
|
{
|
||||||
|
public uint TempMuteTime { get; set; }
|
||||||
|
public LimitConfig ChatLimit { get; set; }
|
||||||
|
public LimitConfig KitLimit { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class LimitConfig
|
||||||
|
{
|
||||||
|
public bool Enabled { get; set; }
|
||||||
|
public uint Limit { get; set; }
|
||||||
|
public uint Cooldown { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MySQLConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; }
|
||||||
|
public string Username { get; set; }
|
||||||
|
public string Password { get; set; }
|
||||||
|
public string Database { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class VNCVMConfig
|
||||||
|
{
|
||||||
|
public string Host { get; set; }
|
||||||
|
public int Port { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class QEMUVMConfig
|
||||||
|
{
|
||||||
|
public string QEMUCmd { get; set; }
|
||||||
|
public bool UseUnixSockets { get; set; }
|
||||||
|
public int VNCPort { get; set; }
|
||||||
|
public int QMPPort { get; set; }
|
||||||
|
public string? QMPSocketDir { get; set; }
|
||||||
|
public bool Snapshots { get; set; }
|
||||||
|
}
|
60
collab-vm-server-1.3/IPData.cs
Normal file
60
collab-vm-server-1.3/IPData.cs
Normal file
|
@ -0,0 +1,60 @@
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Data about a user's IP address, used to prevent multiple votes or turns from the same IP address
|
||||||
|
/// </summary>
|
||||||
|
public class IPData
|
||||||
|
{
|
||||||
|
public bool IsTurning { get; set; }
|
||||||
|
public bool IsVoting { get; set; }
|
||||||
|
public MuteStatus MuteStatus { get; set; }
|
||||||
|
public event EventHandler Unmuted;
|
||||||
|
|
||||||
|
private Timer tempMuteTimer;
|
||||||
|
|
||||||
|
public IPData()
|
||||||
|
{
|
||||||
|
IsTurning = false;
|
||||||
|
IsVoting = false;
|
||||||
|
MuteStatus = MuteStatus.None;
|
||||||
|
tempMuteTimer = new();
|
||||||
|
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
|
||||||
|
tempMuteTimer.Elapsed += (_, _) => Unmute();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resets properties that are not persistent across user disconnects
|
||||||
|
/// </summary>
|
||||||
|
public void Reset()
|
||||||
|
{
|
||||||
|
IsTurning = false;
|
||||||
|
IsVoting = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Mute(bool permanent)
|
||||||
|
{
|
||||||
|
if (MuteStatus != MuteStatus.None) return;
|
||||||
|
if (permanent)
|
||||||
|
{
|
||||||
|
this.MuteStatus = MuteStatus.Permanent;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.MuteStatus = MuteStatus.Temporary;
|
||||||
|
tempMuteTimer.Stop();
|
||||||
|
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
|
||||||
|
tempMuteTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Unmute()
|
||||||
|
{
|
||||||
|
if (MuteStatus == MuteStatus.None) return;
|
||||||
|
MuteStatus = MuteStatus.None;
|
||||||
|
Unmuted.Invoke(this, EventArgs.Empty);
|
||||||
|
tempMuteTimer.Stop();
|
||||||
|
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
|
||||||
|
}
|
||||||
|
}
|
11
collab-vm-server-1.3/ListVM.cs
Normal file
11
collab-vm-server-1.3/ListVM.cs
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class ListVM
|
||||||
|
{
|
||||||
|
public string ID { get; set; }
|
||||||
|
public string Name { get; set; }
|
||||||
|
public byte[] Thumbnail { get; set; }
|
||||||
|
}
|
8
collab-vm-server-1.3/MuteStatus.cs
Normal file
8
collab-vm-server-1.3/MuteStatus.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public enum MuteStatus
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
Temporary,
|
||||||
|
Permanent
|
||||||
|
}
|
33
collab-vm-server-1.3/Permissions.cs
Normal file
33
collab-vm-server-1.3/Permissions.cs
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class Permissions
|
||||||
|
{
|
||||||
|
public bool Restore { get; set; }
|
||||||
|
public bool Reboot { get; set; }
|
||||||
|
public bool Ban { get; set; }
|
||||||
|
public bool ForceVote { get; set; }
|
||||||
|
public bool Mute { get; set; }
|
||||||
|
public bool Kick { get; set; }
|
||||||
|
public bool BypassTurn { get; set; }
|
||||||
|
public bool Rename { get; set; }
|
||||||
|
public bool GrabIP { get; set; }
|
||||||
|
public bool XSS { get; set; }
|
||||||
|
public bool HideScreen { get; set; }
|
||||||
|
|
||||||
|
public int ToMask()
|
||||||
|
{
|
||||||
|
int perms = 0;
|
||||||
|
if (Restore) perms |= 1;
|
||||||
|
if (Reboot) perms |= 2;
|
||||||
|
if (Ban) perms |= 4;
|
||||||
|
if (ForceVote) perms |= 8;
|
||||||
|
if (Mute) perms |= 16;
|
||||||
|
if (Kick) perms |= 32;
|
||||||
|
if (BypassTurn) perms |= 64;
|
||||||
|
if (Rename) perms |= 128;
|
||||||
|
if (GrabIP) perms |= 256;
|
||||||
|
if (XSS) perms |= 512;
|
||||||
|
if (HideScreen) perms |= 1024;
|
||||||
|
return perms;
|
||||||
|
}
|
||||||
|
}
|
71
collab-vm-server-1.3/Program.cs
Normal file
71
collab-vm-server-1.3/Program.cs
Normal file
|
@ -0,0 +1,71 @@
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using Tomlet;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
class Program
|
||||||
|
{
|
||||||
|
// the collabvm 1 to end all collabvm 1s
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private static HTTPServer http;
|
||||||
|
private static VM[] vms;
|
||||||
|
|
||||||
|
// Public fields
|
||||||
|
public static VMManager VMManager { get; private set; }
|
||||||
|
public static Random rnd = new();
|
||||||
|
public static IConfig Config;
|
||||||
|
public static Database? Database;
|
||||||
|
// These can go here for now, might move them later
|
||||||
|
public static readonly string ScreenHiddenBase64 = Convert.ToBase64String(Utilities.GetAsset("screenhidden.jpeg"));
|
||||||
|
public static readonly byte[] ScreenHiddenThumb = Utilities.GetAsset("screenhiddenthumb.jpeg");
|
||||||
|
|
||||||
|
public async static Task Main(string[] args)
|
||||||
|
{
|
||||||
|
// Load the config file
|
||||||
|
string configraw;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
configraw = await File.ReadAllTextAsync("config.toml");
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.FATAL, "Failed to read config file: " + ex.Message);
|
||||||
|
Environment.Exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Config = TomletMain.To<IConfig>(configraw);
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.FATAL, "Failed to parse config file: " + ex.Message);
|
||||||
|
Environment.Exit(1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Config.Validate();
|
||||||
|
Utilities.Log(LogLevel.INFO, "CollabVM Server 1.3 starting up...");
|
||||||
|
// Register kill signal handlers
|
||||||
|
Console.CancelKeyPress += (_, _) => Exit();
|
||||||
|
PosixSignalRegistration.Create(PosixSignal.SIGTERM, (_) => Exit());
|
||||||
|
// Initialize the database
|
||||||
|
if (Config.MySQL != null)
|
||||||
|
{
|
||||||
|
Database = new Database(Config.MySQL);
|
||||||
|
await Database.OpenAsync();
|
||||||
|
Utilities.Log(LogLevel.INFO, "Connected to MySQL Database.");
|
||||||
|
}
|
||||||
|
// Initialize the VMs
|
||||||
|
VMManager = new VMManager(Config.VMs);
|
||||||
|
await VMManager.StartAll();
|
||||||
|
// Start the HTTP server
|
||||||
|
http = new HTTPServer(Config.HTTP);
|
||||||
|
await http.Run();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Exit()
|
||||||
|
{
|
||||||
|
VMManager.StopAll().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
}
|
8
collab-vm-server-1.3/Rank.cs
Normal file
8
collab-vm-server-1.3/Rank.cs
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public enum Rank
|
||||||
|
{
|
||||||
|
Unregistered = 0,
|
||||||
|
Admin = 2,
|
||||||
|
Moderator = 3,
|
||||||
|
}
|
47
collab-vm-server-1.3/RateLimiter.cs
Normal file
47
collab-vm-server-1.3/RateLimiter.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class RateLimiter
|
||||||
|
{
|
||||||
|
private uint limit, interval, requestCount;
|
||||||
|
private Timer timer;
|
||||||
|
private bool isRunning;
|
||||||
|
|
||||||
|
public RateLimiter(uint limit, uint interval)
|
||||||
|
{
|
||||||
|
this.limit = limit;
|
||||||
|
this.interval = interval;
|
||||||
|
requestCount = 0;
|
||||||
|
timer = new(interval * 1000);
|
||||||
|
timer.AutoReset = false;
|
||||||
|
timer.Elapsed += (_, _) => Reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Limit()
|
||||||
|
{
|
||||||
|
this.requestCount++;
|
||||||
|
if (this.requestCount == this.limit)
|
||||||
|
{
|
||||||
|
Reset();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (!isRunning)
|
||||||
|
{
|
||||||
|
timer.Start();
|
||||||
|
isRunning = true;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reset()
|
||||||
|
{
|
||||||
|
isRunning = false;
|
||||||
|
requestCount = 0;
|
||||||
|
timer.Stop();
|
||||||
|
timer.Interval = limit * 1000;
|
||||||
|
}
|
||||||
|
}
|
79
collab-vm-server-1.3/ResetVote.cs
Normal file
79
collab-vm-server-1.3/ResetVote.cs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
using System.Timers;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class ResetVote
|
||||||
|
{
|
||||||
|
private readonly List<User> _yesVotes;
|
||||||
|
private readonly List<User> _noVotes;
|
||||||
|
private readonly Timer _ticker;
|
||||||
|
private uint _timeRemaining;
|
||||||
|
|
||||||
|
public User[] YesVotes => _yesVotes.ToArray();
|
||||||
|
public User[] NoVotes => _noVotes.ToArray();
|
||||||
|
public uint TimeRemaining => _timeRemaining;
|
||||||
|
public event EventHandler<ResetVoteStatus> Finished;
|
||||||
|
|
||||||
|
public ResetVote(uint time)
|
||||||
|
{
|
||||||
|
_yesVotes = new();
|
||||||
|
_noVotes = new();
|
||||||
|
_timeRemaining = time;
|
||||||
|
_ticker = new();
|
||||||
|
_ticker.Interval = 1000;
|
||||||
|
_ticker.Elapsed += TickerOnElapsed;
|
||||||
|
_ticker.AutoReset = true;
|
||||||
|
_ticker.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TickerOnElapsed(object? sender, ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
_timeRemaining--;
|
||||||
|
if (_timeRemaining == 0)
|
||||||
|
EndVote();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void EndVote(bool? force = null)
|
||||||
|
{
|
||||||
|
_ticker.Stop();
|
||||||
|
bool result = force ?? _yesVotes.Count >= _noVotes.Count;
|
||||||
|
Finished.Invoke(this, GetStatus(force));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Vote(User user, bool vote)
|
||||||
|
{
|
||||||
|
if (_timeRemaining == 0) return;
|
||||||
|
if (vote)
|
||||||
|
{
|
||||||
|
if (_yesVotes.Contains(user)) return;
|
||||||
|
_noVotes.Remove(user);
|
||||||
|
_yesVotes.Add(user);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (_noVotes.Contains(user)) return;
|
||||||
|
_yesVotes.Remove(user);
|
||||||
|
_noVotes.Add(user);
|
||||||
|
}
|
||||||
|
user.IPData!.IsVoting = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void ClearVote(User user)
|
||||||
|
{
|
||||||
|
_yesVotes.Remove(user);
|
||||||
|
_noVotes.Remove(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ResetVoteStatus GetStatus(bool? force = null)
|
||||||
|
{
|
||||||
|
return new ResetVoteStatus
|
||||||
|
{
|
||||||
|
YesVotes = YesVotes,
|
||||||
|
NoVotes = NoVotes,
|
||||||
|
TimeRemaining = TimeRemaining,
|
||||||
|
Finished = _timeRemaining == 0,
|
||||||
|
Result = force ?? _yesVotes.Count >= _noVotes.Count
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
10
collab-vm-server-1.3/ResetVoteStatus.cs
Normal file
10
collab-vm-server-1.3/ResetVoteStatus.cs
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class ResetVoteStatus
|
||||||
|
{
|
||||||
|
public User[] YesVotes { get; set; }
|
||||||
|
public User[] NoVotes { get; set; }
|
||||||
|
public uint TimeRemaining { get; set; }
|
||||||
|
public bool Finished { get; set; }
|
||||||
|
public bool Result { get; set; }
|
||||||
|
}
|
165
collab-vm-server-1.3/TurnQueue.cs
Normal file
165
collab-vm-server-1.3/TurnQueue.cs
Normal file
|
@ -0,0 +1,165 @@
|
||||||
|
using System.Timers;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class TurnQueue
|
||||||
|
{
|
||||||
|
// Public properties and events
|
||||||
|
public event EventHandler<TurnStatus> Turn;
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private readonly Queue<User> queue;
|
||||||
|
private readonly uint turnTime;
|
||||||
|
private readonly Timer timer;
|
||||||
|
private uint currentTurnRemainingTime;
|
||||||
|
private User? indefiniteTurn;
|
||||||
|
|
||||||
|
public TurnQueue(uint turnTime)
|
||||||
|
{
|
||||||
|
queue = new();
|
||||||
|
this.turnTime = turnTime;
|
||||||
|
timer = new();
|
||||||
|
timer.Interval = 1000;
|
||||||
|
timer.Elapsed += TimerOnElapsed;
|
||||||
|
timer.AutoReset = true;
|
||||||
|
currentTurnRemainingTime = 0;
|
||||||
|
indefiniteTurn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void TimerOnElapsed(object? sender, ElapsedEventArgs e)
|
||||||
|
{
|
||||||
|
if (indefiniteTurn != null) return;
|
||||||
|
if (queue.Count == 0) return; // This shouldn't happen, but just in case
|
||||||
|
currentTurnRemainingTime--;
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Turn tick, {currentTurnRemainingTime} seconds remaining on {queue.Peek().Username}'s turn");
|
||||||
|
if (currentTurnRemainingTime == 0)
|
||||||
|
{
|
||||||
|
NextTurn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void NextTurn()
|
||||||
|
{
|
||||||
|
if (queue.Count == 0) { return; }
|
||||||
|
if (indefiniteTurn != null)
|
||||||
|
{
|
||||||
|
indefiniteTurn = null;
|
||||||
|
SendTurnUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queue.Dequeue();
|
||||||
|
if (queue.Count > 0)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"It is now {queue.Peek().Username}'s turn");
|
||||||
|
currentTurnRemainingTime = turnTime;
|
||||||
|
}
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SendTurnUpdate()
|
||||||
|
{
|
||||||
|
Turn.Invoke(this, CurrentTurn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public TurnStatus CurrentTurn()
|
||||||
|
{
|
||||||
|
if (indefiniteTurn != null)
|
||||||
|
return new TurnStatus
|
||||||
|
{
|
||||||
|
Queue = queue.ToArray().Prepend(indefiniteTurn).ToArray(),
|
||||||
|
TimeRemaining = 999999999,
|
||||||
|
};
|
||||||
|
return new TurnStatus
|
||||||
|
{
|
||||||
|
Queue = queue.ToArray(),
|
||||||
|
TimeRemaining = currentTurnRemainingTime
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AddUser(User user)
|
||||||
|
{
|
||||||
|
if (queue.Contains(user) || indefiniteTurn == user) return;
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Adding user {user.Username} to turn queue");
|
||||||
|
queue.Enqueue(user);
|
||||||
|
if (queue.Count == 1)
|
||||||
|
{
|
||||||
|
currentTurnRemainingTime = turnTime;
|
||||||
|
timer.Start();
|
||||||
|
}
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RemoveUser(User user)
|
||||||
|
{
|
||||||
|
if (user == indefiniteTurn)
|
||||||
|
{
|
||||||
|
indefiniteTurn = null;
|
||||||
|
SendTurnUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!queue.Contains(user)) return;
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Removing user {user.Username} to turn queue");
|
||||||
|
if (queue.Peek() == user && indefiniteTurn == null)
|
||||||
|
{
|
||||||
|
NextTurn();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var _queue = queue.ToArray();
|
||||||
|
queue.Clear();
|
||||||
|
foreach (User u in _queue)
|
||||||
|
if (u != user) queue.Enqueue(u);
|
||||||
|
if (queue.Count == 0)
|
||||||
|
{
|
||||||
|
timer.Stop();
|
||||||
|
}
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
indefiniteTurn = null;
|
||||||
|
queue.Clear();
|
||||||
|
timer.Stop();
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public User? CurrentUser()
|
||||||
|
{
|
||||||
|
if (indefiniteTurn != null) return indefiniteTurn;
|
||||||
|
if (queue.Count == 0) return null;
|
||||||
|
return queue.Peek();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void GiveTurn(User user)
|
||||||
|
{
|
||||||
|
if (indefiniteTurn != null)
|
||||||
|
{
|
||||||
|
indefiniteTurn = user;
|
||||||
|
SendTurnUpdate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queue.Count == 0)
|
||||||
|
{
|
||||||
|
AddUser(user);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queue.Peek() == user) return;
|
||||||
|
RemoveUser(user);
|
||||||
|
var _queue = queue.ToArray();
|
||||||
|
queue.Clear();
|
||||||
|
queue.Enqueue(user);
|
||||||
|
foreach (User u in _queue)
|
||||||
|
queue.Enqueue(u);
|
||||||
|
this.currentTurnRemainingTime = turnTime;
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void IndefiniteTurn(User user)
|
||||||
|
{
|
||||||
|
RemoveUser(user);
|
||||||
|
this.indefiniteTurn = user;
|
||||||
|
SendTurnUpdate();
|
||||||
|
}
|
||||||
|
}
|
7
collab-vm-server-1.3/TurnStatus.cs
Normal file
7
collab-vm-server-1.3/TurnStatus.cs
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class TurnStatus
|
||||||
|
{
|
||||||
|
public User[] Queue { get; set; }
|
||||||
|
public uint TimeRemaining { get; set; }
|
||||||
|
}
|
675
collab-vm-server-1.3/User.cs
Normal file
675
collab-vm-server-1.3/User.cs
Normal file
|
@ -0,0 +1,675 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.WebSockets;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class User
|
||||||
|
{
|
||||||
|
// Events
|
||||||
|
public event EventHandler<EventArgs> Disconnected;
|
||||||
|
public event EventHandler<string> ConnectedToVM;
|
||||||
|
public event EventHandler<string> Renamed;
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private WebSocket socket;
|
||||||
|
private readonly CancellationTokenSource tokenSource;
|
||||||
|
// Timer for when the connection times out
|
||||||
|
private readonly Timer timeoutTimer;
|
||||||
|
// This becomes true if the timer runs out.
|
||||||
|
// If the timer runs out while this is false, the user is sent a NOP message
|
||||||
|
// If the timer runs out while this is true, the user is disconnected
|
||||||
|
private bool timeOut = false;
|
||||||
|
// VM that the user is currently connected to
|
||||||
|
private VM? vm = null;
|
||||||
|
// Whether or not the user has been disposed
|
||||||
|
private bool _disposed = false;
|
||||||
|
// Username
|
||||||
|
private string? _username = null;
|
||||||
|
// IP
|
||||||
|
private IPAddress _ip;
|
||||||
|
// Rank
|
||||||
|
private Rank _rank = Rank.Unregistered;
|
||||||
|
// Limits
|
||||||
|
private readonly RateLimiter? ChatLimiter;
|
||||||
|
private readonly RateLimiter? KitLimiter;
|
||||||
|
// If true, can take turns while turns are disabled
|
||||||
|
private bool turnsAllowed = false;
|
||||||
|
|
||||||
|
// Public properties
|
||||||
|
public string? Username => this._username;
|
||||||
|
public IPAddress IP => this._ip;
|
||||||
|
public Rank Rank => this._rank;
|
||||||
|
public IPData? IPData { get; set; }
|
||||||
|
|
||||||
|
|
||||||
|
public User(WebSocket socket, IPAddress ip)
|
||||||
|
{
|
||||||
|
if (Program.Config.Limits.ChatLimit.Enabled)
|
||||||
|
ChatLimiter = new RateLimiter(Program.Config.Limits.ChatLimit.Limit, Program.Config.Limits.ChatLimit.Cooldown);
|
||||||
|
if (Program.Config.Limits.KitLimit.Enabled)
|
||||||
|
KitLimiter = new RateLimiter(Program.Config.Limits.KitLimit.Limit, Program.Config.Limits.KitLimit.Cooldown);
|
||||||
|
this.socket = socket;
|
||||||
|
this._ip = ip;
|
||||||
|
tokenSource = new CancellationTokenSource();
|
||||||
|
timeoutTimer = new Timer(3000);
|
||||||
|
timeoutTimer.Elapsed += async (sender, args) => await TimeoutCallback();
|
||||||
|
timeoutTimer.AutoReset = false;
|
||||||
|
timeoutTimer.Start();
|
||||||
|
this.Disconnected += OnDisconnected;
|
||||||
|
this.ConnectedToVM += delegate { };
|
||||||
|
this.Renamed += delegate { };
|
||||||
|
SendAsync("3.nop;");
|
||||||
|
ReadLoop(tokenSource.Token);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnDisconnected(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
this.timeoutTimer.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendAsync(string msg)
|
||||||
|
{
|
||||||
|
if (socket.State != WebSocketState.Open)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await socket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(msg)), WebSocketMessageType.Text, true,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ProcessMessage(string[] msgArr)
|
||||||
|
{
|
||||||
|
if (msgArr.Length < 1)
|
||||||
|
{
|
||||||
|
await Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.timeOut = false;
|
||||||
|
this.timeoutTimer.Stop();
|
||||||
|
this.timeoutTimer.Start();
|
||||||
|
switch (msgArr[0])
|
||||||
|
{
|
||||||
|
case "nop":
|
||||||
|
break;
|
||||||
|
case "list":
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.DEBUG, "Getting list of VMs...");
|
||||||
|
var list = await Program.VMManager.GetList();
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Got list of {list.Length} VMs");
|
||||||
|
List<string> listmsg = new();
|
||||||
|
listmsg.Add("list");
|
||||||
|
foreach (ListVM vm in list)
|
||||||
|
{
|
||||||
|
listmsg.Add(vm.ID);
|
||||||
|
listmsg.Add(vm.Name);
|
||||||
|
listmsg.Add(Convert.ToBase64String(vm.Thumbnail));
|
||||||
|
}
|
||||||
|
|
||||||
|
await SendAsync(Guacutils.Encode(listmsg.ToArray()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "rename":
|
||||||
|
{
|
||||||
|
if (msgArr.Length == 1)
|
||||||
|
await this.AssignGuestUsername(this.vm);
|
||||||
|
else
|
||||||
|
await this.Rename(msgArr[1]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "connect":
|
||||||
|
{
|
||||||
|
if (msgArr.Length != 2 || this._username == null)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("connect", "0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.vm != null)
|
||||||
|
{
|
||||||
|
// TODO: Implement VM switching
|
||||||
|
await this.SendAsync(Guacutils.Encode("connect", "0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var vm = Program.VMManager.GetVM(msgArr[1]);
|
||||||
|
if (vm == null || !vm.Controller.IsRunning)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("connect", "0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.Users.Any(user => user.Username == this._username))
|
||||||
|
{
|
||||||
|
await this.AssignGuestUsername(vm);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.vm = vm;
|
||||||
|
await this.SendAsync(Guacutils.Encode("connect", "1", "1", vm.Controller.Snapshots ? "1" : "0", "0"));
|
||||||
|
List<string> usermsg = new(new[] { "adduser", vm.Users.Count.ToString() });
|
||||||
|
foreach (User user in vm.Users)
|
||||||
|
{
|
||||||
|
usermsg.Add(user.Username);
|
||||||
|
usermsg.Add(((int)user.Rank).ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.SendAsync(Guacutils.Encode(usermsg.ToArray()));
|
||||||
|
await this.vm.AddUser(this);
|
||||||
|
List<string> chatmsg = new();
|
||||||
|
chatmsg.Add("chat");
|
||||||
|
foreach (ChatMessage msg in vm.ChatHistory)
|
||||||
|
{
|
||||||
|
chatmsg.Add(msg.Username);
|
||||||
|
chatmsg.Add(msg.Message);
|
||||||
|
}
|
||||||
|
chatmsg.Add("");
|
||||||
|
chatmsg.Add(vm.Config.MOTD);
|
||||||
|
await this.SendAsync(Guacutils.Encode(chatmsg.ToArray()));
|
||||||
|
var turn = vm.TurnQueue.CurrentTurn();
|
||||||
|
if (turn.Queue.Length > 0) await SendTurnUpdate(turn);
|
||||||
|
if (vm.Vote != null) await SendVoteUpdate(vm.Vote.GetStatus());
|
||||||
|
this.ConnectedToVM.Invoke(this, vm.Config.ID);
|
||||||
|
if (vm.ScreenHidden && _rank == Rank.Unregistered)
|
||||||
|
{
|
||||||
|
await this.SendScreenSize(new(1024, 768));
|
||||||
|
await this.SendRect(Program.ScreenHiddenBase64, 0, 0);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await this.SendScreenSize(vm.GetScreenSize());
|
||||||
|
await this.SendRect(Convert.ToBase64String(await vm.GetFramebufferJpeg()), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "chat":
|
||||||
|
{
|
||||||
|
if (this.vm == null || msgArr.Length != 2 || IPData!.MuteStatus != MuteStatus.None) return;
|
||||||
|
if (_rank == Rank.Unregistered && ChatLimiter != null && !ChatLimiter.Limit())
|
||||||
|
{
|
||||||
|
if (IPData!.MuteStatus == MuteStatus.None) await Mute(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var msg = msgArr[1].Trim();
|
||||||
|
if (msg.Length < 1) return;
|
||||||
|
if (msg.Length > Program.Config.Chat.MaxMessageLength) msg = msg.Substring(0, (int)Program.Config.Chat.MaxMessageLength);
|
||||||
|
await vm.SendChat(this, msg);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "mouse":
|
||||||
|
{
|
||||||
|
if (msgArr.Length != 4 || this.vm == null || (!HasTurn() && _rank != Rank.Admin)) return;
|
||||||
|
if (KitLimiter != null && !KitLimiter.Limit())
|
||||||
|
{
|
||||||
|
await Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int x, y, mask;
|
||||||
|
if (!int.TryParse(msgArr[1], out x) || !int.TryParse(msgArr[2], out y) ||
|
||||||
|
!int.TryParse(msgArr[3], out mask)) return;
|
||||||
|
// TODO: Turns
|
||||||
|
await vm.SendMouse(x, y, mask);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "key":
|
||||||
|
{
|
||||||
|
if (this.vm == null || msgArr.Length != 3 || (!HasTurn() && _rank != Rank.Admin)) return;
|
||||||
|
if (KitLimiter != null && !KitLimiter.Limit())
|
||||||
|
{
|
||||||
|
await Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
int keysym, down;
|
||||||
|
if (!int.TryParse(msgArr[1], out keysym) || !int.TryParse(msgArr[2], out down)) return;
|
||||||
|
if (down != 0 && down != 1) return;
|
||||||
|
// TODO: Turns
|
||||||
|
await vm.SendKey(keysym, down == 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "turn":
|
||||||
|
{
|
||||||
|
if (this.vm == null || msgArr.Length > 2 || IPData!.MuteStatus != MuteStatus.None) return;
|
||||||
|
if (msgArr.Length == 1 || msgArr[1] == "1" && !IPData!.IsTurning && (vm.TurnsAllowed || turnsAllowed || _rank == Rank.Admin || _rank == Rank.Moderator))
|
||||||
|
{
|
||||||
|
vm.TurnQueue.AddUser(this);
|
||||||
|
} else if (msgArr[1] == "0")
|
||||||
|
{
|
||||||
|
vm.TurnQueue.RemoveUser(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "vote":
|
||||||
|
{
|
||||||
|
if (this.vm == null || msgArr.Length != 2 || IPData!.IsVoting || IPData!.MuteStatus != MuteStatus.None) return;
|
||||||
|
if (!vm.Controller.Snapshots)
|
||||||
|
{
|
||||||
|
await this.SendChat("", "This VM does not support voting to reset");
|
||||||
|
}
|
||||||
|
bool? vote = msgArr[1] switch
|
||||||
|
{
|
||||||
|
"1" => true,
|
||||||
|
"0" => false,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (vote == null) return;
|
||||||
|
if (vm.Vote == null && !vm.VoteCooldown.IsReady)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("vote", "3", vm.VoteCooldown.TimeRemaining.ToString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await vm.VoteReset(this, vote.Value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "admin":
|
||||||
|
{
|
||||||
|
if (msgArr.Length < 2) return;
|
||||||
|
switch (msgArr[1])
|
||||||
|
{
|
||||||
|
case "2":
|
||||||
|
{
|
||||||
|
// Login
|
||||||
|
if (msgArr.Length != 3) return;
|
||||||
|
using var sha = SHA256.Create();
|
||||||
|
var hash = Utilities.BytesToHex(sha.ComputeHash(Encoding.UTF8.GetBytes(msgArr[2])));
|
||||||
|
if (hash == Program.Config.Staff.AdminPasswordHash)
|
||||||
|
{
|
||||||
|
this._rank = Rank.Admin;
|
||||||
|
await this.SendAsync(Guacutils.Encode("admin", "0", "1"));
|
||||||
|
} else if (Program.Config.Staff.ModeratorEnabled && hash == Program.Config.Staff.ModPasswordHash)
|
||||||
|
{
|
||||||
|
this._rank = Rank.Moderator;
|
||||||
|
await this.SendAsync(Guacutils.Encode("admin", "0", "3", Program.Config.ModPermissions.ToMask().ToString()));
|
||||||
|
} else if (vm != null && vm.Config.TurnPasswordHash == hash)
|
||||||
|
{
|
||||||
|
this.turnsAllowed = true;
|
||||||
|
await this.SendChat("", "You may now take turns.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("admin", "0", "0"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (vm.ScreenHidden)
|
||||||
|
{
|
||||||
|
await this.SendScreenSize(vm.GetScreenSize());
|
||||||
|
await this.SendRect(Convert.ToBase64String(await vm.GetFramebufferJpeg()), 0, 0);
|
||||||
|
}
|
||||||
|
if (this.vm != null) await vm.ReannounceUser(this);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "5":
|
||||||
|
{
|
||||||
|
// Monitor
|
||||||
|
if (_rank != Rank.Admin || msgArr.Length != 4) return;
|
||||||
|
var _vm = Program.VMManager.GetVM(msgArr[2]);
|
||||||
|
if (_vm == null) return;
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"[{_vm.Config.ID}] {_username} is running \"{msgArr[3]}\"");
|
||||||
|
var output = await _vm.Controller.MonitorCommand(msgArr[3]);
|
||||||
|
await SendAsync(Guacutils.Encode("admin", "2", output));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "8":
|
||||||
|
{
|
||||||
|
// Restore snapshot
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Restore) || msgArr.Length > 3) return;
|
||||||
|
VM? _vm;
|
||||||
|
if (msgArr.Length == 3)
|
||||||
|
{
|
||||||
|
_vm = Program.VMManager.GetVM(msgArr[2]);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_vm = this.vm;
|
||||||
|
}
|
||||||
|
if (_vm == null) return;
|
||||||
|
await _vm.Controller.Reset();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "10":
|
||||||
|
{
|
||||||
|
// Reboot
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Reboot) || msgArr.Length != 3) return;
|
||||||
|
VM? _vm = Program.VMManager.GetVM(msgArr[2]);
|
||||||
|
if (_vm == null) return;
|
||||||
|
await _vm.Controller.Reboot();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "12":
|
||||||
|
{
|
||||||
|
// Ban
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Ban) || msgArr.Length < 3 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
await user.Ban(msgArr.Length == 4 ? msgArr[3] : null);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "13":
|
||||||
|
{
|
||||||
|
// Force vote
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.ForceVote) || msgArr.Length != 3 || this.vm?.Vote == null) return;
|
||||||
|
bool? vote = msgArr[2] switch
|
||||||
|
{
|
||||||
|
"1" => true,
|
||||||
|
"0" => false,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (vote == null) return;
|
||||||
|
vm.Vote.EndVote(vote);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "14":
|
||||||
|
{
|
||||||
|
// Mute
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Mute) || msgArr.Length != 4 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
bool? permanent = msgArr[3] == "1" ? true : msgArr[3] == "0" ? false : null;
|
||||||
|
if (permanent == null) return;
|
||||||
|
await user.Mute((bool)permanent);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "15":
|
||||||
|
{
|
||||||
|
// Kick
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Kick) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
await user.Close();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "16":
|
||||||
|
{
|
||||||
|
// End turn
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
vm.TurnQueue.RemoveUser(user);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "17":
|
||||||
|
{
|
||||||
|
// Clear turn queue
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3) return;
|
||||||
|
VM? _vm = Program.VMManager.GetVM(msgArr[2]);
|
||||||
|
if (_vm == null) return;
|
||||||
|
_vm.TurnQueue.Clear();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "18":
|
||||||
|
{
|
||||||
|
// Rename user
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Rename) || msgArr.Length != 4 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
await user.Rename(msgArr[3]);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "19":
|
||||||
|
{
|
||||||
|
// Get IP
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.GrabIP) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
var user = vm.Users.Find(u => u.Username == msgArr[2]);
|
||||||
|
if (user == null) return;
|
||||||
|
await SendAsync(Guacutils.Encode("admin", "19", msgArr[2], user.IP.ToString()));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "20":
|
||||||
|
{
|
||||||
|
// Bypass turn
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn)) return;
|
||||||
|
if (vm == null) return;
|
||||||
|
vm.TurnQueue.GiveTurn(this);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "21":
|
||||||
|
{
|
||||||
|
// XSS
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.XSS) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
await vm.SendChat(this, msgArr[2], true, _rank == Rank.Moderator);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "22":
|
||||||
|
{
|
||||||
|
// Toggle Turns
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
if (msgArr[2] == "1")
|
||||||
|
{
|
||||||
|
vm.TurnsAllowed = true;
|
||||||
|
} else if (msgArr[2] == "0")
|
||||||
|
{
|
||||||
|
vm.TurnQueue.Clear();
|
||||||
|
vm.TurnsAllowed = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "23":
|
||||||
|
{
|
||||||
|
// Indefinite turn
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || vm == null) return;
|
||||||
|
vm.TurnQueue.IndefiniteTurn(this);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "24":
|
||||||
|
{
|
||||||
|
// Hide screen
|
||||||
|
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.HideScreen) || msgArr.Length != 3 || vm == null) return;
|
||||||
|
bool? hidden = msgArr[2] switch
|
||||||
|
{
|
||||||
|
"1" => false,
|
||||||
|
"0" => true,
|
||||||
|
_ => null
|
||||||
|
};
|
||||||
|
if (hidden == null) return;
|
||||||
|
await vm.HideScreen(hidden.Value);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
await Close();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task TimeoutCallback()
|
||||||
|
{
|
||||||
|
if (!timeOut)
|
||||||
|
{
|
||||||
|
await SendAsync("3.nop;");
|
||||||
|
timeOut = true;
|
||||||
|
timeoutTimer.Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
await Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ReadLoop(CancellationToken token)
|
||||||
|
{
|
||||||
|
ArraySegment<byte> receivebuffer = new ArraySegment<byte>(new byte[8192]);
|
||||||
|
WebSocketReceiveResult result;
|
||||||
|
while (!tokenSource.IsCancellationRequested && socket.State == WebSocketState.Open)
|
||||||
|
{
|
||||||
|
using MemoryStream ms = new MemoryStream();
|
||||||
|
do
|
||||||
|
{
|
||||||
|
result = await socket.ReceiveAsync(receivebuffer, token);
|
||||||
|
if (result.MessageType == WebSocketMessageType.Close)
|
||||||
|
{
|
||||||
|
Disconnected.Invoke(this, new EventArgs());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.MessageType == WebSocketMessageType.Binary)
|
||||||
|
{
|
||||||
|
await Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
await ms.WriteAsync(receivebuffer.Array, 0, result.Count, token);
|
||||||
|
} while (!result.EndOfMessage);
|
||||||
|
string msg;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
msg = Encoding.UTF8.GetString(ms.ToArray());
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.DEBUG, "Failed to decode websocket message");
|
||||||
|
await Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
string[] msgArr;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
msgArr = Guacutils.Decode(msg);
|
||||||
|
} catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.DEBUG, "Failed to decode guacamole message " + msg);
|
||||||
|
await Close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ProcessMessage(msgArr);
|
||||||
|
}
|
||||||
|
this.Disconnected.Invoke(this, new EventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendRect(string jpg64, int x, int y)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("png", "0", "0", x.ToString(), y.ToString(), jpg64));
|
||||||
|
await this.SendAsync(Guacutils.Encode("sync", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendScreenSize(Size size)
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("size", "0", size.Width.ToString(), size.Height.ToString()));
|
||||||
|
}
|
||||||
|
public async Task Close()
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("disconnect"));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
|
||||||
|
} catch { /* ignored */ }
|
||||||
|
Disconnected.Invoke(this, new EventArgs());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Ban(string? reason = null)
|
||||||
|
{
|
||||||
|
if (Program.Config.Bans.UseInternalBlacklist)
|
||||||
|
{
|
||||||
|
if (reason != null) await Program.Database!.AddBan(this._ip.ToString(), reason);
|
||||||
|
else await Program.Database!.AddBan(this._ip.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Program.Config.Bans.RunCommand != null)
|
||||||
|
{
|
||||||
|
var cmd = Program.Config.Bans.RunCommand
|
||||||
|
.Replace("$IP", this._ip.ToString())
|
||||||
|
.Replace("$NAME", this._username!);
|
||||||
|
if (reason != null) cmd = cmd.Replace("$REASON", reason);
|
||||||
|
await Utilities.ExecuteCommand(cmd);
|
||||||
|
}
|
||||||
|
await Close();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task AssignGuestUsername(VM? vm)
|
||||||
|
{
|
||||||
|
string username;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
username = $"guest{Program.rnd.Next(10000, 99999).ToString()}";
|
||||||
|
} while (vm != null && vm.Users.Any(user => user.Username == username));
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Assigning guest username {username} to {this._ip.ToString()}");
|
||||||
|
return Rename(username);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Rename(string username)
|
||||||
|
{
|
||||||
|
var oldname = this._username;
|
||||||
|
var newname = username.Trim();
|
||||||
|
if (this._username == newname)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("rename", "0", "0", this._username, ((int)this._rank).ToString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (this.vm != null && this.vm.Users.Any(user => user.Username == newname))
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("rename", "0", "1", this._username, ((int)this._rank).ToString()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!new Regex(@"^[a-zA-Z0-9\ \-_\.]+$").IsMatch(newname) || newname.Length > 20 || newname.Length < 3)
|
||||||
|
{
|
||||||
|
if (this._username == null)
|
||||||
|
{
|
||||||
|
await this.AssignGuestUsername(this.vm);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.SendAsync(Guacutils.Encode("rename", "0", "2", _username, ((int)this._rank).ToString()));
|
||||||
|
}
|
||||||
|
this._username = newname;
|
||||||
|
if (oldname != null) this.Renamed.Invoke(this, oldname);
|
||||||
|
await SendAsync(Guacutils.Encode("rename", "0", "0", this._username, ((int)this._rank).ToString()));
|
||||||
|
if (oldname != null) Utilities.Log(LogLevel.INFO, $"Rename from {oldname} at {_ip.ToString()} to {newname}");
|
||||||
|
else Utilities.Log(LogLevel.INFO, $"{_ip.ToString()} connected as {newname}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendRename(string oldname, string newname, Rank rank)
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("rename", "1", oldname, newname, ((int)rank).ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendNewUser(string username, Rank rank)
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("adduser", "1", username, ((int)rank).ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendDisconnect(string username)
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("remuser", "1", username));
|
||||||
|
}
|
||||||
|
public async Task SendChat(string username, string message)
|
||||||
|
{
|
||||||
|
await SendAsync(Guacutils.Encode("chat", username, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendTurnUpdate(TurnStatus status)
|
||||||
|
{
|
||||||
|
List<string> msg = new();
|
||||||
|
msg.Add("turn");
|
||||||
|
msg.Add((status.TimeRemaining * 1000).ToString());
|
||||||
|
msg.Add(status.Queue.Length.ToString());
|
||||||
|
if (status.Queue.Length > 0)
|
||||||
|
{
|
||||||
|
foreach (User user in status.Queue)
|
||||||
|
msg.Add(user.Username!);
|
||||||
|
if (status.Queue[0] != this && status.Queue.Contains(this))
|
||||||
|
msg.Add(((status.TimeRemaining * 1000) + ((Array.IndexOf(status.Queue, this) - 1) * Program.Config.Turns.TurnTime * 1000 )).ToString());
|
||||||
|
}
|
||||||
|
await SendAsync(Guacutils.Encode(msg.ToArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendVoteUpdate(ResetVoteStatus status)
|
||||||
|
{
|
||||||
|
await this.SendAsync(Guacutils.Encode("vote", "1", (status.TimeRemaining * 1000).ToString(), status.YesVotes.Length.ToString(), status.NoVotes.Length.ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool HasTurn()
|
||||||
|
{
|
||||||
|
if (vm == null) return false;
|
||||||
|
return vm.TurnQueue.CurrentUser() == this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Mute(bool permanent)
|
||||||
|
{
|
||||||
|
if (vm == null) return;
|
||||||
|
this.IPData!.Mute(permanent);
|
||||||
|
vm.TurnQueue.RemoveUser(this);
|
||||||
|
await this.SendChat("", $"You have been muted{(permanent ? "" : $" for {Program.Config.Limits.TempMuteTime} seconds")}.");
|
||||||
|
}
|
||||||
|
}
|
99
collab-vm-server-1.3/Utilities.cs
Normal file
99
collab-vm-server-1.3/Utilities.cs
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public enum LogLevel
|
||||||
|
{
|
||||||
|
DEBUG,
|
||||||
|
INFO,
|
||||||
|
WARN,
|
||||||
|
ERROR,
|
||||||
|
FATAL
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Utilities
|
||||||
|
{
|
||||||
|
public static void Log(LogLevel level, string msg)
|
||||||
|
{
|
||||||
|
#if !DEBUG
|
||||||
|
if (level == LogLevel.DEBUG)
|
||||||
|
return;
|
||||||
|
#endif
|
||||||
|
StringBuilder logstr = new StringBuilder();
|
||||||
|
logstr.Append("[");
|
||||||
|
logstr.Append(DateTime.Now.ToString("G"));
|
||||||
|
logstr.Append("] [");
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.DEBUG:
|
||||||
|
logstr.Append("DEBUG");
|
||||||
|
break;
|
||||||
|
case LogLevel.INFO:
|
||||||
|
logstr.Append("INFO");
|
||||||
|
break;
|
||||||
|
case LogLevel.WARN:
|
||||||
|
logstr.Append("WARN");
|
||||||
|
break;
|
||||||
|
case LogLevel.ERROR:
|
||||||
|
logstr.Append("ERROR");
|
||||||
|
break;
|
||||||
|
case LogLevel.FATAL:
|
||||||
|
logstr.Append("FATAL");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new ArgumentException("Invalid log level");
|
||||||
|
}
|
||||||
|
logstr.Append("] ");
|
||||||
|
logstr.Append(msg);
|
||||||
|
switch (level)
|
||||||
|
{
|
||||||
|
case LogLevel.DEBUG:
|
||||||
|
case LogLevel.INFO:
|
||||||
|
Console.WriteLine(logstr.ToString());
|
||||||
|
break;
|
||||||
|
case LogLevel.WARN:
|
||||||
|
case LogLevel.ERROR:
|
||||||
|
case LogLevel.FATAL:
|
||||||
|
Console.Error.Write(logstr.ToString());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string[] ParseCommand(string cmd)
|
||||||
|
{
|
||||||
|
return new Regex("(?<=\")[^\"]*(?=\")|[^\" ]+")
|
||||||
|
.Matches(cmd)
|
||||||
|
.Select(m => m.Value)
|
||||||
|
.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string BytesToHex(byte[] buf)
|
||||||
|
{
|
||||||
|
StringBuilder outstr = new();
|
||||||
|
for (int i = 0; i < buf.Length; i++)
|
||||||
|
outstr.Append(buf[i].ToString("x2"));
|
||||||
|
return outstr.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Task ExecuteCommand(string cmd)
|
||||||
|
{
|
||||||
|
var proc = new Process();
|
||||||
|
proc.StartInfo.FileName = cmd.Split(" ")[0];
|
||||||
|
proc.StartInfo.Arguments = cmd.Substring(cmd.IndexOf(' ') + 1);
|
||||||
|
proc.StartInfo.UseShellExecute = true;
|
||||||
|
proc.StartInfo.CreateNoWindow = true;
|
||||||
|
proc.Start();
|
||||||
|
return proc.WaitForExitAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static byte[] GetAsset(string name)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Assets", name);
|
||||||
|
return File.ReadAllBytes(path);
|
||||||
|
}
|
||||||
|
}
|
255
collab-vm-server-1.3/VM.cs
Normal file
255
collab-vm-server-1.3/VM.cs
Normal file
|
@ -0,0 +1,255 @@
|
||||||
|
using System.Net;
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.PixelFormats;
|
||||||
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class VM
|
||||||
|
{
|
||||||
|
// This class is in charge of what CollabVM considers a "VM", meaning chat, turns, all that fun stuff.
|
||||||
|
// The management of the VM itself is done by a VMController, which this class also manages.
|
||||||
|
|
||||||
|
// Public properties and events
|
||||||
|
public VMController Controller { get; }
|
||||||
|
public VMConfig Config { get; }
|
||||||
|
public List<User> Users { get; } = new();
|
||||||
|
public CircularBuffer.CircularBuffer<ChatMessage> ChatHistory { get; } = new((int)Program.Config.Chat.ChatHistoryLength);
|
||||||
|
public TurnQueue TurnQueue { get; } = new(Program.Config.Turns.TurnTime);
|
||||||
|
public ResetVote? Vote { get; private set; }
|
||||||
|
public Dictionary<IPAddress, IPData> IPs { get; } = new();
|
||||||
|
public Cooldown VoteCooldown { get; } = new(Program.Config.Votes.VoteCooldown);
|
||||||
|
public bool TurnsAllowed { get; set; }
|
||||||
|
public bool ScreenHidden => screenHidden;
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private bool screenHidden = false;
|
||||||
|
|
||||||
|
public VM(VMConfig config, VMController controller)
|
||||||
|
{
|
||||||
|
this.Controller = controller;
|
||||||
|
this.Config = config;
|
||||||
|
this.TurnsAllowed = config.TurnsAllowed;
|
||||||
|
// Subscribe to the controller's events
|
||||||
|
this.Controller.Rect += ControllerOnRect;
|
||||||
|
this.Controller.ScreenSize += ControllerOnScreenSize;
|
||||||
|
TurnQueue.Turn += async (_, e) => await SendTurnQueue(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ControllerOnScreenSize(object? sender, Size e)
|
||||||
|
{
|
||||||
|
// Send the new size to all users
|
||||||
|
foreach (User user in this.Users)
|
||||||
|
{
|
||||||
|
if (screenHidden && user.Rank == Rank.Unregistered) continue;
|
||||||
|
user.SendScreenSize(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void ControllerOnRect(object? sender, DirtyRectData e)
|
||||||
|
{
|
||||||
|
// Encode the dirty rect as a JPEG
|
||||||
|
await using var ms = new MemoryStream();
|
||||||
|
Utilities.Log(LogLevel.DEBUG, $"Loading {e.Width}x{e.Height} rect with buffer size {e.Data.Length}");
|
||||||
|
Image<Rgba32> img;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
img = Image.LoadPixelData<Rgba32>(e.Data, e.Width, e.Height);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.ERROR, $"Failed to load {e.Width}x{e.Height} rect with buffer size {e.Data.Length}");
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
await img.SaveAsJpegAsync(ms);
|
||||||
|
var jpeg = ms.GetBuffer();
|
||||||
|
var jpg64 = Convert.ToBase64String(jpeg);
|
||||||
|
// Send the dirty rect to all users
|
||||||
|
foreach (User user in this.Users)
|
||||||
|
{
|
||||||
|
if (screenHidden && user.Rank == Rank.Unregistered) continue;
|
||||||
|
user.SendRect(jpg64, e.X, e.Y);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<byte[]> GetThumbnail()
|
||||||
|
{
|
||||||
|
if (screenHidden) return Program.ScreenHiddenThumb;
|
||||||
|
var fb = await this.Controller.GetFramebufferData();
|
||||||
|
Console.WriteLine($"got fb, size {Controller.FramebufferWidth}x{Controller.FramebufferHeight}");
|
||||||
|
var thmb = Image.LoadPixelData<Rgba32>(fb, Controller.FramebufferWidth, Controller.FramebufferHeight);
|
||||||
|
Console.WriteLine("loaded into image");
|
||||||
|
thmb.Mutate(ctx => ctx.Resize(400, 300));
|
||||||
|
await using var ms = new MemoryStream();
|
||||||
|
await thmb.SaveAsJpegAsync(ms);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<byte[]> GetFramebufferPixelData() => this.Controller.GetFramebufferData();
|
||||||
|
|
||||||
|
public async Task<byte[]> GetFramebufferJpeg()
|
||||||
|
{
|
||||||
|
var fb = await this.GetFramebufferPixelData();
|
||||||
|
var img = Image.LoadPixelData<Rgba32>(fb, Controller.FramebufferWidth, Controller.FramebufferHeight);
|
||||||
|
await using var ms = new MemoryStream();
|
||||||
|
await img.SaveAsJpegAsync(ms);
|
||||||
|
return ms.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Size GetScreenSize()
|
||||||
|
{
|
||||||
|
return new Size(this.Controller.FramebufferWidth, this.Controller.FramebufferHeight);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task AddUser(User user)
|
||||||
|
{
|
||||||
|
this.Users.Add(user);
|
||||||
|
if (IPs.TryGetValue(user.IP, out var p))
|
||||||
|
user.IPData = p;
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var ipd = new IPData();
|
||||||
|
IPs.Add(user.IP, ipd);
|
||||||
|
user.IPData = ipd;
|
||||||
|
}
|
||||||
|
|
||||||
|
user.IPData!.Unmuted += async (_, _) =>
|
||||||
|
{
|
||||||
|
// If the user is no longer connected, don't send the unmute
|
||||||
|
if (!this.Users.Contains(user)) return;
|
||||||
|
await user.SendChat("", "You are no longer muted.");
|
||||||
|
};
|
||||||
|
user.Renamed += async (_, e) =>
|
||||||
|
{
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
await u.SendRename(e, user.Username!, user.Rank);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
user.Disconnected += async (_, _) =>
|
||||||
|
{
|
||||||
|
this.Users.Remove(user);
|
||||||
|
this.TurnQueue.RemoveUser(user);
|
||||||
|
user.IPData.Reset();
|
||||||
|
if (this.Vote != null && (this.Vote.YesVotes.Contains(user) || this.Vote.NoVotes.Contains(user)))
|
||||||
|
{
|
||||||
|
this.Vote.ClearVote(user);
|
||||||
|
var status = this.Vote.GetStatus();
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
await u.SendVoteUpdate(status);
|
||||||
|
}
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
await u.SendDisconnect(user.Username!);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
await u.SendNewUser(user.Username!, user.Rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendChat(User user, string message, bool xss = false, bool excludeAdmins = false)
|
||||||
|
{
|
||||||
|
var messageSanitized = WebUtility.HtmlEncode(message);
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
if (xss && (u.Rank != Rank.Admin || !excludeAdmins))
|
||||||
|
await u.SendChat(user.Username!, message);
|
||||||
|
else
|
||||||
|
await u.SendChat(user.Username!, messageSanitized);
|
||||||
|
}
|
||||||
|
ChatHistory.PushFront(new ChatMessage {Username = user.Username!, Message = messageSanitized});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendMouse(int x, int y, int mask)
|
||||||
|
{
|
||||||
|
await this.Controller.SendMouse(x, y, mask);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendKey(int keysym, bool down)
|
||||||
|
{
|
||||||
|
await this.Controller.SendKeysym(keysym, down);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task ReannounceUser(User user)
|
||||||
|
{
|
||||||
|
foreach (var u in this.Users)
|
||||||
|
{
|
||||||
|
await u.SendNewUser(user.Username, user.Rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task SendTurnQueue(TurnStatus? s = null)
|
||||||
|
{
|
||||||
|
TurnStatus status = s ?? this.TurnQueue.CurrentTurn();
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
u.IPData!.IsTurning = status.Queue.Contains(u);
|
||||||
|
await u.SendTurnUpdate(status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task VoteReset(User user, bool vote)
|
||||||
|
{
|
||||||
|
if (this.Vote == null)
|
||||||
|
{
|
||||||
|
this.Vote = new ResetVote(Program.Config.Votes.VoteTime);
|
||||||
|
this.Vote.Finished += async (_, e) =>
|
||||||
|
{
|
||||||
|
this.Vote = null;
|
||||||
|
this.VoteCooldown.Run();
|
||||||
|
foreach (var u in Users)
|
||||||
|
{
|
||||||
|
u.IPData!.IsVoting = false;
|
||||||
|
await u.SendAsync(Guacutils.Encode("vote", "2"));
|
||||||
|
}
|
||||||
|
if (e.Result)
|
||||||
|
{
|
||||||
|
foreach (var u in Users)
|
||||||
|
await u.SendChat("", "The vote to reset the VM has won.");
|
||||||
|
await this.Controller.Reset();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
foreach (var u in Users)
|
||||||
|
await u.SendChat("", "The vote to reset the VM has lost.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
{
|
||||||
|
await u.SendAsync(Guacutils.Encode("vote", "0"));
|
||||||
|
await u.SendChat("", $"{user.Username} has started a vote to reset the VM.");
|
||||||
|
}
|
||||||
|
} else
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
await u.SendChat("", $"{user.Username} has voted {(vote ? "yes" : "no")}.");
|
||||||
|
this.Vote.Vote(user, vote);
|
||||||
|
var status = this.Vote.GetStatus();
|
||||||
|
foreach (User u in this.Users)
|
||||||
|
await u.SendVoteUpdate(status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task HideScreen(bool hidden)
|
||||||
|
{
|
||||||
|
if (hidden)
|
||||||
|
{
|
||||||
|
this.screenHidden = true;
|
||||||
|
foreach (User u in this.Users.Where(u => u.Rank == Rank.Unregistered))
|
||||||
|
{
|
||||||
|
await u.SendScreenSize(new Size(1024, 768));
|
||||||
|
await u.SendRect(Program.ScreenHiddenBase64, 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this.screenHidden = false;
|
||||||
|
foreach (User u in this.Users.Where(u => u.Rank == Rank.Unregistered))
|
||||||
|
{
|
||||||
|
await u.SendScreenSize(GetScreenSize());
|
||||||
|
await u.SendRect(Convert.ToBase64String(await GetFramebufferJpeg()), 0, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
24
collab-vm-server-1.3/VMController.cs
Normal file
24
collab-vm-server-1.3/VMController.cs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public abstract class VMController
|
||||||
|
{
|
||||||
|
public abstract event EventHandler<DirtyRectData> Rect;
|
||||||
|
public abstract event EventHandler<Size> ScreenSize;
|
||||||
|
|
||||||
|
public abstract int FramebufferWidth { get; }
|
||||||
|
public abstract int FramebufferHeight { get; }
|
||||||
|
public abstract bool IsRunning { get; }
|
||||||
|
|
||||||
|
public abstract bool Snapshots { get; }
|
||||||
|
public abstract Task Start();
|
||||||
|
public abstract Task Stop();
|
||||||
|
public abstract Task Reboot();
|
||||||
|
public abstract Task Reset();
|
||||||
|
public abstract Task<string> MonitorCommand(string cmd);
|
||||||
|
|
||||||
|
public abstract Task<byte[]> GetFramebufferData();
|
||||||
|
public abstract Task SendKeysym(int keysym, bool down);
|
||||||
|
public abstract Task SendMouse(int x, int y, int mask);
|
||||||
|
}
|
293
collab-vm-server-1.3/VMControllers/QEMUController.cs
Normal file
293
collab-vm-server-1.3/VMControllers/QEMUController.cs
Normal file
|
@ -0,0 +1,293 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.Net;
|
||||||
|
using System.Net.Sockets;
|
||||||
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Nodes;
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
using QMPSharp;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
using Timer = System.Timers.Timer;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.VMControllers;
|
||||||
|
|
||||||
|
public class QEMUController : VMController
|
||||||
|
{
|
||||||
|
public override event EventHandler<DirtyRectData> Rect;
|
||||||
|
public override event EventHandler<Size> ScreenSize;
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private string _id;
|
||||||
|
private readonly string _qemuPath;
|
||||||
|
private readonly string[] _qemuArgs;
|
||||||
|
private readonly int _vncPort;
|
||||||
|
private readonly int _qmpPort;
|
||||||
|
private readonly string? _qmpSocket;
|
||||||
|
private Process? _qemuProc;
|
||||||
|
private QMPClient? _qmp;
|
||||||
|
private VNCDisplay _vnc;
|
||||||
|
private int _width;
|
||||||
|
private int _height;
|
||||||
|
private bool _running = false;
|
||||||
|
private bool _expectedExit = false;
|
||||||
|
// Restart/reconnect timers
|
||||||
|
private Timer? _qemuRestartTimer;
|
||||||
|
private Timer _qmpReconnectTimer;
|
||||||
|
// Restart/reconnect error levels
|
||||||
|
private int _qemuRestartErrorLevel = 0;
|
||||||
|
private int _qmpReconnectErrorLevel = 0;
|
||||||
|
public override int FramebufferWidth => _width;
|
||||||
|
public override int FramebufferHeight => _height;
|
||||||
|
public override bool IsRunning => _running;
|
||||||
|
public override bool Snapshots { get; }
|
||||||
|
|
||||||
|
public QEMUController(string id, QEMUVMConfig config)
|
||||||
|
{
|
||||||
|
this._id = id;
|
||||||
|
this._vncPort = config.VNCPort;
|
||||||
|
this.Snapshots = config.Snapshots;
|
||||||
|
this._vnc = new VNCDisplay("127.0.0.1", _vncPort);
|
||||||
|
this._vnc.Rect += VncOnRect;
|
||||||
|
this._vnc.SizeChanged += VncOnSizeChanged;
|
||||||
|
if (config.UseUnixSockets)
|
||||||
|
{
|
||||||
|
this._qmpSocket = (config.QMPSocketDir ?? "/tmp") + $"/collab-vm-qmp-{id}.sock";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
this._qmpPort = config.QMPPort;
|
||||||
|
}
|
||||||
|
var cmd = Utilities.ParseCommand(config.QEMUCmd);
|
||||||
|
this._qemuPath = cmd[0];
|
||||||
|
var cvmArgs = new List<string>();
|
||||||
|
cvmArgs.Add("-no-shutdown");
|
||||||
|
cvmArgs.Add("-vnc");
|
||||||
|
cvmArgs.Add("127.0.0.1:" + (_vncPort - 5900));
|
||||||
|
cvmArgs.Add("-qmp");
|
||||||
|
if (config.UseUnixSockets)
|
||||||
|
{
|
||||||
|
cvmArgs.Add("unix:" + _qmpSocket + ",server,nowait");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
cvmArgs.Add("tcp:127.0.0.1:" + _qmpPort + ",server,nowait");
|
||||||
|
}
|
||||||
|
if (config.Snapshots)
|
||||||
|
{
|
||||||
|
cvmArgs.Add("-snapshot");
|
||||||
|
}
|
||||||
|
this._qemuArgs = cmd.Skip(1).Concat(cvmArgs).ToArray();
|
||||||
|
_qmpReconnectTimer = new(5000);
|
||||||
|
_qmpReconnectTimer.AutoReset = false;
|
||||||
|
_qmpReconnectTimer.Elapsed += async (_, _) => await ConnectQMP();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VncOnSizeChanged(object? sender, MarcusW.VncClient.Size e)
|
||||||
|
{
|
||||||
|
// Don't trigger a framebuffer reset if the server misbehaves and sends an identical size instruction
|
||||||
|
if (e.Width == this._width && e.Height == this._height) return;
|
||||||
|
this.ScreenSize.Invoke(this, new Size(e.Width, e.Height));
|
||||||
|
this._width = e.Width;
|
||||||
|
this._height = e.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VncOnRect(object? sender, DirtyRectData e)
|
||||||
|
{
|
||||||
|
this.Rect.Invoke(this, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Start()
|
||||||
|
{
|
||||||
|
_expectedExit = false;
|
||||||
|
// Make sure the QMP socket doesn't exist
|
||||||
|
if (_qmpSocket != null && File.Exists(_qmpSocket))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.Delete(_qmpSocket);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.FATAL, "Failed to delete QMP socket: " + ex.Message);
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Create the QEMU process
|
||||||
|
_qemuProc?.Dispose();
|
||||||
|
_qemuProc = new Process();
|
||||||
|
_qemuProc.StartInfo.FileName = _qemuPath;
|
||||||
|
foreach (string arg in _qemuArgs)
|
||||||
|
_qemuProc.StartInfo.ArgumentList.Add(arg);
|
||||||
|
_qemuProc.StartInfo.UseShellExecute = false;
|
||||||
|
_qemuProc.StartInfo.RedirectStandardOutput = true;
|
||||||
|
_qemuProc.StartInfo.RedirectStandardError = true;
|
||||||
|
_qemuProc.StartInfo.RedirectStandardInput = true;
|
||||||
|
_qemuProc.StartInfo.CreateNoWindow = true;
|
||||||
|
_qemuProc.EnableRaisingEvents = true;
|
||||||
|
_qemuProc.OutputDataReceived += QemuProcOnOutputDataReceived;
|
||||||
|
_qemuProc.ErrorDataReceived += QemuProcOnErrorDataReceived;
|
||||||
|
_qemuProc.Exited += QemuProcOnExited;
|
||||||
|
_qemuProc.Start();
|
||||||
|
// Give QEMU 2 seconds to start up
|
||||||
|
await Task.Delay(2000);
|
||||||
|
// Connect to QMP
|
||||||
|
await ConnectQMP();
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] QMP Connected");
|
||||||
|
_qemuRestartErrorLevel = 0;
|
||||||
|
_qmpReconnectErrorLevel = 0;
|
||||||
|
// Connect VNC
|
||||||
|
await this._vnc.Connect();
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] VNC Connected");
|
||||||
|
this._running = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConnectQMP()
|
||||||
|
{
|
||||||
|
_qmp?.Dispose();
|
||||||
|
Socket socket;
|
||||||
|
EndPoint endpoint;
|
||||||
|
if (this._qmpSocket != null)
|
||||||
|
{
|
||||||
|
socket = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.IP);
|
||||||
|
endpoint = new UnixDomainSocketEndPoint(_qmpSocket);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
|
||||||
|
endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), this._qmpPort);
|
||||||
|
}
|
||||||
|
this._qmp = new QMPClient(socket, endpoint);
|
||||||
|
this._qmp.QMPEventReceived += QmpOnQMPEventReceived;
|
||||||
|
this._qmp.Disconnected += QmpOnDisconnected;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await this._qmp.ConnectAsync();
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.ERROR, $"[{_id}] Failed to connect to QMP: {e.Message}");
|
||||||
|
if (_qmpReconnectErrorLevel > 5)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.ERROR, "QMP reconnect failed too many times, restarting QEMU...");
|
||||||
|
await StopQEMU();
|
||||||
|
Start();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_qmpReconnectErrorLevel++;
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] Reconnecting in 5 seconds...");
|
||||||
|
_qmpReconnectTimer.Start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QmpOnDisconnected(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.ERROR, $"[{_id}] QMP disconnected.");
|
||||||
|
if (_expectedExit) return;
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] Reconnecting in 5 seconds...");
|
||||||
|
_qmpReconnectTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void QmpOnQMPEventReceived(object? sender, QMPEvent e)
|
||||||
|
{
|
||||||
|
switch (e.Event)
|
||||||
|
{
|
||||||
|
case "STOP":
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] VM was shutdown, rebooting...");
|
||||||
|
await _qmp.Reboot();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "RESET":
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] Got QEMU reset event");
|
||||||
|
await _qmp.Resume();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void QemuProcOnExited(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_expectedExit) return;
|
||||||
|
Utilities.Log(LogLevel.ERROR, $"[{_id}] QEMU exited, restarting in 5 seconds...");
|
||||||
|
// Disconnect QMP and VNC
|
||||||
|
this._qmp?.Dispose();
|
||||||
|
await this._vnc.Disconnect();
|
||||||
|
// Schedule a restart
|
||||||
|
_qemuRestartTimer?.Dispose();
|
||||||
|
_qemuRestartTimer = new Timer(5000);
|
||||||
|
_qemuRestartTimer.AutoReset = false;
|
||||||
|
_qemuRestartTimer.Elapsed += async (_, _) =>
|
||||||
|
{
|
||||||
|
await this.Start();
|
||||||
|
};
|
||||||
|
_qemuRestartTimer.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QemuProcOnErrorDataReceived(object sender, DataReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.WARN, $"[{_id}] QEMU sent to stderr: {e.Data}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void QemuProcOnOutputDataReceived(object sender, DataReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
Utilities.Log(LogLevel.INFO, $"[{_id}] QEMU sent to stdout: {e.Data}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Stop()
|
||||||
|
{
|
||||||
|
_running = false;
|
||||||
|
await StopQEMU();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task StopQEMU()
|
||||||
|
{
|
||||||
|
_expectedExit = true;
|
||||||
|
if (this._qemuProc == null || this._qemuProc.HasExited) return;
|
||||||
|
await this._vnc.Disconnect();
|
||||||
|
if (_qmp == null || _qmp.Disposed)
|
||||||
|
{
|
||||||
|
_qemuProc.Kill();
|
||||||
|
await this._qemuProc.WaitForExitAsync();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this._qmp.SendAsync(new JsonObject { { "execute", "quit" } });
|
||||||
|
var tmr = new Timer(5000);
|
||||||
|
tmr.AutoReset = false;
|
||||||
|
tmr.Elapsed += (_, _) =>
|
||||||
|
{
|
||||||
|
if (_qemuProc.HasExited) return;
|
||||||
|
Utilities.Log(LogLevel.WARN, $"[{_id}] QEMU took too long to exit, killing...");
|
||||||
|
_qemuProc.Kill();
|
||||||
|
};
|
||||||
|
tmr.Start();
|
||||||
|
await this._qemuProc.WaitForExitAsync();
|
||||||
|
tmr.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task Reboot() => this._qmp.Reboot();
|
||||||
|
|
||||||
|
public override async Task Reset()
|
||||||
|
{
|
||||||
|
if (!Snapshots) return;
|
||||||
|
await this.StopQEMU();
|
||||||
|
await this.Start();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task<string> MonitorCommand(string cmd)
|
||||||
|
{
|
||||||
|
var response = await _qmp.HumanMonitorCommand(cmd);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<byte[]> GetFramebufferData()
|
||||||
|
{
|
||||||
|
return Task.Run(byte[] () => _vnc.GetFramebufferData());
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task SendKeysym(int keysym, bool down) => _vnc.SendKeysym(keysym, down);
|
||||||
|
|
||||||
|
public override Task SendMouse(int x, int y, int mask) => _vnc.SendMouse(x, y, mask);
|
||||||
|
}
|
79
collab-vm-server-1.3/VMControllers/VNCController.cs
Normal file
79
collab-vm-server-1.3/VMControllers/VNCController.cs
Normal file
|
@ -0,0 +1,79 @@
|
||||||
|
using CollabVM.Server.DisplayControllers.VNC;
|
||||||
|
using SixLabors.ImageSharp;
|
||||||
|
|
||||||
|
namespace CollabVM.Server.VMControllers;
|
||||||
|
|
||||||
|
public class VNCController : VMController
|
||||||
|
{
|
||||||
|
private string host;
|
||||||
|
private int port;
|
||||||
|
private VNCDisplay vnc;
|
||||||
|
private int width;
|
||||||
|
private int height;
|
||||||
|
|
||||||
|
public VNCController(string host, int port)
|
||||||
|
{
|
||||||
|
this.host = host;
|
||||||
|
this.port = port;
|
||||||
|
this.vnc = new VNCDisplay(host, port);
|
||||||
|
this.vnc.Rect += VncOnRect;
|
||||||
|
this.vnc.SizeChanged += VncOnSizeChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VncOnSizeChanged(object? sender, MarcusW.VncClient.Size e)
|
||||||
|
{
|
||||||
|
// Don't trigger a framebuffer reset if the server misbehaves and sends an identical size instruction
|
||||||
|
if (e.Width == this.width && e.Height == this.height) return;
|
||||||
|
this.ScreenSize.Invoke(this, new Size(e.Width, e.Height));
|
||||||
|
this.width = e.Width;
|
||||||
|
this.height = e.Height;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void VncOnRect(object? sender, DirtyRectData e)
|
||||||
|
{
|
||||||
|
this.Rect.Invoke(this, e);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override event EventHandler<DirtyRectData> Rect;
|
||||||
|
public override event EventHandler<Size> ScreenSize;
|
||||||
|
public override int FramebufferWidth => this.width;
|
||||||
|
public override int FramebufferHeight => this.height;
|
||||||
|
public override bool IsRunning => true;
|
||||||
|
public override bool Snapshots => false;
|
||||||
|
public override async Task Start()
|
||||||
|
{
|
||||||
|
await this.vnc.Connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Stop()
|
||||||
|
{
|
||||||
|
await this.vnc.Disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Reboot()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task Reset()
|
||||||
|
{
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public override Task<string> MonitorCommand(string cmd) => Task.FromResult("This VM does not have a monitor.");
|
||||||
|
|
||||||
|
public override async Task<byte[]> GetFramebufferData()
|
||||||
|
{
|
||||||
|
return vnc.GetFramebufferData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task SendKeysym(int keysym, bool down)
|
||||||
|
{
|
||||||
|
await vnc.SendKeysym(keysym, down);
|
||||||
|
}
|
||||||
|
|
||||||
|
public override async Task SendMouse(int x, int y, int mask)
|
||||||
|
{
|
||||||
|
await vnc.SendMouse(x, y, mask);
|
||||||
|
}
|
||||||
|
}
|
69
collab-vm-server-1.3/VMManager.cs
Normal file
69
collab-vm-server-1.3/VMManager.cs
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
using CollabVM.Server.Config;
|
||||||
|
using CollabVM.Server.VMControllers;
|
||||||
|
|
||||||
|
namespace CollabVM.Server;
|
||||||
|
|
||||||
|
public class VMManager
|
||||||
|
{
|
||||||
|
// This class is in charge of managing all the VMs
|
||||||
|
|
||||||
|
// Private fields
|
||||||
|
private readonly List<VM> VMs = new();
|
||||||
|
|
||||||
|
public VMManager(VMConfig[] vms)
|
||||||
|
{
|
||||||
|
// Create the VMs
|
||||||
|
foreach (VMConfig vm in vms)
|
||||||
|
{
|
||||||
|
if (vm.VNC != null)
|
||||||
|
{
|
||||||
|
VMs.Add(new VM(vm, new VNCController(vm.VNC.Host, vm.VNC.Port)));
|
||||||
|
}
|
||||||
|
else if (vm.QEMU != null)
|
||||||
|
{
|
||||||
|
VMs.Add(new VM(vm, new QEMUController(vm.ID, vm.QEMU)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StartAll()
|
||||||
|
{
|
||||||
|
List<Task> tsks = new();
|
||||||
|
foreach (VM vm in VMs)
|
||||||
|
{
|
||||||
|
tsks.Add(vm.Controller.Start());
|
||||||
|
}
|
||||||
|
await Task.WhenAll(tsks);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task StopAll()
|
||||||
|
{
|
||||||
|
foreach (VM vm in VMs)
|
||||||
|
{
|
||||||
|
await vm.Controller.Stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public VM? GetVM(string id)
|
||||||
|
{
|
||||||
|
return VMs.Find(vm => vm.Config.ID == id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ListVM[]> GetList()
|
||||||
|
{
|
||||||
|
List<ListVM> list = new();
|
||||||
|
foreach (VM vm in VMs)
|
||||||
|
{
|
||||||
|
if (!vm.Controller.IsRunning) continue;
|
||||||
|
var thumb = await vm.GetThumbnail();
|
||||||
|
Console.WriteLine("got thumbnail");
|
||||||
|
list.Add(new ListVM()
|
||||||
|
{
|
||||||
|
ID = vm.Config.ID,
|
||||||
|
Name = vm.Config.Name,
|
||||||
|
Thumbnail = thumb
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return list.ToArray();
|
||||||
|
}
|
||||||
|
}
|
34
collab-vm-server-1.3/collab-vm-server-1.3.csproj
Normal file
34
collab-vm-server-1.3/collab-vm-server-1.3.csproj
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
|
<RootNamespace>CollabVM.Server</RootNamespace>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<AssemblyName>CollabVM.Server</AssemblyName>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="CircularBuffer" Version="1.3.0" />
|
||||||
|
<PackageReference Include="MySqlConnector" Version="2.3.3" />
|
||||||
|
<PackageReference Include="Samboy063.Tomlet" Version="5.2.0" />
|
||||||
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.1" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\MarcusW.VncClient\src\MarcusW.VncClient\MarcusW.VncClient.csproj" />
|
||||||
|
<ProjectReference Include="..\QMPSharp\QMPSharp\QMPSharp.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="Assets\screenhidden.jpeg">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
<None Update="Assets\screenhiddenthumb.jpeg">
|
||||||
|
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
119
config.toml
Normal file
119
config.toml
Normal file
|
@ -0,0 +1,119 @@
|
||||||
|
[HTTP]
|
||||||
|
# The host for the HTTP server to bind to.
|
||||||
|
# If you're reverse proxying behind nginx, this should probably be 127.0.0.1
|
||||||
|
# otherwise, 0.0.0.0 will open it to the internet.
|
||||||
|
Host = "0.0.0.0"
|
||||||
|
# Port for the HTTP server to listen on
|
||||||
|
Port = 6004
|
||||||
|
# Set to true if you will be proxying your VMs behing a reverse proxy, like NGINX.
|
||||||
|
# This is required for UserVMs.
|
||||||
|
ReverseProxy = false
|
||||||
|
# IPs allowed to reverse proxy your VMs. 99% of the time, this will just be 127.0.0.1
|
||||||
|
ProxyAllowedIPs = ["127.0.0.1"]
|
||||||
|
# Set to true to whitelist certain webapps from connecting to your VMs.
|
||||||
|
OriginCheck = false
|
||||||
|
# List of domains allowed to host webapps that connect to your VMs.
|
||||||
|
AllowedOrigins = ["https://computernewb.com", "http://localhost:3000"]
|
||||||
|
|
||||||
|
[Turns]
|
||||||
|
# How long each turn is
|
||||||
|
TurnTime = 20
|
||||||
|
|
||||||
|
[Votes]
|
||||||
|
# How long a vote to reset lasts
|
||||||
|
VoteTime = 30
|
||||||
|
# The amount of time before another vote to reset can be started
|
||||||
|
VoteCooldown = 120
|
||||||
|
|
||||||
|
[Chat]
|
||||||
|
# Maximum length for chat messages. Messages above this length will be truncated
|
||||||
|
MaxMessageLength = 100
|
||||||
|
# The max amount of messages to store in the chat history and send to new clients, before old messages are deleted
|
||||||
|
ChatHistoryLength = 10
|
||||||
|
|
||||||
|
[Staff]
|
||||||
|
# Password hashes can be generated with the following command:
|
||||||
|
# echo -n '<password>' | sha256sum -
|
||||||
|
# SHA256 Hash of the Admin password. (Default: hunter2)
|
||||||
|
AdminPasswordHash = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"
|
||||||
|
# If the moderator role is enabled
|
||||||
|
ModeratorEnabled = true
|
||||||
|
# SHA256 Hash of the Mod password. (Default: hunter3)
|
||||||
|
ModPasswordHash = "fb8c2e2b85ca81eb4350199faddd983cb26af3064614e737ea9f479621cfa57a"
|
||||||
|
|
||||||
|
[Bans]
|
||||||
|
# If set to true, the server will store, track, and enforce bans in the mysql database.
|
||||||
|
# Requires mysql to be defined
|
||||||
|
UseInternalBlacklist = true
|
||||||
|
# If set, the server will run this command whenever a user is banned
|
||||||
|
# $IP - The IP of the banned user
|
||||||
|
# $NAME - Username of the banned user
|
||||||
|
# $REASON - Optional ban reason
|
||||||
|
#RunCommand = ""
|
||||||
|
|
||||||
|
[Limits]
|
||||||
|
# How long temporary mutes last
|
||||||
|
TempMuteTime = 30
|
||||||
|
# How many messages may be sent within the specified period of time before the user is temporarily muted
|
||||||
|
ChatLimit = { Enabled = true, Limit = 5, Cooldown = 5 }
|
||||||
|
# How many mouse and keyboard instructions may be sent within the specified period of time before the user is disconnected ("kit protection")
|
||||||
|
KitLimit = { Enabled = true, Limit = 700, Cooldown = 1 }
|
||||||
|
|
||||||
|
# Defines a MySQL server to connect to.
|
||||||
|
# This is only required if you are using the internal banlist, and may be commented otherwise
|
||||||
|
[MySQL]
|
||||||
|
Host = "127.0.0.1"
|
||||||
|
Username = "collabvm"
|
||||||
|
Password = "hunter2"
|
||||||
|
Database = "collabvm"
|
||||||
|
|
||||||
|
# Defines permissions moderators have. May be commented if moderators are not enabled
|
||||||
|
[ModPermissions]
|
||||||
|
# Restore the VM to snapshot
|
||||||
|
Restore = true
|
||||||
|
# Reboot the VM
|
||||||
|
Reboot = true
|
||||||
|
# Ban a user
|
||||||
|
Ban = true
|
||||||
|
# Forcibly end a vote-for-reset
|
||||||
|
ForceVote = true
|
||||||
|
# Mute a user
|
||||||
|
Mute = true
|
||||||
|
# Kick a user
|
||||||
|
Kick = true
|
||||||
|
# Manipulate the turn queue (toggle turns, end turns, steal turn, clear turn queue)
|
||||||
|
BypassTurn = true
|
||||||
|
# Rename a user
|
||||||
|
Rename = true
|
||||||
|
# Get a user's IP
|
||||||
|
GrabIP = true
|
||||||
|
# Send an XSS (not HTML sanitized) message
|
||||||
|
XSS = true
|
||||||
|
# Hide the screen from all non-staff
|
||||||
|
HideScreen = true
|
||||||
|
|
||||||
|
# The following section defines a VM. This section may be duplicated for any additional VMs.
|
||||||
|
[[VMs]]
|
||||||
|
# Node ID of the VM. Must be unique
|
||||||
|
ID = "examplevm"
|
||||||
|
# DIsplay name for the VM. Formatted with HTML
|
||||||
|
Name = "Test VM"
|
||||||
|
# Message of the day, sent when a user joins the VM
|
||||||
|
MOTD = "Welcome"
|
||||||
|
# Now you may configure a VM controller by uncommenting one of the below...
|
||||||
|
|
||||||
|
# For a VM that simply connects to a VNC server,
|
||||||
|
#VNC = {Host = "127.0.0.1", Port = 5901}
|
||||||
|
|
||||||
|
# A QEMU VM.
|
||||||
|
# QEMUCmd - QEMU start command. The pash to the QEMU executable MUST be specified in full.
|
||||||
|
# UseUnixSockets - Use UNIX domain sockets. Only available on Linux. Strongly recommended if available.
|
||||||
|
# QMPSocketDir - Path where the QMP socket is stored. Defaults to /tmp if not specified. 99% of the time you do not need to specify this
|
||||||
|
# QMPPort - If UseUnixSockets is disabled, a port for the QMP server to listen on. Required on windows
|
||||||
|
# VNCPort - Port to use for the VNC server. Must be at least 5900 and must be unique among other VMs.
|
||||||
|
# Snapshots - True if the VM state is temporary, and may be reset through a vote. If you set this to false on a public VM, prepare for it to get trashed quick.
|
||||||
|
QEMU = {QEMUCmd = "/bin/qemu-system-x86_64 -accel kvm -cpu host -smp cores=4 -m 4G", UseUnixSockets = true, VNCPort = 5900, Snapshots = true}
|
||||||
|
# True if the public can take turns, false to limit to staff and those with the turn password. This can be toggled at runtime with opcode 22
|
||||||
|
TurnsAllowed = true
|
||||||
|
# If defined, the hash for a password non-staff can use to take turns while they are disabled. (Default: hunter4)
|
||||||
|
TurnPasswordHash = "18183dd9009f2b7e1b44f9c4af287589c2415bc6258f547815b246ffeb955122"
|
Loading…
Reference in a new issue