Browse Source

[Distributed] Slave security with RSA signature of servers

isundil 2 months ago
parent
commit
ea01c86ca1

+ 4 - 1
config.sample.json

@@ -1,4 +1,7 @@
 {
     "port": 9090,
-    "slave": false
+    "slave": false,
+    "slaves": [ "http://some.host:1234" ],
+    "masterPubKey": "generated by npm run generate-key",
+    "masterPrivateKey": "generated by npm run generate-key"
 }

+ 2 - 1
package.json

@@ -11,7 +11,8 @@
     "webpack": "npx webpack -o ./build/ --mode=production -c ./front/webpack.config.js --env=output=front.mins.js",
     "webpack-devel": "npx webpack -o ./build/ --mode=development -c ./front/webpack.config.js --env=output=front.js",
     "run": "tsoa spec-and-routes && npx ts-node src/index.ts",
-    "run-dev": "nodemon src/index.ts"
+    "run-dev": "nodemon src/index.ts",
+    "generate-key": "npx ts-node src/index.ts --generate-key"
   },
   "repository": {
     "type": "git",

+ 11 - 2
src/config.ts

@@ -5,6 +5,8 @@ export interface Configuration {
     port: number;
     slave: boolean;
     slaves: Array<string>;
+    masterPubKey: string;
+    masterPrivateKey: string;
     hostname: string;
 }
 
@@ -12,14 +14,21 @@ class ConfigurationManagerLoader {
     public static Load(fileName: string): Configuration {
         let config = require(fileName);
 
-        if (config.slave && config.slaves?.length) {
+        config.slaves = config.slaves || [];
+        if (config.slave && config.slaves?.length)
             throw new Error("Cannot have slaves while beeing slave");
-        }
+        if (config.slave && !config.masterPubKey)
+            throw new Error("Missing master public key for slave. Run with --generate-masterkey get get one");
+        if (config.slaves.length && !config.masterPrivateKey)
+            throw new Error("Missing master private key for slaves. Run with --generate-masterkey get get one");
 
         return {
             port: config.port || 9090,
+
             slave: config.slave || false,
             slaves: config.slaves || new Array(),
+            masterPubKey: config.masterPubKey || "",
+            masterPrivateKey: config.masterPrivateKey || "",
 
             hostname: os.hostname()
         };

+ 66 - 38
src/index.ts

@@ -1,45 +1,73 @@
-import express, { json, urlencoded } from "express";
+import express, { json, NextFunction, Request, Response, urlencoded } from "express";
 import { RegisterRoutes } from "../build/routes";
 import { HtmlController } from "./routes/htmlControllers";
 import swaggerUi from "swagger-ui-express";
 import { engine } from 'express-handlebars';
 import path from "path";
 import ConfigurationManager from "./config";
+import slavery from "./slavery";
+import {UnauthorizedMasterApiKey} from "./models/unauthorizedApi";
 
-const app = express();
-
-if (!ConfigurationManager.slave) {
-    // Whiskers html template
-    app.engine('handlebars', engine());
-    app.set('view engine', 'handlebars');
-    app.set('views', path.join(__dirname, '../templates'));
-}
-
-// Swagger UI
-app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(require(path.join(__dirname, "../build/swagger.json"))));
-
-// API routes
-app.use(
-    urlencoded({
-        extended: true,
-    })
-);
-app.use(json());
-if (!ConfigurationManager.slave)
-    HtmlController.RegisterHtmlPages(app);
-RegisterRoutes(app);
-
-// Static resources
-if (!ConfigurationManager.slave) {
-    app.use("/js/front.js", express.static(path.join(__dirname, "../build/"), {index: 'front.js'}));
-    app.use("/js/front.min.js", express.static(path.join(__dirname, "../build/"), {index: 'front.min.js'}));
-    app.use("/js/front.js.map", express.static(path.join(__dirname, "../build/"), {index: 'front.js.map'}));
-    app.use("/js/front.min.js.map", express.static(path.join(__dirname, "../build/"), {index: 'front.min.js.map'}));
-    app.use(express.static(path.join(__dirname, 'public')));
-}
-
-app.listen(ConfigurationManager.port,
-    () => console.log(`Server listening on port ${ConfigurationManager.port}.`))
-    .on("error", (error) => {
-        throw new Error(error.message);
-    });
+(async () => {
+    if (process.argv.indexOf("--generate-key") > 0) {
+        await slavery.generateKey();
+        process.exit(0);
+    }
+
+    const app = express();
+
+    if (!ConfigurationManager.slave) {
+        // Whiskers html template
+        app.engine('handlebars', engine());
+        app.set('view engine', 'handlebars');
+        app.set('views', path.join(__dirname, '../templates'));
+    }
+
+    // Swagger UI
+    app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(require(path.join(__dirname, "../build/swagger.json"))));
+
+    // API routes
+    app.use(
+        urlencoded({
+            extended: true,
+        })
+    );
+    app.use(json());
+    if (!ConfigurationManager.slave)
+        HtmlController.RegisterHtmlPages(app);
+    RegisterRoutes(app);
+
+    // Error handling
+    app.use((err: unknown,
+        _req: Request,
+        res: Response,
+        next: NextFunction) => {
+            if (err instanceof UnauthorizedMasterApiKey) {
+                return res.status(403).json({
+                    message: "Api key validation failed"
+                });
+            }
+            else if (err instanceof Error) {
+                return res.status(500).json({
+                    message: err.message
+                });
+            }
+            next();
+            return;
+        });
+
+    // Static resources
+    if (!ConfigurationManager.slave) {
+        app.use("/js/front.js", express.static(path.join(__dirname, "../build/"), {index: 'front.js'}));
+        app.use("/js/front.min.js", express.static(path.join(__dirname, "../build/"), {index: 'front.min.js'}));
+        app.use("/js/front.js.map", express.static(path.join(__dirname, "../build/"), {index: 'front.js.map'}));
+        app.use("/js/front.min.js.map", express.static(path.join(__dirname, "../build/"), {index: 'front.min.js.map'}));
+        app.use(express.static(path.join(__dirname, 'public')));
+    }
+
+    app.listen(ConfigurationManager.port,
+        () => console.log(`Server listening on port ${ConfigurationManager.port}.`))
+        .on("error", (error) => {
+            throw new Error(error.message);
+        });
+})();

+ 4 - 0
src/models/unauthorizedApi.ts

@@ -0,0 +1,4 @@
+
+export class UnauthorizedMasterApiKey extends Error {
+}
+

+ 11 - 2
src/routes/api_sysInfoController.ts

@@ -1,6 +1,7 @@
 import {
     Controller,
     Get,
+    Header,
     Route
 } from "tsoa";
 import {CpuInfo, DriveInfo, LiveSystemInfoDescription, NetworkInfo, SystemInfoDescription} from "../models/systemInfo";
@@ -8,6 +9,8 @@ import os from 'os';
 import dns from 'dns';
 import si from 'systeminformation';
 import ConfigurationManager from "../config";
+import slavery from "../slavery";
+import {UnauthorizedMasterApiKey} from "../models/unauthorizedApi";
 
 @Route("/api/sysinfo")
 export class SystemdInfoController extends Controller {
@@ -60,7 +63,9 @@ export class SystemdInfoController extends Controller {
     }
 
     @Get("/")
-    public async getInfo(): Promise<SystemInfoDescription> {
+    public async getInfo(@Header() apiKeySignature: string|undefined): Promise<SystemInfoDescription> {
+        if (ConfigurationManager.masterPubKey && !slavery.checkMasterKey(apiKeySignature))
+            throw new UnauthorizedMasterApiKey();
         const system = await si.system();
         let result: SystemInfoDescription = { systemInfo: {}};
         result.systemInfo[ConfigurationManager.hostname] = {
@@ -81,6 +86,7 @@ export class SystemdInfoController extends Controller {
             model: system.model,
             drives: (await this.readDriveInfo())
         };
+        await slavery.getSlaveData(result.systemInfo, "systemInfo", "/api/sysinfo");
         return result;
     }
 
@@ -103,7 +109,9 @@ export class SystemdInfoController extends Controller {
     }
 
     @Get("/live")
-    public async getLiveInfo(): Promise<LiveSystemInfoDescription> {
+    public async getLiveInfo(@Header() apiKeySignature: string|undefined): Promise<LiveSystemInfoDescription> {
+        if (ConfigurationManager.masterPubKey && !slavery.checkMasterKey(apiKeySignature))
+            throw new UnauthorizedMasterApiKey();
         const memInfo = await si.mem();
         const cpuSpeed = await this.getCpuUsage();
         let result: LiveSystemInfoDescription = { liveSystemInfo: {}};
@@ -113,6 +121,7 @@ export class SystemdInfoController extends Controller {
             currentCpuUsed: cpuSpeed.reduce((a, b) => a +b, 0) /cpuSpeed.length,
             cpuByCore: cpuSpeed
         };
+        await slavery.getSlaveData(result.liveSystemInfo, "liveSystemInfo", "/api/sysinfo/live");
         return result;
     }
 }

+ 7 - 1
src/routes/api_systemdController.ts

@@ -1,19 +1,25 @@
 import {
     Controller,
     Get,
+    Header,
     Route
 } from "tsoa";
 import systemdService from "../services/systemdService";
 import ConfigurationManager from "../config";
 import {ServiceDescription} from "../models/service";
+import slavery from "../slavery";
+import {UnauthorizedMasterApiKey} from "../models/unauthorizedApi";
 
 @Route("/api/services")
 export class SystemdServiceController extends Controller {
 
     @Get("/list")
-    public async getAllServices(): Promise<ServiceDescription> {
+    public async getAllServices(@Header() apiKeySignature: string|undefined): Promise<ServiceDescription> {
+        if (ConfigurationManager.masterPubKey && !slavery.checkMasterKey(apiKeySignature))
+            throw new UnauthorizedMasterApiKey();
         let result: ServiceDescription = { services: {}};
         result.services[ConfigurationManager.hostname] = (await systemdService.getAllServices());
+        await slavery.getSlaveData(result.services, "services", "/api/services/list");
         return result;
     }
 }

+ 74 - 0
src/slavery.ts

@@ -0,0 +1,74 @@
+import ConfigurationManager from "./config";
+import http from 'http';
+import crypto from 'crypto';
+
+class SlaveManager {
+    private publicKey: string | null;
+    private privateKey: string | null;
+
+    public constructor() {
+        this.publicKey = ConfigurationManager.masterPubKey ? Buffer.from(ConfigurationManager.masterPubKey, "base64").toString("ascii") : null;
+        this.privateKey = ConfigurationManager.masterPrivateKey ? Buffer.from(ConfigurationManager.masterPrivateKey, "base64").toString("ascii") : null;
+    }
+
+    private signRequest(): string {
+        return crypto.sign("rsa-sha256", Buffer.from("_"), this.privateKey!).toString("base64");
+    }
+
+    private async getSlaveDataForNode(node: string, jsonResponsePath: string, urlPath: string): Promise<any> {
+        return new Promise((ok, ko) => {
+            http.get(`${node}${urlPath}`, {
+                headers: {
+                    apiKeySignature: this.signRequest()
+                }
+            }, response => {
+                let responseBody: string[] = [];
+                response.on('error', ko);
+                response.on('data', data => {
+                    responseBody.push(data);
+                });
+                response.once('end', () => {
+                    const responseJsonData = JSON.parse(responseBody.join(""));
+                    ok(responseJsonData[jsonResponsePath]);
+                });
+            }).on('error', ko);
+        });
+    }
+
+    public async generateKey(): Promise<void> {
+        const {
+            publicKey,
+            privateKey,
+        } = crypto.generateKeyPairSync('rsa-pss', {
+            modulusLength: 4096
+        });
+        this.privateKey = Buffer.from(privateKey.export({ format: "pem", type: "pkcs8" })).toString("base64"),
+        this.publicKey = Buffer.from(publicKey.export({ format: "pem", type: "spki" })).toString("base64")
+        console.log({
+            masterPubKey: this.publicKey,
+            privateKey: this.privateKey
+        });
+    }
+
+    public checkMasterKey(sign: string|undefined): boolean {
+        if (!sign)
+            return false;
+        return crypto.verify("rsa-sha256", Buffer.from("_"), this.publicKey!, Buffer.from(sign, "base64"));
+    }
+
+    public async getSlaveData(resultObj: any, jsonResponsePath: string, urlPath: string) {
+        let resultArray = await Promise.allSettled(ConfigurationManager.slaves.map(x => this.getSlaveDataForNode(x, jsonResponsePath, urlPath)));
+        for (let i of resultArray) {
+            if (i.status === 'fulfilled') {
+                for (let host in (i.value as any)) {
+                    resultObj[host] = (i.value as any)[host];
+                }
+            } else {
+                console.error(i);
+            }
+        }
+    }
+}
+
+export default new SlaveManager();
+