about as ready as its gonna fucking get
This commit is contained in:
commit
574be6faee
40 changed files with 3383 additions and 0 deletions
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