Files
op-packages/shunt/files/shunt.rpcd
T

310 lines
6.3 KiB
Plaintext

// shunt - rpcd backend for the LuCI frontend
//
// Stateless: renders the configuration through the same modules the daemon
// uses and reads the rest from the kernel. The daemon is asked only for the
// facts nothing outside its process can see.
//
// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
import { popen, readfile } from 'fs';
const ubus = require('ubus');
const rtnl = require('rtnl');
// The rtnl constants hang off a `const` sub-object, not off the module - the
// example in lib/rtnl.c's own header says otherwise and yields null.
const RT = rtnl.const;
import { load as config_load, parse as config_parse } from 'shunt.config';
import { resolve as netifd_resolve } from 'shunt.netifd';
import { compile as match_compile } from 'shunt.match';
import { compile as nft_compile } from 'shunt.nft';
import { compile as route_compile } from 'shunt.route';
import { names as poll_names } from 'shunt.poll';
const TABLE_FAMILY = 'inet';
const TABLE_NAME = 'shunt';
function daemon_status() {
let conn = ubus.connect();
if (!conn)
return null;
let r = conn.call('shunt', 'status');
return r ?? null;
}
// The same sequence as the daemon's build_state(), minus the logging: two
// readings of one config file are how a status view starts to lie.
function render() {
let sections = config_load();
if (sections == null)
return null;
let cfg = config_parse(sections);
if (!cfg)
return null;
let dump = null;
let conn = ubus.connect();
if (conn)
dump = conn.call('network.interface', 'dump');
cfg.policies = netifd_resolve(cfg.policies, dump);
let m = match_compile(cfg.policies);
let n = nft_compile(cfg.policies);
let r = route_compile(cfg.policies, n.marks);
return { cfg, matcher: m, nft: n, route: r };
}
// Policy devices whose marked traffic the kernel would drop: max(all, <dev>),
// blocked only when all is strict (1) and the device is not loose itself. Same
// logic as the daemon. When rp_filter_manage is on the daemon has already set
// these to 2, so this reads back empty on its own.
function rp_filter_blocked(policies) {
let rp = (k) => trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
if (rp('all') != '1')
return [];
let seen = {}, blocked = [];
for (let p in (policies ?? [])) {
let dev = p.interface;
if (!length(dev ?? '') || seen[dev])
continue;
seen[dev] = true;
let v = rp(dev);
// Absent device: no traffic, nothing dropped - not blocked. The
// daemon re-checks on ifup when it appears.
if (v == '')
continue;
if (v != '2')
push(blocked, dev);
}
return blocked;
}
// Same check the daemon logs, surfaced for the UI: which of all/default carry
// strict reverse path filtering, which drops shunt's asymmetric traffic.
function rp_filter_strict() {
let strict = [];
for (let key in [ 'all', 'default' ])
if (trim(readfile(`/proc/sys/net/ipv4/conf/${key}/rp_filter`) ?? '') == '1')
push(strict, key);
return strict;
}
function nft_table() {
let fh = popen(sprintf('nft -j list table %s %s 2>/dev/null',
TABLE_FAMILY, TABLE_NAME), 'r');
if (!fh)
return null;
let out = fh.read('all');
fh.close();
if (!length(out ?? ''))
return null;
let j = json(out);
return j?.nftables ? j : null;
}
function nft_sets(table) {
let out = {};
for (let item in (table?.nftables ?? [])) {
let s = item?.set;
if (!s?.name)
continue;
let elems = [];
for (let e in (s.elem ?? [])) {
let v = e?.elem ?? e;
let val = v?.val ?? v;
push(elems, {
addr: (type(val) == 'object') ? (val.prefix ? sprintf('%s/%d', val.prefix.addr, val.prefix.len) : null) : val,
expires: v?.expires,
packets: v?.counter?.packets,
bytes: v?.counter?.bytes
});
}
out[s.name] = elems;
}
return out;
}
function kernel_rules(marks) {
let want = {};
for (let m in (marks ?? []))
want[sprintf('%d', m.mark)] = m.name;
let res = rtnl.request(RT.RTM_GETRULE, RT.NLM_F_DUMP,
{ family: RT.AF_UNSPEC });
if (res == null)
return null;
let out = {};
for (let r in res) {
if (r?.fwmark == null)
continue;
let name = want[sprintf('%d', r.fwmark)];
if (!name)
continue;
if (!out[name])
out[name] = [];
push(out[name], {
family: r.family,
priority: r.priority,
table: r.table,
fwmark: r.fwmark,
fwmask: r.fwmask
});
}
return out;
}
function kernel_routes(marks) {
let out = {};
for (let m in (marks ?? [])) {
let n = 0;
for (let fam in [ RT.AF_INET, RT.AF_INET6 ]) {
let res = rtnl.request(RT.RTM_GETROUTE, RT.NLM_F_DUMP,
{ family: fam, table: m.rt_table });
if (res == null) {
n = null;
break;
}
for (let r in res)
if (r?.table == m.rt_table)
n++;
}
out[m.name] = n;
}
return out;
}
return {
'luci.shunt': {
status: {
args: {},
call: function(req) {
let st = render();
if (!st)
return { error: 'cannot read /etc/config/shunt' };
let svc = daemon_status();
let table = nft_table();
let rules = kernel_rules(st.nft.marks);
let routes = kernel_routes(st.nft.marks);
let policies = [];
for (let m in st.nft.marks) {
let p = null;
for (let c in st.cfg.policies)
if (c.name == m.name)
p = c;
push(policies, {
name: m.name,
mark: m.mark,
rt_table: m.rt_table,
rt_prio: m.rt_prio,
interface: p?.interface,
fallback: p?.fallback,
domains: length(p?.domains ?? []),
rules: rules ? length(rules[m.name] ?? []) : null,
routes: routes[m.name]
});
}
return {
running: (svc != null),
applied: (table != null),
service: svc,
global: st.cfg.global,
policies,
poll_names: length(poll_names(st.cfg.policies)),
rp_filter_blocked: rp_filter_blocked(st.cfg.policies),
rp_filter_strict: rp_filter_strict(),
issues: [
...st.cfg.issues,
...st.matcher.issues,
...st.nft.issues,
...st.route.issues
]
};
}
},
sets: {
args: { policy: '' },
call: function(req) {
let table = nft_table();
if (!table)
return { sets: {} };
let all = nft_sets(table);
let want = req.args?.policy;
if (!length(want ?? ''))
return { sets: all };
let out = {};
for (let name in all) {
let at = (substr(name, 0, 1) == 'm') ? 2 : 3;
if (substr(name, at) == want)
out[name] = all[name];
}
return { sets: out };
}
}
}
};