about as ready as its gonna fucking get

This commit is contained in:
Elijah R 2024-01-03 17:29:02 -05:00
commit 574be6faee
40 changed files with 3383 additions and 0 deletions

399
.gitignore vendored Normal file
View file

@ -0,0 +1,399 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/

6
.gitmodules vendored Normal file
View file

@ -0,0 +1,6 @@
[submodule "MarcusW.VncClient"]
path = MarcusW.VncClient
url = https://github.com/elijahr2411/MarcusW.VncClient
[submodule "QMPSharp"]
path = QMPSharp
url = https://git.computernewb.com/Elijah/QMPSharp.git

1
MarcusW.VncClient Submodule

@ -0,0 +1 @@
Subproject commit 71fa923ce24a49ac48111915852c5953f5363373

1
QMPSharp Submodule

@ -0,0 +1 @@
Subproject commit f0de933380c0c6488306d04b340f9a1599a4d217

28
collab-vm-server-1.3.sln Normal file
View file

@ -0,0 +1,28 @@

Microsoft Visual Studio Solution File, Format Version 12.00
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "collab-vm-server-1.3", "collab-vm-server-1.3\collab-vm-server-1.3.csproj", "{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MarcusW.VncClient", "MarcusW.VncClient\src\MarcusW.VncClient\MarcusW.VncClient.csproj", "{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "QMPSharp", "QMPSharp\QMPSharp\QMPSharp.csproj", "{7DA24013-9AD2-4D59-8EE4-AF73C5516603}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D9D8EED3-FD8D-4E27-9B8D-F568DF7CDB9F}.Release|Any CPU.Build.0 = Release|Any CPU
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B11D0AFD-CD55-49C8-9AD7-22F6117D8773}.Release|Any CPU.Build.0 = Release|Any CPU
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7DA24013-9AD2-4D59-8EE4-AF73C5516603}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View file

@ -0,0 +1,7 @@
namespace CollabVM.Server;
public class ChatMessage
{
public string Username { get; set; }
public string Message { get; set; }
}

View file

@ -0,0 +1,43 @@
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
public class Cooldown
{
private uint _interval, _timeRemaining;
private Timer _timer;
public bool IsReady => _timeRemaining == 0;
public uint TimeRemaining => _timeRemaining;
public Cooldown(uint interval)
{
_interval = interval;
_timeRemaining = 0;
_timer = new(1000);
_timer.AutoReset = true;
_timer.Elapsed += (_, _) => Tick();
}
public bool Run()
{
if (_timeRemaining == 0)
{
_timeRemaining = _interval;
_timer.Start();
return true;
}
else
{
return false;
}
}
public void Tick()
{
_timeRemaining--;
if (_timeRemaining == 0)
{
_timer.Stop();
}
}
}

View file

@ -0,0 +1,74 @@
using CollabVM.Server.Config;
using MySqlConnector;
namespace CollabVM.Server;
public class Database
{
private MySqlConnection db;
private readonly MySQLConfig config;
private bool connected = false;
public Database(MySQLConfig config)
{
this.config = config;
var connstr = new MySqlConnectionStringBuilder();
connstr.Server = config.Host;
connstr.UserID = config.Username;
connstr.Password = config.Password;
connstr.Database = config.Database;
db = new MySqlConnection(connstr.ToString());
}
public async Task OpenAsync()
{
await db.OpenAsync();
await InitTables();
connected = true;
}
private async Task InitTables()
{
await using var cmd = db.CreateCommand();
cmd.CommandText +=
"CREATE TABLE IF NOT EXISTS bans (ip TEXT NOT NULL UNIQUE, reason TEXT, date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP); ";
await cmd.ExecuteNonQueryAsync();
}
public async Task AddBan(string ip, string reason)
{
if (!connected) throw new InvalidOperationException("Database is not connected");
await using var cmd = db.CreateCommand();
cmd.CommandText = "INSERT INTO bans (ip, reason) VALUES (@ip, @reason)";
cmd.Parameters.AddWithValue("@ip", ip);
cmd.Parameters.AddWithValue("@reason", reason);
await cmd.ExecuteNonQueryAsync();
}
public async Task AddBan(string ip)
{
if (!connected) throw new InvalidOperationException("Database is not connected");
await using var cmd = db.CreateCommand();
cmd.CommandText = "INSERT INTO bans (ip) VALUES (@ip)";
cmd.Parameters.AddWithValue("@ip", ip);
await cmd.ExecuteNonQueryAsync();
}
public async Task RemoveBan(string ip)
{
if (!connected) throw new InvalidOperationException("Database is not connected");
await using var cmd = db.CreateCommand();
cmd.CommandText = "DELETE FROM bans WHERE ip = @ip";
cmd.Parameters.AddWithValue("@ip", ip);
await cmd.ExecuteNonQueryAsync();
}
public async Task<bool> IsBanned(string ip)
{
if (!connected) throw new InvalidOperationException("Database is not connected");
await using var cmd = db.CreateCommand();
cmd.CommandText = "SELECT COUNT(*) FROM bans WHERE ip = @ip";
cmd.Parameters.AddWithValue("@ip", ip);
var count = await cmd.ExecuteScalarAsync();
return (long)count! > 0;
}
}

View file

@ -0,0 +1,17 @@
namespace CollabVM.Server;
public class DirtyRect
{
public int X { get; set; }
public int Y { get; set; }
public int Width { get; set; }
public int Height { get; set; }
}
public class DirtyRectData : DirtyRect
{
/// <summary>
/// The data of the dirty rect represented in RGBA32 format.
/// </summary>
public byte[] Data { get; set; }
}

View file

@ -0,0 +1,65 @@
using System.Timers;
using Timer = System.Timers.Timer;
namespace CollabVM.Server.Display;
// This class is in charge of batching dirty rects into a single rect.
// This is needed because certain VNC servers (like QEMU) send a lot of dirty rects, which clogs up the socket.
// By default, this class will batch collected rects every 33ms (30 FPS).
public class RectBatcher
{
private const int BatchInterval = 33;
private const int InitialBufferSize = 1024 * 768 * 4;
private byte[] mergeBuffer;
public event EventHandler<DirtyRectData> Rect;
private List<DirtyRect> Rects;
private Timer timer;
private Func<int, int, int, int, byte[], byte[]> GrabRect;
public void AddRect(DirtyRect rect)
{
Rects.Add(rect);
}
public RectBatcher(Func<int, int, int, int, byte[], byte[]> getRect)
{
mergeBuffer = new byte[InitialBufferSize];
this.GrabRect = getRect;
Rects = new();
timer = new(BatchInterval);
timer.Elapsed += (_, _) => TimerOnElapsed();
timer.AutoReset = true;
timer.Start();
}
private async Task TimerOnElapsed()
{
if (Rects.Count == 0) return;
var rects = Rects;
Rects = new();
int mergedX = 0, mergedY = 0, mergedWidth = 0, mergedHeight = 0;
foreach (var rect in rects)
{
if (rect.X < mergedX) mergedX = rect.X;
if (rect.Y < mergedY) mergedY = rect.Y;
if (rect.X + rect.Width > mergedX + mergedWidth) mergedWidth = rect.X + rect.Width;
if (rect.Y + rect.Height > mergedY + mergedHeight) mergedHeight = rect.Y + rect.Height;
}
Utilities.Log(LogLevel.DEBUG, $"Batching {Rects.Count} rects into one {mergedWidth}x{mergedHeight} rect at {mergedX},{mergedY}");
// Create a rect from data already in the framebuffer
if (mergeBuffer.Length < mergedWidth * mergedHeight * 4)
{
mergeBuffer = new byte[mergedWidth * mergedHeight * 4];
}
var data = GrabRect(mergedX, mergedY, mergedWidth, mergedHeight, mergeBuffer);
// Fire the event
Rect.Invoke(this, new DirtyRectData
{
Data = mergeBuffer,
Width = mergedWidth,
Height = mergedHeight,
X = mergedX,
Y = mergedY
});
}
}

View file

@ -0,0 +1,61 @@
using Microsoft.Extensions.Logging;
namespace CollabVM.Server.DisplayControllers.VNC;
public class CollabVMLogger : ILogger
{
public IDisposable? BeginScope<TState>(TState state) where TState : notnull
{
return NullScope.Instance;
}
public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel)
{
return true;
}
public void Log<TState>(Microsoft.Extensions.Logging.LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func<TState, Exception?, string> formatter)
{
LogLevel level = logLevel switch
{
Microsoft.Extensions.Logging.LogLevel.Trace => LogLevel.DEBUG,
Microsoft.Extensions.Logging.LogLevel.Debug => LogLevel.DEBUG,
Microsoft.Extensions.Logging.LogLevel.Information => LogLevel.INFO,
Microsoft.Extensions.Logging.LogLevel.Warning => LogLevel.WARN,
Microsoft.Extensions.Logging.LogLevel.Error => LogLevel.ERROR,
Microsoft.Extensions.Logging.LogLevel.Critical => LogLevel.FATAL,
Microsoft.Extensions.Logging.LogLevel.None => LogLevel.FATAL,
_ => throw new ArgumentException("Invalid log level")
};
Utilities.Log(level, "VNC: " + formatter(state, exception));
}
}
public class CollabVMLoggerProvider : ILoggerProvider
{
public void Dispose()
{
return;
}
public ILogger CreateLogger(string categoryName)
{
return new CollabVMLogger();
}
}
/// <summary>
/// Represents an empty logging scope without any logic.
/// </summary>
public class NullScope : IDisposable
{
/// <summary>
/// Gets the default instance of the <see cref="NullScope"/>.
/// </summary>
public static NullScope Instance { get; } = new NullScope();
private NullScope() { }
/// <inheritdoc />
public void Dispose() { }
}

View file

@ -0,0 +1,16 @@
using MarcusW.VncClient;
using MarcusW.VncClient.Protocol.SecurityTypes;
using MarcusW.VncClient.Security;
namespace CollabVM.Server.DisplayControllers.VNC;
public class VNCAuthenticationHandler : IAuthenticationHandler
{
public Task<TInput> ProvideAuthenticationInputAsync<TInput>(RfbConnection connection, ISecurityType securityType,
IAuthenticationInputRequest<TInput> request) where TInput : class, IAuthenticationInput
{
// For now, we only support passwordless authentication
// MAYBE TODO: Implement password authentication
throw new NotImplementedException();
}
}

View file

@ -0,0 +1,103 @@
using System.Collections.Immutable;
using CollabVM.Server.Display;
using MarcusW.VncClient;
using MarcusW.VncClient.Protocol.EncodingTypes;
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes;
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes.Frame;
using MarcusW.VncClient.Protocol.Implementation.EncodingTypes.Pseudo;
using MarcusW.VncClient.Protocol.Implementation.MessageTypes.Outgoing;
using MarcusW.VncClient.Protocol.Implementation.Services.Transports;
using MarcusW.VncClient.Rendering;
using Microsoft.Extensions.Logging;
namespace CollabVM.Server.DisplayControllers.VNC;
public class VNCDisplay
{
public event EventHandler<DirtyRectData> Rect;
public event EventHandler<Size> SizeChanged;
private string host;
private int port;
private VncClient vnc;
private RfbConnection? rfb;
private RectBatcher batcher;
VNCRenderTarget renderTarget;
public VNCDisplay(string host, int port)
{
this.host = host;
this.port = port;
var loggerFactory = new LoggerFactory();
loggerFactory.AddProvider(new CollabVMLoggerProvider());
this.vnc = new VncClient(loggerFactory);
this.renderTarget = new VNCRenderTarget();
this.renderTarget.Updated += RenderTargetOnUpdated;
this.renderTarget.SizeChanged += (_, s) => SizeChanged.Invoke(this, s);
this.batcher = new(renderTarget.GetRectangle);
this.batcher.Rect += BatcherOnRect;
}
private void BatcherOnRect(object? sender, DirtyRectData e)
{
this.Rect.Invoke(this, e);
}
private async void RenderTargetOnUpdated(object? sender, Rectangle e)
{
Utilities.Log(LogLevel.DEBUG, $"New {e.Size.Width}x{e.Size.Height} rect at {e.Position.X},{e.Position.Y}");
batcher.AddRect(new DirtyRect
{
Width = e.Size.Width,
Height = e.Size.Height,
X = e.Position.X,
Y = e.Position.Y
});
}
public async Task Connect()
{
if (this.rfb != null) return;
this.rfb = await this.vnc.ConnectAsync(new()
{
TransportParameters = new TcpTransportParameters()
{
Host = this.host,
Port = this.port
},
RenderFlags = RenderFlags.UpdateByRectangle,
InitialRenderTarget = this.renderTarget,
AuthenticationHandler = new VNCAuthenticationHandler(),
EncodingTypes = new EncodingTypes[] {
EncodingTypes.RawEncodingType,
EncodingTypes.CopyRectEncodingType,
EncodingTypes.ContinuousUpdatesEncodingType,
EncodingTypes.ExtendedDesktopSizeEncodingType,
EncodingTypes.DesktopSizeEncodingType,
EncodingTypes.LastRectEncodingType
}
});
}
public async Task Disconnect()
{
if (this.rfb == null) return;
await this.rfb.CloseAsync();
this.rfb = null;
}
public byte[] GetFramebufferData() => renderTarget.GetFramebufferData();
public async Task SendKeysym(int keysym, bool down)
{
if (rfb == null) return;
await rfb.SendMessageAsync(new KeyEventMessage(down, (KeySymbol)keysym));
}
public async Task SendMouse(int x, int y, int mask)
{
if (rfb == null) return;
await rfb.SendMessageAsync(new PointerEventMessage(new Position(x, y), (MouseButtons)mask));
}
}

View file

@ -0,0 +1,35 @@
using MarcusW.VncClient;
using MarcusW.VncClient.Rendering;
namespace CollabVM.Server.DisplayControllers.VNC;
public class VNCFramebufferReference : IFramebufferRectangleReference
{
public event EventHandler<Rectangle> Updated;
private SemaphoreSlim fbLock;
public void Dispose()
{
fbLock.Release();
}
public IntPtr Address { get; }
public Size Size { get; }
public PixelFormat Format { get; }
public double HorizontalDpi { get; }
public double VerticalDpi { get; }
public VNCFramebufferReference(IntPtr fb, int width, int height, SemaphoreSlim fbLock)
{
Address = fb;
Size = new Size(width, height);
Format = new PixelFormat("RGBA32", 32, 32, true, true, true, 255, 255, 255, 255, 24, 16, 8, 0);
HorizontalDpi = 96;
VerticalDpi = 96;
this.fbLock = fbLock;
fbLock.Wait();
}
public void UpdateRectangle(Rectangle rect)
{
Updated.Invoke(this, rect);
}
}

View file

@ -0,0 +1,74 @@
using System.Collections.Immutable;
using System.Runtime.InteropServices;
using MarcusW.VncClient;
using MarcusW.VncClient.Rendering;
namespace CollabVM.Server.DisplayControllers.VNC;
public class VNCRenderTarget : IRenderTarget
{
private IntPtr framebuffer;
private int width;
private int height;
private SemaphoreSlim fbLock = new(1, 1);
public event EventHandler<Rectangle> Updated;
public event EventHandler<Size> SizeChanged;
public VNCRenderTarget()
{
framebuffer = IntPtr.Zero;
width = 0;
height = 0;
}
public IFramebufferReference GrabFramebufferReference(Size size, IImmutableSet<Screen> layout)
{
if (framebuffer == IntPtr.Zero || width != size.Width || height != size.Height)
{
if (framebuffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(framebuffer);
}
framebuffer = Marshal.AllocHGlobal(size.Width * size.Height * 4);
width = size.Width;
height = size.Height;
SizeChanged.Invoke(this, size);
}
var refer = new VNCFramebufferReference(framebuffer, size.Width, size.Height, fbLock);
refer.Updated += (_, rect) =>
{
Updated.Invoke(this, rect);
};
return refer;
}
public byte[] GetFramebufferData()
{
byte[] data = new byte[width * height * 4];
Marshal.Copy(framebuffer, data, 0, data.Length);
return data;
}
public byte[] GetRectangle(int x, int y, int rwidth, int rheight, byte[]? buffer = null)
{
fbLock.Wait();
byte[] data;
if (buffer != null)
{
if (buffer.Length < rwidth * rheight * 4)
{
throw new ArgumentException("Buffer is too small", nameof(buffer));
}
data = buffer;
}
else
{
data = new byte[rwidth * rheight * 4];
}
for (int i = 0; i < rheight; i++)
{
Marshal.Copy(framebuffer + ((y + i) * this.width + x) * 4, data, i * rwidth * 4, rwidth * 4);
}
fbLock.Release();
return data;
}
}

View file

@ -0,0 +1,46 @@
using System.Text;
namespace CollabVM.Server;
/// <summary>
/// Utilities for converting lists of strings to and from Guacamole format
/// </summary>
public static class Guacutils {
/// <summary>
/// Encode an array of strings to guacamole format
/// </summary>
/// <param name="msgArr">List of strings to be encoded</param>
/// <returns>A guacamole string array containing the provided strings</returns>
public static string Encode(params string[] msgArr)
{
var res = new StringBuilder();
int i = 0;
foreach (string s in msgArr) {
res.Append(s.Length.ToString());
res.Append(".");
res.Append(s);
if (i == msgArr.Length - 1) res.Append(";");
else res.Append(",");
i++;
}
return res.ToString();
}
/// <summary>
/// Decode a guacamole string array
/// </summary>
/// <param name="msg">String containing a guacamole array</param>
/// <returns>An array of strings</returns>
public static string[] Decode(string msg) {
List<string> outArr = new List<string>();
int pos = 0;
while (pos < msg.Length - 1) {
int dotpos = msg.IndexOf('.', pos + 1);
string lenstr = msg.Substring(pos, dotpos - pos);
int len = int.Parse(lenstr);
string str = msg.Substring(dotpos + 1, len);
outArr.Add(str);
pos = dotpos + len + 2;
}
return outArr.ToArray();
}
}

View file

@ -0,0 +1,140 @@
using System.Net;
using CollabVM.Server.Config;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
namespace CollabVM.Server;
public class HTTPServer
{
// this class does all the asp.net stuff to make a simple websocket server
// TODO MAYBE: Switch to using standalone kestrel stuff instead of asp.net?
// Private fields
private bool shutdown = false;
private readonly HTTPConfig config;
private readonly WebApplication app;
private readonly List<User> users = new();
public HTTPServer(HTTPConfig config)
{
this.config = config;
WebApplicationBuilder builder = WebApplication.CreateBuilder();
IPAddress ip;
// TODO: Move to a dedicated config validation method
if (!IPAddress.TryParse(config.Host, out ip))
{
Utilities.Log(LogLevel.FATAL, "Invalid IP address in config file");
Environment.Exit(1);
}
if (config.Port < 1 || config.Port > 65535)
{
Utilities.Log(LogLevel.FATAL, "Invalid port in config file");
Environment.Exit(1);
}
builder.WebHost.UseKestrel(k =>
{
k.Listen(ip, config.Port);
});
#if DEBUG
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Debug);
#else
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Warning);
#endif
this.app = builder.Build();
this.app.UseWebSockets();
this.app.Lifetime.ApplicationStarted.Register(this.OnServerStarted);
this.app.Lifetime.ApplicationStopping.Register(this.OnServerStopping);
this.app.MapGet("/", HandleRequest);
}
private async void OnServerStopping()
{
shutdown = true;
foreach (User user in this.users)
await user.Close();
}
private async Task HandleRequest(HttpContext context)
{
if (!context.WebSockets.IsWebSocketRequest)
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 426;
await context.Response.WriteAsync("This endpoint only accepts websocket connections");
return;
}
if (config.OriginCheck && !config.AllowedOrigins!.Contains(context.Request.Headers.Origin[0]))
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 403;
await context.Response.WriteAsync("Origin not allowed");
return;
}
if (!context.WebSockets.WebSocketRequestedProtocols.Contains("guacamole"))
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 426;
await context.Response.WriteAsync("Invalid websocket protocol");
return;
}
IPAddress ip;
if (Program.Config.HTTP.ReverseProxy)
{
if (!Program.Config.HTTP.ProxyAllowedIPs!.Contains(context.Connection.RemoteIpAddress!.ToString()))
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 403;
await context.Response.WriteAsync("You are not allowed to connect to this server");
Utilities.Log(LogLevel.WARN,
$"An IP address not allowed to proxy connections ({context.Connection.RemoteIpAddress.ToString()}) attempted to connect. This mean your server port is exposed to the internet.");
return;
}
if (context.Request.Headers["X-Forwarded-For"].Count == 0)
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Missing X-Forwarded-For header");
return;
}
if (!IPAddress.TryParse(context.Request.Headers["X-Forwarded-For"][0], out ip))
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 400;
await context.Response.WriteAsync("Invalid X-Forwarded-For header");
return;
}
} else ip = context.Connection.RemoteIpAddress!;
if (Program.Config.Bans.UseInternalBlacklist && await Program.Database!.IsBanned(ip.ToString()))
{
context.Response.ContentType = "text/plain";
context.Response.StatusCode = 403;
await context.Response.WriteAsync("You are banned from this server");
return;
}
var socket = await context.WebSockets.AcceptWebSocketAsync("guacamole");
var socketFinishedTcs = new TaskCompletionSource<object?>();
User user = new User(socket, ip);
this.users.Add(user);
user.Disconnected += (_, _) =>
{
socketFinishedTcs.TrySetResult(null);
if (!shutdown) users.Remove(user);
};
// keep the middleware alive until the socket is closed
await socketFinishedTcs.Task;
}
private void OnServerStarted()
{
Utilities.Log(LogLevel.INFO, $"HTTP Server Listening on port {this.config.Port}");
}
public Task Run()
{
return this.app.RunAsync();
}
}

View file

@ -0,0 +1,121 @@
namespace CollabVM.Server.Config;
public class IConfig
{
public HTTPConfig HTTP { get; set; }
public TurnConfig Turns { get; set; }
public VoteConfig Votes { get; set; }
public ChatConfig Chat { get; set; }
public StaffConfig Staff { get; set; }
public BanConfig Bans { get; set; }
public LimitsConfig Limits { get; set; }
public MySQLConfig? MySQL { get; set; }
public Permissions ModPermissions { get; set; }
public VMConfig[] VMs { get; set; }
public void Validate()
{
if (HTTP == null) throw new Exception("HTTP is a required section in the config file");
if (VMs == null) throw new Exception("VMs is a required section in the config file");
if (Turns == null) throw new Exception("Turns is a required section in the config file");
if (Votes == null) throw new Exception("Votes is a required section in the config file");
if (Chat == null) throw new Exception("Chat is a required section in the config file");
if (Staff == null) throw new Exception("Staff is a required section in the config file");
if (Staff.ModeratorEnabled && ModPermissions == null) throw new Exception("ModPermissions is a required section in the config file");
if (Bans == null) throw new Exception("Bans is a required section in the config file");
if (Limits == null) throw new Exception("Limits is a required section in the config file");
if (Bans.UseInternalBlacklist && MySQL == null) throw new Exception("MySQL is a required section in the config file if UseInternalBlacklist is true");
// TODO: Expand this to check sub-sections, probably have each section implement its own Validate() method
}
}
public class HTTPConfig
{
public string Host { get; set; }
public int Port { get; set; }
public bool ReverseProxy { get; set; }
public string[]? ProxyAllowedIPs { get; set; }
public bool OriginCheck { get; set; }
public string[]? AllowedOrigins { get; set; }
}
public class VMConfig
{
public string ID { get; set; }
public string Name { get; set; }
public string MOTD { get; set; }
public bool TurnsAllowed { get; set; }
public string TurnPasswordHash { get; set; }
// Controllers
public VNCVMConfig? VNC { get; set; }
public QEMUVMConfig? QEMU { get; set; }
}
public class StaffConfig
{
public string AdminPasswordHash { get; set; }
public bool ModeratorEnabled { get; set; }
public string ModPasswordHash { get; set; }
}
public class TurnConfig
{
public uint TurnTime { get; set; }
}
public class ChatConfig
{
public uint MaxMessageLength { get; set; }
public uint ChatHistoryLength { get; set; }
}
public class VoteConfig
{
public uint VoteTime { get; set; }
public uint VoteCooldown { get; set; }
}
public class BanConfig
{
public bool UseInternalBlacklist { get; set; }
public string? RunCommand { get; set; }
}
public class LimitsConfig
{
public uint TempMuteTime { get; set; }
public LimitConfig ChatLimit { get; set; }
public LimitConfig KitLimit { get; set; }
}
public class LimitConfig
{
public bool Enabled { get; set; }
public uint Limit { get; set; }
public uint Cooldown { get; set; }
}
public class MySQLConfig
{
public string Host { get; set; }
public string Username { get; set; }
public string Password { get; set; }
public string Database { get; set; }
}
public class VNCVMConfig
{
public string Host { get; set; }
public int Port { get; set; }
}
public class QEMUVMConfig
{
public string QEMUCmd { get; set; }
public bool UseUnixSockets { get; set; }
public int VNCPort { get; set; }
public int QMPPort { get; set; }
public string? QMPSocketDir { get; set; }
public bool Snapshots { get; set; }
}

View file

@ -0,0 +1,60 @@
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
/// <summary>
/// Data about a user's IP address, used to prevent multiple votes or turns from the same IP address
/// </summary>
public class IPData
{
public bool IsTurning { get; set; }
public bool IsVoting { get; set; }
public MuteStatus MuteStatus { get; set; }
public event EventHandler Unmuted;
private Timer tempMuteTimer;
public IPData()
{
IsTurning = false;
IsVoting = false;
MuteStatus = MuteStatus.None;
tempMuteTimer = new();
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
tempMuteTimer.Elapsed += (_, _) => Unmute();
}
/// <summary>
/// Resets properties that are not persistent across user disconnects
/// </summary>
public void Reset()
{
IsTurning = false;
IsVoting = false;
}
public void Mute(bool permanent)
{
if (MuteStatus != MuteStatus.None) return;
if (permanent)
{
this.MuteStatus = MuteStatus.Permanent;
}
else
{
this.MuteStatus = MuteStatus.Temporary;
tempMuteTimer.Stop();
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
tempMuteTimer.Start();
}
}
public void Unmute()
{
if (MuteStatus == MuteStatus.None) return;
MuteStatus = MuteStatus.None;
Unmuted.Invoke(this, EventArgs.Empty);
tempMuteTimer.Stop();
tempMuteTimer.Interval = Program.Config.Limits.TempMuteTime * 1000;
}
}

View file

@ -0,0 +1,11 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace CollabVM.Server;
public class ListVM
{
public string ID { get; set; }
public string Name { get; set; }
public byte[] Thumbnail { get; set; }
}

View file

@ -0,0 +1,8 @@
namespace CollabVM.Server;
public enum MuteStatus
{
None,
Temporary,
Permanent
}

View file

@ -0,0 +1,33 @@
namespace CollabVM.Server;
public class Permissions
{
public bool Restore { get; set; }
public bool Reboot { get; set; }
public bool Ban { get; set; }
public bool ForceVote { get; set; }
public bool Mute { get; set; }
public bool Kick { get; set; }
public bool BypassTurn { get; set; }
public bool Rename { get; set; }
public bool GrabIP { get; set; }
public bool XSS { get; set; }
public bool HideScreen { get; set; }
public int ToMask()
{
int perms = 0;
if (Restore) perms |= 1;
if (Reboot) perms |= 2;
if (Ban) perms |= 4;
if (ForceVote) perms |= 8;
if (Mute) perms |= 16;
if (Kick) perms |= 32;
if (BypassTurn) perms |= 64;
if (Rename) perms |= 128;
if (GrabIP) perms |= 256;
if (XSS) perms |= 512;
if (HideScreen) perms |= 1024;
return perms;
}
}

View file

@ -0,0 +1,71 @@
using System.Runtime.InteropServices;
using CollabVM.Server.Config;
using Tomlet;
namespace CollabVM.Server;
class Program
{
// the collabvm 1 to end all collabvm 1s
// Private fields
private static HTTPServer http;
private static VM[] vms;
// Public fields
public static VMManager VMManager { get; private set; }
public static Random rnd = new();
public static IConfig Config;
public static Database? Database;
// These can go here for now, might move them later
public static readonly string ScreenHiddenBase64 = Convert.ToBase64String(Utilities.GetAsset("screenhidden.jpeg"));
public static readonly byte[] ScreenHiddenThumb = Utilities.GetAsset("screenhiddenthumb.jpeg");
public async static Task Main(string[] args)
{
// Load the config file
string configraw;
try
{
configraw = await File.ReadAllTextAsync("config.toml");
}
catch (Exception ex)
{
Utilities.Log(LogLevel.FATAL, "Failed to read config file: " + ex.Message);
Environment.Exit(1);
return;
}
try
{
Config = TomletMain.To<IConfig>(configraw);
} catch (Exception ex)
{
Utilities.Log(LogLevel.FATAL, "Failed to parse config file: " + ex.Message);
Environment.Exit(1);
return;
}
Config.Validate();
Utilities.Log(LogLevel.INFO, "CollabVM Server 1.3 starting up...");
// Register kill signal handlers
Console.CancelKeyPress += (_, _) => Exit();
PosixSignalRegistration.Create(PosixSignal.SIGTERM, (_) => Exit());
// Initialize the database
if (Config.MySQL != null)
{
Database = new Database(Config.MySQL);
await Database.OpenAsync();
Utilities.Log(LogLevel.INFO, "Connected to MySQL Database.");
}
// Initialize the VMs
VMManager = new VMManager(Config.VMs);
await VMManager.StartAll();
// Start the HTTP server
http = new HTTPServer(Config.HTTP);
await http.Run();
}
public static void Exit()
{
VMManager.StopAll().GetAwaiter().GetResult();
}
}

View file

@ -0,0 +1,8 @@
namespace CollabVM.Server;
public enum Rank
{
Unregistered = 0,
Admin = 2,
Moderator = 3,
}

View file

@ -0,0 +1,47 @@
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
public class RateLimiter
{
private uint limit, interval, requestCount;
private Timer timer;
private bool isRunning;
public RateLimiter(uint limit, uint interval)
{
this.limit = limit;
this.interval = interval;
requestCount = 0;
timer = new(interval * 1000);
timer.AutoReset = false;
timer.Elapsed += (_, _) => Reset();
}
public bool Limit()
{
this.requestCount++;
if (this.requestCount == this.limit)
{
Reset();
return false;
}
else
{
if (!isRunning)
{
timer.Start();
isRunning = true;
}
return true;
}
}
private void Reset()
{
isRunning = false;
requestCount = 0;
timer.Stop();
timer.Interval = limit * 1000;
}
}

View file

@ -0,0 +1,79 @@
using System.Timers;
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
public class ResetVote
{
private readonly List<User> _yesVotes;
private readonly List<User> _noVotes;
private readonly Timer _ticker;
private uint _timeRemaining;
public User[] YesVotes => _yesVotes.ToArray();
public User[] NoVotes => _noVotes.ToArray();
public uint TimeRemaining => _timeRemaining;
public event EventHandler<ResetVoteStatus> Finished;
public ResetVote(uint time)
{
_yesVotes = new();
_noVotes = new();
_timeRemaining = time;
_ticker = new();
_ticker.Interval = 1000;
_ticker.Elapsed += TickerOnElapsed;
_ticker.AutoReset = true;
_ticker.Start();
}
private void TickerOnElapsed(object? sender, ElapsedEventArgs e)
{
_timeRemaining--;
if (_timeRemaining == 0)
EndVote();
}
public void EndVote(bool? force = null)
{
_ticker.Stop();
bool result = force ?? _yesVotes.Count >= _noVotes.Count;
Finished.Invoke(this, GetStatus(force));
}
public void Vote(User user, bool vote)
{
if (_timeRemaining == 0) return;
if (vote)
{
if (_yesVotes.Contains(user)) return;
_noVotes.Remove(user);
_yesVotes.Add(user);
}
else
{
if (_noVotes.Contains(user)) return;
_yesVotes.Remove(user);
_noVotes.Add(user);
}
user.IPData!.IsVoting = true;
}
public void ClearVote(User user)
{
_yesVotes.Remove(user);
_noVotes.Remove(user);
}
public ResetVoteStatus GetStatus(bool? force = null)
{
return new ResetVoteStatus
{
YesVotes = YesVotes,
NoVotes = NoVotes,
TimeRemaining = TimeRemaining,
Finished = _timeRemaining == 0,
Result = force ?? _yesVotes.Count >= _noVotes.Count
};
}
}

View file

@ -0,0 +1,10 @@
namespace CollabVM.Server;
public class ResetVoteStatus
{
public User[] YesVotes { get; set; }
public User[] NoVotes { get; set; }
public uint TimeRemaining { get; set; }
public bool Finished { get; set; }
public bool Result { get; set; }
}

View file

@ -0,0 +1,165 @@
using System.Timers;
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
public class TurnQueue
{
// Public properties and events
public event EventHandler<TurnStatus> Turn;
// Private fields
private readonly Queue<User> queue;
private readonly uint turnTime;
private readonly Timer timer;
private uint currentTurnRemainingTime;
private User? indefiniteTurn;
public TurnQueue(uint turnTime)
{
queue = new();
this.turnTime = turnTime;
timer = new();
timer.Interval = 1000;
timer.Elapsed += TimerOnElapsed;
timer.AutoReset = true;
currentTurnRemainingTime = 0;
indefiniteTurn = null;
}
private void TimerOnElapsed(object? sender, ElapsedEventArgs e)
{
if (indefiniteTurn != null) return;
if (queue.Count == 0) return; // This shouldn't happen, but just in case
currentTurnRemainingTime--;
Utilities.Log(LogLevel.DEBUG, $"Turn tick, {currentTurnRemainingTime} seconds remaining on {queue.Peek().Username}'s turn");
if (currentTurnRemainingTime == 0)
{
NextTurn();
}
}
public void NextTurn()
{
if (queue.Count == 0) { return; }
if (indefiniteTurn != null)
{
indefiniteTurn = null;
SendTurnUpdate();
return;
}
queue.Dequeue();
if (queue.Count > 0)
{
Utilities.Log(LogLevel.DEBUG, $"It is now {queue.Peek().Username}'s turn");
currentTurnRemainingTime = turnTime;
}
SendTurnUpdate();
}
private void SendTurnUpdate()
{
Turn.Invoke(this, CurrentTurn());
}
public TurnStatus CurrentTurn()
{
if (indefiniteTurn != null)
return new TurnStatus
{
Queue = queue.ToArray().Prepend(indefiniteTurn).ToArray(),
TimeRemaining = 999999999,
};
return new TurnStatus
{
Queue = queue.ToArray(),
TimeRemaining = currentTurnRemainingTime
};
}
public void AddUser(User user)
{
if (queue.Contains(user) || indefiniteTurn == user) return;
Utilities.Log(LogLevel.DEBUG, $"Adding user {user.Username} to turn queue");
queue.Enqueue(user);
if (queue.Count == 1)
{
currentTurnRemainingTime = turnTime;
timer.Start();
}
SendTurnUpdate();
}
public void RemoveUser(User user)
{
if (user == indefiniteTurn)
{
indefiniteTurn = null;
SendTurnUpdate();
return;
}
if (!queue.Contains(user)) return;
Utilities.Log(LogLevel.DEBUG, $"Removing user {user.Username} to turn queue");
if (queue.Peek() == user && indefiniteTurn == null)
{
NextTurn();
return;
}
var _queue = queue.ToArray();
queue.Clear();
foreach (User u in _queue)
if (u != user) queue.Enqueue(u);
if (queue.Count == 0)
{
timer.Stop();
}
SendTurnUpdate();
}
public void Clear()
{
indefiniteTurn = null;
queue.Clear();
timer.Stop();
SendTurnUpdate();
}
public User? CurrentUser()
{
if (indefiniteTurn != null) return indefiniteTurn;
if (queue.Count == 0) return null;
return queue.Peek();
}
public void GiveTurn(User user)
{
if (indefiniteTurn != null)
{
indefiniteTurn = user;
SendTurnUpdate();
return;
}
if (queue.Count == 0)
{
AddUser(user);
return;
}
if (queue.Peek() == user) return;
RemoveUser(user);
var _queue = queue.ToArray();
queue.Clear();
queue.Enqueue(user);
foreach (User u in _queue)
queue.Enqueue(u);
this.currentTurnRemainingTime = turnTime;
SendTurnUpdate();
}
public void IndefiniteTurn(User user)
{
RemoveUser(user);
this.indefiniteTurn = user;
SendTurnUpdate();
}
}

View file

@ -0,0 +1,7 @@
namespace CollabVM.Server;
public class TurnStatus
{
public User[] Queue { get; set; }
public uint TimeRemaining { get; set; }
}

View file

@ -0,0 +1,675 @@
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using SixLabors.ImageSharp;
using Timer = System.Timers.Timer;
namespace CollabVM.Server;
public class User
{
// Events
public event EventHandler<EventArgs> Disconnected;
public event EventHandler<string> ConnectedToVM;
public event EventHandler<string> Renamed;
// Private fields
private WebSocket socket;
private readonly CancellationTokenSource tokenSource;
// Timer for when the connection times out
private readonly Timer timeoutTimer;
// This becomes true if the timer runs out.
// If the timer runs out while this is false, the user is sent a NOP message
// If the timer runs out while this is true, the user is disconnected
private bool timeOut = false;
// VM that the user is currently connected to
private VM? vm = null;
// Whether or not the user has been disposed
private bool _disposed = false;
// Username
private string? _username = null;
// IP
private IPAddress _ip;
// Rank
private Rank _rank = Rank.Unregistered;
// Limits
private readonly RateLimiter? ChatLimiter;
private readonly RateLimiter? KitLimiter;
// If true, can take turns while turns are disabled
private bool turnsAllowed = false;
// Public properties
public string? Username => this._username;
public IPAddress IP => this._ip;
public Rank Rank => this._rank;
public IPData? IPData { get; set; }
public User(WebSocket socket, IPAddress ip)
{
if (Program.Config.Limits.ChatLimit.Enabled)
ChatLimiter = new RateLimiter(Program.Config.Limits.ChatLimit.Limit, Program.Config.Limits.ChatLimit.Cooldown);
if (Program.Config.Limits.KitLimit.Enabled)
KitLimiter = new RateLimiter(Program.Config.Limits.KitLimit.Limit, Program.Config.Limits.KitLimit.Cooldown);
this.socket = socket;
this._ip = ip;
tokenSource = new CancellationTokenSource();
timeoutTimer = new Timer(3000);
timeoutTimer.Elapsed += async (sender, args) => await TimeoutCallback();
timeoutTimer.AutoReset = false;
timeoutTimer.Start();
this.Disconnected += OnDisconnected;
this.ConnectedToVM += delegate { };
this.Renamed += delegate { };
SendAsync("3.nop;");
ReadLoop(tokenSource.Token);
}
private void OnDisconnected(object? sender, EventArgs e)
{
this.timeoutTimer.Stop();
}
public async Task SendAsync(string msg)
{
if (socket.State != WebSocketState.Open)
{
return;
}
await socket.SendAsync(new ArraySegment<byte>(Encoding.UTF8.GetBytes(msg)), WebSocketMessageType.Text, true,
CancellationToken.None);
}
private async Task ProcessMessage(string[] msgArr)
{
if (msgArr.Length < 1)
{
await Close();
return;
}
this.timeOut = false;
this.timeoutTimer.Stop();
this.timeoutTimer.Start();
switch (msgArr[0])
{
case "nop":
break;
case "list":
{
Utilities.Log(LogLevel.DEBUG, "Getting list of VMs...");
var list = await Program.VMManager.GetList();
Utilities.Log(LogLevel.DEBUG, $"Got list of {list.Length} VMs");
List<string> listmsg = new();
listmsg.Add("list");
foreach (ListVM vm in list)
{
listmsg.Add(vm.ID);
listmsg.Add(vm.Name);
listmsg.Add(Convert.ToBase64String(vm.Thumbnail));
}
await SendAsync(Guacutils.Encode(listmsg.ToArray()));
}
break;
case "rename":
{
if (msgArr.Length == 1)
await this.AssignGuestUsername(this.vm);
else
await this.Rename(msgArr[1]);
}
break;
case "connect":
{
if (msgArr.Length != 2 || this._username == null)
{
await this.SendAsync(Guacutils.Encode("connect", "0"));
return;
}
if (this.vm != null)
{
// TODO: Implement VM switching
await this.SendAsync(Guacutils.Encode("connect", "0"));
return;
}
var vm = Program.VMManager.GetVM(msgArr[1]);
if (vm == null || !vm.Controller.IsRunning)
{
await this.SendAsync(Guacutils.Encode("connect", "0"));
return;
}
if (vm.Users.Any(user => user.Username == this._username))
{
await this.AssignGuestUsername(vm);
}
this.vm = vm;
await this.SendAsync(Guacutils.Encode("connect", "1", "1", vm.Controller.Snapshots ? "1" : "0", "0"));
List<string> usermsg = new(new[] { "adduser", vm.Users.Count.ToString() });
foreach (User user in vm.Users)
{
usermsg.Add(user.Username);
usermsg.Add(((int)user.Rank).ToString());
}
await this.SendAsync(Guacutils.Encode(usermsg.ToArray()));
await this.vm.AddUser(this);
List<string> chatmsg = new();
chatmsg.Add("chat");
foreach (ChatMessage msg in vm.ChatHistory)
{
chatmsg.Add(msg.Username);
chatmsg.Add(msg.Message);
}
chatmsg.Add("");
chatmsg.Add(vm.Config.MOTD);
await this.SendAsync(Guacutils.Encode(chatmsg.ToArray()));
var turn = vm.TurnQueue.CurrentTurn();
if (turn.Queue.Length > 0) await SendTurnUpdate(turn);
if (vm.Vote != null) await SendVoteUpdate(vm.Vote.GetStatus());
this.ConnectedToVM.Invoke(this, vm.Config.ID);
if (vm.ScreenHidden && _rank == Rank.Unregistered)
{
await this.SendScreenSize(new(1024, 768));
await this.SendRect(Program.ScreenHiddenBase64, 0, 0);
}
else
{
await this.SendScreenSize(vm.GetScreenSize());
await this.SendRect(Convert.ToBase64String(await vm.GetFramebufferJpeg()), 0, 0);
}
}
break;
case "chat":
{
if (this.vm == null || msgArr.Length != 2 || IPData!.MuteStatus != MuteStatus.None) return;
if (_rank == Rank.Unregistered && ChatLimiter != null && !ChatLimiter.Limit())
{
if (IPData!.MuteStatus == MuteStatus.None) await Mute(false);
return;
}
var msg = msgArr[1].Trim();
if (msg.Length < 1) return;
if (msg.Length > Program.Config.Chat.MaxMessageLength) msg = msg.Substring(0, (int)Program.Config.Chat.MaxMessageLength);
await vm.SendChat(this, msg);
}
break;
case "mouse":
{
if (msgArr.Length != 4 || this.vm == null || (!HasTurn() && _rank != Rank.Admin)) return;
if (KitLimiter != null && !KitLimiter.Limit())
{
await Close();
return;
}
int x, y, mask;
if (!int.TryParse(msgArr[1], out x) || !int.TryParse(msgArr[2], out y) ||
!int.TryParse(msgArr[3], out mask)) return;
// TODO: Turns
await vm.SendMouse(x, y, mask);
}
break;
case "key":
{
if (this.vm == null || msgArr.Length != 3 || (!HasTurn() && _rank != Rank.Admin)) return;
if (KitLimiter != null && !KitLimiter.Limit())
{
await Close();
return;
}
int keysym, down;
if (!int.TryParse(msgArr[1], out keysym) || !int.TryParse(msgArr[2], out down)) return;
if (down != 0 && down != 1) return;
// TODO: Turns
await vm.SendKey(keysym, down == 1);
}
break;
case "turn":
{
if (this.vm == null || msgArr.Length > 2 || IPData!.MuteStatus != MuteStatus.None) return;
if (msgArr.Length == 1 || msgArr[1] == "1" && !IPData!.IsTurning && (vm.TurnsAllowed || turnsAllowed || _rank == Rank.Admin || _rank == Rank.Moderator))
{
vm.TurnQueue.AddUser(this);
} else if (msgArr[1] == "0")
{
vm.TurnQueue.RemoveUser(this);
}
}
break;
case "vote":
{
if (this.vm == null || msgArr.Length != 2 || IPData!.IsVoting || IPData!.MuteStatus != MuteStatus.None) return;
if (!vm.Controller.Snapshots)
{
await this.SendChat("", "This VM does not support voting to reset");
}
bool? vote = msgArr[1] switch
{
"1" => true,
"0" => false,
_ => null
};
if (vote == null) return;
if (vm.Vote == null && !vm.VoteCooldown.IsReady)
{
await this.SendAsync(Guacutils.Encode("vote", "3", vm.VoteCooldown.TimeRemaining.ToString()));
return;
}
await vm.VoteReset(this, vote.Value);
}
break;
case "admin":
{
if (msgArr.Length < 2) return;
switch (msgArr[1])
{
case "2":
{
// Login
if (msgArr.Length != 3) return;
using var sha = SHA256.Create();
var hash = Utilities.BytesToHex(sha.ComputeHash(Encoding.UTF8.GetBytes(msgArr[2])));
if (hash == Program.Config.Staff.AdminPasswordHash)
{
this._rank = Rank.Admin;
await this.SendAsync(Guacutils.Encode("admin", "0", "1"));
} else if (Program.Config.Staff.ModeratorEnabled && hash == Program.Config.Staff.ModPasswordHash)
{
this._rank = Rank.Moderator;
await this.SendAsync(Guacutils.Encode("admin", "0", "3", Program.Config.ModPermissions.ToMask().ToString()));
} else if (vm != null && vm.Config.TurnPasswordHash == hash)
{
this.turnsAllowed = true;
await this.SendChat("", "You may now take turns.");
}
else
{
await this.SendAsync(Guacutils.Encode("admin", "0", "0"));
return;
}
if (vm.ScreenHidden)
{
await this.SendScreenSize(vm.GetScreenSize());
await this.SendRect(Convert.ToBase64String(await vm.GetFramebufferJpeg()), 0, 0);
}
if (this.vm != null) await vm.ReannounceUser(this);
}
break;
case "5":
{
// Monitor
if (_rank != Rank.Admin || msgArr.Length != 4) return;
var _vm = Program.VMManager.GetVM(msgArr[2]);
if (_vm == null) return;
Utilities.Log(LogLevel.DEBUG, $"[{_vm.Config.ID}] {_username} is running \"{msgArr[3]}\"");
var output = await _vm.Controller.MonitorCommand(msgArr[3]);
await SendAsync(Guacutils.Encode("admin", "2", output));
}
break;
case "8":
{
// Restore snapshot
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Restore) || msgArr.Length > 3) return;
VM? _vm;
if (msgArr.Length == 3)
{
_vm = Program.VMManager.GetVM(msgArr[2]);
}
else
{
_vm = this.vm;
}
if (_vm == null) return;
await _vm.Controller.Reset();
}
break;
case "10":
{
// Reboot
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Reboot) || msgArr.Length != 3) return;
VM? _vm = Program.VMManager.GetVM(msgArr[2]);
if (_vm == null) return;
await _vm.Controller.Reboot();
}
break;
case "12":
{
// Ban
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Ban) || msgArr.Length < 3 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
await user.Ban(msgArr.Length == 4 ? msgArr[3] : null);
}
break;
case "13":
{
// Force vote
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.ForceVote) || msgArr.Length != 3 || this.vm?.Vote == null) return;
bool? vote = msgArr[2] switch
{
"1" => true,
"0" => false,
_ => null
};
if (vote == null) return;
vm.Vote.EndVote(vote);
}
break;
case "14":
{
// Mute
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Mute) || msgArr.Length != 4 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
bool? permanent = msgArr[3] == "1" ? true : msgArr[3] == "0" ? false : null;
if (permanent == null) return;
await user.Mute((bool)permanent);
}
break;
case "15":
{
// Kick
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Kick) || msgArr.Length != 3 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
await user.Close();
}
break;
case "16":
{
// End turn
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
vm.TurnQueue.RemoveUser(user);
}
break;
case "17":
{
// Clear turn queue
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3) return;
VM? _vm = Program.VMManager.GetVM(msgArr[2]);
if (_vm == null) return;
_vm.TurnQueue.Clear();
}
break;
case "18":
{
// Rename user
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.Rename) || msgArr.Length != 4 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
await user.Rename(msgArr[3]);
}
break;
case "19":
{
// Get IP
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.GrabIP) || msgArr.Length != 3 || vm == null) return;
var user = vm.Users.Find(u => u.Username == msgArr[2]);
if (user == null) return;
await SendAsync(Guacutils.Encode("admin", "19", msgArr[2], user.IP.ToString()));
}
break;
case "20":
{
// Bypass turn
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn)) return;
if (vm == null) return;
vm.TurnQueue.GiveTurn(this);
}
break;
case "21":
{
// XSS
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.XSS) || msgArr.Length != 3 || vm == null) return;
await vm.SendChat(this, msgArr[2], true, _rank == Rank.Moderator);
}
break;
case "22":
{
// Toggle Turns
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || msgArr.Length != 3 || vm == null) return;
if (msgArr[2] == "1")
{
vm.TurnsAllowed = true;
} else if (msgArr[2] == "0")
{
vm.TurnQueue.Clear();
vm.TurnsAllowed = false;
}
}
break;
case "23":
{
// Indefinite turn
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.BypassTurn) || vm == null) return;
vm.TurnQueue.IndefiniteTurn(this);
}
break;
case "24":
{
// Hide screen
if (_rank != Rank.Admin && (_rank != Rank.Moderator || !Program.Config.ModPermissions.HideScreen) || msgArr.Length != 3 || vm == null) return;
bool? hidden = msgArr[2] switch
{
"1" => false,
"0" => true,
_ => null
};
if (hidden == null) return;
await vm.HideScreen(hidden.Value);
}
break;
}
}
break;
default:
await Close();
break;
}
}
public async Task TimeoutCallback()
{
if (!timeOut)
{
await SendAsync("3.nop;");
timeOut = true;
timeoutTimer.Start();
}
else
{
await Close();
}
}
private async Task ReadLoop(CancellationToken token)
{
ArraySegment<byte> receivebuffer = new ArraySegment<byte>(new byte[8192]);
WebSocketReceiveResult result;
while (!tokenSource.IsCancellationRequested && socket.State == WebSocketState.Open)
{
using MemoryStream ms = new MemoryStream();
do
{
result = await socket.ReceiveAsync(receivebuffer, token);
if (result.MessageType == WebSocketMessageType.Close)
{
Disconnected.Invoke(this, new EventArgs());
return;
}
if (result.MessageType == WebSocketMessageType.Binary)
{
await Close();
}
await ms.WriteAsync(receivebuffer.Array, 0, result.Count, token);
} while (!result.EndOfMessage);
string msg;
try
{
msg = Encoding.UTF8.GetString(ms.ToArray());
} catch (Exception ex)
{
Utilities.Log(LogLevel.DEBUG, "Failed to decode websocket message");
await Close();
return;
}
string[] msgArr;
try
{
msgArr = Guacutils.Decode(msg);
} catch (Exception ex)
{
Utilities.Log(LogLevel.DEBUG, "Failed to decode guacamole message " + msg);
await Close();
return;
}
ProcessMessage(msgArr);
}
this.Disconnected.Invoke(this, new EventArgs());
}
public async Task SendRect(string jpg64, int x, int y)
{
await this.SendAsync(Guacutils.Encode("png", "0", "0", x.ToString(), y.ToString(), jpg64));
await this.SendAsync(Guacutils.Encode("sync", DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString()));
}
public async Task SendScreenSize(Size size)
{
await SendAsync(Guacutils.Encode("size", "0", size.Width.ToString(), size.Height.ToString()));
}
public async Task Close()
{
await SendAsync(Guacutils.Encode("disconnect"));
try
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, null, CancellationToken.None);
} catch { /* ignored */ }
Disconnected.Invoke(this, new EventArgs());
}
public async Task Ban(string? reason = null)
{
if (Program.Config.Bans.UseInternalBlacklist)
{
if (reason != null) await Program.Database!.AddBan(this._ip.ToString(), reason);
else await Program.Database!.AddBan(this._ip.ToString());
}
if (Program.Config.Bans.RunCommand != null)
{
var cmd = Program.Config.Bans.RunCommand
.Replace("$IP", this._ip.ToString())
.Replace("$NAME", this._username!);
if (reason != null) cmd = cmd.Replace("$REASON", reason);
await Utilities.ExecuteCommand(cmd);
}
await Close();
}
public Task AssignGuestUsername(VM? vm)
{
string username;
do
{
username = $"guest{Program.rnd.Next(10000, 99999).ToString()}";
} while (vm != null && vm.Users.Any(user => user.Username == username));
Utilities.Log(LogLevel.DEBUG, $"Assigning guest username {username} to {this._ip.ToString()}");
return Rename(username);
}
public async Task Rename(string username)
{
var oldname = this._username;
var newname = username.Trim();
if (this._username == newname)
{
await this.SendAsync(Guacutils.Encode("rename", "0", "0", this._username, ((int)this._rank).ToString()));
return;
}
if (this.vm != null && this.vm.Users.Any(user => user.Username == newname))
{
await this.SendAsync(Guacutils.Encode("rename", "0", "1", this._username, ((int)this._rank).ToString()));
return;
}
if (!new Regex(@"^[a-zA-Z0-9\ \-_\.]+$").IsMatch(newname) || newname.Length > 20 || newname.Length < 3)
{
if (this._username == null)
{
await this.AssignGuestUsername(this.vm);
return;
}
await this.SendAsync(Guacutils.Encode("rename", "0", "2", _username, ((int)this._rank).ToString()));
}
this._username = newname;
if (oldname != null) this.Renamed.Invoke(this, oldname);
await SendAsync(Guacutils.Encode("rename", "0", "0", this._username, ((int)this._rank).ToString()));
if (oldname != null) Utilities.Log(LogLevel.INFO, $"Rename from {oldname} at {_ip.ToString()} to {newname}");
else Utilities.Log(LogLevel.INFO, $"{_ip.ToString()} connected as {newname}");
}
public async Task SendRename(string oldname, string newname, Rank rank)
{
await SendAsync(Guacutils.Encode("rename", "1", oldname, newname, ((int)rank).ToString()));
}
public async Task SendNewUser(string username, Rank rank)
{
await SendAsync(Guacutils.Encode("adduser", "1", username, ((int)rank).ToString()));
}
public async Task SendDisconnect(string username)
{
await SendAsync(Guacutils.Encode("remuser", "1", username));
}
public async Task SendChat(string username, string message)
{
await SendAsync(Guacutils.Encode("chat", username, message));
}
public async Task SendTurnUpdate(TurnStatus status)
{
List<string> msg = new();
msg.Add("turn");
msg.Add((status.TimeRemaining * 1000).ToString());
msg.Add(status.Queue.Length.ToString());
if (status.Queue.Length > 0)
{
foreach (User user in status.Queue)
msg.Add(user.Username!);
if (status.Queue[0] != this && status.Queue.Contains(this))
msg.Add(((status.TimeRemaining * 1000) + ((Array.IndexOf(status.Queue, this) - 1) * Program.Config.Turns.TurnTime * 1000 )).ToString());
}
await SendAsync(Guacutils.Encode(msg.ToArray()));
}
public async Task SendVoteUpdate(ResetVoteStatus status)
{
await this.SendAsync(Guacutils.Encode("vote", "1", (status.TimeRemaining * 1000).ToString(), status.YesVotes.Length.ToString(), status.NoVotes.Length.ToString()));
}
public bool HasTurn()
{
if (vm == null) return false;
return vm.TurnQueue.CurrentUser() == this;
}
public async Task Mute(bool permanent)
{
if (vm == null) return;
this.IPData!.Mute(permanent);
vm.TurnQueue.RemoveUser(this);
await this.SendChat("", $"You have been muted{(permanent ? "" : $" for {Program.Config.Limits.TempMuteTime} seconds")}.");
}
}

View file

@ -0,0 +1,99 @@
using System.Diagnostics;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
namespace CollabVM.Server;
public enum LogLevel
{
DEBUG,
INFO,
WARN,
ERROR,
FATAL
}
public static class Utilities
{
public static void Log(LogLevel level, string msg)
{
#if !DEBUG
if (level == LogLevel.DEBUG)
return;
#endif
StringBuilder logstr = new StringBuilder();
logstr.Append("[");
logstr.Append(DateTime.Now.ToString("G"));
logstr.Append("] [");
switch (level)
{
case LogLevel.DEBUG:
logstr.Append("DEBUG");
break;
case LogLevel.INFO:
logstr.Append("INFO");
break;
case LogLevel.WARN:
logstr.Append("WARN");
break;
case LogLevel.ERROR:
logstr.Append("ERROR");
break;
case LogLevel.FATAL:
logstr.Append("FATAL");
break;
default:
throw new ArgumentException("Invalid log level");
}
logstr.Append("] ");
logstr.Append(msg);
switch (level)
{
case LogLevel.DEBUG:
case LogLevel.INFO:
Console.WriteLine(logstr.ToString());
break;
case LogLevel.WARN:
case LogLevel.ERROR:
case LogLevel.FATAL:
Console.Error.Write(logstr.ToString());
break;
}
}
public static string[] ParseCommand(string cmd)
{
return new Regex("(?<=\")[^\"]*(?=\")|[^\" ]+")
.Matches(cmd)
.Select(m => m.Value)
.ToArray();
}
public static string BytesToHex(byte[] buf)
{
StringBuilder outstr = new();
for (int i = 0; i < buf.Length; i++)
outstr.Append(buf[i].ToString("x2"));
return outstr.ToString();
}
public static Task ExecuteCommand(string cmd)
{
var proc = new Process();
proc.StartInfo.FileName = cmd.Split(" ")[0];
proc.StartInfo.Arguments = cmd.Substring(cmd.IndexOf(' ') + 1);
proc.StartInfo.UseShellExecute = true;
proc.StartInfo.CreateNoWindow = true;
proc.Start();
return proc.WaitForExitAsync();
}
public static byte[] GetAsset(string name)
{
var path = Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location), "Assets", name);
return File.ReadAllBytes(path);
}
}

255
collab-vm-server-1.3/VM.cs Normal file
View file

@ -0,0 +1,255 @@
using System.Net;
using CollabVM.Server.Config;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace CollabVM.Server;
public class VM
{
// This class is in charge of what CollabVM considers a "VM", meaning chat, turns, all that fun stuff.
// The management of the VM itself is done by a VMController, which this class also manages.
// Public properties and events
public VMController Controller { get; }
public VMConfig Config { get; }
public List<User> Users { get; } = new();
public CircularBuffer.CircularBuffer<ChatMessage> ChatHistory { get; } = new((int)Program.Config.Chat.ChatHistoryLength);
public TurnQueue TurnQueue { get; } = new(Program.Config.Turns.TurnTime);
public ResetVote? Vote { get; private set; }
public Dictionary<IPAddress, IPData> IPs { get; } = new();
public Cooldown VoteCooldown { get; } = new(Program.Config.Votes.VoteCooldown);
public bool TurnsAllowed { get; set; }
public bool ScreenHidden => screenHidden;
// Private fields
private bool screenHidden = false;
public VM(VMConfig config, VMController controller)
{
this.Controller = controller;
this.Config = config;
this.TurnsAllowed = config.TurnsAllowed;
// Subscribe to the controller's events
this.Controller.Rect += ControllerOnRect;
this.Controller.ScreenSize += ControllerOnScreenSize;
TurnQueue.Turn += async (_, e) => await SendTurnQueue(e);
}
private void ControllerOnScreenSize(object? sender, Size e)
{
// Send the new size to all users
foreach (User user in this.Users)
{
if (screenHidden && user.Rank == Rank.Unregistered) continue;
user.SendScreenSize(e);
}
}
private async void ControllerOnRect(object? sender, DirtyRectData e)
{
// Encode the dirty rect as a JPEG
await using var ms = new MemoryStream();
Utilities.Log(LogLevel.DEBUG, $"Loading {e.Width}x{e.Height} rect with buffer size {e.Data.Length}");
Image<Rgba32> img;
try
{
img = Image.LoadPixelData<Rgba32>(e.Data, e.Width, e.Height);
}
catch (Exception ex)
{
Utilities.Log(LogLevel.ERROR, $"Failed to load {e.Width}x{e.Height} rect with buffer size {e.Data.Length}");
throw;
}
await img.SaveAsJpegAsync(ms);
var jpeg = ms.GetBuffer();
var jpg64 = Convert.ToBase64String(jpeg);
// Send the dirty rect to all users
foreach (User user in this.Users)
{
if (screenHidden && user.Rank == Rank.Unregistered) continue;
user.SendRect(jpg64, e.X, e.Y);
}
}
public async Task<byte[]> GetThumbnail()
{
if (screenHidden) return Program.ScreenHiddenThumb;
var fb = await this.Controller.GetFramebufferData();
Console.WriteLine($"got fb, size {Controller.FramebufferWidth}x{Controller.FramebufferHeight}");
var thmb = Image.LoadPixelData<Rgba32>(fb, Controller.FramebufferWidth, Controller.FramebufferHeight);
Console.WriteLine("loaded into image");
thmb.Mutate(ctx => ctx.Resize(400, 300));
await using var ms = new MemoryStream();
await thmb.SaveAsJpegAsync(ms);
return ms.ToArray();
}
public Task<byte[]> GetFramebufferPixelData() => this.Controller.GetFramebufferData();
public async Task<byte[]> GetFramebufferJpeg()
{
var fb = await this.GetFramebufferPixelData();
var img = Image.LoadPixelData<Rgba32>(fb, Controller.FramebufferWidth, Controller.FramebufferHeight);
await using var ms = new MemoryStream();
await img.SaveAsJpegAsync(ms);
return ms.ToArray();
}
public Size GetScreenSize()
{
return new Size(this.Controller.FramebufferWidth, this.Controller.FramebufferHeight);
}
public async Task AddUser(User user)
{
this.Users.Add(user);
if (IPs.TryGetValue(user.IP, out var p))
user.IPData = p;
else
{
var ipd = new IPData();
IPs.Add(user.IP, ipd);
user.IPData = ipd;
}
user.IPData!.Unmuted += async (_, _) =>
{
// If the user is no longer connected, don't send the unmute
if (!this.Users.Contains(user)) return;
await user.SendChat("", "You are no longer muted.");
};
user.Renamed += async (_, e) =>
{
foreach (User u in this.Users)
{
await u.SendRename(e, user.Username!, user.Rank);
}
};
user.Disconnected += async (_, _) =>
{
this.Users.Remove(user);
this.TurnQueue.RemoveUser(user);
user.IPData.Reset();
if (this.Vote != null && (this.Vote.YesVotes.Contains(user) || this.Vote.NoVotes.Contains(user)))
{
this.Vote.ClearVote(user);
var status = this.Vote.GetStatus();
foreach (User u in this.Users)
await u.SendVoteUpdate(status);
}
foreach (User u in this.Users)
{
await u.SendDisconnect(user.Username!);
}
};
foreach (User u in this.Users)
{
await u.SendNewUser(user.Username!, user.Rank);
}
}
public async Task SendChat(User user, string message, bool xss = false, bool excludeAdmins = false)
{
var messageSanitized = WebUtility.HtmlEncode(message);
foreach (User u in this.Users)
{
if (xss && (u.Rank != Rank.Admin || !excludeAdmins))
await u.SendChat(user.Username!, message);
else
await u.SendChat(user.Username!, messageSanitized);
}
ChatHistory.PushFront(new ChatMessage {Username = user.Username!, Message = messageSanitized});
}
public async Task SendMouse(int x, int y, int mask)
{
await this.Controller.SendMouse(x, y, mask);
}
public async Task SendKey(int keysym, bool down)
{
await this.Controller.SendKeysym(keysym, down);
}
public async Task ReannounceUser(User user)
{
foreach (var u in this.Users)
{
await u.SendNewUser(user.Username, user.Rank);
}
}
public async Task SendTurnQueue(TurnStatus? s = null)
{
TurnStatus status = s ?? this.TurnQueue.CurrentTurn();
foreach (User u in this.Users)
{
u.IPData!.IsTurning = status.Queue.Contains(u);
await u.SendTurnUpdate(status);
}
}
public async Task VoteReset(User user, bool vote)
{
if (this.Vote == null)
{
this.Vote = new ResetVote(Program.Config.Votes.VoteTime);
this.Vote.Finished += async (_, e) =>
{
this.Vote = null;
this.VoteCooldown.Run();
foreach (var u in Users)
{
u.IPData!.IsVoting = false;
await u.SendAsync(Guacutils.Encode("vote", "2"));
}
if (e.Result)
{
foreach (var u in Users)
await u.SendChat("", "The vote to reset the VM has won.");
await this.Controller.Reset();
}
else
{
foreach (var u in Users)
await u.SendChat("", "The vote to reset the VM has lost.");
}
};
foreach (User u in this.Users)
{
await u.SendAsync(Guacutils.Encode("vote", "0"));
await u.SendChat("", $"{user.Username} has started a vote to reset the VM.");
}
} else
foreach (User u in this.Users)
await u.SendChat("", $"{user.Username} has voted {(vote ? "yes" : "no")}.");
this.Vote.Vote(user, vote);
var status = this.Vote.GetStatus();
foreach (User u in this.Users)
await u.SendVoteUpdate(status);
}
public async Task HideScreen(bool hidden)
{
if (hidden)
{
this.screenHidden = true;
foreach (User u in this.Users.Where(u => u.Rank == Rank.Unregistered))
{
await u.SendScreenSize(new Size(1024, 768));
await u.SendRect(Program.ScreenHiddenBase64, 0, 0);
}
}
else
{
this.screenHidden = false;
foreach (User u in this.Users.Where(u => u.Rank == Rank.Unregistered))
{
await u.SendScreenSize(GetScreenSize());
await u.SendRect(Convert.ToBase64String(await GetFramebufferJpeg()), 0, 0);
}
}
}
}

View file

@ -0,0 +1,24 @@
using SixLabors.ImageSharp;
namespace CollabVM.Server;
public abstract class VMController
{
public abstract event EventHandler<DirtyRectData> Rect;
public abstract event EventHandler<Size> ScreenSize;
public abstract int FramebufferWidth { get; }
public abstract int FramebufferHeight { get; }
public abstract bool IsRunning { get; }
public abstract bool Snapshots { get; }
public abstract Task Start();
public abstract Task Stop();
public abstract Task Reboot();
public abstract Task Reset();
public abstract Task<string> MonitorCommand(string cmd);
public abstract Task<byte[]> GetFramebufferData();
public abstract Task SendKeysym(int keysym, bool down);
public abstract Task SendMouse(int x, int y, int mask);
}

View file

@ -0,0 +1,293 @@
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Text.Json;
using System.Text.Json.Nodes;
using CollabVM.Server.Config;
using CollabVM.Server.DisplayControllers.VNC;
using QMPSharp;
using SixLabors.ImageSharp;
using Timer = System.Timers.Timer;
namespace CollabVM.Server.VMControllers;
public class QEMUController : VMController
{
public override event EventHandler<DirtyRectData> Rect;
public override event EventHandler<Size> ScreenSize;
// Private fields
private string _id;
private readonly string _qemuPath;
private readonly string[] _qemuArgs;
private readonly int _vncPort;
private readonly int _qmpPort;
private readonly string? _qmpSocket;
private Process? _qemuProc;
private QMPClient? _qmp;
private VNCDisplay _vnc;
private int _width;
private int _height;
private bool _running = false;
private bool _expectedExit = false;
// Restart/reconnect timers
private Timer? _qemuRestartTimer;
private Timer _qmpReconnectTimer;
// Restart/reconnect error levels
private int _qemuRestartErrorLevel = 0;
private int _qmpReconnectErrorLevel = 0;
public override int FramebufferWidth => _width;
public override int FramebufferHeight => _height;
public override bool IsRunning => _running;
public override bool Snapshots { get; }
public QEMUController(string id, QEMUVMConfig config)
{
this._id = id;
this._vncPort = config.VNCPort;
this.Snapshots = config.Snapshots;
this._vnc = new VNCDisplay("127.0.0.1", _vncPort);
this._vnc.Rect += VncOnRect;
this._vnc.SizeChanged += VncOnSizeChanged;
if (config.UseUnixSockets)
{
this._qmpSocket = (config.QMPSocketDir ?? "/tmp") + $"/collab-vm-qmp-{id}.sock";
}
else
{
this._qmpPort = config.QMPPort;
}
var cmd = Utilities.ParseCommand(config.QEMUCmd);
this._qemuPath = cmd[0];
var cvmArgs = new List<string>();
cvmArgs.Add("-no-shutdown");
cvmArgs.Add("-vnc");
cvmArgs.Add("127.0.0.1:" + (_vncPort - 5900));
cvmArgs.Add("-qmp");
if (config.UseUnixSockets)
{
cvmArgs.Add("unix:" + _qmpSocket + ",server,nowait");
}
else
{
cvmArgs.Add("tcp:127.0.0.1:" + _qmpPort + ",server,nowait");
}
if (config.Snapshots)
{
cvmArgs.Add("-snapshot");
}
this._qemuArgs = cmd.Skip(1).Concat(cvmArgs).ToArray();
_qmpReconnectTimer = new(5000);
_qmpReconnectTimer.AutoReset = false;
_qmpReconnectTimer.Elapsed += async (_, _) => await ConnectQMP();
}
private void VncOnSizeChanged(object? sender, MarcusW.VncClient.Size e)
{
// Don't trigger a framebuffer reset if the server misbehaves and sends an identical size instruction
if (e.Width == this._width && e.Height == this._height) return;
this.ScreenSize.Invoke(this, new Size(e.Width, e.Height));
this._width = e.Width;
this._height = e.Height;
}
private void VncOnRect(object? sender, DirtyRectData e)
{
this.Rect.Invoke(this, e);
}
public override async Task Start()
{
_expectedExit = false;
// Make sure the QMP socket doesn't exist
if (_qmpSocket != null && File.Exists(_qmpSocket))
{
try
{
File.Delete(_qmpSocket);
}
catch (Exception ex)
{
Utilities.Log(LogLevel.FATAL, "Failed to delete QMP socket: " + ex.Message);
Environment.Exit(1);
}
}
// Create the QEMU process
_qemuProc?.Dispose();
_qemuProc = new Process();
_qemuProc.StartInfo.FileName = _qemuPath;
foreach (string arg in _qemuArgs)
_qemuProc.StartInfo.ArgumentList.Add(arg);
_qemuProc.StartInfo.UseShellExecute = false;
_qemuProc.StartInfo.RedirectStandardOutput = true;
_qemuProc.StartInfo.RedirectStandardError = true;
_qemuProc.StartInfo.RedirectStandardInput = true;
_qemuProc.StartInfo.CreateNoWindow = true;
_qemuProc.EnableRaisingEvents = true;
_qemuProc.OutputDataReceived += QemuProcOnOutputDataReceived;
_qemuProc.ErrorDataReceived += QemuProcOnErrorDataReceived;
_qemuProc.Exited += QemuProcOnExited;
_qemuProc.Start();
// Give QEMU 2 seconds to start up
await Task.Delay(2000);
// Connect to QMP
await ConnectQMP();
Utilities.Log(LogLevel.INFO, $"[{_id}] QMP Connected");
_qemuRestartErrorLevel = 0;
_qmpReconnectErrorLevel = 0;
// Connect VNC
await this._vnc.Connect();
Utilities.Log(LogLevel.INFO, $"[{_id}] VNC Connected");
this._running = true;
}
private async Task ConnectQMP()
{
_qmp?.Dispose();
Socket socket;
EndPoint endpoint;
if (this._qmpSocket != null)
{
socket = new(AddressFamily.Unix, SocketType.Stream, ProtocolType.IP);
endpoint = new UnixDomainSocketEndPoint(_qmpSocket);
}
else
{
socket = new(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp);
endpoint = new IPEndPoint(IPAddress.Parse("127.0.0.1"), this._qmpPort);
}
this._qmp = new QMPClient(socket, endpoint);
this._qmp.QMPEventReceived += QmpOnQMPEventReceived;
this._qmp.Disconnected += QmpOnDisconnected;
try
{
await this._qmp.ConnectAsync();
}
catch (Exception e)
{
Utilities.Log(LogLevel.ERROR, $"[{_id}] Failed to connect to QMP: {e.Message}");
if (_qmpReconnectErrorLevel > 5)
{
Utilities.Log(LogLevel.ERROR, "QMP reconnect failed too many times, restarting QEMU...");
await StopQEMU();
Start();
}
else
{
_qmpReconnectErrorLevel++;
Utilities.Log(LogLevel.INFO, $"[{_id}] Reconnecting in 5 seconds...");
_qmpReconnectTimer.Start();
}
}
}
private void QmpOnDisconnected(object? sender, EventArgs e)
{
Utilities.Log(LogLevel.ERROR, $"[{_id}] QMP disconnected.");
if (_expectedExit) return;
Utilities.Log(LogLevel.INFO, $"[{_id}] Reconnecting in 5 seconds...");
_qmpReconnectTimer.Start();
}
private async void QmpOnQMPEventReceived(object? sender, QMPEvent e)
{
switch (e.Event)
{
case "STOP":
{
Utilities.Log(LogLevel.INFO, $"[{_id}] VM was shutdown, rebooting...");
await _qmp.Reboot();
}
break;
case "RESET":
{
Utilities.Log(LogLevel.INFO, $"[{_id}] Got QEMU reset event");
await _qmp.Resume();
}
break;
}
}
private async void QemuProcOnExited(object? sender, EventArgs e)
{
if (_expectedExit) return;
Utilities.Log(LogLevel.ERROR, $"[{_id}] QEMU exited, restarting in 5 seconds...");
// Disconnect QMP and VNC
this._qmp?.Dispose();
await this._vnc.Disconnect();
// Schedule a restart
_qemuRestartTimer?.Dispose();
_qemuRestartTimer = new Timer(5000);
_qemuRestartTimer.AutoReset = false;
_qemuRestartTimer.Elapsed += async (_, _) =>
{
await this.Start();
};
_qemuRestartTimer.Start();
}
private void QemuProcOnErrorDataReceived(object sender, DataReceivedEventArgs e)
{
Utilities.Log(LogLevel.WARN, $"[{_id}] QEMU sent to stderr: {e.Data}");
}
private void QemuProcOnOutputDataReceived(object sender, DataReceivedEventArgs e)
{
Utilities.Log(LogLevel.INFO, $"[{_id}] QEMU sent to stdout: {e.Data}");
}
public override async Task Stop()
{
_running = false;
await StopQEMU();
}
private async Task StopQEMU()
{
_expectedExit = true;
if (this._qemuProc == null || this._qemuProc.HasExited) return;
await this._vnc.Disconnect();
if (_qmp == null || _qmp.Disposed)
{
_qemuProc.Kill();
await this._qemuProc.WaitForExitAsync();
return;
}
await this._qmp.SendAsync(new JsonObject { { "execute", "quit" } });
var tmr = new Timer(5000);
tmr.AutoReset = false;
tmr.Elapsed += (_, _) =>
{
if (_qemuProc.HasExited) return;
Utilities.Log(LogLevel.WARN, $"[{_id}] QEMU took too long to exit, killing...");
_qemuProc.Kill();
};
tmr.Start();
await this._qemuProc.WaitForExitAsync();
tmr.Stop();
}
public override Task Reboot() => this._qmp.Reboot();
public override async Task Reset()
{
if (!Snapshots) return;
await this.StopQEMU();
await this.Start();
}
public override async Task<string> MonitorCommand(string cmd)
{
var response = await _qmp.HumanMonitorCommand(cmd);
return response;
}
public override Task<byte[]> GetFramebufferData()
{
return Task.Run(byte[] () => _vnc.GetFramebufferData());
}
public override Task SendKeysym(int keysym, bool down) => _vnc.SendKeysym(keysym, down);
public override Task SendMouse(int x, int y, int mask) => _vnc.SendMouse(x, y, mask);
}

View file

@ -0,0 +1,79 @@
using CollabVM.Server.DisplayControllers.VNC;
using SixLabors.ImageSharp;
namespace CollabVM.Server.VMControllers;
public class VNCController : VMController
{
private string host;
private int port;
private VNCDisplay vnc;
private int width;
private int height;
public VNCController(string host, int port)
{
this.host = host;
this.port = port;
this.vnc = new VNCDisplay(host, port);
this.vnc.Rect += VncOnRect;
this.vnc.SizeChanged += VncOnSizeChanged;
}
private void VncOnSizeChanged(object? sender, MarcusW.VncClient.Size e)
{
// Don't trigger a framebuffer reset if the server misbehaves and sends an identical size instruction
if (e.Width == this.width && e.Height == this.height) return;
this.ScreenSize.Invoke(this, new Size(e.Width, e.Height));
this.width = e.Width;
this.height = e.Height;
}
private void VncOnRect(object? sender, DirtyRectData e)
{
this.Rect.Invoke(this, e);
}
public override event EventHandler<DirtyRectData> Rect;
public override event EventHandler<Size> ScreenSize;
public override int FramebufferWidth => this.width;
public override int FramebufferHeight => this.height;
public override bool IsRunning => true;
public override bool Snapshots => false;
public override async Task Start()
{
await this.vnc.Connect();
}
public override async Task Stop()
{
await this.vnc.Disconnect();
}
public override async Task Reboot()
{
}
public override async Task Reset()
{
}
public override Task<string> MonitorCommand(string cmd) => Task.FromResult("This VM does not have a monitor.");
public override async Task<byte[]> GetFramebufferData()
{
return vnc.GetFramebufferData();
}
public override async Task SendKeysym(int keysym, bool down)
{
await vnc.SendKeysym(keysym, down);
}
public override async Task SendMouse(int x, int y, int mask)
{
await vnc.SendMouse(x, y, mask);
}
}

View file

@ -0,0 +1,69 @@
using CollabVM.Server.Config;
using CollabVM.Server.VMControllers;
namespace CollabVM.Server;
public class VMManager
{
// This class is in charge of managing all the VMs
// Private fields
private readonly List<VM> VMs = new();
public VMManager(VMConfig[] vms)
{
// Create the VMs
foreach (VMConfig vm in vms)
{
if (vm.VNC != null)
{
VMs.Add(new VM(vm, new VNCController(vm.VNC.Host, vm.VNC.Port)));
}
else if (vm.QEMU != null)
{
VMs.Add(new VM(vm, new QEMUController(vm.ID, vm.QEMU)));
}
}
}
public async Task StartAll()
{
List<Task> tsks = new();
foreach (VM vm in VMs)
{
tsks.Add(vm.Controller.Start());
}
await Task.WhenAll(tsks);
}
public async Task StopAll()
{
foreach (VM vm in VMs)
{
await vm.Controller.Stop();
}
}
public VM? GetVM(string id)
{
return VMs.Find(vm => vm.Config.ID == id);
}
public async Task<ListVM[]> GetList()
{
List<ListVM> list = new();
foreach (VM vm in VMs)
{
if (!vm.Controller.IsRunning) continue;
var thumb = await vm.GetThumbnail();
Console.WriteLine("got thumbnail");
list.Add(new ListVM()
{
ID = vm.Config.ID,
Name = vm.Config.Name,
Thumbnail = thumb
});
}
return list.ToArray();
}
}

View file

@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<RootNamespace>CollabVM.Server</RootNamespace>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>CollabVM.Server</AssemblyName>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CircularBuffer" Version="1.3.0" />
<PackageReference Include="MySqlConnector" Version="2.3.3" />
<PackageReference Include="Samboy063.Tomlet" Version="5.2.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.5.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MarcusW.VncClient\src\MarcusW.VncClient\MarcusW.VncClient.csproj" />
<ProjectReference Include="..\QMPSharp\QMPSharp\QMPSharp.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Assets\screenhidden.jpeg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="Assets\screenhiddenthumb.jpeg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

119
config.toml Normal file
View file

@ -0,0 +1,119 @@
[HTTP]
# The host for the HTTP server to bind to.
# If you're reverse proxying behind nginx, this should probably be 127.0.0.1
# otherwise, 0.0.0.0 will open it to the internet.
Host = "0.0.0.0"
# Port for the HTTP server to listen on
Port = 6004
# Set to true if you will be proxying your VMs behing a reverse proxy, like NGINX.
# This is required for UserVMs.
ReverseProxy = false
# IPs allowed to reverse proxy your VMs. 99% of the time, this will just be 127.0.0.1
ProxyAllowedIPs = ["127.0.0.1"]
# Set to true to whitelist certain webapps from connecting to your VMs.
OriginCheck = false
# List of domains allowed to host webapps that connect to your VMs.
AllowedOrigins = ["https://computernewb.com", "http://localhost:3000"]
[Turns]
# How long each turn is
TurnTime = 20
[Votes]
# How long a vote to reset lasts
VoteTime = 30
# The amount of time before another vote to reset can be started
VoteCooldown = 120
[Chat]
# Maximum length for chat messages. Messages above this length will be truncated
MaxMessageLength = 100
# The max amount of messages to store in the chat history and send to new clients, before old messages are deleted
ChatHistoryLength = 10
[Staff]
# Password hashes can be generated with the following command:
# echo -n '<password>' | sha256sum -
# SHA256 Hash of the Admin password. (Default: hunter2)
AdminPasswordHash = "f52fbd32b2b3b86ff88ef6c490628285f482af15ddcb29541f94bcf526a3f6c7"
# If the moderator role is enabled
ModeratorEnabled = true
# SHA256 Hash of the Mod password. (Default: hunter3)
ModPasswordHash = "fb8c2e2b85ca81eb4350199faddd983cb26af3064614e737ea9f479621cfa57a"
[Bans]
# If set to true, the server will store, track, and enforce bans in the mysql database.
# Requires mysql to be defined
UseInternalBlacklist = true
# If set, the server will run this command whenever a user is banned
# $IP - The IP of the banned user
# $NAME - Username of the banned user
# $REASON - Optional ban reason
#RunCommand = ""
[Limits]
# How long temporary mutes last
TempMuteTime = 30
# How many messages may be sent within the specified period of time before the user is temporarily muted
ChatLimit = { Enabled = true, Limit = 5, Cooldown = 5 }
# How many mouse and keyboard instructions may be sent within the specified period of time before the user is disconnected ("kit protection")
KitLimit = { Enabled = true, Limit = 700, Cooldown = 1 }
# Defines a MySQL server to connect to.
# This is only required if you are using the internal banlist, and may be commented otherwise
[MySQL]
Host = "127.0.0.1"
Username = "collabvm"
Password = "hunter2"
Database = "collabvm"
# Defines permissions moderators have. May be commented if moderators are not enabled
[ModPermissions]
# Restore the VM to snapshot
Restore = true
# Reboot the VM
Reboot = true
# Ban a user
Ban = true
# Forcibly end a vote-for-reset
ForceVote = true
# Mute a user
Mute = true
# Kick a user
Kick = true
# Manipulate the turn queue (toggle turns, end turns, steal turn, clear turn queue)
BypassTurn = true
# Rename a user
Rename = true
# Get a user's IP
GrabIP = true
# Send an XSS (not HTML sanitized) message
XSS = true
# Hide the screen from all non-staff
HideScreen = true
# The following section defines a VM. This section may be duplicated for any additional VMs.
[[VMs]]
# Node ID of the VM. Must be unique
ID = "examplevm"
# DIsplay name for the VM. Formatted with HTML
Name = "Test VM"
# Message of the day, sent when a user joins the VM
MOTD = "Welcome"
# Now you may configure a VM controller by uncommenting one of the below...
# For a VM that simply connects to a VNC server,
#VNC = {Host = "127.0.0.1", Port = 5901}
# A QEMU VM.
# QEMUCmd - QEMU start command. The pash to the QEMU executable MUST be specified in full.
# UseUnixSockets - Use UNIX domain sockets. Only available on Linux. Strongly recommended if available.
# QMPSocketDir - Path where the QMP socket is stored. Defaults to /tmp if not specified. 99% of the time you do not need to specify this
# QMPPort - If UseUnixSockets is disabled, a port for the QMP server to listen on. Required on windows
# VNCPort - Port to use for the VNC server. Must be at least 5900 and must be unique among other VMs.
# Snapshots - True if the VM state is temporary, and may be reset through a vote. If you set this to false on a public VM, prepare for it to get trashed quick.
QEMU = {QEMUCmd = "/bin/qemu-system-x86_64 -accel kvm -cpu host -smp cores=4 -m 4G", UseUnixSockets = true, VNCPort = 5900, Snapshots = true}
# True if the public can take turns, false to limit to staff and those with the turn password. This can be toggled at runtime with opcode 22
TurnsAllowed = true
# If defined, the hash for a password non-staff can use to take turns while they are disabled. (Default: hunter4)
TurnPasswordHash = "18183dd9009f2b7e1b44f9c4af287589c2415bc6258f547815b246ffeb955122"