diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..d450231 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,55 @@ +# Dependencies +node_modules/ +npm-debug.log +yarn-error.log + +# Build output +dist/ + +# Proto samples (will be mounted as volume) +proto_samples/ + +# Git +.git/ +.gitignore +.gitattributes + +# Documentation +*.md +README* +CHANGELOG* +LICENSE* + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.* + +# Testing +coverage/ +.nyc_output/ + +# Docker +Dockerfile +docker-compose.yml +.dockerignore + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml + +# Misc +*.log +tmp/ +temp/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cc9cf00 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +# Stage 1: Build +FROM node:20-alpine AS builder + +# Install Yarn globally +RUN corepack enable && corepack prepare yarn@1.22.22 --activate + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ + +# Install all dependencies (including devDependencies for build) +# Skip scripts to prevent prepare hook from running before source is copied +RUN yarn install --frozen-lockfile --ignore-scripts + +# Copy source code +COPY . . + +# Build the application (compile TypeScript + copy static assets) +RUN yarn build + +# Stage 2: Production +FROM node:20-alpine + +# Install Yarn globally +RUN corepack enable && corepack prepare yarn@1.22.22 --activate + +# Set working directory +WORKDIR /app + +# Copy package files +COPY package.json yarn.lock ./ + +# Install only production dependencies +# Skip scripts since we're only copying pre-built artifacts +RUN yarn install --frozen-lockfile --production --ignore-scripts + +# Copy built application from builder stage +COPY --from=builder /app/dist ./dist + +# Copy config directory structure (for config.json mount point) +# Note: example.config.json is already in dist/config/ from the build process +# This ensures the directory structure exists for the volume mount +COPY --from=builder /app/dist/config/example.config.json ./dist/config/ + +# Create proto_samples directory +RUN mkdir -p proto_samples + +# Expose the default port +EXPOSE 8081 + +# Set NODE_ENV to production +ENV NODE_ENV=production + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD node -e "require('http').get('http://localhost:8081/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})" + +# Start the application +CMD ["node", "dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4498507 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + protodecoder-ui: + build: + context: . + dockerfile: Dockerfile + container_name: protodecoder-ui + ports: + - "8081:8081" + volumes: + - ./src/config/config.json:/app/dist/config/config.json:ro + - ./proto_samples:/app/proto_samples + restart: unless-stopped + environment: + - NODE_ENV=production + healthcheck: + test: ["CMD", "node", "-e", "require('http').get('http://localhost:8081/', (r) => {process.exit(r.statusCode === 200 ? 0 : 1)})"] + interval: 30s + timeout: 10s + start_period: 5s + retries: 3 diff --git a/src/config/example.config.json b/src/config/example.config.json index 36c5ba5..c6f31ce 100644 --- a/src/config/example.config.json +++ b/src/config/example.config.json @@ -1,5 +1,6 @@ { "default_port": 8081, + "web_password": null, "trafficlight_identifier": "AwesomeProtoSender", "redirect_to_golbat_url": null, "redirect_to_golbat_token": null, diff --git a/src/index.ts b/src/index.ts index 41764fb..d2bb8ff 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ import http from "http"; import fs from "fs"; +import crypto from "crypto"; import { WebStreamBuffer, getIPAddress, handleData, moduleConfigIsAvailable, redirect_post_golbat } from "./utils"; import { decodePayload, decodePayloadTraffic } from "./parser/proto-parser"; import SampleSaver from "./utils/sample-saver"; @@ -18,36 +19,151 @@ const portBind = config["default_port"]; // Initialize sample saver const sampleSaver = config.sample_saving ? new SampleSaver(config.sample_saving) : null; +// Authentication setup +const WEB_PASSWORD = config["web_password"]; +const AUTH_REQUIRED = WEB_PASSWORD !== null && WEB_PASSWORD !== undefined && WEB_PASSWORD !== ""; +const sessions = new Set(); + +// Helper functions for authentication +function generateSessionToken(): string { + return crypto.randomBytes(32).toString("hex"); +} + +function parseCookies(cookieHeader: string | undefined): Record { + const cookies: Record = {}; + if (!cookieHeader) return cookies; + + cookieHeader.split(';').forEach(cookie => { + const parts = cookie.trim().split('='); + if (parts.length === 2) { + cookies[parts[0]] = parts[1]; + } + }); + return cookies; +} + +function isAuthenticated(req: http.IncomingMessage): boolean { + if (!AUTH_REQUIRED) return true; + + const cookies = parseCookies(req.headers.cookie); + const sessionToken = cookies['session_token']; + return !!(sessionToken && sessions.has(sessionToken)); +} + +function requireAuth(req: http.IncomingMessage, res: http.ServerResponse): boolean { + if (!isAuthenticated(req)) { + res.writeHead(302, { Location: '/login' }); + res.end(); + return false; + } + return true; +} + // server const httpServer = http.createServer(function (req, res) { let incomingData: Array = []; + + // Authentication routes + if (req.url === "/login" && req.method === "GET") { + if (isAuthenticated(req)) { + res.writeHead(302, { Location: '/' }); + res.end(); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + const loginHTML = fs.readFileSync("./dist/views/login.html"); + res.end(loginHTML); + return; + } + + if (req.url === "/auth/login" && req.method === "POST") { + req.on("data", function (chunk) { + incomingData.push(chunk); + }); + req.on("end", function () { + try { + const requestData = incomingData.join(""); + const parsedData = JSON.parse(requestData); + + if (parsedData.password === WEB_PASSWORD) { + const sessionToken = generateSessionToken(); + sessions.add(sessionToken); + + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": `session_token=${sessionToken}; HttpOnly; Path=/; Max-Age=86400` + }); + res.end(JSON.stringify({ success: true })); + } else { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, message: "Invalid password" })); + } + } catch (error) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, message: "Invalid request" })); + } + }); + return; + } + + if (req.url === "/auth/logout" && req.method === "POST") { + const cookies = parseCookies(req.headers.cookie); + const sessionToken = cookies['session_token']; + if (sessionToken) { + sessions.delete(sessionToken); + } + + res.writeHead(200, { + "Content-Type": "application/json", + "Set-Cookie": "session_token=; HttpOnly; Path=/; Max-Age=0" + }); + res.end(JSON.stringify({ success: true })); + return; + } + + if (req.url === "/auth/status" && req.method === "GET") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ authRequired: AUTH_REQUIRED })); + return; + } + switch (req.url) { case "/golbat": req.on("data", function (chunk) { incomingData.push(chunk); }); req.on("end", function () { - const requestData = incomingData.join(""); - let parsedData = JSON.parse(requestData); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(""); - if (Array.isArray(parsedData)) { - console.error("Incoming Data is an array, need to be single object"); - return; - } - // redirect because endpoint is in use there, leave null to ignore. - // ex http://123.123.123.123:9001/raw - // this need a test ping ok or throw for better. - if (config["redirect_to_golbat_url"]) { - try { - redirect_post_golbat(config["redirect_to_golbat_url"], config["redirect_to_golbat_token"], JSON.stringify(parsedData)); + try { + const requestData = incomingData.join(""); + let parsedData = JSON.parse(requestData); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(""); + if (Array.isArray(parsedData)) { + console.error("Incoming Data is an array, need to be single object"); + return; } - catch (err) { - console.error("Endpoint golbat offline or bad!" + err); + // Validate required fields + if (!parsedData['contents'] || !Array.isArray(parsedData['contents'])) { + console.error("Invalid golbat data: 'contents' field missing or not an array"); + return; } - } - const identifier = parsedData['username']; - for (let i = 0; i < parsedData['contents'].length; i++) { + if (parsedData['contents'].length === 0) { + console.error("Invalid golbat data: 'contents' array is empty"); + return; + } + // redirect because endpoint is in use there, leave null to ignore. + // ex http://123.123.123.123:9001/raw + // this need a test ping ok or throw for better. + if (config["redirect_to_golbat_url"]) { + try { + redirect_post_golbat(config["redirect_to_golbat_url"], config["redirect_to_golbat_token"], JSON.stringify(parsedData)); + } + catch (err) { + console.error("Endpoint golbat offline or bad!" + err); + } + } + const identifier = parsedData['username']; + for (let i = 0; i < parsedData['contents'].length; i++) { const rawRequest = parsedData['contents'][i].request || ""; const rawResponse = parsedData['contents'][i].payload || ""; @@ -85,6 +201,9 @@ const httpServer = http.createServer(function (req, res) { } } } + } catch (error) { + console.error("Error processing golbat request:", error); + } }); break; case "/traffic": @@ -111,25 +230,34 @@ const httpServer = http.createServer(function (req, res) { incomingData.push(chunk); }); req.on("end", () => { - const requestData = incomingData.join(""); - let parsedData = JSON.parse(requestData); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(""); - const parsedResponseData = decodePayload( - parsedData.contents, - "response" - ); - if (typeof parsedResponseData === "string") { - incomingProtoWebBufferInst.write({ error: parsedResponseData }); - } else { - for (let parsedObject of parsedResponseData) { - parsedObject.identifier = - parsedData["uuid"] || - parsedData["devicename"] || - parsedData["deviceName"] || - parsedData["instanceName"]; - incomingProtoWebBufferInst.write(parsedObject); + try { + const requestData = incomingData.join(""); + let parsedData = JSON.parse(requestData); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(""); + // Validate required fields + if (!parsedData.contents) { + console.error("Invalid raw data: 'contents' field missing"); + return; } + const parsedResponseData = decodePayload( + parsedData.contents, + "response" + ); + if (typeof parsedResponseData === "string") { + incomingProtoWebBufferInst.write({ error: parsedResponseData }); + } else { + for (let parsedObject of parsedResponseData) { + parsedObject.identifier = + parsedData["uuid"] || + parsedData["devicename"] || + parsedData["deviceName"] || + parsedData["instanceName"]; + incomingProtoWebBufferInst.write(parsedObject); + } + } + } catch (error) { + console.error("Error processing raw request:", error); } }); break; @@ -138,46 +266,60 @@ const httpServer = http.createServer(function (req, res) { incomingData.push(chunk); }); req.on("end", function () { - const requestData = incomingData.join(""); - let parsedData = JSON.parse(requestData); - res.writeHead(200, { "Content-Type": "application/json" }); - res.end(""); - const parsedRequestData = decodePayload(parsedData.contents, "request"); - if (typeof parsedRequestData === "string") { - outgoingProtoWebBufferInst.write({ error: parsedRequestData }); - } else { - for (let parsedObject of parsedRequestData) { - parsedObject.identifier = - parsedData["uuid"] || - parsedData["devicename"] || - parsedData["deviceName"] || - parsedData["instanceName"]; - outgoingProtoWebBufferInst.write(parsedObject); + try { + const requestData = incomingData.join(""); + let parsedData = JSON.parse(requestData); + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(""); + // Validate required fields + if (!parsedData.contents) { + console.error("Invalid debug data: 'contents' field missing"); + return; } + const parsedRequestData = decodePayload(parsedData.contents, "request"); + if (typeof parsedRequestData === "string") { + outgoingProtoWebBufferInst.write({ error: parsedRequestData }); + } else { + for (let parsedObject of parsedRequestData) { + parsedObject.identifier = + parsedData["uuid"] || + parsedData["devicename"] || + parsedData["deviceName"] || + parsedData["instanceName"]; + outgoingProtoWebBufferInst.write(parsedObject); + } + } + } catch (error) { + console.error("Error processing debug request:", error); } }); break; case "/images/favicon.png": + if (!requireAuth(req, res)) break; res.writeHead(200, { "Content-Type": "image/png" }); const favicon = fs.readFileSync("./dist/views/images/favicon.png"); res.end(favicon); break; case "/css/style.css": + if (!requireAuth(req, res)) break; res.writeHead(200, { "Content-Type": "text/css" }); const pageCssL = fs.readFileSync("./dist/views/css/style.css"); res.end(pageCssL); break; case "/json-viewer/jquery.json-viewer.css": + if (!requireAuth(req, res)) break; res.writeHead(200, { "Content-Type": "text/css" }); const pageCss = fs.readFileSync("node_modules/jquery.json-viewer/json-viewer/jquery.json-viewer.css"); res.end(pageCss); break; case "/json-viewer/jquery.json-viewer.js": + if (!requireAuth(req, res)) break; res.writeHead(200, { "Content-Type": "text/javascript" }); const pageJs = fs.readFileSync("node_modules/jquery.json-viewer/json-viewer/jquery.json-viewer.js"); res.end(pageJs); break; case "/": + if (!requireAuth(req, res)) break; res.writeHead(200, { "Content-Type": "text/html" }); const pageHTML = fs.readFileSync("./dist/views/print-protos.html"); res.end(pageHTML); @@ -189,6 +331,22 @@ const httpServer = http.createServer(function (req, res) { }); var io = require("socket.io")(httpServer); + +// Socket.IO authentication middleware +if (AUTH_REQUIRED) { + io.use((socket, next) => { + const cookieHeader = socket.handshake.headers.cookie; + const cookies = parseCookies(cookieHeader); + const sessionToken = cookies['session_token']; + + if (sessionToken && sessions.has(sessionToken)) { + next(); + } else { + next(new Error('Authentication required')); + } + }); +} + var incoming = io.of("/incoming").on("connection", function (socket) { const reader = { read: function (data: object) { @@ -221,9 +379,12 @@ var outgoing = io.of("/outgoing").on("connection", function (socket) { httpServer.keepAliveTimeout = 0; httpServer.listen(portBind, function () { + const authStatus = AUTH_REQUIRED ? "ENABLED - Password required to access web UI" : "DISABLED"; const welcome = ` Server start access of this in urls: http://localhost:${portBind} or WLAN mode http://${getIPAddress()}:${portBind}. - + + - Web Authentication: ${authStatus} + - Clients MITM: 1) --=FurtiF™=- Tools EndPoints: http://${getIPAddress()}:${portBind}/traffic or http://${getIPAddress()}:${portBind}/golbat (depending on the modes chosen) 2) If Other set here... diff --git a/src/utils/index.ts b/src/utils/index.ts index 4c57e70..6315029 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -34,6 +34,15 @@ export function getIPAddress() { } export function handleData(incoming: WebStreamBuffer, outgoing: WebStreamBuffer, identifier: any, parsedData: string, sampleSaver?: any) { + // Validate required fields + if (!parsedData['protos'] || !Array.isArray(parsedData['protos'])) { + console.error("Invalid traffic data: 'protos' field missing or not an array"); + return; + } + if (parsedData['protos'].length === 0) { + console.error("Invalid traffic data: 'protos' array is empty"); + return; + } for (let i = 0; i < parsedData['protos'].length; i++) { const rawRequest = parsedData['protos'][i].request || ""; const rawResponse = parsedData['protos'][i].response || ""; diff --git a/src/views/login.html b/src/views/login.html new file mode 100644 index 0000000..3f6982b --- /dev/null +++ b/src/views/login.html @@ -0,0 +1,165 @@ + + + + + + + + ProtoDecoderUI - Login + + + + + + + + + diff --git a/src/views/print-protos.html b/src/views/print-protos.html index 9f588e4..58c926d 100644 --- a/src/views/print-protos.html +++ b/src/views/print-protos.html @@ -45,6 +45,7 @@

+

@@ -484,6 +485,22 @@