this works, to the extent that i tested it
This commit is contained in:
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
.env
|
.env
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
actions.log
|
||||||
|
logs/
|
||||||
29
index.js
Normal file
29
index.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
43
package.json
43
package.json
@@ -1,20 +1,27 @@
|
|||||||
{
|
{
|
||||||
"name": "smithy-pelican-router-webhook-api",
|
"name": "smithy-pelican-router-webhook-api",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"description": "api to manage the router for pelican servers",
|
"description": "api to manage the router for pelican servers",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"author": "may p",
|
"author": "may p",
|
||||||
"type": "commonjs",
|
"type": "module",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"repository": {
|
||||||
"test": "."
|
"type": "git",
|
||||||
},
|
"url": "git+https://git.bigsmithy.org/Smithy/smithy-pelican-router-webhook-api.git"
|
||||||
"dependencies": {
|
},
|
||||||
"body-parser": "^2.2.0",
|
"scripts": {
|
||||||
"crypto": "^1.0.1",
|
"start": "node index.js",
|
||||||
"dotenv": "^17.2.3",
|
"dev": "NODE_ENV=development node index.js"
|
||||||
"express": "^5.1.0",
|
},
|
||||||
"fs": "^0.0.1-security",
|
"dependencies": {
|
||||||
"ssh2": "^1.17.0"
|
"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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
63
routes/exec.js
Normal file
63
routes/exec.js
Normal file
@@ -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;
|
||||||
32
routes/webhook.js
Normal file
32
routes/webhook.js
Normal file
@@ -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;
|
||||||
48
services/natQueue.js
Normal file
48
services/natQueue.js
Normal file
@@ -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}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
58
services/sshExec.js
Normal file
58
services/sshExec.js
Normal file
@@ -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
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
54
util/Console.js
Normal file
54
util/Console.js
Normal file
@@ -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');
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user