this works, to the extent that i tested it
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
node_modules/
|
||||
.env
|
||||
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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
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