Compare commits

9 Commits

Author SHA1 Message Date
37e103da88 it works now, cleaned up 2026-03-23 17:54:41 +02:00
b309bd3649 iwasnot 2026-03-23 17:46:12 +02:00
fd63d1e697 amiright? 2026-03-23 17:29:59 +02:00
5b6f7d352f moron, me 2026-03-23 17:13:43 +02:00
3554b5984f it is not removing the damn rule, added logging rq 2026-03-23 17:08:07 +02:00
98d387bcd2 forgot that it has to be a url 2026-03-21 16:18:46 +02:00
665c1fcc97 IT HAS TCP/UDP OPTIONS 2026-03-21 15:17:29 +02:00
190b8f9990 clarify .env 2026-03-21 15:00:41 +02:00
1b023ea802 removed unused stuff, changed natQ to use opnsense api 2026-03-21 14:54:25 +02:00
7 changed files with 75 additions and 159 deletions

View File

@@ -1,12 +1,10 @@
ROUTER_HOST=192.168.0.1 OPNSENSE_HOST=https://192.168.1.1
ROUTER_USER=username OPNSENSE_KEY=keeeeey
ROUTER_PASS=password OPNSENSE_SECRET=seeeecret
WEBHOOK_SECRET=secret WEBHOOK_SECRET=secret
ROUTER_WAN_IF=gi0 BIND_ADDRESS=192.168.1.2 # this is the ip hosting this api
BIND_ADDRESS=192.168.0.2
ADMIN_PASS=password ADMIN_PASS=password

View File

@@ -8,7 +8,6 @@ import { fileURLToPath } from 'url';
dotenv.config(); dotenv.config();
import webhookRouter from './routes/webhook.js'; import webhookRouter from './routes/webhook.js';
import execRouter from './routes/exec.js';
import { info } from './util/Console.js'; import { info } from './util/Console.js';
@@ -19,7 +18,6 @@ app.use(cookieParser());
app.use(express.static('public')); app.use(express.static('public'));
app.use('/webhook', webhookRouter); app.use('/webhook', webhookRouter);
app.use('/exec', execRouter);
const PORT = process.env.PORT || 3000; const PORT = process.env.PORT || 3000;
const HOST = process.env.BIND_ADDRESS || '0.0.0.0'; const HOST = process.env.BIND_ADDRESS || '0.0.0.0';

View File

@@ -1,7 +1,7 @@
{ {
"name": "smithy-pelican-router-webhook-api", "name": "smithy-pelican-router-webhook-api",
"version": "0.0.1", "version": "0.0.2",
"description": "api to manage the router for pelican servers", "description": "api to manage opnsense rules for pelican servers",
"license": "ISC", "license": "ISC",
"author": "may p", "author": "may p",
"type": "module", "type": "module",

View File

@@ -1,63 +0,0 @@
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;

View File

@@ -12,13 +12,12 @@ router.post('/', (req, res) => {
const body = req.body || {}; const body = req.body || {};
const event = body.event || ''; const event = body.event || '';
const data = body || {}; const data = body || {};
info(`Webhook: ${event}\n${JSON.stringify(body)}`); info(`Webhook: ${event}\nbody: ${JSON.stringify(body)}`);
// return res.status(418).json({ error: 'im a teapot' }); // temporary
try { try {
if (event === 'created: Allocation') { if (event === 'created: Allocation') {
enqueueNatAdd(data.ip, data.port, 'tcp'); enqueueNatAdd(data.ip, data.port);
} else if (event === 'deleted: Allocation') { } else if (event === 'deleted: Allocation') {
enqueueNatRemove(data.ip, data.port, 'tcp'); enqueueNatRemove(data.ip, data.port);
} else { } else {
warn(`Unhandled webhook event: ${event}`); warn(`Unhandled webhook event: ${event}`);
} }

View File

@@ -1,5 +1,4 @@
import { error, info, success } from "../util/Console.js"; import { error, info, success } from "../util/Console.js";
import { runRouterCmds } from "./sshExec.js";
const queue = []; const queue = [];
let running = false; let running = false;
@@ -13,38 +12,81 @@ async function processQueue() {
if (queue.length === 0) { running = false; return; } if (queue.length === 0) { running = false; return; }
running = true; running = true;
const t = queue.shift(); const t = queue.shift();
try { await t(); } catch (err) { error('Queue error: ' + String(err)); } try { await t(); } catch (err) { error('Q error ' + err); }
setImmediate(processQueue); setImmediate(processQueue);
} }
export function enqueueCmd(fn) { async function opnsenseRequest(method, path, body = null) {
enqueue(() => fn()); const auth = Buffer.from(`${process.env.OPNSENSE_KEY}:${process.env.OPNSENSE_SECRET}`).toString('base64');
const opts = {
method,
headers: {
'Authorization': `Basic ${auth}`,
},
};
if (body) {
opts.headers['Content-Type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(`${process.env.OPNSENSE_HOST}${path}`, opts);
const text = await res.text();
try {
return JSON.parse(text);
} catch {
error(`got non-json response: ${text}`);
return null;
}
} }
export function enqueueNatAdd(ip, port, proto='tcp') { async function applyRules() {
await opnsenseRequest('POST', '/api/firewall/d_nat/apply');
}
export function enqueueNatAdd(ip, port, proto = 'tcp/udp') {
enqueue(async () => { enqueue(async () => {
info(`NAT ADD ${proto} ${ip}:${port}`); info(`adding rule udp+tcp at ${ip}:${port}`);
await runRouterCmds([
'conf t', await opnsenseRequest('POST', '/api/firewall/d_nat/addRule', {
`ip nat inside source static tcp ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, rule: {
`ip nat inside source static udp ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, interface: 'wan',
'end', protocol: 'tcp/udp',
'wr mem' destination: {
]); network: 'wanip',
success(`NAT ADDED ${proto} ${ip}:${port}`); port: `${port}`,
},
target: ip,
'local-port': `${port}`,
descr: `pelican-${ip}-${port}`,
disabled: '0',
}
});
await applyRules();
success(`added rule udp+tcp at ${ip}:${port}`);
}); });
} }
export function enqueueNatRemove(ip, port, proto='tcp') { export function enqueueNatRemove(ip, port, proto = 'tcp/udp') {
enqueue(async () => { enqueue(async () => {
info(`NAT DEL ${proto} ${ip}:${port}`); info(`deleting rule udp+tcp at ${ip}:${port}`);
await runRouterCmds([
'conf t', const result = await opnsenseRequest('POST', '/api/firewall/d_nat/searchRule', {
`no ip nat inside source static tcp ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, current: 1,
`no ip nat inside source static udp ${ip} ${port} interface ${process.env.ROUTER_WAN_IF || 'gi0'} ${port}`, rowCount: 1000,
'end', searchPhrase: `pelican-${ip}-${port}`,
'wr mem' });
]);
success(`NAT DELETED ${proto} ${ip}:${port}`); const rows = result?.rows || [];
for (const rule of rows) {
if (rule.descr === `pelican-${ip}-${port}`) {
let res = await opnsenseRequest('POST', `/api/firewall/d_nat/delRule/${rule.uuid}`);
info(JSON.stringify(res));
success(`deleted rule udp+tcp at ${ip}:${port}`);
}
}
await applyRules();
success('Successfully applied rules');
}); });
} }

View File

@@ -1,58 +0,0 @@
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
});
});
}