diff --git a/.gitignore b/.gitignore index 055395e..c6442f3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ node_modules/ .env -package-lock.json \ No newline at end of file +package-lock.json +actions.log +logs/ \ No newline at end of file diff --git a/index.js b/index.js new file mode 100644 index 0000000..6c01cf5 --- /dev/null +++ b/index.js @@ -0,0 +1,29 @@ +import express from 'express'; +import bodyParser from 'body-parser'; +import helmet from 'helmet'; +import cookieParser from 'cookie-parser'; +import dotenv from 'dotenv'; +import path from 'path'; +import { fileURLToPath } from 'url'; +dotenv.config(); + +import webhookRouter from './routes/webhook.js'; +import execRouter from './routes/exec.js'; + +import { info } from './util/Console.js'; + +const app = express(); +app.use(helmet()); +app.use(bodyParser.json()); +app.use(cookieParser()); +app.use(express.static('public')); + +app.use('/webhook', webhookRouter); +app.use('/exec', execRouter); + +const PORT = process.env.PORT || 3000; +const HOST = process.env.BIND_ADDRESS || '0.0.0.0'; + +app.listen(PORT, HOST, () => { + info(`API listening on ${HOST}:${PORT}`); +}); \ No newline at end of file diff --git a/package.json b/package.json index faeb67e..6e1decc 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,27 @@ { - "name": "smithy-pelican-router-webhook-api", - "version": "0.0.1", - "description": "api to manage the router for pelican servers", - "license": "ISC", - "author": "may p", - "type": "commonjs", - "main": "index.js", - "scripts": { - "test": "." - }, - "dependencies": { - "body-parser": "^2.2.0", - "crypto": "^1.0.1", - "dotenv": "^17.2.3", - "express": "^5.1.0", - "fs": "^0.0.1-security", - "ssh2": "^1.17.0" - } + "name": "smithy-pelican-router-webhook-api", + "version": "0.0.1", + "description": "api to manage the router for pelican servers", + "license": "ISC", + "author": "may p", + "type": "module", + "main": "index.js", + "repository": { + "type": "git", + "url": "git+https://git.bigsmithy.org/Smithy/smithy-pelican-router-webhook-api.git" + }, + "scripts": { + "start": "node index.js", + "dev": "NODE_ENV=development node index.js" + }, + "dependencies": { + "body-parser": "^1.20.2", + "colors": "^1.4.0", + "cookie-parser": "^1.4.6", + "dotenv": "^16.0.0", + "express": "^4.18.2", + "fs": "^0.0.1-security", + "helmet": "^7.0.0", + "ssh2": "^1.11.0" + } } diff --git a/routes/exec.js b/routes/exec.js new file mode 100644 index 0000000..86c6923 --- /dev/null +++ b/routes/exec.js @@ -0,0 +1,63 @@ +import express from 'express'; +import { enqueueCmd } from '../services/natQueue.js'; +import { runRouterCmds } from '../services/sshExec.js'; +import { error, info } from '../util/Console.js'; + +const router = express.Router(); + +const SAFE_CMD_REGX = /^\s*(show|ping|traceroute|terminal length|show ip|show interfaces|show version|show running-config|show ip nat)\b/i; + +function checkAdminToken(req) { + const token = req.headers['x-admin-token'] || req.body.token || req.query.token; + const saved = req.app.get('adminToken'); + const expiry = req.app.get('adminTokenExpory') || 0; + if (!token || !saved) return false; + if (Date.now() > expiry) return false; + return token === saved; +} + +router.post('/', async (req, res) => { + const cmd = (req.body && req.body.cmd) || ''; + if (!cmd) return res.status(400).json({ error: 'no command' }); + const isAdmin = checkAdminToken(req); + if (!isAdmin && !SAFE_CMD_REGX.test(cmd)) return res.status(403).json({ error: 'not allowed' }); + + info(`Exec requested${isAdmin ? ' [admin]' : ''}: ${cmd}`); + + try { + enqueueCmd(() => runRouterCmds([cmd])); + res.json({ success: true }); + } catch (err) { + error(err); + res.status(500).json({ error: 'exec failed' }); + } +}); + +router.get('/stream', (req, res) => { + const cmd = req.query.cmd || ''; + if (!cmd) return res.status(400).send('no command'); + const isAdmin = checkAdminToken(req); + if (!isAdmin && !SAFE_CMD_REGX.test(cmd)) return res.status(400).send('not allowed'); + + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders && res.flushHeaders(); + + enqueueCmd(() => new Promise(async (resolve) => { + try { + await runRouterCmds([cmd], chunk => { + res.write(`data: ${chunk.replace(/\n/g, '\\n')}\n\n`); + }); + res.write('data: [[EOF]]\n\n'); + res.end(); + resolve() + } catch (err) { + res.write(`data: [ERROR] ${String(err)}\n\n`); + res.end(); + resolve(); + } + })); +}); + +export default router; \ No newline at end of file diff --git a/routes/webhook.js b/routes/webhook.js new file mode 100644 index 0000000..60f0ec6 --- /dev/null +++ b/routes/webhook.js @@ -0,0 +1,32 @@ +import express from 'express'; +import { enqueueNatAdd, enqueueNatRemove } from '../services/natQueue.js'; +import { error, info, warn } from '../util/Console.js'; + +const router = express.Router(); + +router.post('/', (req, res) => { + const sig = req.headers['x-webhook-signature'] || req.headers['x-hook-signature'] || ''; + const secret = process.env.WEBHOOK_SECRET || ''; + if (!sig || sig !== secret) return res.status(403).json({ error: 'invalid signature' }); + + const body = req.body || {}; + const event = body.event || ''; + const data = body || {}; + info(`Webhook: ${event}\n${JSON.stringify(body)}`); + // return res.status(418).json({ error: 'im a teapot' }); // temporary + try { + if (event === 'created: Allocation') { + enqueueNatAdd(data.ip, data.port, 'tcp'); + } else if (event === 'deleted: Allocation') { + enqueueNatRemove(data.ip, data.port, 'tcp'); + } else { + warn(`Unhandled webhook event: ${event}`); + } + return res.json({ success: true }); + } catch (err) { + error(err); + return res.status(500).json({ error: 'error ocurred' }); + } +}); + +export default router; \ No newline at end of file diff --git a/services/natQueue.js b/services/natQueue.js new file mode 100644 index 0000000..2391e6b --- /dev/null +++ b/services/natQueue.js @@ -0,0 +1,48 @@ +import { error, info, success } from "../util/Console.js"; +import { runRouterCmds } from "./sshExec.js"; + +const queue = []; +let running = false; + +function enqueue(task) { + queue.push(task); + if (!running) processQueue(); +} + +async function processQueue() { + if (queue.length === 0) { running = false; return; } + running = true; + const t = queue.shift(); + try { await t(); } catch (err) { error('Queue error: ' + String(err)); } + setImmediate(processQueue); +} + +export function enqueueCmd(fn) { + enqueue(() => fn()); +} + +export function enqueueNatAdd(ip, port, proto='tcp') { + enqueue(async () => { + info(`NAT ADD ${proto} ${ip}:${port}`); + await runRouterCmds([ + 'conf t', + `ip nat inside source static ${proto} ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, + 'end', + 'wr mem' + ]); + success(`NAT ADDED ${proto} ${ip}:${port}`); + }); +} + +export function enqueueNatRemove(ip, port, proto='tcp') { + enqueue(async () => { + info(`NAT DEL ${proto} ${ip}:${port}`); + await runRouterCmds([ + 'conf t', + `no ip nat inside source static ${proto} ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, + 'end', + 'wr mem' + ]); + success(`NAT DELETED ${proto} ${ip}:${port}`); + }); +} \ No newline at end of file diff --git a/services/sshExec.js b/services/sshExec.js new file mode 100644 index 0000000..dc26d22 --- /dev/null +++ b/services/sshExec.js @@ -0,0 +1,58 @@ +import { Client } from "ssh2"; +import { info } from "../util/Console.js"; +import fs from 'fs'; + + +export function runRouterCmds(cmds, onData) { + const ROUTER = { + host: process.env.ROUTER_HOST || '192.168.0.1', + username: process.env.ROUTER_USER || 'cisco', + password: process.env.ROUTER_PASS || '', + wanInterface: process.env.ROUTER_WAN_IF || 'gi0' + }; + + return new Promise((resolve, reject) => { + const conn = new Client(); + let out = ''; + conn.on('ready', () => { + conn.shell((err, stream) => { + if (err) return reject(err); + stream.on('data', d => { + const s = d.toString(); + out += s; + if (onData) onData(s); + }); + stream.on('close', () => { + conn.end(); + resolve(out); + }); + cmds.forEach(c => stream.write(c + '\n')); + stream.end('exit\n'); + }); + }).on('error', reject).connect({ + host: ROUTER.host, + username: ROUTER.username, + password: ROUTER.password, + readyTimeout: 20000, + algorithms: { + kex: [ + 'diffie-hellman-group14-sha1', + 'diffie-hellman-group-exchange-sha1', + ], + cipher: [ + 'aes128-ctr', + 'aes192-ctr', + 'aes256-ctr', + ], + serverHostKey: [ + 'ssh-rsa', + ], + hmac: [ + 'hmac-sha1', + 'hmac-sha2-256', + ] + }, + hostVerifier: () => true + }); + }); +} \ No newline at end of file diff --git a/util/Console.js b/util/Console.js new file mode 100644 index 0000000..b81aee6 --- /dev/null +++ b/util/Console.js @@ -0,0 +1,54 @@ +import 'colors'; +import fs from 'fs'; + +/** + * @param {string[]} message + */ +export const info = (...message) => { + const time = new Date().toLocaleTimeString(); + let fileContent = fs.readFileSync('./logs/terminal.log', 'utf-8'); + + console.info(`[${time}]`.gray, '[Info]'.blue, message.join(' ')); + fileContent += [`[${time}]`.gray, '[Info]'.blue, message.join(' ')].join(' ') + '\n'; + + fs.writeFileSync('./logs/terminal.log', fileContent, 'utf-8'); +} + +/** + * @param {string[]} message + */ +export const success = (...message) => { + const time = new Date().toLocaleTimeString(); + let fileContent = fs.readFileSync('./logs/terminal.log', 'utf-8'); + + console.info(`[${time}]`.gray, '[OK]'.green, message.join(' ')); + fileContent += [`[${time}]`.gray, '[OK]'.green, message.join(' ')].join(' ') + '\n'; + + fs.writeFileSync('./logs/terminal.log', fileContent, 'utf-8'); +} + +/** + * + */ +export const error = (...message) => { + const time = new Date().toLocaleTimeString(); + let fileContent = fs.readFileSync('./logs/terminal.log', 'utf-8'); + + console.error(`[${time}]`.gray, '[Error]'.red, message.join(' ')); + fileContent += [`[${time}]`.gray, '[Error]'.red, message.join(' ')].join(' ') + '\n' + + fs.writeFileSync('./logs/terminal.log', fileContent, 'utf-8') +} + +/** + * @param {string[]} message + */ +export const warn = (...message) => { + const time = new Date().toLocaleTimeString(); + let fileContent = fs.readFileSync('./logs/terminal.log', 'utf-8'); + + console.info(`[${time}]`.gray, '[Warn]'.yellow, message.join(' ')); + fileContent += [`[${time}]`.gray, '[Warn]'.yellow, message.join(' ')].join(' ') + '\n'; + + fs.writeFileSync('./logs/terminal.log', fileContent, 'utf-8'); +}