this works, to the extent that i tested it

This commit is contained in:
2025-11-09 13:21:10 +02:00
parent 24d0d5d8f5
commit 5e992810ea
8 changed files with 312 additions and 19 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,5 @@
node_modules/ node_modules/
.env .env
package-lock.json package-lock.json
actions.log
logs/

29
index.js Normal file
View 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}`);
});

View File

@@ -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
View 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
View 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
View 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
View 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
View 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');
}