diff --git a/docs/configuration.md b/docs/configuration.md index 86d1a647..f78c609f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -109,6 +109,14 @@ This should be less than both the timeout and collection interval configured for Defaults to 8. +### master.stream_timeout + +Timeout in seconds for proxy streams through the master that has been created but is not yet sending data to be automatically closed. +Streams that have started sending data before this timeout is not affected. + +Defaults to 15. + + ### master.default_role_id Role to automatically grant to new users. diff --git a/packages/ctl/ctl.js b/packages/ctl/ctl.js index c510e4db..86578c6e 100755 --- a/packages/ctl/ctl.js +++ b/packages/ctl/ctl.js @@ -378,6 +378,57 @@ instanceCommands.add(new libCommand.Command({ }, })); +instanceCommands.add(new libCommand.Command({ + definition: ["upload-save ", "Upload a save to an instance", (yargs) => { + yargs.positional("instance", { describe: "Instance to upload to", type: "string" }); + yargs.positional("filepath", { describe: "Path to save to upload", type: "string" }); + yargs.options({ + "name": { describe: "Name to give save on server", nargs: 1, type: "string" }, + }); + }], + handler: async function(args, control) { + let filename = args.name || path.basename(args.filepath); + if (!filename.endsWith(".zip")) { + throw new libErrors.CommandError("Save name must end with .zip"); + } + // phin doesn't support streaming requests :( + let content = await fs.readFile(args.filepath); + + let instanceId = await libCommand.resolveInstance(control, args.instance); + let url = new URL(control.config.get("control.master_url")); + url.pathname += "api/upload-save"; + url.searchParams.append("instance_id", instanceId); + url.searchParams.append("filename", filename); + + let result = await phin({ + url, method: "POST", + headers: { + "X-Access-Token": control.config.get("control.master_token"), + "Content-Type": "application/zip", + }, + core: { ca: control.tlsCa }, + data: content, + parse: "json", + }); + + for (let error of result.body.errors || []) { + logger.error(error); + } + + for (let requestError of result.body.request_errors || []) { + logger.error(error); + } + + if (result.body.saves && result.body.saves.length) { + logger.info(`Successfully uploaded as ${result.body.saves[0]}`); + } + + if ((result.body.errors || []).length || (result.body.request_errors || []).length) { + throw new libErrors.CommandError("Uploading save failed"); + } + }, +})); + instanceCommands.add(new libCommand.Command({ definition: ["export-data ", "Export item icons and locale from instance", (yargs) => { yargs.positional("instance", { describe: "Instance to export from", type: "string" }); diff --git a/packages/lib/config/definitions.js b/packages/lib/config/definitions.js index 5b5aacc8..043611f9 100644 --- a/packages/lib/config/definitions.js +++ b/packages/lib/config/definitions.js @@ -101,6 +101,13 @@ MasterGroup.define({ type: "number", initial_value: 8, }); +MasterGroup.define({ + name: "proxy_stream_timeout", + title: "Proxy Stream Timeout", + description: "Timeout in seconds for proxy streams to start flowing", + type: "number", + initial_value: 15, +}); MasterGroup.define({ name: "default_role_id", title: "Default role", diff --git a/packages/lib/link/messages.js b/packages/lib/link/messages.js index 7e390fed..b96ff115 100644 --- a/packages/lib/link/messages.js +++ b/packages/lib/link/messages.js @@ -533,6 +533,19 @@ messages.createSave = new Request({ }, }); +messages.pullSave = new Request({ + type: "pull_save", + links: ["master-slave"], + requestProperties: { + "instance_id": { type: "integer" }, + "stream_id": { type: "string" }, + "filename": { type: "string" }, + }, + responseProperties: { + "save": { type: "string" }, + }, +}); + messages.setSaveListSubscriptions = new Request({ type: "set_save_list_subscriptions", links: ["control-master"], diff --git a/packages/lib/users.js b/packages/lib/users.js index 947bc807..f637c7e1 100644 --- a/packages/lib/users.js +++ b/packages/lib/users.js @@ -381,6 +381,11 @@ definePermission({ title: "Create new instance save", description: "Create new savegames on instances.", }); +definePermission({ + name: "core.instance.save.upload", + title: "Upload save to instance", + description: "Upload savegames to instances.", +}); definePermission({ name: "core.instance.save.list_subscribe", title: "Subscribe to save list updatess", diff --git a/packages/master/package.json b/packages/master/package.json index 9e026116..b9116808 100644 --- a/packages/master/package.json +++ b/packages/master/package.json @@ -20,9 +20,9 @@ }, "dependencies": { "@clusterio/lib": "^2.0.0-alpha.6", + "busboy": "^0.3.1", "compression": "^1.7.2", "express": "^4.16.2", - "express-fileupload": "1.2.0", "fs-extra": "^8.1.0", "jsonwebtoken": "^8.2.1", "jszip": "^3.2.2", diff --git a/packages/master/src/BaseConnection.js b/packages/master/src/BaseConnection.js index 191f1bf7..232b1942 100644 --- a/packages/master/src/BaseConnection.js +++ b/packages/master/src/BaseConnection.js @@ -23,25 +23,7 @@ class BaseConnection extends libLink.Link { } async forwardRequestToInstance(message, request) { - let instance = this._master.instances.get(message.data.instance_id); - if (!instance) { - throw new libErrors.RequestError(`Instance with ID ${message.data.instance_id} does not exist`); - } - - let slaveId = instance.config.get("instance.assigned_slave"); - if (slaveId === null) { - throw new libErrors.RequestError("Instance is not assigned to a slave"); - } - - let connection = this._master.wsServer.slaveConnections.get(slaveId); - if (!connection) { - throw new libErrors.RequestError("Slave containing instance is not connected"); - } - if (request.plugin && !connection.plugins.has(request.plugin)) { - throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`); - } - - return await request.send(connection, message.data); + return await this._master.forwardRequestToInstance(request, message.data); } async forwardEventToInstance(message, event) { diff --git a/packages/master/src/Master.js b/packages/master/src/Master.js index 22367afb..8f97c975 100644 --- a/packages/master/src/Master.js +++ b/packages/master/src/Master.js @@ -2,7 +2,6 @@ const compression = require("compression"); const events = require("events"); const express = require("express"); -const expressFileupload = require("express-fileupload"); const fs = require("fs-extra"); const http = require("http"); const https = require("https"); @@ -49,6 +48,7 @@ class Master { this.app = express(); this.app.locals.master = this; + this.app.locals.streams = new Map(); /** * Mapping of slave id to slave info @@ -336,7 +336,6 @@ class Master { hitCounter.inc(); next(); }); - app.use(expressFileupload()); app.use(compression()); // Set folder to serve static content from (the website) @@ -492,6 +491,41 @@ class Master { }); }; } + + /** + * Forward the given request to the slave contaning the instance + * + * Finds the slave which the instance with the given instance ID is + * currently assigned to and forwards the request to that instance. + * The request data must have an instance_id parameter. + * + * @param {module:lib/link.Request} request - The request to send. + * @param {object} data - Data to pass with the request. + * @param {number} data.instance_id - + * ID of instance to send this request towards + * @returns {object} data returned from the request. + */ + async forwardRequestToInstance(request, data) { + let instance = this.instances.get(data.instance_id); + if (!instance) { + throw new libErrors.RequestError(`Instance with ID ${data.instance_id} does not exist`); + } + + let slaveId = instance.config.get("instance.assigned_slave"); + if (slaveId === null) { + throw new libErrors.RequestError("Instance is not assigned to a slave"); + } + + let connection = this.wsServer.slaveConnections.get(slaveId); + if (!connection) { + throw new libErrors.RequestError("Slave containing instance is not connected"); + } + if (request.plugin && !connection.plugins.has(request.plugin)) { + throw new libErrors.RequestError(`Slave containing instance does not have ${request.plugin} plugin`); + } + + return await request.send(connection, data); + } } module.exports = Master; diff --git a/packages/master/src/routes.js b/packages/master/src/routes.js index 8ab2cba0..d54d3197 100644 --- a/packages/master/src/routes.js +++ b/packages/master/src/routes.js @@ -1,13 +1,18 @@ "use strict"; +const Busboy = require("busboy"); +const crypto = require("crypto"); +const events = require("events"); const fs = require("fs-extra"); const JSZip = require("jszip"); const jwt = require("jsonwebtoken"); const path = require("path"); +const util = require("util"); const libHelpers = require("@clusterio/lib/helpers"); const libLink = require("@clusterio/lib/link"); const libPlugin = require("@clusterio/lib/plugin"); const libPrometheus = require("@clusterio/lib/prometheus"); +const { logger } = require("@clusterio/lib/logging"); const { endpointHitCounter } = require("./metrics"); @@ -106,6 +111,36 @@ function validateSlaveToken(req, res, next) { next(); } +function validateUserToken(req, res, next) { + let token = req.header("x-access-token"); + if (!token) { + res.sendStatus(401); + return; + } + + try { + let tokenPayload = jwt.verify( + token, + req.app.locals.master.config.get("master.auth_secret"), + { audience: "user" } + ); + let user = req.app.locals.master.userManager.users.get(tokenPayload.user); + if (!user) { + throw new Error("invalid user"); + } + if (tokenPayload.iat < user.tokenValidAfter) { + throw new Error("invalid token"); + } + res.locals.user = user; + + } catch (err) { + res.sendStatus(401); + return; + } + + next(); +} + // Handle an uploaded export package. async function uploadExport(req, res) { endpointHitCounter.labels(req.route.path).inc(); @@ -142,6 +177,191 @@ async function uploadExport(req, res) { res.sendStatus(200); } +async function createProxyStream(app) { + let asyncRandomBytes = util.promisify(crypto.randomBytes); + let id = (await asyncRandomBytes(8)).toString("hex"); + let stream = { + id, + flowing: false, + size: null, + mime: null, + events: new events.EventEmitter(), + timeout: setTimeout(() => { + stream.events.emit("timeout"); + }, app.locals.master.config.get("master.proxy_stream_timeout") * 1000), + }; + stream.events.on("close", () => { + clearTimeout(stream.timeout); + app.locals.streams.delete(id); + }); + stream.events.on("timeout", () => { + stream.events.emit("close"); + }); + app.locals.streams.set(id, stream); + return stream; +} + +async function getStream(req, res) { + let stream = req.app.locals.streams.get(req.params.id); + if (!stream || stream.flowing) { + res.sendStatus(404); + return; + } + + function startStream() { + res.append("Content-Type", stream.mime); + if (stream.size) { + res.append("Content-Length", stream.size); + } + stream.source.pipe(res); + stream.flowing = true; + res.on("finish", () => { + stream.events.emit("close"); + }); + clearTimeout(stream.timeout); + } + + if (stream.source) { + startStream(); + } else { + stream.events.on("source", startStream); + stream.events.on("timeout", () => { + res.sendStatus(500); + }); + } +} + + +async function uploadSave(req, res) { + try { + res.locals.user.checkPermission("core.instance.save.upload"); + } catch (err) { + res.status(403).json({ request_errors: [err.message] }); + return; + } + + let contentType = req.get("Content-Type"); + let match = /^([!#$%&'*+\-.^_`|~0-9A-Za-z]+\/[!#$%&'*+\-.^_`|~0-9A-Za-z]+)/.exec(contentType); + if (!match) { + res.status(415).json({ request_errors: ["invalid Content-Type"] }); + return; + } + let mimeType = match[1].toLowerCase(); + + let tasks = []; + let errors = []; + let requestErrors = []; + let saves = []; + + async function handleFile(instanceId, stream, filename, streamMime) { + let proxyStream = await createProxyStream(req.app); + proxyStream.source = stream; + proxyStream.mime = streamMime; + let timeout = new Promise((_, reject) => { + proxyStream.events.on("timeout", () => { + stream.resume(); + reject(new Error("Timed out establishing stream to slave")); + }); + }); + + try { + let result = await Promise.race([ + req.app.locals.master.forwardRequestToInstance(libLink.messages.pullSave, { + instance_id: instanceId, + stream_id: proxyStream.id, + filename, + }), + timeout, + ]); + saves.push(result.save); + + } catch (err) { + proxyStream.events.emit("close"); + logger.error(`Error uploading save: ${err.message}`); + errors.push(err.message); + stream.resume(); + } + } + + if (mimeType === "multipart/form-data") { + await new Promise(resolve => { + let fields = {}; + let busboy = new Busboy({ headers: req.headers }); + busboy.on("file", (fieldname, stream, filename, transferEncoding, fileMime) => { + if (fields.instanceId === undefined) { + requestErrors.push("instance_id must come before files uploaded"); + } + + if (fileMime !== "application/zip") { + requestErrors.push("invalid file Content-Type"); + } + + if (!filename.endsWith(".zip")) { + requestErrors.push("filename must end with .zip"); + } + + if (errors.length || requestErrors.length) { + stream.resume(); + return; + } + + tasks.push(handleFile(fields.instanceId, stream, filename, fileMime)); + }); + busboy.on("field", (fieldname, value, fieldnameTruncated, valueTruncated, transferEncoding, valueMime) => { + if (fieldname === "instance_id") { + fields.instanceId = Number.parseInt(value, 10); + if (Number.isNaN(fields.instanceId)) { + requestErrors.push("invalid instance_id"); + } + } + }); + busboy.on("finish", resolve); + busboy.on("error", (err) => { + logger.error(`Error parsing multipart request in upload-save:\n${err.stack}`); + errors.push(err.message); + }); + req.pipe(busboy); + }); + + } else if (mimeType === "application/zip") { + let filename = req.query.filename; + if (typeof filename !== "string") { + requestErrors.push("Missing or invalid filename parameter"); + } else if (!filename.endsWith(".zip")) { + requestErrors.push("filename must end with .zip"); + } + let instanceId = Number.parseInt(req.query.instance_id, 10); + if (Number.isNaN(instanceId)) { + requestErrors.push("Missing or invalid instance_id parameter"); + } + + if (errors.length || requestErrors.length) { + req.resume(); + } else { + tasks.push(handleFile(instanceId, req, filename, mimeType)); + } + + } else { + res.status(415).json({ request_errors: ["invalid Content-Type"] }); + return; + } + + await Promise.all(tasks); + + if (errors.length) { + res.status(500); + res.json({ errors, request_errors: requestErrors }); + return; + } + if (requestErrors.length) { + res.status(400); + res.json({ request_errors: requestErrors }); + return; + } + + res.json({ saves }); +} + function addRouteHandlers(app) { app.get("/metrics", (req, res, next) => getMetrics(req, res, next).catch(next)); app.get("/api/plugins", getPlugins); @@ -149,6 +369,11 @@ function addRouteHandlers(app) { validateSlaveToken, (req, res, next) => uploadExport(req, res).catch(next) ); + app.get("/api/stream/:id", (req, res, next) => getStream(req, res).catch(next)); + app.post("/api/upload-save", + validateUserToken, + (req, res, next) => uploadSave(req, res).catch(next) + ); } // Routes used in the web interface and served by the master server @@ -170,4 +395,5 @@ const webRoutes = [ module.exports = { addRouteHandlers, webRoutes, + createProxyStream, }; diff --git a/packages/slave/slave.js b/packages/slave/slave.js index 3aacbff9..c0875fb0 100755 --- a/packages/slave/slave.js +++ b/packages/slave/slave.js @@ -37,6 +37,7 @@ const libConfig = require("@clusterio/lib/config"); const libSharedCommands = require("@clusterio/lib/shared_commands"); const { ConsoleTransport, levels, logger } = require("@clusterio/lib/logging"); const libLoggingUtils = require("@clusterio/lib/logging_utils"); +const libHelpers = require("@clusterio/lib/helpers"); const instanceRconCommandDuration = new libPrometheus.Histogram( @@ -1374,6 +1375,65 @@ class Slave extends libLink.Link { await request.send(instanceConnection, message.data); } + async pullSaveRequestHandler(message) { + let { instance_id, stream_id, filename } = message.data; + try { + checkFilename(filename); + } catch (err) { + throw new libErrors.RequestError(`Save name ${err.message}`); + } + let instanceInfo = this.instanceInfos.get(instance_id); + if (!instanceInfo) { + throw new libErrors.RequestError(`Instance with ID ${instance_id} does not exist`); + } + + let url = new URL(this.config.get("slave.master_url")); + url.pathname += `api/stream/${stream_id}`; + let response = await phin({ + url, method: "GET", + core: { ca: this.tlsCa }, + stream: true, + }); + + if (response.statusCode !== 200) { + let content = await libHelpers.readStream(response); + throw new libErrors.RequestError(`Stream returned ${response.statusCode}: ${content.toString()}`); + } + + let savesDir = path.join(instanceInfo.path, "saves"); + let tempFilename = filename.replace(/(\.zip)?$/, ".tmp.zip"); + let stream; + while (true) { + try { + stream = fs.createWriteStream(path.join(savesDir, tempFilename), { flags: "wx" }); + await events.once(stream, "open"); + break; + } catch (err) { + if (err.code === "EEXIST") { + tempFilename = await libFileOps.findUnusedName(savesDir, tempFilename, ".tmp.zip"); + } else { + throw err; + } + } + } + response.pipe(stream); + await events.once(stream, "finish"); + + filename = await libFileOps.findUnusedName(savesDir, filename, ".zip"); + await fs.rename(path.join(savesDir, tempFilename), path.join(savesDir, filename)); + + let instanceConnection = this.instanceConnections.get(instance_id); + let saveList; + if (instanceConnection) { + saveList = (await libLink.messages.listSaves.send(instanceConnection, { instance_id })).list; + } else { + saveList = await Instance.listSaves(savesDir, null); + } + + libLink.messages.saveListUpdate.send(this, { instance_id, list: saveList }); + return { save: filename }; + } + async exportDataRequestHandler(message, request) { let instanceId = message.data.instance_id; let instanceConnection = await this._connectInstance(instanceId); diff --git a/packages/web_ui/src/components/InstanceViewPage.jsx b/packages/web_ui/src/components/InstanceViewPage.jsx index bcf9b974..bd96d526 100644 --- a/packages/web_ui/src/components/InstanceViewPage.jsx +++ b/packages/web_ui/src/components/InstanceViewPage.jsx @@ -12,10 +12,8 @@ import InstanceConsole from "./InstanceConsole"; import InstanceRcon from "./InstanceRcon"; import AssignInstanceModal from "./AssignInstanceModal"; import StartStopInstanceButton from "./StartStopInstanceButton"; -import CreateSaveModal from "./CreateSaveModal"; import LoadScenarioModal from "./LoadScenarioModal"; import SavesList from "./SavesList"; -import SectionHeader from "./SectionHeader"; import { notifyErrorHandler } from "../util/notify"; import { useInstance } from "../model/instance"; import { useSlave } from "../model/slave"; @@ -116,7 +114,6 @@ export default function InstanceViewPage(props) { {instance["status"]} - /> Console diff --git a/packages/web_ui/src/components/SavesList.jsx b/packages/web_ui/src/components/SavesList.jsx index c3d2e8fb..fcf2e6b4 100644 --- a/packages/web_ui/src/components/SavesList.jsx +++ b/packages/web_ui/src/components/SavesList.jsx @@ -1,11 +1,13 @@ import React, { useContext, useState } from "react"; -import { Button, Space, Table, Tooltip } from "antd"; +import { Button, Space, Table, Tooltip, Upload } from "antd"; import CaretLeftOutlined from "@ant-design/icons/CaretLeftOutlined"; import LeftOutlined from "@ant-design/icons/LeftOutlined"; import libLink from "@clusterio/lib/link"; import ControlContext from "./ControlContext"; +import CreateSaveModal from "./CreateSaveModal"; +import SectionHeader from "./SectionHeader"; import { useSaves } from "../model/saves"; import { notifyErrorHandler } from "../util/notify"; @@ -26,7 +28,7 @@ export default function SavesList(props) { let saves = useSaves(props.instance.id); let [starting, setStarting] = useState(false); - return , }} />; + + let uploadProps = { + name: "file", + accept: ".zip", + headers: { + "X-Access-Token": control.connector.token, + }, + data: { + instance_id: props.instance.id, + }, + showUploadList: false, + action: `${webRoot}api/upload-save`, + // TODO: show progress to user. + }; + + return
+ + + + + + /> + + {saveTable} + +
; } diff --git a/packages/web_ui/src/index.css b/packages/web_ui/src/index.css index 421f2a36..97d8a4a6 100644 --- a/packages/web_ui/src/index.css +++ b/packages/web_ui/src/index.css @@ -164,6 +164,10 @@ body { white-space: pre-wrap; } +.save-list-dragger .ant-upload.ant-upload-btn { + padding: 0; +} + .rcon-result code { display: block; background: #000; diff --git a/test/integration/clusterio.js b/test/integration/clusterio.js index 396a7669..64eb0a17 100644 --- a/test/integration/clusterio.js +++ b/test/integration/clusterio.js @@ -358,6 +358,25 @@ describe("Integration of Clusterio", function() { }); }); + describe("instance upload-save", function() { + it("should upload a zip file", async function() { + await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test"); + await execCtl("instance upload-save 44 upload.zip"); + assert( + await fs.pathExists(path.join("temp", "test", "instances", "test", "saves", "upload.zip")), + "file not uploaded to saves directory" + ); + }); + it("should reject non-zip files", async function() { + await fs.outputFile(path.join("temp", "test", "invalid"), "a test"); + await assert.rejects(execCtl("instance upload-save 44 invalid")); + }); + it("should reject path traversal attacks", async function() { + await fs.outputFile(path.join("temp", "test", "upload.zip"), "a test"); + await assert.rejects(execCtl("instance upload-save 44 upload.zip --name ../traversal.zip")); + }); + }); + describe("instance delete", function() { it("deletes the instance", async function() { slowTest(this); diff --git a/test/master.js b/test/master.js deleted file mode 100644 index 50510de8..00000000 --- a/test/master.js +++ /dev/null @@ -1,9 +0,0 @@ -"use strict"; -const assert = require("assert").strict; - -const mock = require("./mock"); -const master = require("@clusterio/master/master"); - - -describe("Master testing", function() { -}); diff --git a/test/master/routes.js b/test/master/routes.js new file mode 100644 index 00000000..dea48c03 --- /dev/null +++ b/test/master/routes.js @@ -0,0 +1,290 @@ +"use strict"; +const assert = require("assert").strict; +const events = require("events"); +const FormData = require("form-data"); +const jwt = require("jsonwebtoken"); +const http = require("http"); +const phin = require("phin"); + +const routes = require("@clusterio/master/src/routes"); +const mock = require("../mock"); + + +describe("master/src/routes", function() { + let master; + let server; + let port; + beforeEach(async function() { + master = new mock.MockMaster(); + routes.addRouteHandlers(master.app); + server = http.createServer(master.app); + server.listen(0, "localhost"); + await events.once(server, "listening"); + port = server.address().port; + }); + afterEach(function() { + server.close(); + }); + describe("/api/stream/:id", function() { + it("should respond with 404 if stream doesn't exist", async function() { + let response = await phin(`http://localhost:${port}/api/stream/01020304`); + assert.equal(response.statusCode, 404); + }); + it("should respond with 500 if stream times out", async function() { + let stream = await routes.createProxyStream(master.app); + let response = await phin(`http://localhost:${port}/api/stream/${stream.id}`); + assert.equal(response.statusCode, 500); + }); + }); + describe("/api/upload-save", function() { + it("should respond with 401 with invalid token", async function() { + let response; + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { "Content-Type": "application/zip" }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 401); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "Content-Type": "application/zip", + "X-Access-Token": "invalid", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 401); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "Content-Type": "application/zip", + "X-Access-Token": jwt.sign({ aud: "user", user: "invalid" }, "TestSecretDoNotUse"), + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 401); + master.userManager.users.get("test").tokenValidAfter = Math.floor((Date.now() + 60e3) / 1000); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "Content-Type": "application/zip", + "X-Access-Token": jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse"), + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 401); + }); + + it("should respond with 403 when user has insufficient permission", async function() { + let response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "Content-Type": "application/zip", + "X-Access-Token": jwt.sign({ aud: "user", user: "player" }, "TestSecretDoNotUse"), + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 403); + }); + + let testToken = jwt.sign({ aud: "user", user: "test" }, "TestSecretDoNotUse"); + it("should respond with 415 if content type is missing or invalid", async function() { + let response; + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 415); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "invalid", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 415); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=file.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "text/plain", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 415); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid Content-Type"] }); + }); + + it("should respond with 400 if invalid multipart request", async function() { + let response; + let form; + form = new FormData(); + form.append("instance_id", "123"); + form.append("file", "totally a zip file", { filename: "test.zip", contentType: "text/plain" }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save`, method: "POST", + headers: { + "X-Access-Token": testToken, + ...form.getHeaders(), + }, + data: form.getBuffer(), + }); + assert.equal(response.statusCode, 400); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["invalid file Content-Type"] }); + form = new FormData(); + form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" }); + form.append("instance_id", "123"); + response = await phin({ + url: `http://localhost:${port}/api/upload-save`, method: "POST", + headers: { + "X-Access-Token": testToken, + ...form.getHeaders(), + }, + data: form.getBuffer(), + }); + assert.equal(response.statusCode, 400); + assert.deepEqual( + JSON.parse(response.body), { request_errors: ["instance_id must come before files uploaded"] } + ); + form = new FormData(); + form.append("instance_id", "123"); + form.append("file", "totally a zip file", { filename: "test.txt", contentType: "application/zip" }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save`, method: "POST", + headers: { + "X-Access-Token": testToken, + ...form.getHeaders(), + }, + data: form.getBuffer(), + }); + assert.equal(response.statusCode, 400); + assert.deepEqual( + JSON.parse(response.body), { request_errors: ["filename must end with .zip"] } + ); + form = new FormData(); + form.append("instance_id", "invalid"); + form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save`, method: "POST", + headers: { + "X-Access-Token": testToken, + ...form.getHeaders(), + }, + data: form.getBuffer(), + }); + assert.equal(response.statusCode, 400); + assert.deepEqual( + JSON.parse(response.body), { request_errors: ["invalid instance_id"] } + ); + }); + + it("should respond with 400 if invalid file request", async function() { + let response; + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 400); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["Missing or invalid filename parameter"] }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.txt`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 400); + assert.deepEqual(JSON.parse(response.body), { request_errors: ["filename must end with .zip"] }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?filename=test.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 400); + assert.deepEqual( + JSON.parse(response.body), { request_errors: ["Missing or invalid instance_id parameter"] } + ); + }); + + it("should respond with 500 if the transfer failed", async function() { + master.forwardRequestToInstance = async (data, request) => { + throw new Error("Something went wrong"); + }; + let response; + let form; + form = new FormData(); + form.append("instance_id", "123"); + form.append("file", "totally a zip file", { filename: "test.zip", contentType: "application/zip" }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save`, method: "POST", + headers: { + "X-Access-Token": testToken, + ...form.getHeaders(), + }, + data: form.getBuffer(), + }); + assert.equal(response.statusCode, 500); + assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] }); + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 500); + assert.deepEqual(JSON.parse(response.body), { errors: ["Something went wrong"], request_errors: [] }); + master.forwardRequestToInstance = async (data, request) => { + await new Promise(() => {}); + }; + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.equal(response.statusCode, 500); + assert.deepEqual( + JSON.parse(response.body), { errors: ["Timed out establishing stream to slave"], request_errors: [] } + ); + }); + + it("should complete a valid transfer", async function() { + master.forwardRequestToInstance = async (request, data) => { + let stream = await phin({ + url: `http://localhost:${port}/api/stream/${data.stream_id}`, + }); + assert.equal(stream.body.toString(), "totally a zip file"); + return { save: data.filename }; + }; + let response; + response = await phin({ + url: `http://localhost:${port}/api/upload-save?instance_id=123&filename=test.zip`, method: "POST", + headers: { + "X-Access-Token": testToken, + "Content-Type": "application/zip", + }, + data: "totally a zip file", + }); + assert.deepEqual(JSON.parse(response.body), { saves: ["test.zip"] }); + assert.equal(response.statusCode, 200); + }); + }); +}); diff --git a/test/mock.js b/test/mock.js index 5d11a8e0..0787c403 100644 --- a/test/mock.js +++ b/test/mock.js @@ -150,9 +150,12 @@ class MockControl extends libLink.Link { class MockMaster { constructor() { this.app = express(); + this.app.locals.master = this; + this.app.locals.streams = new Map(); this.mockConfigEntries = new Map([ ["master.external_address", "test"], ["master.auth_secret", "TestSecretDoNotUse"], + ["master.proxy_stream_timeout", 1], ]); this.config = { get: (name) => { @@ -227,6 +230,7 @@ module.exports = { MockInstance, MockSlave, MockControl, + MockMaster, createMasterPlugin, createInstancePlugin, diff --git a/test/package.json b/test/package.json index 205930a2..539f5007 100644 --- a/test/package.json +++ b/test/package.json @@ -10,6 +10,7 @@ "@clusterio/master": "^2.0.0-alpha.6", "@clusterio/slave": "^2.0.0-alpha.6", "express": "^4.16.2", + "form-data": "^4.0.0", "fs-extra": "^8.1.0", "jsonwebtoken": "^8.2.1", "jszip": "^3.2.2",