This commit is contained in:
ElijahR2411 2023-12-04 11:42:18 -05:00
commit 82ab96e352
13 changed files with 435 additions and 0 deletions

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
build/

1
.npmrc Normal file
View file

@ -0,0 +1 @@
package-lock=false

13
config.example.json Normal file
View file

@ -0,0 +1,13 @@
{
"DisallowedFilenameChars": ["\"","<",">","|","\u0000","\u0001","\u0002","\u0003","\u0004","\u0005","\u0006","\u0007","\b","\t","\n","\u000b","\f","\r","\u000e","\u000f","\u0010","\u0011","\u0012","\u0013","\u0014","\u0015","\u0016","\u0017","\u0018","\u0019","\u001a","\u001b","\u001c","\u001d","\u001e","\u001f",":","*","?","\\","/"],
"ListenPort": 8369,
"MaxFileSize": 104857600,
"BlockedMD5": [],
"RateLimit": 10,
"VMs": [
{
"ID": "vm1",
"SocketPath": "/tmp/vm1-uploads.sock"
}
]
}

13
config.json Normal file
View file

@ -0,0 +1,13 @@
{
"DisallowedFilenameChars": ["\"","<",">","|","\u0000","\u0001","\u0002","\u0003","\u0004","\u0005","\u0006","\u0007","\b","\t","\n","\u000b","\f","\r","\u000e","\u000f","\u0010","\u0011","\u0012","\u0013","\u0014","\u0015","\u0016","\u0017","\u0018","\u0019","\u001a","\u001b","\u001c","\u001d","\u001e","\u001f",":","*","?","\\","/"],
"ListenPort": 8369,
"MaxFileSize": 104857600,
"BlockedMD5": ["038e69dec7760dbf8d52f0f2b0677280"],
"RateLimit": 10,
"VMs": [
{
"ID": "forkievm1",
"SocketPath": "/tmp/vm1-uploads.sock"
}
]
}

24
package.json Normal file
View file

@ -0,0 +1,24 @@
{
"name": "collabvm-agent-server",
"version": "1.0.0",
"description": "A server to upload files to a VM",
"main": "build/index.js",
"type": "module",
"scripts": {
"build": "tsc"
},
"author": "Elijah R",
"license": "GPL-3.0",
"devDependencies": {
"@types/msgpack-lite": "^0.1.11",
"@types/node": "^20.10.3",
"typescript": "^5.3.2"
},
"dependencies": {
"@types/md5": "^2.3.5",
"async-mutex": "^0.4.0",
"fastify": "^4.24.3",
"md5": "^2.3.0",
"msgpack-lite": "^0.1.26"
}
}

11
src/IConfig.ts Normal file
View file

@ -0,0 +1,11 @@
export default interface IConfig {
DisallowedFilenameChars : string[];
ListenPort : number;
MaxFileSize : number;
BlockedMD5: string[];
RateLimit : number;
VMs : {
ID : string;
SocketPath : string;
}[]
}

9
src/Protocol.ts Normal file
View file

@ -0,0 +1,9 @@
export interface ProtocolMessage {
Operation : ProtocolOperation;
Filename? : string;
FileData? : Buffer;
}
export enum ProtocolOperation {
UploadFile = 0,
}

30
src/Ratelimit.ts Normal file
View file

@ -0,0 +1,30 @@
export default class RateLimit {
private cooldown : number;
private ready : boolean;
private interval? : NodeJS.Timeout;
private timeRemaining : number;
constructor(cooldown : number) {
this.cooldown = cooldown;
this.ready = true;
this.timeRemaining = 0;
}
Limit() : boolean {
if (this.ready) {
this.ready = false;
this.timeRemaining = this.cooldown;
this.interval = setInterval(() => {
this.timeRemaining--;
if (this.timeRemaining <= 0) {
this.ready = true;
clearInterval(this.interval!);
}
}, 1000);
return true;
} else {
return false;
}
}
GetRemaining() : number {
return this.timeRemaining;
}
}

63
src/VM.ts Normal file
View file

@ -0,0 +1,63 @@
import { Socket } from "net";
import * as protocol from './Protocol.js'
import * as msgpack from 'msgpack-lite';
import {Mutex} from 'async-mutex';
import log from './log.js';
export default class VM {
#socketpath : string;
#socket : Socket;
#writeLock : Mutex = new Mutex();
connected : boolean = false;
constructor(socketpath : string) {
this.#socketpath = socketpath;
this.#socket = new Socket();
this.#socket.connect(socketpath);
this.#socket.on('connect', () => {
this.connected = true;
log("INFO", `Connected to VM at ${socketpath}`);
});
}
UploadFile(filename : string, data : Buffer) : Promise<void> {
return new Promise(async (res, rej) => {
const msg : protocol.ProtocolMessage = {
Operation: protocol.ProtocolOperation.UploadFile,
Filename: filename,
FileData: data
};
var payload = msgpack.encode(msg);
var header = Buffer.alloc(4);
header.writeUInt32LE(payload.length);
// Make sure we dont write two messages at the same time
var release = await this.#writeLock.acquire();
await this.#writeData(header);
// shit gets fucked if i dont do this
await sleep(100);
await this.#writeData(payload);
// All done
release();
res();
});
}
#writeData(data : Buffer) : Promise<void> {
return new Promise((resolve, reject) => {
this.#socket.write(data, (err) => {
if (err) {
reject(err);
} else {
resolve();
}
});
});
}
}
function sleep(ms : number) : Promise<void> {
return new Promise((res, rej) => {
setTimeout(() => {
res();
}, ms);
})
}

98
src/index.ts Normal file
View file

@ -0,0 +1,98 @@
import Fastify, {errorCodes} from 'fastify';
import { readFileSync } from 'fs';
import IConfig from './IConfig.js';
import VM from './VM.js';
import md5 from 'md5';
import log from './log.js';
import RateLimit from './Ratelimit.js';
log("INFO", "CollabVM Agent Server Starting up...");
// Load the config file
var config : IConfig;
try {
var configraw = readFileSync("./config.json");
config = JSON.parse(configraw.toString());
} catch (e) {
console.error("Failed to load config file: " + (e as Error).message);
process.exit(1);
}
var VMs = new Map<string, VM>();
config.VMs.forEach((v) => {
VMs.set(v.ID, new VM(v.SocketPath));
});
var IPs = new Map<string, RateLimit>();
const app = Fastify({
trustProxy: true,
bodyLimit: config.MaxFileSize,
});
app.setErrorHandler((err, req, res) => {
if (err.code === "FST_ERR_CTP_BODY_TOO_LARGE") {
res.status(413);
res.header("Access-Control-Allow-Origin", "*");
res.send({ success: false, result: "File too large" });
} else {
res.send(err);
}
});
app.addContentTypeParser("application/octet-stream", {parseAs: "buffer"}, (req, body, done) => {
done(null, body);
});
app.options("/:vm/*", async (req, res) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "PUT, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type");
res.header("Allow", "PUT, OPTIONS");
res.status(204);
return;
});
app.put("/:vm/:filename", async (req, res) => {
res.header("Content-Type", "application/json");
res.header("Access-Control-Allow-Origin", "*")
const { vm, filename }: {vm : string, filename : string} = (req.params as any);
log("INFO", `${vm}: ${req.ip} is uploading "${filename}"`);
if (req.headers['content-type'] !== "application/octet-stream") {
res.status(400);
return { success: false, result: "Invalid content-type" };
}
if (parseInt(req.headers["content-length"] as string) > config.MaxFileSize) {
res.status(400);
return { success: false, result: "File too large" };
}
if (!VMs.has(vm)) {
res.status(400);
return { success: false, result: "Invalid VM" };
}
if (config.DisallowedFilenameChars.some((c) => filename.includes(c))) {
res.status(400);
return { success: false, result: "Filename contains disallowed characters" };
}
if (IPs.has(req.ip)) {
if (!IPs.get(req.ip)!.Limit()) {
res.status(429);
return { success: false, result: `Please wait ${IPs.get(req.ip)!.GetRemaining()} seconds before uploading another file` }
}
} else {
IPs.set(req.ip, new RateLimit(config.RateLimit));
}
var filedata = req.body as Buffer;
var hash = md5(filedata);
if (config.BlockedMD5.indexOf(hash) !== -1) {
res.status(400);
log("INFO", `${vm}: ${req.ip} tried to upload "${filename}" with blocked MD5 ${hash}`);
return { success: false, result: "That file is not allowed" };
}
await VMs.get(vm)!.UploadFile(filename, filedata);
log("INFO", `${vm}: ${req.ip} uploaded "${filename}" with MD5 ${hash}`);
return { success: true, result: "File uploaded" };
});
log("INFO", "Starting HTTP server on port " + config.ListenPort);
app.listen({
port: config.ListenPort,
host: "127.0.0.1"
});

7
src/log.ts Normal file
View file

@ -0,0 +1,7 @@
export default function log(loglevel : string, ...message : string[]) {
console[
(loglevel === "ERROR" || loglevel === "FATAL") ? "error" :
(loglevel === "WARN") ? "warn" :
"log"
](`[${new Date().toLocaleString()}] [${loglevel}]`, ...message);
}

109
tsconfig.json Normal file
View file

@ -0,0 +1,109 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "es2022", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./build", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

55
web.js Normal file
View file

@ -0,0 +1,55 @@
var modalel = document.createElement('div');
modalel.innerHTML = `
<div class="modal-dialog">
<div class="modal-content bg-dark text-light">
<div class="modal-header">
<h5 class="modal-title">Upload File</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-success" role="alert" style="display:none;" id="agentSuccessAlert"></div>
<div class="alert alert-danger" role="alert" style="display:none;" id="agentErrorAlert"></div>
<input type="file" class="form-control" id="agentfile"/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="agentUploadBtn">Upload</button>
</div>
</div>
</div>
`;
modalel.classList.add('modal');
modalel.tabIndex = -1;
document.body.appendChild(modalel);
var fileinput = modalel.querySelector('#agentfile');
var uploadbtn = modalel.querySelector('#agentUploadBtn');
var successalert = modalel.querySelector('#agentSuccessAlert');
var erroralert = modalel.querySelector('#agentErrorAlert');
uploadbtn.addEventListener('click', async () => {
if (fileinput.files.length == 0) return;
successalert.style.display = 'none';
erroralert.style.display = 'none';
var file = fileinput.files[0];
var result = await fetch(`https://vmup.elijahr.dev/${window.VMName}/${file.name}`, {
method: 'PUT',
body: file,
headers: {
'Content-Type': 'application/octet-stream'
}
});
var json = await result.json();
if (json.success) {
successalert.style.display = 'block';
successalert.innerText = json.result;
} else {
erroralert.style.display = 'block';
erroralert.innerText = json.result;
}
});
var modal = new bootstrap.Modal(modalel);
var btn = document.createElement('button');
btn.innerHTML = '<i class="fa-solid fa-upload"></i> Upload File';
btn.classList.add('btn', 'btn-secondary');
btn.addEventListener('click', () => {
modal.show();
});
document.getElementById('btns').appendChild(btn);