From 574be6faee85731a234dd54494d1d33004f42961 Mon Sep 17 00:00:00 2001 From: Elijah R Date: Wed, 3 Jan 2024 17:29:02 -0500 Subject: [PATCH] about as ready as its gonna fucking get --- .gitignore | 399 +++++++++++ .gitmodules | 6 + MarcusW.VncClient | 1 + QMPSharp | 1 + collab-vm-server-1.3.sln | 28 + collab-vm-server-1.3/Assets/screenhidden.jpeg | Bin 0 -> 66883 bytes .../Assets/screenhiddenthumb.jpeg | Bin 0 -> 21343 bytes collab-vm-server-1.3/ChatMessage.cs | 7 + collab-vm-server-1.3/Cooldown.cs | 43 ++ collab-vm-server-1.3/Database.cs | 74 ++ collab-vm-server-1.3/DirtyRectData.cs | 17 + collab-vm-server-1.3/Display/RectBatcher.cs | 65 ++ .../Display/VNC/CollabVMLogger.cs | 61 ++ .../Display/VNC/VNCAuthenticationHandler.cs | 16 + .../Display/VNC/VNCDisplay.cs | 103 +++ .../Display/VNC/VNCFramebufferReference.cs | 35 + .../Display/VNC/VNCRenderTarget.cs | 74 ++ collab-vm-server-1.3/Guacutils.cs | 46 ++ collab-vm-server-1.3/HTTPServer.cs | 140 ++++ collab-vm-server-1.3/IConfig.cs | 121 ++++ collab-vm-server-1.3/IPData.cs | 60 ++ collab-vm-server-1.3/ListVM.cs | 11 + collab-vm-server-1.3/MuteStatus.cs | 8 + collab-vm-server-1.3/Permissions.cs | 33 + collab-vm-server-1.3/Program.cs | 71 ++ collab-vm-server-1.3/Rank.cs | 8 + collab-vm-server-1.3/RateLimiter.cs | 47 ++ collab-vm-server-1.3/ResetVote.cs | 79 ++ collab-vm-server-1.3/ResetVoteStatus.cs | 10 + collab-vm-server-1.3/TurnQueue.cs | 165 +++++ collab-vm-server-1.3/TurnStatus.cs | 7 + collab-vm-server-1.3/User.cs | 675 ++++++++++++++++++ collab-vm-server-1.3/Utilities.cs | 99 +++ collab-vm-server-1.3/VM.cs | 255 +++++++ collab-vm-server-1.3/VMController.cs | 24 + .../VMControllers/QEMUController.cs | 293 ++++++++ .../VMControllers/VNCController.cs | 79 ++ collab-vm-server-1.3/VMManager.cs | 69 ++ .../collab-vm-server-1.3.csproj | 34 + config.toml | 119 +++ 40 files changed, 3383 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 160000 MarcusW.VncClient create mode 160000 QMPSharp create mode 100644 collab-vm-server-1.3.sln create mode 100644 collab-vm-server-1.3/Assets/screenhidden.jpeg create mode 100644 collab-vm-server-1.3/Assets/screenhiddenthumb.jpeg create mode 100644 collab-vm-server-1.3/ChatMessage.cs create mode 100644 collab-vm-server-1.3/Cooldown.cs create mode 100644 collab-vm-server-1.3/Database.cs create mode 100644 collab-vm-server-1.3/DirtyRectData.cs create mode 100644 collab-vm-server-1.3/Display/RectBatcher.cs create mode 100644 collab-vm-server-1.3/Display/VNC/CollabVMLogger.cs create mode 100644 collab-vm-server-1.3/Display/VNC/VNCAuthenticationHandler.cs create mode 100644 collab-vm-server-1.3/Display/VNC/VNCDisplay.cs create mode 100644 collab-vm-server-1.3/Display/VNC/VNCFramebufferReference.cs create mode 100644 collab-vm-server-1.3/Display/VNC/VNCRenderTarget.cs create mode 100644 collab-vm-server-1.3/Guacutils.cs create mode 100644 collab-vm-server-1.3/HTTPServer.cs create mode 100644 collab-vm-server-1.3/IConfig.cs create mode 100644 collab-vm-server-1.3/IPData.cs create mode 100644 collab-vm-server-1.3/ListVM.cs create mode 100644 collab-vm-server-1.3/MuteStatus.cs create mode 100644 collab-vm-server-1.3/Permissions.cs create mode 100644 collab-vm-server-1.3/Program.cs create mode 100644 collab-vm-server-1.3/Rank.cs create mode 100644 collab-vm-server-1.3/RateLimiter.cs create mode 100644 collab-vm-server-1.3/ResetVote.cs create mode 100644 collab-vm-server-1.3/ResetVoteStatus.cs create mode 100644 collab-vm-server-1.3/TurnQueue.cs create mode 100644 collab-vm-server-1.3/TurnStatus.cs create mode 100644 collab-vm-server-1.3/User.cs create mode 100644 collab-vm-server-1.3/Utilities.cs create mode 100644 collab-vm-server-1.3/VM.cs create mode 100644 collab-vm-server-1.3/VMController.cs create mode 100644 collab-vm-server-1.3/VMControllers/QEMUController.cs create mode 100644 collab-vm-server-1.3/VMControllers/VNCController.cs create mode 100644 collab-vm-server-1.3/VMManager.cs create mode 100644 collab-vm-server-1.3/collab-vm-server-1.3.csproj create mode 100644 config.toml 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 0000000000000000000000000000000000000000..c02988a61904fa8945e75f64240f2c931f502473 GIT binary patch literal 66883 zcmeFZ1yEg0vo^YL2_blpAVGt>ySuvw*|@s|4ess|+=EMy5ZocS2McaNgL5}|nl-1Arc)JVic$H+j( z%mS9hz{JeOz{$nHM8wF>#mvaX#0)?JL2+;Z4tUI0@CEVVUz&nH{$2j_Opj|wBW6xc zc3kxIHjZ=##DV?W{pg21IVwRyK}YZoI_5gmZ!E zr)GL$qF*ddmb}CoVB>^s9Y91ZbS!iXwBRLhVIl_0a4? z#7hjG{}08i?PO*D7W{89u(p2E_7}IKlc)=L@PCJxql&v7h+YZgXzT1?1QK-t**KB> zHQdr^W8ULQ@Y0dxUelpn9+}iHf2r#mLbAjo}n2CWFWXQrw%fiHLNNdPw#6ru! zY{+h4V8~>|$-wfL97!8TCj%QJ(32dnWIA)O5C&!@BW4yu6Ixbg5Qvrq#9%_p$->S~ z%f@8Pzy@MuV&Y&i{A;|tgE`oZ46Od6K2LIt!E%_`Oh85kU=`TeI2mYJ7}z;!4Ol@8 zv>YZ(%qE5$ENmRcU^&J{Tw=Bk)&}5pFt;`^1<_mBflP^i1rC>>f+R076CJ~!9~G<& zoJ_#HU}rVAF}8Je{Igxf+#00pWblL?BO50(cs3>uPEH0^c1EzBPr!4jfgBvc9`hvZ zSJeGdpX*or*gB}#+FJ1v{~eTn^vQy~+StI!Km;7i;9-BqsLEe4O3T2)#Q@d}Y%Q0p zt+Ba@`~QyhDUN}cQuGA91B(a6le z#uNmORIs=F!Djw%7LS9|gq59{lbM#4jm3nPg_Vh&)_}p73G4}-Y%AQJ-v_P@qE z+L}1I8aRLiO~IZ7MgZ(bzqTc!H-F+w`4^|F8R!W-FbcE`oU{x~f1$uhOb^zb{wWau zFoXVoweVNpe+b&Y*ugu&6Z!YX!bkK^`3Hf25cmgye-QWwfqxMA{~rSXN=-mE;8Tt( zIPrK~hpdtm7S>l#k{6Sd5e4UV008;H%E;OgS^xm7ZJZpG#D$17G_{D}wgCii)`STV z0%!(Cj&`!jVk&>(vi?)|f8X%j7@UHDlQJSOn}Cs>gA2fel;{a9=OwT>F8~#BbJkh4VXh<*#U^u8K34?9J0MkUK|A99A z544e)gEg3k1LHDK^-Jn*yz zU>z0QS6{frG)Xd7iR#NE2|ve_8|p&ou!69h^NQ zYX5;7xbLYS$eRTK6);vZBLI+^1_0!yVA(qVh22kg4gSXMzvcN8zsE&D80@D1XXQ!A z|Ct2v=*I^D^^vaClN90UJ@BG*oDGv@9n>t20%05vrF`uWbDbgdrs z;^Z}W5Blu_dCF%x{eyJ&WlMg?D5&@9yb++j&A}9LDeG+k@0Gt@V7|W_E32VA&TmHb zLRxn@PxV|^Im|YZfr#o$v-^bU?BS8NrSsd;00`^PFLUaT%sEm+Xe>U9d|PtwIPPO+ z9WGr)a~8MMGOgai^}wBOp8{`1zgeKhr!DV74~`T0R)ehwK08XEtS+wCa>O6&88^G! zDaO|#O}@7x@2zeud3oez=~?o$;rv#CNa;)G-ITRx4ZW1{G_W%z{Z!ajU-)TT`oT8L zwWuy*Lyox72Fa*hPg6M_?La+TYpd}l#`m{G0|0DCI2-S9*0S5=R`oW{_`&?F!NLUh z8OTqsJvscAPPh79_~c|8Z~iu}J!XrY^_Z%XTt_P7NLuJEirOQf1l|aKtME3}TC|oN zawoD423u*%Y^oa$ZT4nuOY8F0rk46>+)Y}0P4@2FjywVaO)vWO<%>K#B}ZA-sa;k_ zJihMiGIEj(qwMx*-%AMm)^q^uL1q1k`?H#)cZL}GbN6p*HR~sMK3O`>IkNif*=E=h ze)Yzi`h+s^2qb&Q+7ZTl&XzmBjL$IdRy*w2ywPgVWoscBbTHSRy4d&m-IZeFr9Jla zGrc~~J>z*H&haV#>SF50XO-#nz3VjL3-(dh;GJl=i;-d|=?C2#(J$UYH(uP63v%{F zsM?J%2bZgXQ{PXoy}woSy82c;c)uIW~8x`rFwp&t>$b)%fSS?H*FS zJy-7bwLBN?fqy&U*j#Vo3ZAXbxcG;%-^LZ zMxU#!`#t^Lb#v>swBSA7s4r~tUCVj6?_J>e&EKK$?0s$Iv(GFvDS^6a7j3^A!FU^q zw8QVtiqJh1H-nM;c2Yhkl^eAb=00RSI4?c;zc(#_XBdzyd6t!7aL_@+ z;leM{b6#(Rzsyo}@$xLXz=Q{Vop0oNY~@Z*xQvW=*zdGm#P?lgyxPm^gPqU$!jsY5 z?df!(d%`DAN88^j^3jxzW)t5GuW$Kt4R5rnqmE)7FDbymPWWv9JAwM7twb&3RnlvI zI*ZD~+2H#S{wRUplKaF2IscAOTwXt{v$bICmycY#N;n==$gE_X7{4$%m)VjH8Y-_H zH6t8X3H|wWp#I%r6{h`-eT1w1N#hnav4nOEmCcd7*Q0msg>h~83YXOTns&b{3(N

^25g|E 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 Vwgw-$djY1Yb9W^czx?I*|Jl`?M92UD literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..2679319b4c9227ac713cee5da2d727f1319a6f2d GIT binary patch literal 21343 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 _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"