commit 574be6faee85731a234dd54494d1d33004f42961 Author: Elijah R Date: Wed Jan 3 17:29:02 2024 -0500 about as ready as its gonna fucking get diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a255f99 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..9b94b09 --- /dev/null +++ b/.gitmodules @@ -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 diff --git a/MarcusW.VncClient b/MarcusW.VncClient new file mode 160000 index 0000000..71fa923 --- /dev/null +++ b/MarcusW.VncClient @@ -0,0 +1 @@ +Subproject commit 71fa923ce24a49ac48111915852c5953f5363373 diff --git a/QMPSharp b/QMPSharp new file mode 160000 index 0000000..f0de933 --- /dev/null +++ b/QMPSharp @@ -0,0 +1 @@ +Subproject commit f0de933380c0c6488306d04b340f9a1599a4d217 diff --git a/collab-vm-server-1.3.sln b/collab-vm-server-1.3.sln new file mode 100644 index 0000000..6059e27 --- /dev/null +++ b/collab-vm-server-1.3.sln @@ -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 diff --git a/collab-vm-server-1.3/Assets/screenhidden.jpeg b/collab-vm-server-1.3/Assets/screenhidden.jpeg new file mode 100644 index 0000000..c02988a Binary files /dev/null and b/collab-vm-server-1.3/Assets/screenhidden.jpeg differ diff --git a/collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg b/collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg new file mode 100644 index 0000000..2679319 Binary files /dev/null and b/collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg differ diff --git a/collab-vm-server-1.3/ChatMessage.cs b/collab-vm-server-1.3/ChatMessage.cs new file mode 100644 index 0000000..21f2314 --- /dev/null +++ b/collab-vm-server-1.3/ChatMessage.cs @@ -0,0 +1,7 @@ +namespace CollabVM.Server; + +public class ChatMessage +{ + public string Username { get; set; } + public string Message { get; set; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Cooldown.cs b/collab-vm-server-1.3/Cooldown.cs new file mode 100644 index 0000000..48af963 --- /dev/null +++ b/collab-vm-server-1.3/Cooldown.cs @@ -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(); + } + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Database.cs b/collab-vm-server-1.3/Database.cs new file mode 100644 index 0000000..87eebd1 --- /dev/null +++ b/collab-vm-server-1.3/Database.cs @@ -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 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; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/DirtyRectData.cs b/collab-vm-server-1.3/DirtyRectData.cs new file mode 100644 index 0000000..13fee65 --- /dev/null +++ b/collab-vm-server-1.3/DirtyRectData.cs @@ -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 +{ + /// + /// The data of the dirty rect represented in RGBA32 format. + /// + public byte[] Data { get; set; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/RectBatcher.cs b/collab-vm-server-1.3/Display/RectBatcher.cs new file mode 100644 index 0000000..7156494 --- /dev/null +++ b/collab-vm-server-1.3/Display/RectBatcher.cs @@ -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 Rect; + private List Rects; + private Timer timer; + private Func GrabRect; + + public void AddRect(DirtyRect rect) + { + Rects.Add(rect); + } + + public RectBatcher(Func 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 + }); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs b/collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs new file mode 100644 index 0000000..bc1db38 --- /dev/null +++ b/collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs @@ -0,0 +1,61 @@ +using Microsoft.Extensions.Logging; + +namespace CollabVM.Server.DisplayControllers.VNC; + +public class CollabVMLogger : ILogger +{ + public IDisposable? BeginScope(TState state) where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) + { + return true; + } + + public void Log(Microsoft.Extensions.Logging.LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func 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(); + } +} + +/// +/// Represents an empty logging scope without any logic. +/// +public class NullScope : IDisposable +{ + /// + /// Gets the default instance of the . + /// + public static NullScope Instance { get; } = new NullScope(); + + private NullScope() { } + + /// + public void Dispose() { } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs b/collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs new file mode 100644 index 0000000..2ae2ebd --- /dev/null +++ b/collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs @@ -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 ProvideAuthenticationInputAsync(RfbConnection connection, ISecurityType securityType, + IAuthenticationInputRequest request) where TInput : class, IAuthenticationInput + { + // For now, we only support passwordless authentication + // MAYBE TODO: Implement password authentication + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/VNC/VNCDisplay.cs b/collab-vm-server-1.3/Display/VNC/VNCDisplay.cs new file mode 100644 index 0000000..eaf9222 --- /dev/null +++ b/collab-vm-server-1.3/Display/VNC/VNCDisplay.cs @@ -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 Rect; + public event EventHandler 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)); + } + + +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs b/collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs new file mode 100644 index 0000000..04175d7 --- /dev/null +++ b/collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs @@ -0,0 +1,35 @@ +using MarcusW.VncClient; +using MarcusW.VncClient.Rendering; + +namespace CollabVM.Server.DisplayControllers.VNC; + +public class VNCFramebufferReference : IFramebufferRectangleReference +{ + public event EventHandler 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); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs b/collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs new file mode 100644 index 0000000..18fc89e --- /dev/null +++ b/collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs @@ -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 Updated; + public event EventHandler SizeChanged; + + public VNCRenderTarget() + { + framebuffer = IntPtr.Zero; + width = 0; + height = 0; + } + public IFramebufferReference GrabFramebufferReference(Size size, IImmutableSet 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; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Guacutils.cs b/collab-vm-server-1.3/Guacutils.cs new file mode 100644 index 0000000..0f9d2e5 --- /dev/null +++ b/collab-vm-server-1.3/Guacutils.cs @@ -0,0 +1,46 @@ +using System.Text; + +namespace CollabVM.Server; + +/// +/// Utilities for converting lists of strings to and from Guacamole format +/// +public static class Guacutils { + /// + /// Encode an array of strings to guacamole format + /// + /// List of strings to be encoded + /// A guacamole string array containing the provided strings + 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(); + } + /// + /// Decode a guacamole string array + /// + /// String containing a guacamole array + /// An array of strings + public static string[] Decode(string msg) { + List outArr = new List(); + 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(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/HTTPServer.cs b/collab-vm-server-1.3/HTTPServer.cs new file mode 100644 index 0000000..4bd63ee --- /dev/null +++ b/collab-vm-server-1.3/HTTPServer.cs @@ -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 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(); + 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(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/IConfig.cs b/collab-vm-server-1.3/IConfig.cs new file mode 100644 index 0000000..cd0fa6d --- /dev/null +++ b/collab-vm-server-1.3/IConfig.cs @@ -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; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/IPData.cs b/collab-vm-server-1.3/IPData.cs new file mode 100644 index 0000000..819d33a --- /dev/null +++ b/collab-vm-server-1.3/IPData.cs @@ -0,0 +1,60 @@ +using Timer = System.Timers.Timer; + +namespace CollabVM.Server; + +/// +/// Data about a user's IP address, used to prevent multiple votes or turns from the same IP address +/// +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(); + } + + /// + /// Resets properties that are not persistent across user disconnects + /// + 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; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/ListVM.cs b/collab-vm-server-1.3/ListVM.cs new file mode 100644 index 0000000..ef5f137 --- /dev/null +++ b/collab-vm-server-1.3/ListVM.cs @@ -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; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/MuteStatus.cs b/collab-vm-server-1.3/MuteStatus.cs new file mode 100644 index 0000000..f48bf7e --- /dev/null +++ b/collab-vm-server-1.3/MuteStatus.cs @@ -0,0 +1,8 @@ +namespace CollabVM.Server; + +public enum MuteStatus +{ + None, + Temporary, + Permanent +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Permissions.cs b/collab-vm-server-1.3/Permissions.cs new file mode 100644 index 0000000..38424b7 --- /dev/null +++ b/collab-vm-server-1.3/Permissions.cs @@ -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; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Program.cs b/collab-vm-server-1.3/Program.cs new file mode 100644 index 0000000..e9880b5 --- /dev/null +++ b/collab-vm-server-1.3/Program.cs @@ -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(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(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Rank.cs b/collab-vm-server-1.3/Rank.cs new file mode 100644 index 0000000..f510dfb --- /dev/null +++ b/collab-vm-server-1.3/Rank.cs @@ -0,0 +1,8 @@ +namespace CollabVM.Server; + +public enum Rank +{ + Unregistered = 0, + Admin = 2, + Moderator = 3, +} \ No newline at end of file diff --git a/collab-vm-server-1.3/RateLimiter.cs b/collab-vm-server-1.3/RateLimiter.cs new file mode 100644 index 0000000..4d7f438 --- /dev/null +++ b/collab-vm-server-1.3/RateLimiter.cs @@ -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; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/ResetVote.cs b/collab-vm-server-1.3/ResetVote.cs new file mode 100644 index 0000000..2ce0bcc --- /dev/null +++ b/collab-vm-server-1.3/ResetVote.cs @@ -0,0 +1,79 @@ +using System.Timers; +using Timer = System.Timers.Timer; + +namespace CollabVM.Server; + +public class ResetVote +{ + private readonly List _yesVotes; + private readonly List _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 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 + }; + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/ResetVoteStatus.cs b/collab-vm-server-1.3/ResetVoteStatus.cs new file mode 100644 index 0000000..1cc48df --- /dev/null +++ b/collab-vm-server-1.3/ResetVoteStatus.cs @@ -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; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/TurnQueue.cs b/collab-vm-server-1.3/TurnQueue.cs new file mode 100644 index 0000000..a8b929a --- /dev/null +++ b/collab-vm-server-1.3/TurnQueue.cs @@ -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 Turn; + + // Private fields + private readonly Queue 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(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/TurnStatus.cs b/collab-vm-server-1.3/TurnStatus.cs new file mode 100644 index 0000000..be738a6 --- /dev/null +++ b/collab-vm-server-1.3/TurnStatus.cs @@ -0,0 +1,7 @@ +namespace CollabVM.Server; + +public class TurnStatus +{ + public User[] Queue { get; set; } + public uint TimeRemaining { get; set; } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/User.cs b/collab-vm-server-1.3/User.cs new file mode 100644 index 0000000..c7fa870 --- /dev/null +++ b/collab-vm-server-1.3/User.cs @@ -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 Disconnected; + public event EventHandler ConnectedToVM; + public event EventHandler 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(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 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 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 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 receivebuffer = new ArraySegment(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 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")}."); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/Utilities.cs b/collab-vm-server-1.3/Utilities.cs new file mode 100644 index 0000000..f802cc7 --- /dev/null +++ b/collab-vm-server-1.3/Utilities.cs @@ -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); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/VM.cs b/collab-vm-server-1.3/VM.cs new file mode 100644 index 0000000..f658733 --- /dev/null +++ b/collab-vm-server-1.3/VM.cs @@ -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 Users { get; } = new(); + public CircularBuffer.CircularBuffer 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 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 img; + try + { + img = Image.LoadPixelData(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 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(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 GetFramebufferPixelData() => this.Controller.GetFramebufferData(); + + public async Task GetFramebufferJpeg() + { + var fb = await this.GetFramebufferPixelData(); + var img = Image.LoadPixelData(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); + } + } + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/VMController.cs b/collab-vm-server-1.3/VMController.cs new file mode 100644 index 0000000..2f17d12 --- /dev/null +++ b/collab-vm-server-1.3/VMController.cs @@ -0,0 +1,24 @@ +using SixLabors.ImageSharp; + +namespace CollabVM.Server; + +public abstract class VMController +{ + public abstract event EventHandler Rect; + public abstract event EventHandler 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 MonitorCommand(string cmd); + + public abstract Task GetFramebufferData(); + public abstract Task SendKeysym(int keysym, bool down); + public abstract Task SendMouse(int x, int y, int mask); +} \ No newline at end of file diff --git a/collab-vm-server-1.3/VMControllers/QEMUController.cs b/collab-vm-server-1.3/VMControllers/QEMUController.cs new file mode 100644 index 0000000..2c3bea5 --- /dev/null +++ b/collab-vm-server-1.3/VMControllers/QEMUController.cs @@ -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 Rect; + public override event EventHandler 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(); + 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 MonitorCommand(string cmd) + { + var response = await _qmp.HumanMonitorCommand(cmd); + return response; + } + + public override Task 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); +} \ No newline at end of file diff --git a/collab-vm-server-1.3/VMControllers/VNCController.cs b/collab-vm-server-1.3/VMControllers/VNCController.cs new file mode 100644 index 0000000..118ee8a --- /dev/null +++ b/collab-vm-server-1.3/VMControllers/VNCController.cs @@ -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 Rect; + public override event EventHandler 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 MonitorCommand(string cmd) => Task.FromResult("This VM does not have a monitor."); + + public override async Task 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); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/VMManager.cs b/collab-vm-server-1.3/VMManager.cs new file mode 100644 index 0000000..e75f4dc --- /dev/null +++ b/collab-vm-server-1.3/VMManager.cs @@ -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 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 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 GetList() + { + List 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(); + } +} \ No newline at end of file diff --git a/collab-vm-server-1.3/collab-vm-server-1.3.csproj b/collab-vm-server-1.3/collab-vm-server-1.3.csproj new file mode 100644 index 0000000..618f4ad --- /dev/null +++ b/collab-vm-server-1.3/collab-vm-server-1.3.csproj @@ -0,0 +1,34 @@ + + + + Exe + net8.0 + CollabVM.Server + enable + enable + CollabVM.Server + + + + + + + + + + + + + + + + + + PreserveNewest + + + PreserveNewest + + + + diff --git a/config.toml b/config.toml new file mode 100644 index 0000000..0fa1b5c --- /dev/null +++ b/config.toml @@ -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 '' | 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"