Compare commits
9 Commits
main
...
opnsense-e
| Author | SHA1 | Date | |
|---|---|---|---|
| 37e103da88 | |||
| b309bd3649 | |||
| fd63d1e697 | |||
| 5b6f7d352f | |||
| 3554b5984f | |||
| 98d387bcd2 | |||
| 665c1fcc97 | |||
| 190b8f9990 | |||
| 1b023ea802 |
10
.env.example
10
.env.example
@@ -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
|
||||||
|
|
||||||
|
|||||||
2
index.js
2
index.js
@@ -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';
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -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
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user