mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
⛄ Sync 2026-08-26 09:02:21
This commit is contained in:
@@ -1,16 +0,0 @@
|
||||
# Copyright 2026 Dirk Brenken (dev@brenken.org)
|
||||
# This is free software, licensed under the Apache License, Version 2.0
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
LUCI_TITLE:=LuCI support for shunt
|
||||
LUCI_DEPENDS:=+luci-base +shunt
|
||||
|
||||
PKG_VERSION:=0.1.5
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=Apache-2.0
|
||||
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -1,4 +0,0 @@
|
||||
'use strict';
|
||||
'require view.shunt.logtemplate as LogTemplate';
|
||||
|
||||
return LogTemplate.Logview(/\bshunt(\[\d+\])?:/, 'shunt');
|
||||
@@ -1,68 +0,0 @@
|
||||
'use strict';
|
||||
'require rpc';
|
||||
|
||||
const callLogRead = rpc.declare({
|
||||
object: 'log',
|
||||
method: 'read',
|
||||
params: ['lines', 'stream', 'oneshot'],
|
||||
expect: {}
|
||||
});
|
||||
|
||||
function Logview(logtag, name) {
|
||||
return L.view.extend({
|
||||
load: () => Promise.resolve(),
|
||||
|
||||
render: function () {
|
||||
const pollFn = () => {
|
||||
return callLogRead(1000, false, true).then(res => {
|
||||
const logEl = document.getElementById('logfile');
|
||||
if (!logEl) return;
|
||||
const filtered = (res?.log ?? [])
|
||||
.filter(entry => !logtag || (logtag instanceof RegExp
|
||||
? logtag.test(entry.msg)
|
||||
: entry.msg.includes(logtag)))
|
||||
.map(entry => {
|
||||
const d = new Date(entry.time);
|
||||
const pad = n => String(n).padStart(2, '0');
|
||||
const date = `${pad(d.getDate())}/${pad(d.getMonth() + 1)}/${d.getFullYear()}`;
|
||||
const time = `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
return `[${date}-${time}] ${entry.msg}`;
|
||||
});
|
||||
logEl.value = filtered.length > 0
|
||||
? filtered.join('\n')
|
||||
: _('No %s related logs yet!').format(name);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
});
|
||||
};
|
||||
|
||||
this._pollFn = pollFn;
|
||||
L.Poll.add(pollFn);
|
||||
|
||||
return E('div', { class: 'cbi-map' }, [
|
||||
E('div', { class: 'cbi-section' }, [
|
||||
E('div', { class: 'cbi-section-descr' },
|
||||
_('The syslog output, pre-filtered for messages related to: %s').format(name)),
|
||||
E('textarea', {
|
||||
id: 'logfile',
|
||||
style: 'min-height: 500px; max-height: 90vh; width: 100%; padding: 5px; font-family: monospace; resize: vertical;',
|
||||
readonly: 'readonly',
|
||||
wrap: 'off'
|
||||
})
|
||||
])
|
||||
]);
|
||||
},
|
||||
|
||||
unload: function () {
|
||||
if (this._pollFn) {
|
||||
L.Poll.remove(this._pollFn);
|
||||
this._pollFn = null;
|
||||
}
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
}
|
||||
|
||||
return L.Class.extend({ Logview });
|
||||
@@ -1,441 +0,0 @@
|
||||
'use strict';
|
||||
'require dom';
|
||||
'require view';
|
||||
'require poll';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require rpc';
|
||||
'require form';
|
||||
'require tools.widgets as widgets';
|
||||
|
||||
const getStatus = rpc.declare({
|
||||
object: 'luci.shunt',
|
||||
method: 'status'
|
||||
});
|
||||
|
||||
const getPackages = rpc.declare({
|
||||
object: 'rpc-sys',
|
||||
method: 'packagelist',
|
||||
params: ['all'],
|
||||
expect: { packages: {} }
|
||||
});
|
||||
|
||||
function handleAction(ev) {
|
||||
if (ev === 'restart') {
|
||||
const map = document.querySelector('.cbi-map');
|
||||
return dom.callClassMethod(map, 'save')
|
||||
.then(L.bind(ui.changes.apply, ui.changes))
|
||||
.then(function () {
|
||||
return fs.exec_direct('/etc/init.d/shunt', [ev]);
|
||||
});
|
||||
}
|
||||
return fs.exec_direct('/etc/init.d/shunt', [ev]);
|
||||
}
|
||||
|
||||
function fmtMark(mark) {
|
||||
if (mark == null) {
|
||||
return '-';
|
||||
}
|
||||
return '0x%08x'.format(mark);
|
||||
}
|
||||
|
||||
function fmtCount(n) {
|
||||
if (n == null) {
|
||||
return '?';
|
||||
}
|
||||
return '%d'.format(n);
|
||||
}
|
||||
|
||||
function card(label, value, sub, cls) {
|
||||
return E('div', { 'class': 'shunt-card' }, [
|
||||
E('div', { 'class': 'shunt-label' }, label),
|
||||
E('div', { 'class': 'shunt-value ' + (cls || '') }, value),
|
||||
sub ? E('div', { 'class': 'shunt-sub' }, sub) : ''
|
||||
]);
|
||||
}
|
||||
|
||||
function dot(state) {
|
||||
return E('span', { 'class': 'shunt-dot shunt-dot-' + state });
|
||||
}
|
||||
|
||||
function renderState(st) {
|
||||
if (!st || st.running == null) {
|
||||
return E('div', { 'class': 'shunt-state' }, [
|
||||
dot('off'), E('span', {}, _('no answer from the backend'))
|
||||
]);
|
||||
}
|
||||
|
||||
if (st.running && st.applied) {
|
||||
return E('div', { 'class': 'shunt-state' }, [
|
||||
dot('ok'), E('span', {}, _('running, policy applied'))
|
||||
]);
|
||||
}
|
||||
|
||||
if (st.running && !st.applied) {
|
||||
return E('div', { 'class': 'shunt-state' }, [
|
||||
dot('warn'), E('span', {}, _('running, but no ruleset in the kernel'))
|
||||
]);
|
||||
}
|
||||
|
||||
if (!st.running && st.applied) {
|
||||
return E('div', { 'class': 'shunt-state' }, [
|
||||
dot('warn'), E('span', {}, _('ruleset present, service not running'))
|
||||
]);
|
||||
}
|
||||
|
||||
return E('div', { 'class': 'shunt-state' }, [
|
||||
dot('off'), E('span', {}, _('stopped'))
|
||||
]);
|
||||
}
|
||||
|
||||
// The observer's verdict identifiers are module contract - translated for
|
||||
// display only, with the raw key kept beside each label.
|
||||
const DROP_LABEL = {
|
||||
'nomatch': _('Domain is in no policy'),
|
||||
'qtype': _('Not an address query'),
|
||||
'noaddr': _('Answer carried no usable address'),
|
||||
|
||||
'dns:E_RCODE': _('Error reply, e.g. NXDOMAIN'),
|
||||
'dns:E_NOTRESP': _('Not a response'),
|
||||
'dns:E_TRUNC': _('Truncated response'),
|
||||
'dns:E_QDCOUNT': _('Not exactly one question'),
|
||||
'dns:E_SHORT': _('Message ends mid-structure'),
|
||||
'dns:E_MSGLEN': _('Message too long'),
|
||||
'dns:E_QPTR': _('Compression pointer in the question'),
|
||||
'dns:E_LABEL': _('Reserved label type'),
|
||||
'dns:E_NAMELEN': _('Name too long'),
|
||||
'dns:E_CHARSET': _('Name has a byte outside a-z 0-9 - _'),
|
||||
'dns:E_RDLEN': _('Record length does not fit the type'),
|
||||
'dns:E_ANSMAX': _('Too many answers'),
|
||||
|
||||
'frame:E_SHORT': _('Frame ends mid-structure'),
|
||||
'frame:E_ETHER': _('Neither IPv4 nor IPv6'),
|
||||
'frame:E_VLAN': _('Too many stacked VLAN tags'),
|
||||
'frame:E_IPLEN': _('Inconsistent IP header length'),
|
||||
'frame:E_FRAG': _('IP fragment'),
|
||||
'frame:E_EXTHDR': _('Extension header chain too long'),
|
||||
'frame:E_PROTO': _('Not UDP'),
|
||||
'frame:E_UDPLEN': _('Inconsistent UDP length')
|
||||
};
|
||||
|
||||
function dropLabel(key) {
|
||||
return DROP_LABEL[key] || key;
|
||||
}
|
||||
|
||||
function renderSnoop(svc) {
|
||||
if (!svc || !svc.snoop) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const drops = svc.snoop.drops || {};
|
||||
const matched = svc.snoop.matched || 0;
|
||||
const keys = Object.keys(drops).sort(function (a, b) {
|
||||
return drops[b] - drops[a] || a.localeCompare(b);
|
||||
});
|
||||
|
||||
let total = 0;
|
||||
|
||||
keys.forEach(function (k) {
|
||||
total += drops[k];
|
||||
});
|
||||
|
||||
const rows = keys.map(function (k) {
|
||||
return E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td left' }, [
|
||||
dropLabel(k),
|
||||
E('span', { 'class': 'shunt-key' }, k)
|
||||
]),
|
||||
E('td', { 'class': 'td right' }, ['%d'.format(drops[k])])
|
||||
]);
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' },
|
||||
[_('DNS responses observed on %s since the service started')
|
||||
.format((svc.snoop.devices || []).join(', ') || '-')]),
|
||||
E('table', { 'class': 'table' }, [
|
||||
E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td left' },
|
||||
E('strong', {}, _('Answers used for a policy'))),
|
||||
E('td', { 'class': 'td right' },
|
||||
E('strong', { 'class': 'shunt-hit' }, '%d'.format(matched)))
|
||||
])
|
||||
].concat(rows)),
|
||||
E('div', { 'class': 'shunt-sub' }, _('%d of %d observed responses were not used. That is normal: the observer sees every answer on the network, and only the ones for a domain you routed are of any interest.')
|
||||
.format(total, total + matched))
|
||||
]);
|
||||
}
|
||||
|
||||
function renderPolicies(st) {
|
||||
if (!st || !st.policies || !st.policies.length) {
|
||||
return E('div', { 'class': 'shunt-sub' }, _('No policy is active.'));
|
||||
}
|
||||
|
||||
const rows = [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Policy')),
|
||||
E('th', { 'class': 'th' }, _('Interface')),
|
||||
E('th', { 'class': 'th' }, _('Mark')),
|
||||
E('th', { 'class': 'th' }, _('Table')),
|
||||
E('th', { 'class': 'th' }, _('Rules')),
|
||||
E('th', { 'class': 'th' }, _('Routes')),
|
||||
E('th', { 'class': 'th' }, _('Fallback'))
|
||||
])
|
||||
];
|
||||
|
||||
st.policies.forEach(function (p) {
|
||||
rows.push(E('tr', { 'class': 'tr' }, [
|
||||
E('td', { 'class': 'td' }, [p.name]),
|
||||
E('td', { 'class': 'td' }, [p.interface || '-']),
|
||||
E('td', { 'class': 'td' }, [fmtMark(p.mark)]),
|
||||
E('td', { 'class': 'td' }, [fmtCount(p.rt_table)]),
|
||||
E('td', { 'class': 'td' }, [fmtCount(p.rules)]),
|
||||
E('td', { 'class': 'td' }, [fmtCount(p.routes)]),
|
||||
E('td', { 'class': 'td' }, [p.fallback || 'main'])
|
||||
]));
|
||||
});
|
||||
|
||||
return E('table', { 'class': 'table' }, rows);
|
||||
}
|
||||
|
||||
function renderIssues(st) {
|
||||
if (!st || !st.issues || !st.issues.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const items = st.issues.map(function (i) {
|
||||
const where = i.entry ? '%s: %s'.format(i.policy, i.entry) : i.policy;
|
||||
return E('li', {}, ['%s - %s'.format(where, i.reason)]);
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' }, _('Rejected settings')),
|
||||
E('ul', { 'class': 'shunt-issues' }, items),
|
||||
E('div', { 'class': 'shunt-sub' }, _('These entries were skipped. Everything else was applied - a rejected entry never takes the service down.'))
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function () {
|
||||
return Promise.all([
|
||||
L.resolveDefault(getStatus(), {}),
|
||||
uci.load('shunt').catch(() => 0),
|
||||
L.resolveDefault(getPackages(true), {})
|
||||
]);
|
||||
},
|
||||
|
||||
render: function (result) {
|
||||
const pkgs = result[2] || {};
|
||||
|
||||
if (!uci.sections('shunt').length) {
|
||||
ui.addNotification(null, E('p', _('No shunt config found!')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let m, s, o;
|
||||
|
||||
m = new form.Map('shunt', 'shunt',
|
||||
_('Policy based routing by mac, source, destination and domain. For further information please check the %s.')
|
||||
.format(`<a style="color:#37c;font-weight:bold;" href="https://github.com/openwrt/packages/blob/master/net/shunt/files/README.md" target="_blank" rel="noreferrer noopener" >${_('online documentation')}</a>`));
|
||||
const style = E('style', { 'type': 'text/css' },
|
||||
'#shunt-status {' +
|
||||
'--shunt-card-bg: rgba(128,128,128,.07);' +
|
||||
'--shunt-card-border: rgba(128,128,128,.28);' +
|
||||
'--shunt-muted: GrayText;' +
|
||||
'--shunt-ok: #1f8a5f;' +
|
||||
'--shunt-warn: #b8860b;' +
|
||||
'--shunt-off: #808080;' +
|
||||
'}' +
|
||||
'@media (prefers-color-scheme: dark) {' +
|
||||
'#shunt-status {' +
|
||||
'--shunt-ok: #63c79b;' +
|
||||
'--shunt-warn: #e0b458;' +
|
||||
'}}' +
|
||||
'#shunt-status .shunt-grid { display: grid; gap: .75em; ' +
|
||||
'grid-template-columns: repeat(auto-fit, minmax(min(12em, 100%), 1fr)); ' +
|
||||
'margin-bottom: .75em; }' +
|
||||
'#shunt-status .shunt-card { background: var(--shunt-card-bg); ' +
|
||||
'border: 1px solid var(--shunt-card-border); border-radius: 8px; ' +
|
||||
'padding: .7em .9em; min-width: 0; overflow-wrap: break-word; }' +
|
||||
'#shunt-status .shunt-block { margin-bottom: .75em; }' +
|
||||
'#shunt-status .shunt-label { font-size: .85em; ' +
|
||||
'color: var(--shunt-muted); margin-bottom: .3em; }' +
|
||||
'#shunt-status .shunt-sub { font-size: .8em; ' +
|
||||
'color: var(--shunt-muted); margin-top: .3em; }' +
|
||||
'#shunt-status .shunt-value { font-size: 1.5em; line-height: 1.3; ' +
|
||||
'font-variant-numeric: tabular-nums; }' +
|
||||
'#shunt-status .shunt-state { display: flex; align-items: center; gap: .5em; }' +
|
||||
'#shunt-status .shunt-dot { width: .6em; height: .6em; border-radius: 50%; ' +
|
||||
'flex: 0 0 auto; background: var(--shunt-muted); }' +
|
||||
'#shunt-status .shunt-dot-ok { background: var(--shunt-ok); }' +
|
||||
'#shunt-status .shunt-dot-warn { background: var(--shunt-warn); }' +
|
||||
'#shunt-status .shunt-dot-off { background: var(--shunt-off); }' +
|
||||
'#shunt-status .shunt-issues { margin: 0; padding-left: 1.2em; }' +
|
||||
'#shunt-status .shunt-key { color: var(--shunt-muted); ' +
|
||||
'font-family: monospace; font-size: .8em; margin-left: .6em; }' +
|
||||
'#shunt-status .shunt-hit { color: var(--shunt-ok); }');
|
||||
|
||||
const setNodes = (id, nodes) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) {
|
||||
dom.content(el, nodes);
|
||||
}
|
||||
};
|
||||
|
||||
// Shown once, not on every poll tick. rp_filter is a box-wide security
|
||||
// setting shunt does not change; a strict value silently drops its
|
||||
// traffic, so the UI names it where the log would otherwise be the
|
||||
// only place. Points at the docs rather than offering a button,
|
||||
// because the change belongs to the administrator.
|
||||
let rpWarned = false;
|
||||
|
||||
const update = (st) => {
|
||||
const svc = st ? st.service : null;
|
||||
// Devices the kernel would drop marked traffic on, on the status
|
||||
// root. Empty when all is loose, when each policy device is loose
|
||||
// itself, or when rp_filter_manage has set them - the daemon reads
|
||||
// the live value, so an enabled switch simply yields an empty list.
|
||||
const blocked = (st && st.rp_filter_blocked) || [];
|
||||
|
||||
if (blocked.length && !rpWarned) {
|
||||
rpWarned = true;
|
||||
ui.addNotification(
|
||||
_('Reverse path filtering is strict'),
|
||||
E('p', {}, [
|
||||
_('Strict rp_filter will drop shunt\'s marked traffic on %s. Set rp_filter to 2 on the policy interface, enable rp_filter_manage to have shunt do it, or loosen it box-wide; see the README.').format(
|
||||
blocked.join(', '))
|
||||
]),
|
||||
'warning');
|
||||
}
|
||||
|
||||
setNodes('shunt-state', renderState(st));
|
||||
setNodes('shunt-version', E('span', {}, ['%s / %s'.format(
|
||||
pkgs['luci-app-shunt'] || _('n/a'), pkgs['shunt'] || _('n/a'))]));
|
||||
setNodes('shunt-learned', E('span', {}, [
|
||||
svc ? fmtCount(svc.dedupe) : '-']));
|
||||
setNodes('shunt-poll', E('span', {}, [svc?.poll
|
||||
? (svc.poll.resolv
|
||||
? _('%d name(s) every %ds').format(svc.poll.names, svc.poll.interval)
|
||||
: _('unavailable - ucode-mod-resolv missing'))
|
||||
: '-']));
|
||||
setNodes('shunt-policies', renderPolicies(st));
|
||||
setNodes('shunt-snoop', renderSnoop(svc));
|
||||
setNodes('shunt-issues', renderIssues(st));
|
||||
};
|
||||
|
||||
// TypedSection: `config global` is anonymous, so there is no section
|
||||
// named 'global' to bind to.
|
||||
o = m.section(form.TypedSection, 'global');
|
||||
o.anonymous = true;
|
||||
o.addremove = false;
|
||||
o.render = L.bind(function () {
|
||||
return E('div', { 'id': 'shunt-status' }, [
|
||||
style,
|
||||
E('div', { 'class': 'shunt-grid' }, [
|
||||
card(_('Service'), E('span', { 'id': 'shunt-state' }, '-'),
|
||||
E('span', {}, [
|
||||
_('Version'), ': ',
|
||||
E('span', { 'id': 'shunt-version' }, '-')
|
||||
])),
|
||||
card(_('Learned addresses'),
|
||||
E('span', { 'id': 'shunt-learned' }, '-'),
|
||||
_('across all policies')),
|
||||
card(_('Poll'), E('span', { 'id': 'shunt-poll' }, '-'),
|
||||
_('wildcards are covered by the observer only'))
|
||||
]),
|
||||
E('div', { 'id': 'shunt-policies' }, ''),
|
||||
E('div', { 'id': 'shunt-snoop' }, ''),
|
||||
E('div', { 'id': 'shunt-issues' }, '')
|
||||
]);
|
||||
}, this);
|
||||
|
||||
poll.add(function () {
|
||||
return L.resolveDefault(getStatus(), null).then(update);
|
||||
}, 2);
|
||||
|
||||
// The status subtree only exists once m.render() has resolved and
|
||||
// View.__init__ has attached the nodes; a direct call here would find
|
||||
// no ids and leave the cards on '-' until the first poll tick.
|
||||
requestAnimationFrame(function () {
|
||||
update(result[0]);
|
||||
});
|
||||
|
||||
s = m.section(form.TypedSection, 'global', _('Settings'));
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
s.tab('general', _('General Settings'));
|
||||
s.tab('snoop', _('DNS Observer Settings'));
|
||||
|
||||
o = s.taboption('general', form.Flag, 'enabled', _('Enabled'),
|
||||
_('Enable the shunt service.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('general', form.Flag, 'debug', _('Debug Logging'),
|
||||
_('Log every observed DNS answer and every set write. Useful for a bug report, noisy in normal operation - on a router running adblock roughly half of all answers are error replies, and each one gets a line.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('general', form.Flag, 'rp_filter_manage', _('Manage rp_filter'),
|
||||
_('Set rp_filter to 2 on shunt\'s own policy interfaces, at start and when one comes up.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('general', form.Value, 'poll_interval', _('Poll Interval'),
|
||||
_('Seconds between poll cycles.'));
|
||||
o.datatype = 'and(uinteger,min(30))';
|
||||
o.placeholder = '300';
|
||||
|
||||
o = s.taboption('general', form.Value, 'entry_ttl', _('Entry Lifetime'),
|
||||
_('Seconds a learned address stays in its Set. Keep this well above the poll interval.'));
|
||||
o.datatype = 'and(uinteger,min(60))';
|
||||
o.placeholder = '1200';
|
||||
o.validate = function (section_id, value) {
|
||||
const iv = this.map.lookupOption('poll_interval', section_id);
|
||||
const interval = (iv && iv[0]) ? (iv[0].formvalue(section_id) || 300) : 300;
|
||||
|
||||
if (value && +value < 2 * +interval) {
|
||||
return _('Should be at least twice the poll interval (%d), otherwise entries expire between cycles.').format(2 * interval);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
o = s.taboption('snoop', form.Flag, 'snoop', _('Passive DNS Observer'),
|
||||
_('Read DNS answers as they pass the LAN device, whichever resolver produced them. Required for wildcard domains, which cannot be resolved ahead of time.'));
|
||||
o.rmempty = false;
|
||||
|
||||
o = s.taboption('snoop', widgets.DeviceSelect, 'snoop_device',
|
||||
_('Observed Devices'),
|
||||
_('The LAN device the DNS answers cross on their way to the clients, normally br-lan. One entry per network segment, see the README.'));
|
||||
o.default = 'br-lan';
|
||||
o.multiple = true;
|
||||
o.noaliases = true;
|
||||
|
||||
s = m.section(form.TypedSection, 'global');
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
s.render = L.bind(function () {
|
||||
return E('div', { 'class': 'cbi-page-actions' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-negative important',
|
||||
'style': 'float:none;margin-right:.4em;',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
return handleAction('stop');
|
||||
})
|
||||
}, [_('Stop')]),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-positive important',
|
||||
'style': 'float:none',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
return handleAction('restart');
|
||||
})
|
||||
}, [_('Save & Restart')])
|
||||
]);
|
||||
});
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
'use strict';
|
||||
'require dom';
|
||||
'require view';
|
||||
'require fs';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require form';
|
||||
|
||||
function handleAction(ev) {
|
||||
if (ev === 'restart') {
|
||||
const map = document.querySelector('.cbi-map');
|
||||
return dom.callClassMethod(map, 'save')
|
||||
.then(L.bind(ui.changes.apply, ui.changes))
|
||||
.then(function () {
|
||||
return fs.exec_direct('/etc/init.d/shunt', [ev]);
|
||||
});
|
||||
}
|
||||
return fs.exec_direct('/etc/init.d/shunt', [ev]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function () {
|
||||
return Promise.all([
|
||||
uci.load('shunt').catch(() => 0),
|
||||
uci.load('network').catch(() => 0)
|
||||
]);
|
||||
},
|
||||
|
||||
render: function () {
|
||||
if (!uci.sections('shunt').length) {
|
||||
ui.addNotification(null, E('p', _('No shunt config found!')), 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let m, s, o;
|
||||
|
||||
m = new form.Map('shunt', _('Policies'),
|
||||
_('Evaluated top to bottom - the first policy a packet matches \
|
||||
wins. Within one policy the selectors are ANDed: source plus domain means only that client, and only to those domains.'));
|
||||
s = m.section(form.GridSection, 'policy');
|
||||
s.addremove = true;
|
||||
s.anonymous = false;
|
||||
s.sortable = true;
|
||||
s.nodescriptions = true;
|
||||
s.addbtntitle = _('Add policy');
|
||||
|
||||
// The section name becomes an nftables identifier, so it is validated
|
||||
// where it is typed rather than silently skipped later.
|
||||
s.renderSectionAdd = function (extra_class) {
|
||||
const el = form.GridSection.prototype.renderSectionAdd.apply(this, arguments);
|
||||
const nameEl = el.querySelector('.cbi-section-create-name');
|
||||
|
||||
if (nameEl) {
|
||||
ui.addValidator(nameEl, 'and(uciname,maxlength(24))', true);
|
||||
}
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
o = s.option(form.Flag, 'enabled', _('Enabled'));
|
||||
o.rmempty = false;
|
||||
o.default = '1';
|
||||
o.editable = true;
|
||||
|
||||
o = s.option(form.Value, 'interface', _('Interface'),
|
||||
_('The device or logical interface this policy routes into. A \
|
||||
netifd name is resolved to its device; any other device name is used as entered.'));
|
||||
o.rmempty = false;
|
||||
|
||||
uci.sections('network', 'interface').forEach(function (n) {
|
||||
if (n['.name'] !== 'loopback') {
|
||||
o.value(n['.name'], '%s (%s)'.format(n['.name'], _('interface')));
|
||||
}
|
||||
});
|
||||
|
||||
o = s.option(form.ListValue, 'fallback', _('Fallback Behavior'));
|
||||
o.value('main', _('Fall through to the normal uplink'));
|
||||
o.value('block', _('Block the traffic (killswitch)'));
|
||||
o.default = 'main';
|
||||
|
||||
o = s.option(form.DynamicList, 'src', _('Source Addresses'),
|
||||
_('Client addresses or prefixes this policy applies to. Leave empty to apply to every client.'));
|
||||
o.datatype = 'ipaddr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'src_mac', _('Source MAC Addresses'),
|
||||
_('Client MACs this policy applies to, ORed with the addresses above.'));
|
||||
o.datatype = 'macaddr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.MultiValue, 'proto', _('Protocols'),
|
||||
_('Restrict to tcp, udp or both. A port without a protocol covers both.'));
|
||||
o.value('tcp', 'tcp');
|
||||
o.value('udp', 'udp');
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'dport', _('Destination Ports'),
|
||||
_('Single ports or ranges like 8000-8080, ANDed with the addresses below.'));
|
||||
o.datatype = 'or(port, portrange)';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'dst', _('Destination Addresses'),
|
||||
_('Destination addresses or prefixes to route into this policy.'));
|
||||
o.datatype = 'ipaddr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.DynamicList, 'domain', _('Domains'),
|
||||
_('example.com matches that name only, *.example.com matches its \
|
||||
subdomains but not the apex - list both to cover both.'));
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'gw4', _('IPv4 Gateway Override'),
|
||||
_('Only needed when the gateway discovered from netifd is wrong. \
|
||||
Point to point interfaces need no gateway at all.'));
|
||||
o.datatype = 'ip4addr';
|
||||
o.modalonly = true;
|
||||
|
||||
o = s.option(form.Value, 'gw6', _('IPv6 Gateway Override'));
|
||||
o.datatype = 'ip6addr';
|
||||
o.modalonly = true;
|
||||
|
||||
s = m.section(form.TypedSection, 'global');
|
||||
s.anonymous = true;
|
||||
s.addremove = false;
|
||||
s.render = L.bind(function () {
|
||||
return E('div', { 'class': 'cbi-page-actions' }, [
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-positive important',
|
||||
'style': 'float:none',
|
||||
'click': ui.createHandlerFn(this, function () {
|
||||
return handleAction('restart');
|
||||
})
|
||||
}, [_('Save & Restart')])
|
||||
]);
|
||||
});
|
||||
|
||||
return m.render();
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
'use strict';
|
||||
'require view';
|
||||
'require dom';
|
||||
'require ui';
|
||||
'require uci';
|
||||
'require rpc';
|
||||
|
||||
const getSets = rpc.declare({
|
||||
object: 'luci.shunt',
|
||||
method: 'sets',
|
||||
params: ['policy']
|
||||
});
|
||||
|
||||
function setKind(name) {
|
||||
return name.substring(0, 1);
|
||||
}
|
||||
|
||||
function setPolicy(name) {
|
||||
return name.substring(name.substring(0, 1) === 'm' ? 2 : 3);
|
||||
}
|
||||
|
||||
function fmtExpiry(sec) {
|
||||
if (sec == null) {
|
||||
return '-';
|
||||
}
|
||||
if (sec >= 3600) {
|
||||
return _('%dh %dm').format(Math.floor(sec / 3600),
|
||||
Math.floor((sec % 3600) / 60));
|
||||
}
|
||||
if (sec >= 60) {
|
||||
return _('%dm %ds').format(Math.floor(sec / 60), sec % 60);
|
||||
}
|
||||
return _('%ds').format(sec);
|
||||
}
|
||||
|
||||
function fmtNum(n) {
|
||||
if (n == null) {
|
||||
return '-';
|
||||
}
|
||||
return '%d'.format(n);
|
||||
}
|
||||
|
||||
function renderCards(sets, kinds, title, empty_hint) {
|
||||
const byPolicy = {};
|
||||
|
||||
Object.keys(sets).forEach(function (name) {
|
||||
if (kinds.indexOf(setKind(name)) < 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const policy = setPolicy(name);
|
||||
|
||||
if (!byPolicy[policy]) {
|
||||
byPolicy[policy] = [];
|
||||
}
|
||||
|
||||
sets[name].forEach(function (e) {
|
||||
byPolicy[policy].push(e);
|
||||
});
|
||||
});
|
||||
|
||||
const policies = Object.keys(byPolicy).filter(function (p) {
|
||||
return byPolicy[p].length > 0;
|
||||
}).sort();
|
||||
|
||||
if (!policies.length) {
|
||||
return empty_hint
|
||||
? E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' }, title),
|
||||
E('div', { 'class': 'shunt-sub' }, empty_hint)
|
||||
])
|
||||
: '';
|
||||
}
|
||||
|
||||
const cards = policies.map(function (policy) {
|
||||
return E('div', { 'class': 'shunt-card' }, [
|
||||
E('div', { 'class': 'shunt-card-title' }, [policy]),
|
||||
E('div', { 'class': 'shunt-addrs' },
|
||||
byPolicy[policy].sort(function (a, b) {
|
||||
return String(a.addr).localeCompare(String(b.addr));
|
||||
}).map(function (e) {
|
||||
return E('div', {}, [
|
||||
e.addr,
|
||||
e.packets ? E('span', { 'class': 'shunt-hits' },
|
||||
_('%d pkt matched').format(e.packets)) : ''
|
||||
]);
|
||||
}))
|
||||
]);
|
||||
});
|
||||
|
||||
return E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' }, title),
|
||||
E('div', { 'class': 'shunt-grid' }, cards)
|
||||
]);
|
||||
}
|
||||
|
||||
function renderLearned(sets) {
|
||||
const rows = [];
|
||||
|
||||
Object.keys(sets).forEach(function (name) {
|
||||
if (setKind(name) !== 'd') {
|
||||
return;
|
||||
}
|
||||
|
||||
const policy = setPolicy(name);
|
||||
|
||||
sets[name].forEach(function (e) {
|
||||
rows.push({
|
||||
policy: policy,
|
||||
addr: e.addr,
|
||||
expires: e.expires,
|
||||
packets: e.packets,
|
||||
bytes: e.bytes
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
rows.sort(function (a, b) {
|
||||
return a.policy.localeCompare(b.policy)
|
||||
|| (b.packets || 0) - (a.packets || 0)
|
||||
|| String(a.addr).localeCompare(String(b.addr));
|
||||
});
|
||||
|
||||
if (!rows.length) {
|
||||
return E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' }, _('Learned addresses')),
|
||||
E('div', { 'class': 'shunt-sub' }, _('Nothing learned yet. The service fills these from its poll cycle and from observed DNS answers.'))
|
||||
]);
|
||||
}
|
||||
|
||||
const tbl = E('table', { 'class': 'table shunt-table' }, [
|
||||
E('tr', { 'class': 'tr table-titles' }, [
|
||||
E('th', { 'class': 'th' }, _('Policy')),
|
||||
E('th', { 'class': 'th' }, _('Address')),
|
||||
E('th', { 'class': 'th' }, _('Expires')),
|
||||
E('th', { 'class': 'th' }, _('Packets routed')),
|
||||
E('th', { 'class': 'th' }, _('Bytes routed'))
|
||||
])
|
||||
]);
|
||||
|
||||
cbi_update_table(tbl, rows.map(function (r) {
|
||||
return [
|
||||
r.policy,
|
||||
r.addr,
|
||||
fmtExpiry(r.expires),
|
||||
fmtNum(r.packets),
|
||||
fmtNum(r.bytes)
|
||||
];
|
||||
}));
|
||||
|
||||
return E('div', { 'class': 'shunt-block' }, [
|
||||
E('div', { 'class': 'shunt-label' },
|
||||
_('Learned addresses (%d)').format(rows.length)),
|
||||
tbl
|
||||
]);
|
||||
}
|
||||
|
||||
return view.extend({
|
||||
load: function () {
|
||||
return Promise.all([
|
||||
uci.load('shunt').catch(() => 0),
|
||||
L.resolveDefault(getSets(''), {})
|
||||
]);
|
||||
},
|
||||
|
||||
render: function (result) {
|
||||
const self = this;
|
||||
|
||||
const render_sets = function (data) {
|
||||
const sets = (data && data.sets) || {};
|
||||
const target = document.getElementById('shunt-sets');
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
dom.content(target, [
|
||||
renderCards(sets, ['c', 'm'], _('Client Selectors'),
|
||||
_('No client is selected, so every client is covered.')),
|
||||
renderCards(sets, ['s'], _('Static Destinations'), null),
|
||||
renderLearned(sets)
|
||||
]);
|
||||
};
|
||||
|
||||
const reload = function () {
|
||||
const sel = document.getElementById('shunt-policy');
|
||||
|
||||
return L.resolveDefault(getSets(sel ? sel.value : ''), {})
|
||||
.then(render_sets);
|
||||
};
|
||||
|
||||
const options = [E('option', { 'value': '' }, _('all policies'))]
|
||||
.concat(uci.sections('shunt', 'policy').map(function (p) {
|
||||
return E('option', { 'value': p['.name'] }, [p['.name']]);
|
||||
}));
|
||||
|
||||
const style = E('style', { 'type': 'text/css' },
|
||||
'#shunt-sets {' +
|
||||
'--shunt-card-bg: rgba(128,128,128,.07);' +
|
||||
'--shunt-card-border: rgba(128,128,128,.28);' +
|
||||
'--shunt-muted: GrayText;' +
|
||||
'}' +
|
||||
'#shunt-sets .shunt-block { margin-bottom: 1.2em; }' +
|
||||
'#shunt-sets .shunt-label { font-size: .85em; ' +
|
||||
'color: var(--shunt-muted); margin-bottom: .4em; }' +
|
||||
'#shunt-sets .shunt-sub { font-size: .85em; color: var(--shunt-muted); }' +
|
||||
'#shunt-sets .shunt-grid { display: grid; gap: .75em; ' +
|
||||
'grid-template-columns: repeat(auto-fit, minmax(min(16em, 100%), 1fr)); }' +
|
||||
'#shunt-sets .shunt-card { background: var(--shunt-card-bg); ' +
|
||||
'border: 1px solid var(--shunt-card-border); border-radius: 8px; ' +
|
||||
'padding: .7em .9em; min-width: 0; }' +
|
||||
'#shunt-sets .shunt-card-title { font-weight: bold; margin-bottom: .3em; }' +
|
||||
'#shunt-sets .shunt-addrs { font-family: monospace; font-size: .9em; ' +
|
||||
'overflow-wrap: anywhere; }' +
|
||||
'#shunt-sets .shunt-hits { color: var(--shunt-muted); ' +
|
||||
'font-size: .85em; margin-left: .6em; }' +
|
||||
'#shunt-sets .shunt-table { table-layout: fixed; width: 100%; }' +
|
||||
'#shunt-sets .shunt-table th:nth-child(1),' +
|
||||
'#shunt-sets .shunt-table td:nth-child(1) { width: 15%; }' +
|
||||
'#shunt-sets .shunt-table th:nth-child(2),' +
|
||||
'#shunt-sets .shunt-table td:nth-child(2) { width: 37%; ' +
|
||||
'overflow-wrap: anywhere; }' +
|
||||
'#shunt-sets .shunt-table th:nth-child(3),' +
|
||||
'#shunt-sets .shunt-table td:nth-child(3) { width: 16%; }' +
|
||||
'#shunt-sets .shunt-table th:nth-child(4),' +
|
||||
'#shunt-sets .shunt-table td:nth-child(4) { width: 16%; }' +
|
||||
'#shunt-sets .shunt-table th:nth-child(5),' +
|
||||
'#shunt-sets .shunt-table td:nth-child(5) { width: 16%; }');
|
||||
|
||||
const page = E('div', { 'class': 'cbi-map' }, [
|
||||
style,
|
||||
E('h2', {}, _('Set Reporting')),
|
||||
E('div', { 'class': 'cbi-section' }, [
|
||||
E('div', { 'class': 'cbi-section-descr' },
|
||||
_('What the nftables Sets currently hold. Counters are reset whenever an entry is refreshed, so they show recent activity, not a lifetime total.'))
|
||||
]),
|
||||
E('div', { 'id': 'shunt-sets' }, ''),
|
||||
E('div', { 'class': 'cbi-page-actions' }, [
|
||||
E('select', {
|
||||
'id': 'shunt-policy',
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'float:none;margin-right:.4em;width:auto;',
|
||||
'change': ui.createHandlerFn(self, reload)
|
||||
}, options),
|
||||
E('button', {
|
||||
'class': 'btn cbi-button cbi-button-action important',
|
||||
'style': 'float:none',
|
||||
'click': ui.createHandlerFn(self, reload)
|
||||
}, [_('Refresh')])
|
||||
])
|
||||
]);
|
||||
|
||||
requestAnimationFrame(function () {
|
||||
render_sets(result[1]);
|
||||
});
|
||||
|
||||
return page;
|
||||
},
|
||||
|
||||
handleSaveApply: null,
|
||||
handleSave: null,
|
||||
handleReset: null
|
||||
});
|
||||
@@ -1,532 +0,0 @@
|
||||
msgid ""
|
||||
msgstr "Content-Type: text/plain; charset=UTF-8"
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:319
|
||||
msgid "%d name(s) every %ds"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:165
|
||||
msgid ""
|
||||
"%d of %d observed responses were not used. That is normal: the observer sees "
|
||||
"every answer on the network, and only the ones for a domain you routed are "
|
||||
"of any interest."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:85
|
||||
msgid "%d pkt matched"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:27
|
||||
msgid "%dh %dm"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:31
|
||||
msgid "%dm %ds"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:33
|
||||
msgid "%ds"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:45
|
||||
msgid "Add policy"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:134
|
||||
msgid "Address"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:97
|
||||
msgid "Answer carried no usable address"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:160
|
||||
msgid "Answers used for a policy"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:78
|
||||
msgid "Block the traffic (killswitch)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:137
|
||||
msgid "Bytes routed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:87
|
||||
msgid "Client MACs this policy applies to, ORed with the addresses above."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:178
|
||||
msgid "Client Selectors"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:82
|
||||
msgid ""
|
||||
"Client addresses or prefixes this policy applies to. Leave empty to apply to "
|
||||
"every client."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:105
|
||||
msgid "Compression pointer in the question"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:368
|
||||
msgid "DNS Observer Settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:155
|
||||
msgid "DNS responses observed on %s since the service started"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:374
|
||||
msgid "Debug Logging"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:102
|
||||
msgid "Destination Addresses"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:97
|
||||
msgid "Destination Ports"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:103
|
||||
msgid "Destination addresses or prefixes to route into this policy."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:95
|
||||
msgid "Domain is in no policy"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:107
|
||||
msgid "Domains"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:371
|
||||
msgid "Enable the shunt service."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:370
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:60
|
||||
msgid "Enabled"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:387
|
||||
msgid "Entry Lifetime"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:99
|
||||
msgid "Error reply, e.g. NXDOMAIN"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:38
|
||||
msgid ""
|
||||
"Evaluated top to bottom - the first policy a packet matches wins. Within one "
|
||||
"policy the selectors are ANDed: source plus domain means only that client, "
|
||||
"and only to those domains."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:135
|
||||
msgid "Expires"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:117
|
||||
msgid "Extension header chain too long"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:77
|
||||
msgid "Fall through to the normal uplink"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:183
|
||||
msgid "Fallback"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:76
|
||||
msgid "Fallback Behavior"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:112
|
||||
msgid "Frame ends mid-structure"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:367
|
||||
msgid "General Settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json:3
|
||||
msgid "Grant access to LuCI app shunt"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:116
|
||||
msgid "IP fragment"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:112
|
||||
msgid "IPv4 Gateway Override"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:118
|
||||
msgid "IPv6 Gateway Override"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:115
|
||||
msgid "Inconsistent IP header length"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:119
|
||||
msgid "Inconsistent UDP length"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:178
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:65
|
||||
msgid "Interface"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:341
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:126
|
||||
msgid "Learned addresses"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:153
|
||||
msgid "Learned addresses (%d)"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:375
|
||||
msgid ""
|
||||
"Log every observed DNS answer and every set write. Useful for a bug report, "
|
||||
"noisy in normal operation - on a router running adblock roughly half of all "
|
||||
"answers are error replies, and each one gets a line."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:378
|
||||
msgid "Manage rp_filter"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:179
|
||||
msgid "Mark"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:103
|
||||
msgid "Message ends mid-structure"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:104
|
||||
msgid "Message too long"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:108
|
||||
msgid "Name has a byte outside a-z 0-9 - _"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:107
|
||||
msgid "Name too long"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:113
|
||||
msgid "Neither IPv4 nor IPv6"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js:33
|
||||
msgid "No %s related logs yet!"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:179
|
||||
msgid "No client is selected, so every client is covered."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:172
|
||||
msgid "No policy is active."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:232
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:31
|
||||
msgid "No shunt config found!"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:118
|
||||
msgid "Not UDP"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:100
|
||||
msgid "Not a response"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:96
|
||||
msgid "Not an address query"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:102
|
||||
msgid "Not exactly one question"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:127
|
||||
msgid ""
|
||||
"Nothing learned yet. The service fills these from its poll cycle and from "
|
||||
"observed DNS answers."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:407
|
||||
msgid "Observed Devices"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:113
|
||||
msgid ""
|
||||
"Only needed when the gateway discovered from netifd is wrong. Point to point "
|
||||
"interfaces need no gateway at all."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:23
|
||||
msgid "Overview"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:136
|
||||
msgid "Packets routed"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:402
|
||||
msgid "Passive DNS Observer"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:37
|
||||
#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:31
|
||||
msgid "Policies"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:177
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:133
|
||||
msgid "Policy"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:239
|
||||
msgid ""
|
||||
"Policy based routing by mac, source, destination and domain. For further "
|
||||
"information please check the %s."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:344
|
||||
msgid "Poll"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:382
|
||||
msgid "Poll Interval"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:47
|
||||
msgid "Processing Log"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:91
|
||||
msgid "Protocols"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:403
|
||||
msgid ""
|
||||
"Read DNS answers as they pass the LAN device, whichever resolver produced "
|
||||
"them. Required for wildcard domains, which cannot be resolved ahead of time."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:109
|
||||
msgid "Record length does not fit the type"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:249
|
||||
msgid "Refresh"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:213
|
||||
msgid "Rejected settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:106
|
||||
msgid "Reserved label type"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:92
|
||||
msgid "Restrict to tcp, udp or both. A port without a protocol covers both."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:304
|
||||
msgid "Reverse path filtering is strict"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:182
|
||||
msgid "Routes"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:181
|
||||
msgid "Rules"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:431
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:133
|
||||
msgid "Save & Restart"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:388
|
||||
msgid ""
|
||||
"Seconds a learned address stays in its Set. Keep this well above the poll "
|
||||
"interval."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:383
|
||||
msgid "Seconds between poll cycles."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:336
|
||||
msgid "Service"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:232
|
||||
#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:39
|
||||
msgid "Set Reporting"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:379
|
||||
msgid ""
|
||||
"Set rp_filter to 2 on shunt's own policy interfaces, at start and when one "
|
||||
"comes up."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:364
|
||||
msgid "Settings"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:396
|
||||
msgid ""
|
||||
"Should be at least twice the poll interval (%d), otherwise entries expire "
|
||||
"between cycles."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:98
|
||||
msgid "Single ports or ranges like 8000-8080, ANDed with the addresses below."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:81
|
||||
msgid "Source Addresses"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:86
|
||||
msgid "Source MAC Addresses"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:180
|
||||
msgid "Static Destinations"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:424
|
||||
msgid "Stop"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:306
|
||||
msgid ""
|
||||
"Strict rp_filter will drop shunt's marked traffic on %s. Set rp_filter to 2 "
|
||||
"on the policy interface, enable rp_filter_manage to have shunt do it, or "
|
||||
"loosen it box-wide; see the README."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:180
|
||||
msgid "Table"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:408
|
||||
msgid ""
|
||||
"The LAN device the DNS answers cross on their way to the clients, normally "
|
||||
"br-lan. One entry per network segment, see the README."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:66
|
||||
msgid ""
|
||||
"The device or logical interface this policy routes into. A netifd name is "
|
||||
"resolved to its device; any other device name is used as entered."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js:44
|
||||
msgid "The syslog output, pre-filtered for messages related to: %s"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:215
|
||||
msgid ""
|
||||
"These entries were skipped. Everything else was applied - a rejected entry "
|
||||
"never takes the service down."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:110
|
||||
msgid "Too many answers"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:114
|
||||
msgid "Too many stacked VLAN tags"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:101
|
||||
msgid "Truncated response"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:338
|
||||
msgid "Version"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:235
|
||||
msgid ""
|
||||
"What the nftables Sets currently hold. Counters are reset whenever an entry "
|
||||
"is refreshed, so they show recent activity, not a lifetime total."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:343
|
||||
msgid "across all policies"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js:192
|
||||
msgid "all policies"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:108
|
||||
msgid ""
|
||||
"example.com matches that name only, *.example.com matches its subdomains but "
|
||||
"not the apex - list both to cover both."
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js:72
|
||||
msgid "interface"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:314
|
||||
msgid "n/a"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:65
|
||||
msgid "no answer from the backend"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:240
|
||||
msgid "online documentation"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:83
|
||||
msgid "ruleset present, service not running"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:77
|
||||
msgid "running, but no ruleset in the kernel"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:71
|
||||
msgid "running, policy applied"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json:3
|
||||
msgid "shunt"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:88
|
||||
msgid "stopped"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:320
|
||||
msgid "unavailable - ucode-mod-resolv missing"
|
||||
msgstr ""
|
||||
|
||||
#: applications/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js:345
|
||||
msgid "wildcards are covered by the observer only"
|
||||
msgstr ""
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/bin/sh
|
||||
rm -f /var/luci-indexcache.*.json
|
||||
[ -x "/etc/init.d/rpcd" ] && /etc/init.d/rpcd reload
|
||||
exit 0
|
||||
@@ -1,54 +0,0 @@
|
||||
{
|
||||
"admin/services/shunt": {
|
||||
"title": "shunt",
|
||||
"order": "65",
|
||||
"action": {
|
||||
"type": "alias",
|
||||
"path": "admin/services/shunt/overview"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [
|
||||
"luci-app-shunt"
|
||||
],
|
||||
"fs": {
|
||||
"/usr/sbin/shunt": "executable",
|
||||
"/etc/init.d/shunt": "executable"
|
||||
},
|
||||
"uci": {
|
||||
"shunt": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin/services/shunt/overview": {
|
||||
"title": "Overview",
|
||||
"order": 10,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "shunt/overview"
|
||||
}
|
||||
},
|
||||
"admin/services/shunt/policies": {
|
||||
"title": "Policies",
|
||||
"order": 20,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "shunt/policies"
|
||||
}
|
||||
},
|
||||
"admin/services/shunt/setreport": {
|
||||
"title": "Set Reporting",
|
||||
"order": 30,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "shunt/setreport"
|
||||
}
|
||||
},
|
||||
"admin/services/shunt/logread": {
|
||||
"title": "Processing Log",
|
||||
"order": 40,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "shunt/logread"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"luci-app-shunt": {
|
||||
"description": "Grant access to LuCI app shunt",
|
||||
"write": {
|
||||
"uci": [
|
||||
"shunt"
|
||||
]
|
||||
},
|
||||
"read": {
|
||||
"ubus": {
|
||||
"luci.shunt": [
|
||||
"status",
|
||||
"sets"
|
||||
],
|
||||
"rpc-sys": [
|
||||
"packagelist"
|
||||
]
|
||||
},
|
||||
"uci": [
|
||||
"shunt"
|
||||
],
|
||||
"file": {
|
||||
"/etc/init.d/shunt stop": [
|
||||
"exec"
|
||||
],
|
||||
"/etc/init.d/shunt start": [
|
||||
"exec"
|
||||
],
|
||||
"/etc/init.d/shunt restart": [
|
||||
"exec"
|
||||
]
|
||||
},
|
||||
"cgi-io": [
|
||||
"exec"
|
||||
],
|
||||
"log": [
|
||||
"read"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,11 +15,11 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-wwand
|
||||
PKG_RELEASE:=5
|
||||
PKG_RELEASE:=6
|
||||
|
||||
PKG_SOURCE_PROTO:=git
|
||||
PKG_SOURCE_URL:=https://github.com/ddimension/luci-app-wwand.git
|
||||
PKG_SOURCE_VERSION:=ae57d82431f6c65e6afa27267bfb71c8bea39bff
|
||||
PKG_SOURCE_VERSION:=a716b595603f2b1bc01d71be199c63bacc1ba9fc
|
||||
PKG_SOURCE_DATE:=2026-08-25
|
||||
PKG_MIRROR_HASH:=skip
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
# shunt - policy based routing via passive DNS observation
|
||||
# Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
|
||||
# This is free software, licensed under the GNU General Public License v3.
|
||||
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=shunt
|
||||
PKG_VERSION:=0.1.5
|
||||
PKG_RELEASE:=1
|
||||
PKG_LICENSE:=GPL-3.0-or-later
|
||||
PKG_LICENSE_FILES:=
|
||||
PKG_MAINTAINER:=Dirk Brenken <dev@brenken.org>
|
||||
|
||||
include $(INCLUDE_DIR)/package.mk
|
||||
|
||||
define Package/shunt
|
||||
SECTION:=net
|
||||
CATEGORY:=Network
|
||||
TITLE:=Policy based routing via passive DNS observation
|
||||
DEPENDS:=+ucode +ucode-mod-fs +ucode-mod-socket +ucode-mod-uci \
|
||||
+ucode-mod-uloop +ucode-mod-resolv +ucode-mod-ubus \
|
||||
+ucode-mod-rtnl +ucode-mod-log +rpcd-mod-ucode +nftables-json +ip
|
||||
PKGARCH:=all
|
||||
endef
|
||||
|
||||
define Package/shunt/description
|
||||
Routes traffic by source, destination and domain into policy interfaces.
|
||||
Domain addresses are learned resolver-independently: a poll loop resolves the
|
||||
configured names and a passive AF_PACKET observer picks every DNS answer off
|
||||
the wire, so shunt works unchanged with dnsmasq, unbound or any other backend.
|
||||
Marks are applied via an own nftables table with per-element
|
||||
counters, routing via fwmark rules and per-policy tables, with gateway
|
||||
discovery and interface events from netifd.
|
||||
endef
|
||||
|
||||
define Package/shunt/conffiles
|
||||
/etc/config/shunt
|
||||
endef
|
||||
|
||||
define Build/Prepare
|
||||
endef
|
||||
|
||||
define Build/Configure
|
||||
endef
|
||||
|
||||
define Build/Compile
|
||||
endef
|
||||
|
||||
define Package/shunt/install
|
||||
$(INSTALL_DIR) $(1)/usr/share/ucode/shunt
|
||||
$(INSTALL_DATA) ./src/*.uc $(1)/usr/share/ucode/shunt/
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/sbin
|
||||
$(INSTALL_BIN) ./files/shunt.uc $(1)/usr/sbin/shunt
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/init.d
|
||||
$(INSTALL_BIN) ./files/shunt.init $(1)/etc/init.d/shunt
|
||||
|
||||
$(INSTALL_DIR) $(1)/etc/config
|
||||
$(INSTALL_CONF) ./files/shunt.config $(1)/etc/config/shunt
|
||||
|
||||
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
|
||||
$(INSTALL_DATA) ./files/shunt.rpcd $(1)/usr/share/rpcd/ucode/shunt
|
||||
|
||||
endef
|
||||
|
||||
$(eval $(call BuildPackage,shunt))
|
||||
-777
@@ -1,777 +0,0 @@
|
||||
<!-- markdownlint-disable -->
|
||||
|
||||
# shunt - policy based routing by mac, source, destination and domain
|
||||
|
||||
## Table of Contents
|
||||
* [Description](#description)
|
||||
* [Quick Start](#quick-start)
|
||||
* [Main Features](#main-features)
|
||||
* [Prerequisites](#prerequisites)
|
||||
* [Which tunnels work](#which-tunnels-work)
|
||||
* [Installation and Usage](#installation-and-usage)
|
||||
* [shunt CLI interface](#shunt-cli-interface)
|
||||
* [shunt config options](#shunt-config-options)
|
||||
* [How addresses are learned](#how-addresses-are-learned)
|
||||
* [What polling costs](#what-polling-costs)
|
||||
* [Examples](#examples)
|
||||
* [What it shells out to](#what-it-shells-out-to)
|
||||
* [What shunt creates on the system](#what-shunt-creates-on-the-system)
|
||||
* [Coexistence with pbr and mwan3](#coexistence-with-pbr-and-mwan3)
|
||||
* [Troubleshooting & debug options](#troubleshooting-and-debug-options)
|
||||
* [Known limitations](#known-limitations)
|
||||
* [Support](#support)
|
||||
* [Removal](#removal)
|
||||
* [Donations](#donations)
|
||||
|
||||
<a id="description"></a>
|
||||
## Description
|
||||
shunt routes selected traffic into a policy interface - a VPN tunnel, a second
|
||||
uplink, a mobile connection - chosen by client address, client MAC,
|
||||
destination or domain. It keeps its own nftables table and one routing table
|
||||
per policy, so it coexists with fw4 and with other routing tools instead of
|
||||
competing with them.
|
||||
|
||||
The one thing that defines the project: **shunt is not bound to any DNS
|
||||
backend.** It works unchanged with dnsmasq, unbound, smartdns, AdGuard Home or
|
||||
anything else, because it never asks the resolver for anything and never sits
|
||||
in the DNS path. Domain to address mapping comes from two sources shunt owns
|
||||
itself, described under [How addresses are learned](#how-addresses-are-learned).
|
||||
|
||||
<a id="quick-start"></a>
|
||||
## Quick Start
|
||||
For a typical setup these few steps are enough - see the sections below for
|
||||
details:
|
||||
1. Install the LuCI companion package: `apk update && apk add luci-app-shunt`
|
||||
(this pulls in the `shunt` backend as a dependency).
|
||||
2. Make reverse path filtering loose and give the policy interface a
|
||||
masquerading firewall zone - both once, both shown under
|
||||
[Prerequisites](#prerequisites). Without the first, marked traffic is
|
||||
dropped; without the second it is marked and routed and then goes nowhere,
|
||||
which looks exactly like shunt not working.
|
||||
4. Open LuCI under `Services -> shunt`, add a policy on the `Policies` tab: pick
|
||||
the `Interface`, name the clients under `Source addresses` or
|
||||
`Source MAC addresses`, and list the `Domains` you want routed.
|
||||
5. Start and verify:
|
||||
|
||||
```sh
|
||||
/etc/init.d/shunt enable
|
||||
/etc/init.d/shunt start
|
||||
shunt check
|
||||
```
|
||||
|
||||
**Please note:** a domain policy only takes effect for a client's *next*
|
||||
connection to an address that has just been learned - see
|
||||
[Known limitations](#known-limitations).
|
||||
|
||||
<a id="main-features"></a>
|
||||
## Main Features
|
||||
* Routes by client address, client MAC, destination CIDR and domain, in any
|
||||
combination
|
||||
* Resolver independent: works with any DNS backend, and with an encrypted
|
||||
upstream, because it reads the plaintext leg between client and resolver
|
||||
* Wildcard domains (`*.example.com`), learned passively as clients use them
|
||||
* Per-policy killswitch: hold the traffic when the interface drops, instead of
|
||||
leaking it out of the normal uplink
|
||||
* Own nftables table and routing tables, disjoint mark range - runs beside
|
||||
`pbr` and `mwan3`
|
||||
* IPv4 and IPv6 throughout, with a MAC selecting a host in both at once
|
||||
* Per-element counters on every set, a ubus status object and a LuCI frontend
|
||||
* No dependency on a specific DNS backend, no resolver configuration, no
|
||||
include files, no hooks into fw4's ruleset
|
||||
|
||||
<a id="prerequisites"></a>
|
||||
## Prerequisites
|
||||
* OpenWrt with fw4/nftables
|
||||
* `ucode` plus `ucode-mod-fs`, `ucode-mod-socket`, `ucode-mod-uci`,
|
||||
`ucode-mod-uloop`, `ucode-mod-resolv`, `ucode-mod-ubus`, `ucode-mod-rtnl`,
|
||||
`ucode-mod-log` and `rpcd-mod-ucode` - all pulled in by the package
|
||||
|
||||
`ucode-mod-resolv` and `ucode-mod-ubus` are soft at runtime: without resolv,
|
||||
poll is skipped and the observer carries the service alone; without ubus,
|
||||
gateway discovery and interface events are skipped and the config's own values
|
||||
are used. Both cost one warning in the log, not a failed start.
|
||||
|
||||
<a id="which-tunnels-work"></a>
|
||||
### Which tunnels work
|
||||
|
||||
Any of them, and there is no supported-protocols list to check against, because
|
||||
shunt never asks what protocol an interface speaks. It consumes two things: the
|
||||
device to route into, and a gateway if one is needed. Both come from netifd,
|
||||
and a device netifd does not manage is taken as given.
|
||||
|
||||
* **Point to point tunnels** - wireguard, OpenVPN `tun*`, L2TP, PPTP,
|
||||
Tailscale, NetBird and the like - need no gateway at all. The route is
|
||||
`default dev <device> table <n>`.
|
||||
* **Ethernet style interfaces** - OpenVPN `tap*`, a second wired uplink, a
|
||||
mobile connection - use the gateway discovered from netifd, or `gw4`/`gw6` if
|
||||
you set them.
|
||||
* **Interfaces netifd does not manage** work by name too. On OpenWrt this is
|
||||
the exception rather than the rule - wireguard, OpenVPN, L2TP and the rest
|
||||
all have netifd protocols and are managed like any other interface. It
|
||||
applies to a tunnel brought up outside netifd, by `wg-quick` or a script of
|
||||
your own. If such an interface does need a gateway, discovery cannot find one
|
||||
and you have to set `gw4`/`gw6` yourself.
|
||||
|
||||
The one thing that does not work is anything that is not a routable interface.
|
||||
Tor is the usual example: it normally offers a SOCKS port, and sending traffic
|
||||
there is a redirect, not a route. shunt marks a packet and looks up a routing
|
||||
table; without a device to put a default route on, there is nothing for it to
|
||||
do. Transparent proxying is out of scope by design, not for want of a special
|
||||
case.
|
||||
|
||||
Two kernel-side prerequisites that shunt does **not** configure for you:
|
||||
|
||||
**`rp_filter` must be loose on the policy interface.** Marked traffic takes an
|
||||
asymmetric path, so strict reverse path filtering drops it. The kernel decides
|
||||
per incoming packet using `max(net.ipv4.conf.all.rp_filter,
|
||||
net.ipv4.conf.<dev>.rp_filter)`, where `2` is loose - so setting the policy
|
||||
interface alone to `2` suffices even while `all` stays strict. Prefer this: it
|
||||
leaves reverse path filtering intact on every other interface.
|
||||
|
||||
```sh
|
||||
cat > /etc/sysctl.d/99-shunt.conf <<'EOF'
|
||||
net.ipv4.conf.phy0-sta0.rp_filter=2
|
||||
EOF
|
||||
sysctl -p /etc/sysctl.d/99-shunt.conf
|
||||
```
|
||||
|
||||
Replace `phy0-sta0` with your policy interface's device - the `Interface`
|
||||
column on the overview shows it - one line per policy device.
|
||||
|
||||
There is a boot-order catch. A device that does not exist yet - a tunnel, or a
|
||||
wifi client interface brought up late - has no `conf/<dev>` entry at boot, so
|
||||
`sysctl -p` cannot set it and skips the line. When the device finally appears
|
||||
it inherits `net.ipv4.conf.default.rp_filter`, and if that is strict the device
|
||||
comes up strict and stays that way until the next `sysctl -p` - which for most
|
||||
setups means until the next reboot, i.e. never in practice. Two ways around it:
|
||||
set `net.ipv4.conf.default.rp_filter=2` as well, which makes every
|
||||
later-appearing interface inherit loose (a little broader, but far short of
|
||||
`all`), or let shunt handle it with the option below.
|
||||
|
||||
**`rp_filter_manage` (optional, off by default).** With it set, shunt itself
|
||||
sets `rp_filter=2` on its own policy devices - at start and again whenever one
|
||||
comes up, which is exactly the boot-order moment a static file misses. It only
|
||||
ever touches the devices shunt routes into, never `all` or `default`, and only
|
||||
while the service runs. It is off by default because changing a security
|
||||
setting should be a deliberate choice:
|
||||
|
||||
```sh
|
||||
uci set shunt.@global[0].rp_filter_manage='1'
|
||||
uci commit shunt
|
||||
/etc/init.d/shunt restart
|
||||
```
|
||||
|
||||
Whichever way you choose, the daemon checks the live per-device value and warns
|
||||
- naming the device - only for a policy device that is still strict, so with
|
||||
`rp_filter_manage` on the warning simply does not appear. The package
|
||||
deliberately ships no box-wide sysctl file: `rp_filter` on `all`/`default` is a
|
||||
distribution default OpenWrt sets strict in `/etc/sysctl.d/10-default.conf`, and
|
||||
loosening it there weakens anti-spoofing on every interface, well beyond
|
||||
shunt's own traffic.
|
||||
|
||||
**Masquerading stays fw4's job.** shunt marks and routes; it does not touch
|
||||
the firewall's NAT. The policy interface needs a zone with `masq` enabled and
|
||||
forwarding from `lan`, exactly as any other uplink. If the interface is a
|
||||
netifd one - say a wireguard interface named `vpn`:
|
||||
|
||||
```sh
|
||||
uci add firewall zone
|
||||
uci set firewall.@zone[-1].name='vpn'
|
||||
uci set firewall.@zone[-1].input='REJECT'
|
||||
uci set firewall.@zone[-1].output='ACCEPT'
|
||||
uci set firewall.@zone[-1].forward='REJECT'
|
||||
uci set firewall.@zone[-1].masq='1'
|
||||
uci set firewall.@zone[-1].mtu_fix='1'
|
||||
uci add_list firewall.@zone[-1].network='vpn'
|
||||
|
||||
uci add firewall forwarding
|
||||
uci set firewall.@forwarding[-1].src='lan'
|
||||
uci set firewall.@forwarding[-1].dest='vpn'
|
||||
|
||||
uci commit firewall
|
||||
/etc/init.d/firewall reload
|
||||
```
|
||||
|
||||
`network` names a **logical interface**, not a device. For a device netifd does
|
||||
not manage - a tunnel brought up outside netifd - use
|
||||
`uci add_list firewall.@zone[-1].device='wg0'` instead. And if the policy
|
||||
interface already has a zone, because it is an ordinary second uplink, there is
|
||||
nothing to do here.
|
||||
|
||||
Symptoms of getting this wrong are worth knowing, because they do not look like
|
||||
a firewall problem: the prerouting counters rise, `nft list set` shows the
|
||||
learned address being hit, and the client's connection simply times out.
|
||||
|
||||
<a id="installation-and-usage"></a>
|
||||
## Installation and Usage
|
||||
* Update your router's apk repository (`apk update`)
|
||||
* Install the LuCI companion package `luci-app-shunt`, which also installs the
|
||||
main `shunt` package as a dependency
|
||||
* Make `rp_filter` loose and give the policy interface a masquerading firewall
|
||||
zone - both are one-time steps with copy-paste commands under
|
||||
[Prerequisites](#prerequisites)
|
||||
* Configure at least one policy, either in LuCI under `Services -> shunt` or by
|
||||
editing `/etc/config/shunt`
|
||||
* Enable and start the service, then run `shunt check` - it prints the mark,
|
||||
routing table and rule priority of every accepted policy, and every rejected
|
||||
value with its reason
|
||||
* Check the `Set Reporting` tab to see which addresses were learned, and the
|
||||
`Processing Log` tab for the service's own messages
|
||||
|
||||
<a id="shunt-cli-interface"></a>
|
||||
## shunt CLI interface
|
||||
All functions are available from the command line, and the config file can be
|
||||
edited directly if you prefer that to LuCI.
|
||||
|
||||
```sh
|
||||
shunt check # render everything, print marks and issues, change nothing
|
||||
shunt run # foreground, the procd service entry point
|
||||
shunt flush # tear down table, rules, routes and the mapping file
|
||||
shunt -v <cmd> # echo every message to the terminal as well
|
||||
```
|
||||
|
||||
`shunt check` is safe at any time, including while the service runs, because it
|
||||
only renders - it never touches the kernel. Run it after every config change.
|
||||
Note that it says nothing about whether the service is *running*; that is what
|
||||
`/etc/init.d/shunt status` and the LuCI overview are for.
|
||||
|
||||
Exit codes: 0 ok, 1 runtime failure, 2 usage or unusable config.
|
||||
|
||||
Logging goes to syslog under the tag `shunt`, so `logread -e shunt` shows
|
||||
everything - the daemon's own lines carry its pid, `shunt[1234]:`. Debug lines
|
||||
stay off unless `-v` is given or `option debug '1'` is set - under procd there
|
||||
is no command line, so a bug report needs the config switch. Expect volume: on
|
||||
a router running adblock roughly half of all observed answers are error
|
||||
replies, and debug gives each one a line.
|
||||
|
||||
`shunt flush` is the escape hatch if the daemon ever dies without tearing
|
||||
down. It is idempotent and safe on a box that never ran shunt.
|
||||
|
||||
<a id="shunt-config-options"></a>
|
||||
## shunt config options
|
||||
|
||||
### Global section
|
||||
|
||||
| Option | Default | Description |
|
||||
| :--- | :--- | :--- |
|
||||
| enabled | `1` | master switch; `0` means the service starts and exits |
|
||||
| debug | `0` | log every observed answer and every set write |
|
||||
| rp_filter_manage | `0` | set rp_filter=2 on shunt's own policy devices, at start and on ifup |
|
||||
| poll_interval | `300` | seconds between poll cycles, at least 30 |
|
||||
| entry_ttl | `1200` | nftables timeout on learned elements, at least 60 |
|
||||
| snoop | `1` | enable the passive DNS observer |
|
||||
| snoop_device | `br-lan` | LAN devices to observe, a list, one entry per segment |
|
||||
|
||||
Values below the minimum are clamped, not rejected, and the clamp is logged.
|
||||
`entry_ttl` should stay well above `poll_interval` - an element is rewritten
|
||||
once its remaining timeout drops below half of `entry_ttl`, so the default pair
|
||||
refreshes comfortably within two poll cycles.
|
||||
|
||||
### Policy sections
|
||||
|
||||
Each `config policy` section is one routing policy. **The section must be
|
||||
named, and the name must match `[A-Za-z0-9_]{1,24}`** - it becomes an nftables
|
||||
identifier, so a section without a name, or one with a hyphen or a dot in it,
|
||||
is rejected as an issue and never rendered. LuCI enforces the same pattern
|
||||
when a policy is added.
|
||||
|
||||
| Option | Description |
|
||||
| :--- | :--- |
|
||||
| enabled | `0` skips the section entirely |
|
||||
| interface | netifd logical name (`wan`, `trm_wwan`) or raw netdev (`wg0`, `phy0-sta0`) |
|
||||
| fallback | `main` (default) or `block`, see below |
|
||||
| gw4 / gw6 | gateway override; normally unnecessary |
|
||||
| src | client addresses or CIDRs whose traffic this policy owns |
|
||||
| src_mac | client MAC addresses, ORed with `src` |
|
||||
| proto | `tcp`, `udp`, or both; a port without one covers both |
|
||||
| dport | destination ports, single or a range like `8000-8080` |
|
||||
| dst | destination addresses or CIDRs |
|
||||
| domain | domain patterns, see below |
|
||||
|
||||
`src`, `src_mac`, `dst` and `domain` are lists and may repeat.
|
||||
|
||||
Interfaces are resolved through netifd: a logical name resolves to its
|
||||
`l3_device`, a raw netdev is adopted if netifd knows it, and a device netifd
|
||||
knows nothing about passes through as given - which on OpenWrt means a tunnel
|
||||
started outside netifd, since wireguard and the other tunnel types have netifd
|
||||
protocols of their own. Gateways are discovered from the same dump, merged
|
||||
across sibling entries, because netifd splits families. `gw4`/`gw6` override
|
||||
discovery and always win; on a point to point interface no gateway is needed
|
||||
at all.
|
||||
|
||||
There is deliberately no list of supported tunnel protocols. shunt asks netifd
|
||||
for the device and the gateway and renders a default route into the policy
|
||||
table - `default via <gw> dev <device>` when a gateway is known,
|
||||
`default dev <device>` when none is needed. Wireguard, OpenVPN in both `tun`
|
||||
and `tap` mode, L2TP, PPTP, Tailscale, NetBird, a second physical uplink or a
|
||||
mobile connection all reduce to those two shapes, so none of them needs a case
|
||||
of its own. See [Which tunnels work](#which-tunnels-work) for the one thing
|
||||
that genuinely does not fit.
|
||||
|
||||
### Selectors are ANDed, client selectors OR each other
|
||||
|
||||
* `src` or `src_mac` alone marks everything from those clients
|
||||
* `dst`, `domain`, `dport` or `proto` alone marks that traffic from everyone
|
||||
* clients plus destinations marks only those clients' traffic to those
|
||||
destinations
|
||||
|
||||
`dport` and `proto` AND with everything else, so a policy with a client, a
|
||||
domain and `dport 443` covers that client's HTTPS traffic to that domain and
|
||||
nothing more. A port without a protocol matches **both** tcp and udp - "port
|
||||
443" almost always means QUIC too, and requiring the protocol would let it
|
||||
slip through unnoticed. If ports or protocols were configured and none of them
|
||||
is usable, the policy is skipped rather than rendered without the narrowing.
|
||||
|
||||
This is the single most common source of "it did not work" reports: with a
|
||||
client and a domain both set, a generic `curl ifconfig.me` from that client
|
||||
correctly takes the normal uplink, because `ifconfig.me` is not in the domain
|
||||
list. That is the policy working, not failing.
|
||||
|
||||
A client MAC and a client address OR each other, so a host may be named either
|
||||
way. If client selectors were configured and **none** of them is usable - a
|
||||
typo in the only address, say - the policy is skipped with an issue rather
|
||||
than falling back to "every client", which is what an absent client selector
|
||||
otherwise means.
|
||||
|
||||
### Selecting clients by MAC
|
||||
|
||||
`src_mac` exists mainly for IPv6. Clients prefer rotating privacy addresses
|
||||
for outgoing traffic, so a single IPv6 address is not a usable selector and the
|
||||
LAN prefix covers every host in the segment. A MAC picks exactly one host, in
|
||||
both address families, and keeps doing so when the addresses change. A policy
|
||||
with only `src_mac` therefore needs no v6 address to route v6.
|
||||
|
||||
Three limits, none of them guessable:
|
||||
|
||||
* **Same layer 2 segment only.** Anything behind another router arrives with
|
||||
that router's MAC.
|
||||
* **Never the router itself.** The `output` chain sees traffic the router
|
||||
generated, which has no ethernet sender, so MAC rules are not installed
|
||||
there. An address based policy does cover the router; a MAC-only one does not.
|
||||
* **Phones randomise their MAC**, though usually stable per network. Use the
|
||||
address the client shows in your DHCP leases, not the one on the label.
|
||||
|
||||
### Domain patterns
|
||||
|
||||
```
|
||||
example.com matches the apex only
|
||||
*.example.com matches subdomains only, at any depth, NOT the apex
|
||||
```
|
||||
|
||||
List both to cover both. This is more typing than dnsmasq's implicit subdomain
|
||||
inclusion, and it is deliberate: dnsmasq's behaviour surprises people
|
||||
regularly, this one does not.
|
||||
|
||||
Precedence, in order:
|
||||
|
||||
1. an exact match always beats any wildcard
|
||||
2. among wildcards the longest suffix wins, so `*.cdn.example.com` beats
|
||||
`*.example.com` regardless of which policy declared them
|
||||
3. the same pattern in two policies belongs to **both**
|
||||
|
||||
Rule 3 is what makes one domain usable by two client groups over two different
|
||||
uplinks: the address is written into each policy's set, and each policy's rule
|
||||
matches only its own clients, so they stay apart. Rules 1 and 2 still decide
|
||||
specificity - a shared `*.example.com` never overrides somebody's exact
|
||||
`www.example.com`.
|
||||
|
||||
Matching is label aligned, never string suffix: `evilexample.com` does not
|
||||
match `*.example.com`. A bad pattern is collected as an issue, never fatal.
|
||||
|
||||
### Policy precedence
|
||||
|
||||
Section order in `/etc/config/shunt`, top to bottom. There is no `priority`
|
||||
option - one less value to set wrong. A packet matching two policies takes the
|
||||
earlier one; rule evaluation ends at the first match.
|
||||
|
||||
Note that domain precedence is resolved *before* this, at the matcher: the most
|
||||
specific pattern wins even if it sits in a later section.
|
||||
|
||||
### Fallback: main or block
|
||||
|
||||
`fallback 'main'` (default) renders no default route into the policy table, so
|
||||
when the policy interface is down the table is empty and marked traffic falls
|
||||
through to `main` - the normal uplink. Traffic keeps flowing, unpolicied.
|
||||
|
||||
`fallback 'block'` adds a blackhole default at metric 9999 to the policy table.
|
||||
While the interface is up its own default has the lower metric and wins; when
|
||||
the interface drops, the kernel withdraws that route and the blackhole catches
|
||||
everything. That is the killswitch: traffic belonging to the policy stops
|
||||
rather than leaking out of the wrong interface.
|
||||
|
||||
<a id="how-addresses-are-learned"></a>
|
||||
## How addresses are learned
|
||||
Two sources feed the same nftables sets, union with an element timeout. They
|
||||
are complementary, not alternative modes.
|
||||
|
||||
* **poll** resolves the configured names through whatever system resolver
|
||||
exists, on a fixed interval. It warms the sets before the first client
|
||||
packet, so first contact does not race. Wildcards are not names and cannot be
|
||||
polled.
|
||||
* **snoop** passively observes DNS responses on the LAN side via AF_PACKET with
|
||||
a BPF filter matching **UDP source port 53** - answers, not questions -
|
||||
including one level of VLAN tagging. It covers CDN variance and wildcards,
|
||||
which poll cannot. It reads; it never writes anything back onto the wire and
|
||||
never sits between a client and its resolver. If it dies, DNS keeps working
|
||||
and only the policy stops applying.
|
||||
|
||||
<a id="what-polling-costs"></a>
|
||||
### What polling costs
|
||||
|
||||
"Polling" invites the assumption of waste, so here is the arithmetic. One cycle
|
||||
is a single call asking for A and AAAA of every listed name: two lookups per
|
||||
name per interval, against the **local** resolver. Ten names at the default 300
|
||||
seconds is 240 lookups an hour - about what a dozen web page loads cost, on a
|
||||
network whose own DNS traffic runs to hundreds of answers in a few minutes.
|
||||
There is no polling of anything else: no interface scanning, no ruleset
|
||||
re-rendering, no periodic writes. An element is only rewritten when its
|
||||
remaining lifetime has dropped below half.
|
||||
|
||||
Two costs worth knowing:
|
||||
|
||||
* The query is synchronous. A name that does not resolve blocks the cycle until
|
||||
it times out (2s, one retry), which delays the service start noticeably if
|
||||
several are wrong. This is why an unresolvable name is reported by name.
|
||||
* For names with a TTL shorter than the interval the local cache has expired,
|
||||
so poll does refetch upstream rather than answering from cache.
|
||||
|
||||
What that means in practice:
|
||||
|
||||
* **Pick the device the answers cross on their way to the clients**, normally
|
||||
`br-lan`. It must be an **Ethernet type** device - a bridge, a VLAN device, a
|
||||
physical port, a wireless interface. A tunnel or PPP interface has no
|
||||
ethernet header, so neither the packet filter nor the decoder can read it,
|
||||
and the failure is silent: nothing matches, nothing is logged.
|
||||
* **Only the client-to-resolver leg matters, and only whether *it* is
|
||||
encrypted.** What the resolver does upstream is irrelevant: the usual OpenWrt
|
||||
setup - unbound or dnsmasq on the router, forwarding upstream over DoT or DoH
|
||||
- is fully covered, because the client asked in plain text over the LAN and
|
||||
the answer comes back the same way.
|
||||
* **A client that speaks DoH or DoT itself is invisible**, because it bypasses
|
||||
the local resolver. That is the one encryption case that costs coverage.
|
||||
* **One entry per layer 2 segment.** A guest or IoT VLAN on its own device never
|
||||
carries the answers of the main LAN, so it needs its own `snoop_device` entry.
|
||||
A device that cannot be opened costs one warning; the others keep running.
|
||||
* **The router's own lookups are not seen.** poll's queries leave through the
|
||||
uplink, not the LAN device.
|
||||
* Not seen either: DNS over TCP, DNS on a port other than 53, and a second
|
||||
stacked VLAN tag.
|
||||
|
||||
A name in a `domain` list that never resolves is reported once, by name and
|
||||
policy:
|
||||
|
||||
```
|
||||
poll: www.example.com (policy vpn) has no address - the policy entry has no effect until it resolves
|
||||
```
|
||||
|
||||
Once on the way in and once on recovery, never in between. The first cycle runs
|
||||
immediately at start, so a typo shows up within seconds. Wildcards cannot
|
||||
produce this message - nothing can tell whether `*.example.com` was ever meant
|
||||
to match anything.
|
||||
|
||||
<a id="examples"></a>
|
||||
## Examples
|
||||
|
||||
**One client, one domain family, over a wireguard tunnel**
|
||||
|
||||
```
|
||||
config policy 'vpn'
|
||||
option enabled '1'
|
||||
option interface 'wg0'
|
||||
option fallback 'main'
|
||||
list src '192.168.1.50'
|
||||
list domain 'example.com'
|
||||
list domain '*.example.com'
|
||||
```
|
||||
|
||||
**A whole IoT VLAN over a mobile uplink, killswitch on**
|
||||
|
||||
The client is named by MAC, so it is covered in both address families without
|
||||
listing a rotating IPv6 address:
|
||||
|
||||
```
|
||||
config global
|
||||
list snoop_device 'br-lan'
|
||||
list snoop_device 'br-iot'
|
||||
|
||||
config policy 'iot'
|
||||
option enabled '1'
|
||||
option interface 'trm_wwan'
|
||||
option fallback 'block'
|
||||
list src_mac 'aa:bb:cc:dd:ee:ff'
|
||||
list domain '*.vendor-cloud.com'
|
||||
```
|
||||
|
||||
**The same domain for two client groups over two uplinks**
|
||||
|
||||
Both policies claim `www.example.com`; each routes only its own clients:
|
||||
|
||||
```
|
||||
config policy 'wwan'
|
||||
option interface 'trm_wwan'
|
||||
list src '10.168.30.70'
|
||||
list domain 'www.example.com'
|
||||
|
||||
config policy 'vpn'
|
||||
option interface 'wg0'
|
||||
list src '10.168.1.20'
|
||||
list domain 'www.example.com'
|
||||
```
|
||||
|
||||
**A destination range without any domain**
|
||||
|
||||
```
|
||||
config policy 'office'
|
||||
option interface 'wg0'
|
||||
list src '192.168.1.0/24'
|
||||
list dst '10.0.0.0/8'
|
||||
```
|
||||
|
||||
<a id="what-it-shells-out-to"></a>
|
||||
### What it shells out to
|
||||
|
||||
Almost nothing. The daemon and the rpcd backend work through ucode's native
|
||||
bindings - `fs`, `socket` for the AF_PACKET observer, `uci`, `uloop`, `ubus`,
|
||||
`resolv`, `rtnl` and `log` - and rpcd carries the LuCI side, so there is no
|
||||
shell glue, no `awk`, no temporary state files. Logging goes to syslog through
|
||||
the binding, not through a `logger` process per line.
|
||||
|
||||
Two external commands remain:
|
||||
|
||||
| Command | Why |
|
||||
| :--- | :--- |
|
||||
| `nft` | the ruleset is applied and read as one atomic batch; ucode has no nftables binding |
|
||||
| `ip` | routes and rules are written this way, although `rtnl` already reads them - replaceable |
|
||||
|
||||
Nothing is ever handed to a shell for parsing: `system()` and `popen()` take an
|
||||
argument array, and where stderr has to be captured the wrapper is
|
||||
`sh -c 'exec "$0" "$@"'`, which passes arguments through untouched.
|
||||
|
||||
<a id="what-shunt-creates-on-the-system"></a>
|
||||
## What shunt creates on the system
|
||||
|
||||
```
|
||||
table inet shunt own table, survives fw4 reloads
|
||||
chain prerouting filter hook prerouting, priority mangle
|
||||
chain output route hook output, priority mangle
|
||||
set d4_<policy> / d6_<policy> learned, flags timeout, per-element counter
|
||||
set s4_<policy> / s6_<policy> static dst, flags interval, counter
|
||||
set c4_<policy> / c6_<policy> client src selectors, interval, counter
|
||||
set m_<policy> client MACs, no family digit, counter
|
||||
|
||||
fwmark <index> << 24, mask 0xff000000
|
||||
ip rule pref 31000 + <index>
|
||||
routing table 8000 + <index>
|
||||
/etc/iproute2/rt_tables.d/shunt.conf the table name mapping
|
||||
```
|
||||
|
||||
The mark mask is fixed at `0xff000000`, which allows 255 policies. The `output`
|
||||
chain is `type route` so the router's own marked traffic is re-routed after the
|
||||
mark is set.
|
||||
|
||||
Every set carries per-element counters, so "is this element ever hit" is one
|
||||
look at `nft list set inet shunt <set>` rather than a tcpdump session. The two
|
||||
kinds count different things: nftables tests a rule left to right, so a
|
||||
**client** set counts every packet that matched the selector, whether or not
|
||||
the destination matched afterwards; a **learned** set is the last lookup in the
|
||||
rule, so a hit there means the packet really was marked. A busy client beside
|
||||
learned addresses at zero is a client that has not visited any of the routed
|
||||
domains, not a fault.
|
||||
|
||||
**Writes are batched, and the interval adapts.** `nft -f` reads the entire
|
||||
ruleset from the kernel before it resolves a single name, so on a box that
|
||||
also runs a tool with very large sets - banIP with 238k elements, measured -
|
||||
one `add element` costs seconds of CPU, and `nft --check` alone costs the
|
||||
same. That is a known bug in nftables (netfilter bugzilla #1735, open
|
||||
since 2024), not something shunt can fix, so observed addresses are collected
|
||||
and applied together by a timer.
|
||||
|
||||
The interval follows what the last write actually cost, between 2 and 60
|
||||
seconds: on an ordinary box a write takes milliseconds and the interval stays
|
||||
at its floor, where the batching is invisible. Where it is expensive the
|
||||
interval grows until nftables takes a bounded share of the machine instead of
|
||||
all of it, at the price of a learned address reaching its set later. Both
|
||||
numbers show up under `debug`.
|
||||
|
||||
**A reload wipes learned state.** Applying the configuration destroys and
|
||||
re-creates the table atomically, so the learned sets start empty. poll rewarms
|
||||
them within one interval and snoop refills from live traffic; expect a short
|
||||
window after a restart where domain policies do not apply yet.
|
||||
|
||||
<a id="coexistence-with-pbr-and-mwan3"></a>
|
||||
## Coexistence with pbr and mwan3
|
||||
shunt is an independent implementation, not a fork of `pbr` and not a drop-in
|
||||
for it - there is no config migration and no attempt at feature parity. Within
|
||||
its scope it is a full alternative.
|
||||
|
||||
Running both at once during a migration is safe by construction:
|
||||
|
||||
| | pbr | mwan3 | shunt |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| fwmark mask | `0x00ff0000` | `0x00003f00` | `0xff000000` |
|
||||
| ip rule pref | 30000 counting down | ~1001-3250 | 31000 counting up |
|
||||
| routing tables | dynamic from ~256 | 1-250 | 8000+n |
|
||||
| nft | chains in fw4's table | | own `inet shunt` table |
|
||||
|
||||
The mark bits are disjoint and all three mask their writes. Where pbr and shunt
|
||||
both match, pbr's lower rule priority wins, deterministically. So move policies
|
||||
over one at a time and retire pbr once its config is empty.
|
||||
|
||||
Anything shunt cannot see is worth knowing about: marks set via `SO_MARK` on a
|
||||
daemon socket (OpenVPN's `--mark`) or by an eBPF program are invisible to any
|
||||
inspection. If a box uses those, check the mark ranges by hand.
|
||||
|
||||
<a id="troubleshooting-and-debug-options"></a>
|
||||
## Troubleshooting & debug options
|
||||
|
||||
### Did the policy actually match?
|
||||
|
||||
The authoritative check needs no route lookups at all:
|
||||
|
||||
```sh
|
||||
nft reset counters table inet shunt
|
||||
# generate traffic from the client
|
||||
nft list chain inet shunt prerouting # rule counters moved?
|
||||
tcpdump -ni <policy-interface> host <addr> # the flow leaves where it should
|
||||
```
|
||||
|
||||
The wire capture is ground truth - but capture a **learned address**, not
|
||||
everything: on a router whose policy interface is also a normal uplink, an
|
||||
unfiltered capture shows traffic that has nothing to do with shunt. An IP echo
|
||||
service is a convenient confirmation, but it only discriminates uplinks that
|
||||
actually have different exits.
|
||||
|
||||
The route lookup variant asks the kernel directly:
|
||||
|
||||
```sh
|
||||
ip route get <addr> mark 0x1000000
|
||||
ip route get <addr>
|
||||
```
|
||||
|
||||
The first answer must name the policy table, the second the normal uplink.
|
||||
|
||||
**The first line needs iproute2's `ip`** (`ip-full`), because BusyBox's
|
||||
`route get` does not understand `mark` - one build rejects it outright, the
|
||||
OpenWrt one sends an incomplete netlink request that the kernel answers with
|
||||
`EINVAL`. The second line, without a mark, works with either. Check which one
|
||||
you have with `readlink -f $(command -v ip)`.
|
||||
|
||||
Adding `from <client> iif br-lan` makes the lookup more precise, with one
|
||||
further catch worth a confused test session: **`iif` is not optional** there.
|
||||
Without it, `from` a non-local address makes the kernel validate a locally
|
||||
originated lookup and answer `ENETUNREACH` regardless of any table's content,
|
||||
which reads like broken routing and is not.
|
||||
|
||||
### What the observer discards, and why
|
||||
|
||||
Most DNS answers on a network are of no use to a routing policy, so snoop
|
||||
counts what it discarded and why. `ubus call shunt status` reports those
|
||||
counters, and the LuCI overview shows them with readable labels.
|
||||
|
||||
| Verdict | Meaning |
|
||||
| :--- | :--- |
|
||||
| qtype | the question was not for an address at all |
|
||||
| noaddr | an address was asked for, the answer carried none |
|
||||
| nomatch | the name belongs to no policy |
|
||||
| dns:E_* | the message did not parse, e.g. `E_RCODE` for NXDOMAIN |
|
||||
| frame:E_* | the packet did not decode, e.g. `E_FRAG` for a fragment |
|
||||
|
||||
`qtype` is usually the largest category and that is expected: current browsers
|
||||
and operating systems ask for **HTTPS records (type 65)** alongside every A and
|
||||
AAAA. `noaddr` is the other half of that distinction: the question *was* A or
|
||||
AAAA, the reply is well formed, and the answer section still holds no address -
|
||||
NODATA.
|
||||
|
||||
The checks run in order and the **first** one wins, so these are "first reason
|
||||
to discard" rather than independent counters: a PTR query for a name you route
|
||||
counts as `qtype`, never as `nomatch`.
|
||||
|
||||
A high discard count is therefore not a fault. The one number that says whether
|
||||
the observer is doing its job is the matched count next to them.
|
||||
|
||||
### When a policy stops applying after a reconnect
|
||||
|
||||
The kernel removes routes from a policy table when the interface goes down,
|
||||
after which the fwmark rule falls through to `main` while every counter keeps
|
||||
counting. shunt handles this on two levels: a ubus listener on
|
||||
`network.interface` rebuilds the route half on ifup/ifdown, and every poll tick
|
||||
replays the route commands as a keeper. Learned sets survive both. If ubus is
|
||||
unavailable, only the keeper remains, so recovery takes up to one
|
||||
`poll_interval`.
|
||||
|
||||
### Debug logging
|
||||
|
||||
```sh
|
||||
uci set shunt.@global[0].debug='1'
|
||||
uci commit shunt
|
||||
/etc/init.d/shunt restart
|
||||
logread -e shunt
|
||||
```
|
||||
|
||||
Set it back to `0` afterwards. Every observed answer and every set write gets a
|
||||
line, which on a busy network is a lot.
|
||||
|
||||
<a id="known-limitations"></a>
|
||||
## Known limitations
|
||||
These are consequences of the design, stated rather than worked around:
|
||||
|
||||
* **Clients that speak DoH or DoT themselves are invisible to snoop.** poll
|
||||
still covers the names you list explicitly; wildcards do not work for those
|
||||
clients. A *resolver* forwarding upstream over DoT or DoH changes nothing.
|
||||
* **Wildcards require snoop.** poll can only resolve names it was given, and
|
||||
`*.example.com` is not a name.
|
||||
* **One CDN address serves many domains.** If a policy routes `example.com` and
|
||||
the address behind it also serves a thousand other sites, those sites follow
|
||||
the same policy. This is unsolvable at layer 3 by anything that routes on
|
||||
addresses.
|
||||
* **The first connection to a newly seen address takes the old path.** snoop
|
||||
learns from the response the client is reading at that moment, so the client's
|
||||
SYN is usually out before the element reaches the set. Measured on a live
|
||||
router: the entire first connection stayed on the normal uplink, and the next
|
||||
connection to the same host started on the policy interface. The switch
|
||||
happens at a connection boundary; shunt does not touch conntrack, so no
|
||||
established flow is ever yanked to a different exit mid-stream. Listing the
|
||||
entry point explicitly closes the gap, because poll warms it before any client
|
||||
asks.
|
||||
* **DNS over TCP is not observed.** Port 53 over TCP needs reassembly, which is
|
||||
out of scope; answers large enough to force TCP are rare in the traffic shunt
|
||||
cares about.
|
||||
* **Route and rule application is best effort.** At boot a tunnel interface may
|
||||
not exist yet. A rule over an empty table falls through to `main`, so the
|
||||
failure mode is "policy not applied yet", never "traffic broken". Each
|
||||
distinct reason is one warning line.
|
||||
* **No interface hotplug.** A device that appears later is picked up on the next
|
||||
`ifup` event or within one poll interval, not immediately.
|
||||
|
||||
**Out of scope permanently:** resolver-integrated set population (dnsmasq
|
||||
`nftset`, AdGuard Home etc.). Being independent of the DNS backend is the entire
|
||||
point of the project, so adopting a backend-specific mechanism would give up
|
||||
the one property that distinguishes it. Also out: DSCP tagging and user
|
||||
include files.
|
||||
|
||||
<a id="support"></a>
|
||||
## Support
|
||||
Please report issues with as much detail as possible - the output of
|
||||
`shunt check`, the relevant part of `logread -e shunt` with `debug` enabled,
|
||||
your `/etc/config/shunt`, and the OpenWrt version of the device.
|
||||
|
||||
<a id="removal"></a>
|
||||
## Removal
|
||||
Stop the service with `/etc/init.d/shunt stop`, which also tears down the
|
||||
nftables table, the routing tables and the ip rules, then remove the `shunt`
|
||||
and `luci-app-shunt` packages if necessary. `shunt flush` does the teardown
|
||||
alone, should anything be left behind.
|
||||
|
||||
<a id="donations"></a>
|
||||
## Donations
|
||||
You like this project - is there a way to donate? Generally speaking "No" - I have a well-paying full-time job and my OpenWrt projects are just a hobby of mine in my spare time.
|
||||
|
||||
If you still insist to donate some bucks ...
|
||||
* I would be happy if you put your money in kind into other, social projects in your area, e.g. a children's hospice
|
||||
* Let's meet and invite me for a coffee if you are in my area, the “Markgräfler Land” in southern Germany or in Switzerland (Basel)
|
||||
* Send your money to my [PayPal account](https://www.paypal.me/DirkBrenken) and I will collect your donations over the year to support various social projects in my area
|
||||
|
||||
No matter what you decide - thank you very much for your support!
|
||||
|
||||
Have fun!
|
||||
Dirk
|
||||
@@ -1,16 +0,0 @@
|
||||
config global
|
||||
option enabled '1'
|
||||
option poll_interval '300'
|
||||
option entry_ttl '1200'
|
||||
option snoop '1'
|
||||
option debug '0'
|
||||
option rp_filter_manage '0'
|
||||
list snoop_device 'br-lan'
|
||||
|
||||
config policy 'vpn'
|
||||
option enabled '0'
|
||||
option interface 'wg0'
|
||||
option fallback 'main'
|
||||
list src '192.168.1.50'
|
||||
list domain 'example.com'
|
||||
list domain '*.example.com'
|
||||
@@ -1,39 +0,0 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
# shunt - policy based routing
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
|
||||
|
||||
START=95
|
||||
USE_PROCD=1
|
||||
|
||||
shunt_init="/etc/init.d/shunt"
|
||||
|
||||
if [ -z "${IPKG_INSTROOT}" ]; then
|
||||
case "${action}" in
|
||||
"stop")
|
||||
"${shunt_init}" running || exit 0
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
start_service() {
|
||||
procd_open_instance "shunt"
|
||||
procd_set_param command /usr/sbin/shunt run
|
||||
procd_set_param respawn 300 5 3
|
||||
procd_set_param stdout 0
|
||||
procd_set_param stderr 1
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
/usr/sbin/shunt flush 2>/dev/null
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "shunt"
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
restart
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1,646 +0,0 @@
|
||||
#!/usr/bin/ucode
|
||||
// shunt - policy based routing daemon
|
||||
//
|
||||
// Reads the config, renders the ruleset and the routes, applies them, then
|
||||
// keeps the learned sets fed from a poll cycle and a passive DNS observer.
|
||||
// All decisions live in the modules; this file is wiring.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
import { popen, writefile, readfile, unlink, mkdir, lstat, error as fs_error } from 'fs';
|
||||
import { openlog, syslog, LOG_PID, LOG_DAEMON, LOG_ERR, LOG_WARNING,
|
||||
LOG_NOTICE, LOG_INFO, LOG_DEBUG } from 'log';
|
||||
import { load as cfg_load, parse as cfg_parse } from 'shunt.config';
|
||||
import { compile as match_compile } from 'shunt.match';
|
||||
import { compile as nft_compile, refresh, teardown } from 'shunt.nft';
|
||||
import { compile as route_compile } from 'shunt.route';
|
||||
import { open as snoop_open, observe, RECV_LEN } from 'shunt.snoop';
|
||||
import { names as poll_names, plan as poll_plan,
|
||||
addresses as poll_addresses,
|
||||
index_results as poll_index } from 'shunt.poll';
|
||||
import { resolve as netifd_resolve } from 'shunt.netifd';
|
||||
import { create as dedupe_create } from 'shunt.dedupe';
|
||||
|
||||
const RT_TABLES = '/etc/iproute2/rt_tables.d/shunt.conf';
|
||||
const TAG = 'shunt';
|
||||
|
||||
let verbose = false;
|
||||
|
||||
let dbg = false;
|
||||
|
||||
// syslog(3) through the binding, not a `logger` process per line. '%s' as the
|
||||
// format because syslog() runs sprintf over its arguments, and an ip error
|
||||
// text can contain a percent sign.
|
||||
const PRIO = { err: LOG_ERR, warn: LOG_WARNING, notice: LOG_NOTICE,
|
||||
info: LOG_INFO, debug: LOG_DEBUG };
|
||||
|
||||
openlog(TAG, LOG_PID, LOG_DAEMON);
|
||||
|
||||
function log(prio, msg) {
|
||||
syslog(PRIO[prio] ?? LOG_NOTICE, '%s', msg);
|
||||
|
||||
if (verbose)
|
||||
warn(sprintf('[%s] %s\n', prio, msg));
|
||||
}
|
||||
|
||||
function debug(msg) {
|
||||
if (dbg)
|
||||
log('debug', msg);
|
||||
}
|
||||
|
||||
let ubus_conn = null;
|
||||
|
||||
function ubus() {
|
||||
if (ubus_conn != null)
|
||||
return ubus_conn;
|
||||
|
||||
try {
|
||||
ubus_conn = require('ubus').connect();
|
||||
}
|
||||
catch (e) {
|
||||
ubus_conn = false;
|
||||
}
|
||||
|
||||
if (!ubus_conn)
|
||||
log('warn', 'ubus unavailable - gateway discovery and interface events disabled');
|
||||
|
||||
return ubus_conn;
|
||||
}
|
||||
|
||||
function netifd_dump() {
|
||||
let c = ubus();
|
||||
return c ? c.call('network.interface', 'dump') : null;
|
||||
}
|
||||
|
||||
function load_config() {
|
||||
let sections = cfg_load();
|
||||
|
||||
if (sections == null) {
|
||||
log('err', 'ucode-mod-uci missing');
|
||||
return null;
|
||||
}
|
||||
|
||||
return cfg_parse(sections);
|
||||
}
|
||||
|
||||
function report(kind, issues) {
|
||||
for (let i in issues)
|
||||
log('warn', sprintf('%s: %J', kind, i));
|
||||
}
|
||||
|
||||
const RUN_DIR = '/tmp/.shunt';
|
||||
const RUN_ERR = RUN_DIR + '/cmd.err';
|
||||
|
||||
function capture_ok() {
|
||||
mkdir(RUN_DIR, 0o700);
|
||||
|
||||
let st = lstat(RUN_DIR);
|
||||
|
||||
return st != null && st.type == 'directory' && st.uid == 0 &&
|
||||
!st.perm.group_write && !st.perm.other_write &&
|
||||
!st.perm.group_read && !st.perm.other_read;
|
||||
}
|
||||
|
||||
function loud(argv) {
|
||||
if (!capture_ok())
|
||||
return { rc: quiet(argv), err: '' };
|
||||
|
||||
let rc = system([ '/bin/sh', '-c',
|
||||
sprintf('exec "$0" "$@" 2>%s', RUN_ERR), ...argv ]);
|
||||
let err = '';
|
||||
|
||||
if (rc != 0)
|
||||
err = replace(trim(readfile(RUN_ERR) ?? ''), /\s*\n\s*/g, '; ');
|
||||
|
||||
unlink(RUN_ERR);
|
||||
|
||||
return { rc, err };
|
||||
}
|
||||
|
||||
// quiet() drops the child's stderr, loud() keeps it for the warning. Neither
|
||||
// may be called `run` - that name is the daemon's own entry point.
|
||||
function quiet(argv) {
|
||||
return system([ '/bin/sh', '-c', 'exec "$0" "$@" 2>/dev/null', ...argv ]);
|
||||
}
|
||||
|
||||
function nft_pipe(batch, what) {
|
||||
let fh = popen('nft -f -', 'w');
|
||||
|
||||
if (!fh) {
|
||||
log('err', sprintf('%s: cannot spawn nft: %s', what, fs_error()));
|
||||
return false;
|
||||
}
|
||||
|
||||
fh.write(batch);
|
||||
|
||||
let rc = fh.close();
|
||||
if (rc != 0) {
|
||||
log('err', sprintf('%s: nft exited %d', what, rc));
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function apply(state) {
|
||||
if (!nft_pipe(state.nft.setup, 'setup'))
|
||||
return false;
|
||||
|
||||
if (length(state.route.rt_tables)) {
|
||||
mkdir('/etc/iproute2/rt_tables.d', 0o755);
|
||||
if (!writefile(RT_TABLES, state.route.rt_tables))
|
||||
log('warn', sprintf('cannot write %s: %s', RT_TABLES, fs_error()));
|
||||
}
|
||||
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
|
||||
let failed = 0;
|
||||
let reasons = {};
|
||||
|
||||
for (let argv in state.route.add) {
|
||||
let r = loud(argv);
|
||||
|
||||
if (r.rc != 0) {
|
||||
let why = length(r.err) ? r.err : sprintf('exit %d', r.rc);
|
||||
|
||||
failed++;
|
||||
reasons[why] = (reasons[why] ?? 0) + 1;
|
||||
debug(sprintf('not applied: %s - %s', join(' ', argv), why));
|
||||
}
|
||||
}
|
||||
|
||||
for (let why in reasons)
|
||||
log('warn', sprintf('%d of %d route/rule command(s) not applied - %s',
|
||||
reasons[why], length(state.route.add), why));
|
||||
|
||||
if (failed)
|
||||
log('warn', 'policy not applied yet - traffic falls through to main; restart once the interface is up');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function flush(state) {
|
||||
if (state)
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
|
||||
nft_pipe(teardown(), 'teardown');
|
||||
|
||||
if (readfile(RT_TABLES) != null)
|
||||
unlink(RT_TABLES);
|
||||
}
|
||||
|
||||
function rp_read(k) {
|
||||
return trim(readfile(`/proc/sys/net/ipv4/conf/${k}/rp_filter`) ?? '');
|
||||
}
|
||||
|
||||
// Distinct, existing policy devices. Deduped so a device shared by several
|
||||
// policies is set or reported once.
|
||||
function policy_devices(policies) {
|
||||
let seen = {}, out = [];
|
||||
|
||||
for (let p in (policies ?? [])) {
|
||||
let dev = p.interface;
|
||||
|
||||
if (length(dev ?? '') && !seen[dev]) {
|
||||
seen[dev] = true;
|
||||
push(out, dev);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// Which policy devices the kernel would drop marked traffic on. rp_filter
|
||||
// takes max(conf.all, conf.<dev>), so a device is blocked only when all is
|
||||
// strict (1 - 0 is off, 2 is loose) AND the device is not loosened itself. A
|
||||
// device with no /proc entry does not exist yet and inherits default.
|
||||
function rp_filter_blocked(policies) {
|
||||
if (rp_read('all') != '1')
|
||||
return [];
|
||||
|
||||
let blocked = [];
|
||||
|
||||
for (let dev in policy_devices(policies)) {
|
||||
let v = rp_read(dev);
|
||||
|
||||
if (v == '')
|
||||
continue;
|
||||
|
||||
if (v != '2')
|
||||
push(blocked, dev);
|
||||
}
|
||||
|
||||
return blocked;
|
||||
}
|
||||
|
||||
// With rp_filter_manage set, shunt loosens rp_filter on its own policy devices
|
||||
// - the per-interface fix the README documents, done automatically. Bounded to
|
||||
// exactly the devices shunt routes into, never all/default, and only on a
|
||||
// device that exists. Off by default: changing a security setting is opt-in.
|
||||
function rp_filter_apply(policies) {
|
||||
for (let dev in policy_devices(policies))
|
||||
if (rp_read(dev) != '' && rp_read(dev) != '2')
|
||||
loud([ 'sysctl', '-w', sprintf('net.ipv4.conf.%s.rp_filter=2', dev) ]);
|
||||
}
|
||||
|
||||
// Reads the live /proc value, so when rp_filter_apply has done its job the
|
||||
// list is empty on its own - no need to consult the switch a second time.
|
||||
function check_rp_filter(policies) {
|
||||
for (let dev in rp_filter_blocked(policies))
|
||||
log('warn', sprintf('rp_filter is strict on %s - shunt\'s marked traffic will be dropped there; set net.ipv4.conf.%s.rp_filter=2 or enable rp_filter_manage, see the README',
|
||||
dev, dev));
|
||||
}
|
||||
|
||||
// silent: build only what a teardown consumes and say nothing about the
|
||||
// configuration - flush() needs route.del and nothing else.
|
||||
function build_state(silent) {
|
||||
let cfg = load_config();
|
||||
if (!cfg)
|
||||
return null;
|
||||
|
||||
if (!silent)
|
||||
report('config', cfg.issues);
|
||||
|
||||
cfg.policies = netifd_resolve(cfg.policies, netifd_dump());
|
||||
|
||||
let matcher = null;
|
||||
|
||||
if (!silent) {
|
||||
matcher = match_compile(cfg.policies);
|
||||
report('domain', matcher.issues);
|
||||
}
|
||||
|
||||
let n = nft_compile(cfg.policies);
|
||||
if (!silent)
|
||||
report('nft', n.issues);
|
||||
|
||||
let r = route_compile(cfg.policies, n.marks);
|
||||
if (!silent)
|
||||
report('route', r.issues);
|
||||
|
||||
return { cfg, matcher, nft: n, route: r };
|
||||
}
|
||||
|
||||
// nft -f reads the entire ruleset before resolving a single name, so on a box
|
||||
// with large sets from another tool one add element costs seconds of CPU.
|
||||
// Writes are collected and applied by a timer whose interval follows the
|
||||
// measured cost: an ordinary box stays at the floor, an expensive one backs
|
||||
// off.
|
||||
const WRITE_MIN = 2;
|
||||
const WRITE_MAX = 60;
|
||||
const WRITE_FACTOR = 3;
|
||||
|
||||
function queue_writes(st, writes, now) {
|
||||
for (let w in writes)
|
||||
if (st.state.nft.learn[w.set] && st.cache.due(w.set, w.addr, now))
|
||||
st.pending[`${w.set}/${w.addr}`] = w;
|
||||
}
|
||||
|
||||
function drain_writes(st) {
|
||||
let due = values(st.pending);
|
||||
|
||||
st.pending = {};
|
||||
|
||||
if (!length(due))
|
||||
return;
|
||||
|
||||
let r = refresh(due, st.state.cfg.global.entry_ttl);
|
||||
report('refresh', r.issues);
|
||||
|
||||
if (!length(r.batch))
|
||||
return;
|
||||
|
||||
let t0 = time();
|
||||
let ok = nft_pipe(r.batch, 'refresh');
|
||||
let cost = time() - t0;
|
||||
|
||||
if (ok)
|
||||
debug(sprintf('%d element(s) written in %ds', length(due), cost));
|
||||
|
||||
let want = cost * WRITE_FACTOR;
|
||||
|
||||
if (want < WRITE_MIN)
|
||||
want = WRITE_MIN;
|
||||
if (want > WRITE_MAX)
|
||||
want = WRITE_MAX;
|
||||
|
||||
if (want != st.interval) {
|
||||
debug(sprintf('write interval %ds -> %ds (last write %ds)',
|
||||
st.interval, want, cost));
|
||||
st.interval = want;
|
||||
st.timer.set(want * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
let uloop, resolv;
|
||||
|
||||
try {
|
||||
uloop = require('uloop');
|
||||
}
|
||||
catch (e) {
|
||||
log('err', 'ucode-mod-uloop missing');
|
||||
return 1;
|
||||
}
|
||||
|
||||
let state = build_state();
|
||||
if (!state)
|
||||
return 2;
|
||||
|
||||
dbg = verbose || state.cfg.global.debug;
|
||||
|
||||
if (!state.cfg.global.enabled) {
|
||||
log('notice', 'disabled in config');
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!length(state.nft.marks)) {
|
||||
log('err', 'no usable policy - not starting, run `shunt check` for the reasons');
|
||||
return 2;
|
||||
}
|
||||
|
||||
if (state.cfg.global.rp_filter_manage)
|
||||
rp_filter_apply(state.cfg.policies);
|
||||
|
||||
check_rp_filter(state.cfg.policies);
|
||||
|
||||
if (!apply(state)) {
|
||||
flush(state);
|
||||
return 1;
|
||||
}
|
||||
|
||||
let cache = dedupe_create(state.cfg.global.entry_ttl);
|
||||
let targets = poll_names(state.cfg.policies);
|
||||
|
||||
let stats = { started: time(), resolv: false, snoop: [],
|
||||
matched: 0, drops: {} };
|
||||
|
||||
try {
|
||||
resolv = require('resolv');
|
||||
}
|
||||
catch (e) {
|
||||
resolv = null;
|
||||
if (length(targets))
|
||||
log('warn', 'ucode-mod-resolv missing - poll disabled, snoop only');
|
||||
}
|
||||
|
||||
stats.resolv = (resolv != null);
|
||||
|
||||
let unresolved = {};
|
||||
|
||||
let wq = { state, cache, pending: {}, interval: WRITE_MIN, timer: null };
|
||||
|
||||
function poll_cycle() {
|
||||
if (!resolv || !length(targets))
|
||||
return;
|
||||
|
||||
let res = resolv.query(targets, { type: [ 'A', 'AAAA' ],
|
||||
timeout: 2000, retries: 1 });
|
||||
if (!res) {
|
||||
log('warn', 'poll: query failed');
|
||||
return;
|
||||
}
|
||||
|
||||
let by = poll_index(res);
|
||||
|
||||
for (let name in targets) {
|
||||
let got = poll_addresses(by, name);
|
||||
let n = length(got.a) + length(got.aaaa);
|
||||
|
||||
if (n && unresolved[name]) {
|
||||
unresolved[name] = false;
|
||||
log('info', sprintf('poll: %s resolves again', name));
|
||||
}
|
||||
else if (!n && !unresolved[name]) {
|
||||
let owners = state.matcher.test(name);
|
||||
|
||||
unresolved[name] = true;
|
||||
log('warn', sprintf('poll: %s%s has no address - the policy entry has no effect until it resolves',
|
||||
name, owners != null
|
||||
? sprintf(' (policy %s)', join(', ', owners)) : ''));
|
||||
}
|
||||
}
|
||||
|
||||
queue_writes(wq, poll_plan(res, state.matcher, targets), time());
|
||||
}
|
||||
|
||||
// The cache is pruned here and not at the tail of poll_cycle():
|
||||
// poll_cycle() returns early without resolv, without pollable names
|
||||
// and on a failed query, while snoop keeps feeding queue_writes() in
|
||||
// all three cases.
|
||||
function tick() {
|
||||
for (let argv in state.route.add)
|
||||
quiet(argv);
|
||||
|
||||
poll_cycle();
|
||||
cache.prune(time());
|
||||
}
|
||||
|
||||
wq.timer = uloop.interval(WRITE_MIN * 1000, () => drain_writes(wq));
|
||||
|
||||
poll_cycle();
|
||||
uloop.interval(state.cfg.global.poll_interval * 1000, tick);
|
||||
|
||||
if (resolv && length(targets))
|
||||
log('info', sprintf('poll: %d name(s) every %ds', length(targets),
|
||||
state.cfg.global.poll_interval));
|
||||
|
||||
let c = ubus();
|
||||
|
||||
if (c) {
|
||||
let pending = null;
|
||||
|
||||
function rebuild_routes() {
|
||||
pending = null;
|
||||
|
||||
let resolved = netifd_resolve(state.cfg.policies, netifd_dump());
|
||||
let r = route_compile(resolved, state.nft.marks);
|
||||
|
||||
report('route', r.issues);
|
||||
|
||||
// A policy device may have just appeared - the boot-time case a
|
||||
// static sysctl.d file misses, since /proc/<dev> did not exist
|
||||
// yet. Re-apply so it is loose from the moment it comes up, then
|
||||
// re-check: with manage on the check reads the value just set
|
||||
// and stays silent, without it this is the moment to warn.
|
||||
if (state.cfg.global.rp_filter_manage)
|
||||
rp_filter_apply(resolved);
|
||||
|
||||
check_rp_filter(resolved);
|
||||
|
||||
for (let argv in state.route.del)
|
||||
quiet(argv);
|
||||
for (let argv in r.add)
|
||||
quiet(argv);
|
||||
|
||||
state.route = r;
|
||||
}
|
||||
|
||||
c.listener('network.interface', (type, msg) => {
|
||||
if (msg?.action != 'ifup' && msg?.action != 'ifdown')
|
||||
return;
|
||||
|
||||
log('info', sprintf('%s %s - rebuilding routes',
|
||||
msg.action, msg.interface ?? '?'));
|
||||
|
||||
if (pending)
|
||||
pending.set(500);
|
||||
else
|
||||
pending = uloop.timer(500, rebuild_routes);
|
||||
});
|
||||
|
||||
// Must be total: an exception in a ubus handler halts uloop and takes
|
||||
// snoop, poll and the keeper down with the reply.
|
||||
function status_reply() {
|
||||
let names = [];
|
||||
|
||||
for (let m in state.nft.marks)
|
||||
push(names, m.name);
|
||||
|
||||
return {
|
||||
started: stats.started,
|
||||
policies: names,
|
||||
poll: {
|
||||
resolv: stats.resolv,
|
||||
names: length(targets),
|
||||
interval: state.cfg.global.poll_interval
|
||||
},
|
||||
snoop: {
|
||||
devices: stats.snoop,
|
||||
matched: stats.matched,
|
||||
drops: stats.drops
|
||||
},
|
||||
dedupe: cache.size()
|
||||
};
|
||||
}
|
||||
|
||||
let obj = c.publish('shunt', { status: { call: () => status_reply() } });
|
||||
|
||||
if (!obj)
|
||||
log('warn', sprintf('cannot publish ubus object: %s',
|
||||
require('ubus').error() ?? 'unknown'));
|
||||
}
|
||||
|
||||
let socks = [];
|
||||
|
||||
if (state.cfg.global.snoop) {
|
||||
let socket = null;
|
||||
|
||||
for (let dev in state.cfg.global.snoop_devices) {
|
||||
let s = snoop_open(dev);
|
||||
|
||||
if (!s.ok) {
|
||||
log('err', sprintf('snoop %s: %s', dev, s.err));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (socket == null)
|
||||
socket = require('socket');
|
||||
|
||||
let sock = s.sock;
|
||||
|
||||
push(socks, sock);
|
||||
push(stats.snoop, dev);
|
||||
|
||||
// Each handler closes over its own socket; binding the loop
|
||||
// variable would leave them all reading the last one opened.
|
||||
uloop.handle(sock, () => {
|
||||
let frame;
|
||||
|
||||
while ((frame = sock.recv(RECV_LEN, socket.MSG_DONTWAIT)) != null) {
|
||||
let v = observe(frame, state.matcher);
|
||||
|
||||
if (v.drop != null) {
|
||||
stats.drops[v.drop] = (stats.drops[v.drop] ?? 0) + 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.matched++;
|
||||
|
||||
let writes = [];
|
||||
for (let policy in v.policies) {
|
||||
for (let a in v.a)
|
||||
push(writes, { set: `d4_${policy}`, addr: a });
|
||||
for (let a in v.aaaa)
|
||||
push(writes, { set: `d6_${policy}`, addr: a });
|
||||
}
|
||||
|
||||
debug(sprintf('snoop: %s -> %s (%d addr)',
|
||||
v.qname, join(', ', v.policies), length(writes)));
|
||||
queue_writes(wq, writes, time());
|
||||
}
|
||||
}, uloop.ULOOP_READ);
|
||||
}
|
||||
|
||||
if (length(socks))
|
||||
log('info', sprintf('snoop: listening on %s',
|
||||
join(', ', stats.snoop)));
|
||||
else if (!resolv || !length(targets)) {
|
||||
log('err', 'neither snoop nor poll available - nothing to do');
|
||||
flush(state);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
log('notice', sprintf('started: %d polic%s, mask 0x%08x',
|
||||
length(state.nft.marks), length(state.nft.marks) == 1 ? 'y' : 'ies',
|
||||
0xff000000));
|
||||
|
||||
uloop.run();
|
||||
|
||||
log('notice', 'stopping');
|
||||
for (let sock in socks)
|
||||
sock.close();
|
||||
flush(state);
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
function check() {
|
||||
verbose = true;
|
||||
|
||||
let state = build_state();
|
||||
if (!state)
|
||||
return 2;
|
||||
|
||||
printf('global: %.2J\n', state.cfg.global);
|
||||
printf('policies: %d accepted, %d mark(s)\n',
|
||||
length(state.cfg.policies), length(state.nft.marks));
|
||||
|
||||
for (let m in state.nft.marks)
|
||||
printf(' %-16s mark 0x%08x table %d pref %d\n',
|
||||
m.name, m.mark, m.rt_table, m.rt_prio);
|
||||
|
||||
let total = length(state.matcher.issues) + length(state.nft.issues) +
|
||||
length(state.route.issues);
|
||||
|
||||
printf('issues: %d (see above)\n', total);
|
||||
printf('poll: %d name(s)\n', length(poll_names(state.cfg.policies)));
|
||||
|
||||
return length(state.nft.marks) ? 0 : 2;
|
||||
}
|
||||
|
||||
let cmd = null;
|
||||
|
||||
for (let a in ARGV) {
|
||||
if (a == '-v')
|
||||
verbose = dbg = true;
|
||||
else if (cmd == null)
|
||||
cmd = a;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case 'run':
|
||||
exit(run());
|
||||
case 'check':
|
||||
exit(check());
|
||||
case 'flush':
|
||||
flush(build_state(true));
|
||||
exit(0);
|
||||
default:
|
||||
warn('usage: shunt [-v] run|check|flush\n');
|
||||
exit(2);
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
// shunt - configuration
|
||||
//
|
||||
// Turns UCI shaped sections into the structures the other modules consume.
|
||||
// load() reads UCI, parse() is pure and fed literals by the tests.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
export const DEFAULTS = {
|
||||
enabled: true,
|
||||
poll_interval: 300,
|
||||
entry_ttl: 1200,
|
||||
snoop: true,
|
||||
snoop_devices: [ 'br-lan' ],
|
||||
debug: false,
|
||||
rp_filter_manage: false
|
||||
};
|
||||
|
||||
export const MIN = {
|
||||
poll_interval: 30,
|
||||
entry_ttl: 60
|
||||
};
|
||||
|
||||
function to_bool(v, dflt) {
|
||||
if (v == null)
|
||||
return dflt;
|
||||
if (v === true || v === false)
|
||||
return v;
|
||||
if (v == '1' || v == 1)
|
||||
return true;
|
||||
if (v == '0' || v == 0)
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
function uniq(list) {
|
||||
let seen = {};
|
||||
let out = [];
|
||||
|
||||
for (let v in list) {
|
||||
if (!seen[v]) {
|
||||
seen[v] = true;
|
||||
push(out, v);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
function to_list(v) {
|
||||
if (v == null)
|
||||
return [];
|
||||
if (type(v) == 'array')
|
||||
return v;
|
||||
return [ v ];
|
||||
}
|
||||
|
||||
// require('uci') sits inside so the module stays importable without it, and
|
||||
// parse() stays pure for the tests.
|
||||
export function load() {
|
||||
let uci;
|
||||
|
||||
try {
|
||||
uci = require('uci');
|
||||
}
|
||||
catch (e) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let sections = [];
|
||||
|
||||
uci.cursor().foreach('shunt', null, (s) => {
|
||||
let values = {};
|
||||
|
||||
for (let k in s)
|
||||
if (substr(k, 0, 1) != '.')
|
||||
values[k] = s[k];
|
||||
|
||||
push(sections, { type: s['.type'], name: s['.name'], values });
|
||||
});
|
||||
|
||||
return sections;
|
||||
};
|
||||
|
||||
export function parse(sections) {
|
||||
let g = { ...DEFAULTS };
|
||||
let policies = [], issues = [];
|
||||
|
||||
function reject(section, option, reason) {
|
||||
push(issues, { section, option, reason });
|
||||
}
|
||||
|
||||
function num_opt(section, values, key) {
|
||||
let v = values[key];
|
||||
if (v == null)
|
||||
return;
|
||||
|
||||
let n = +v;
|
||||
if (type(v) == 'string' && match(v, /^[0-9]+$/) == null || n != n) {
|
||||
reject(section, key, sprintf('not a number: %J, default %d kept',
|
||||
v, g[key]));
|
||||
return;
|
||||
}
|
||||
if (n < MIN[key]) {
|
||||
reject(section, key, sprintf('%d below minimum, clamped to %d',
|
||||
n, MIN[key]));
|
||||
n = MIN[key];
|
||||
}
|
||||
g[key] = n;
|
||||
}
|
||||
|
||||
function bool_opt(section, values, key) {
|
||||
let b = to_bool(values[key], g[key]);
|
||||
if (b === null) {
|
||||
reject(section, key, sprintf('not a boolean: %J, default kept',
|
||||
values[key]));
|
||||
return;
|
||||
}
|
||||
g[key] = b;
|
||||
}
|
||||
|
||||
for (let s in (sections ?? [])) {
|
||||
if (s?.type == 'global') {
|
||||
let v = s.values ?? {};
|
||||
|
||||
bool_opt(s.name ?? 'global', v, 'enabled');
|
||||
bool_opt(s.name ?? 'global', v, 'snoop');
|
||||
bool_opt(s.name ?? 'global', v, 'debug');
|
||||
bool_opt(s.name ?? 'global', v, 'rp_filter_manage');
|
||||
num_opt(s.name ?? 'global', v, 'poll_interval');
|
||||
num_opt(s.name ?? 'global', v, 'entry_ttl');
|
||||
|
||||
if (v.snoop_device != null) {
|
||||
let devs = [];
|
||||
|
||||
for (let d in to_list(v.snoop_device)) {
|
||||
if (type(d) == 'string' && length(d))
|
||||
push(devs, d);
|
||||
else
|
||||
reject(s.name ?? 'global', 'snoop_device',
|
||||
sprintf('not a device name: %J, entry dropped', d));
|
||||
}
|
||||
|
||||
if (length(devs))
|
||||
g.snoop_devices = uniq(devs);
|
||||
else
|
||||
reject(s.name ?? 'global', 'snoop_device',
|
||||
'no usable device, default kept');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (s?.type != 'policy')
|
||||
continue;
|
||||
|
||||
let v = s.values ?? {};
|
||||
|
||||
if (to_bool(v.enabled, true) !== true)
|
||||
continue;
|
||||
|
||||
push(policies, {
|
||||
name: s.name,
|
||||
interface: v.interface,
|
||||
fallback: v.fallback,
|
||||
gw4: v.gw4,
|
||||
gw6: v.gw6,
|
||||
src: to_list(v.src),
|
||||
src_mac: to_list(v.src_mac),
|
||||
dport: to_list(v.dport),
|
||||
proto: to_list(v.proto),
|
||||
dst: to_list(v.dst),
|
||||
domains: to_list(v.domain)
|
||||
});
|
||||
}
|
||||
|
||||
return { global: g, policies, issues };
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
// shunt - write suppression
|
||||
//
|
||||
// Remembers which (set, address) pairs were written recently so a repeated
|
||||
// DNS answer does not rewrite an element that is still fresh.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
export function create(entry_ttl) {
|
||||
let last = {};
|
||||
|
||||
function due(set, addr, now) {
|
||||
let k = `${set}/${addr}`;
|
||||
let t = last[k];
|
||||
|
||||
if (t != null && (now - t) * 2 < entry_ttl)
|
||||
return false;
|
||||
|
||||
last[k] = now;
|
||||
return true;
|
||||
}
|
||||
|
||||
function prune(now) {
|
||||
let n = 0;
|
||||
|
||||
for (let k in last) {
|
||||
if (now - last[k] >= entry_ttl) {
|
||||
delete last[k];
|
||||
n++;
|
||||
}
|
||||
}
|
||||
|
||||
return n;
|
||||
}
|
||||
|
||||
function size() {
|
||||
return length(keys(last));
|
||||
}
|
||||
|
||||
return { due, prune, size };
|
||||
};
|
||||
@@ -1,249 +0,0 @@
|
||||
// shunt - DNS message parser
|
||||
//
|
||||
// Parses a response far enough to answer: which name was asked for, and
|
||||
// which A/AAAA addresses came back. Never trusts a length off the wire.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
// Hard limits: message size, name length, answers processed per message.
|
||||
export const LIM = {
|
||||
msg: 4096,
|
||||
labels: 63,
|
||||
name: 255,
|
||||
answers: 64
|
||||
};
|
||||
|
||||
export const TYPE = {
|
||||
A: 1,
|
||||
NS: 2,
|
||||
CNAME: 5,
|
||||
SOA: 6,
|
||||
TXT: 16,
|
||||
AAAA: 28,
|
||||
OPT: 41
|
||||
};
|
||||
|
||||
// Parser verdicts. These identifiers are contract - fixtures and the LuCI
|
||||
// labels compare them verbatim.
|
||||
export const ERR = {
|
||||
SHORT: 'E_SHORT',
|
||||
MSGLEN: 'E_MSGLEN',
|
||||
NOTRESP: 'E_NOTRESP',
|
||||
TRUNC: 'E_TRUNC',
|
||||
RCODE: 'E_RCODE',
|
||||
QDCOUNT: 'E_QDCOUNT',
|
||||
QPTR: 'E_QPTR',
|
||||
LABEL: 'E_LABEL',
|
||||
NAMELEN: 'E_NAMELEN',
|
||||
CHARSET: 'E_CHARSET',
|
||||
RDLEN: 'E_RDLEN',
|
||||
ANSMAX: 'E_ANSMAX'
|
||||
};
|
||||
|
||||
const HDR_LEN = 12;
|
||||
const RR_FIXED = 10;
|
||||
|
||||
const F_QR = 0x8000;
|
||||
const F_TC = 0x0200;
|
||||
const M_RCODE = 0x000f;
|
||||
|
||||
const LBL_MASK = 0xc0;
|
||||
const LBL_PTR = 0xc0;
|
||||
|
||||
function u16at(buf, off) {
|
||||
return (ord(buf, off) << 8) | ord(buf, off + 1);
|
||||
}
|
||||
|
||||
function fmt4(buf, off) {
|
||||
return sprintf('%d.%d.%d.%d',
|
||||
ord(buf, off), ord(buf, off + 1),
|
||||
ord(buf, off + 2), ord(buf, off + 3));
|
||||
}
|
||||
|
||||
function fmt6(buf, off) {
|
||||
let g = [];
|
||||
for (let i = 0; i < 8; i++)
|
||||
push(g, u16at(buf, off + i * 2));
|
||||
|
||||
let bs = -1, bl = 0, cs = -1, cl = 0;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
if (g[i] != 0) {
|
||||
cs = -1;
|
||||
cl = 0;
|
||||
continue;
|
||||
}
|
||||
if (cs < 0)
|
||||
cs = i;
|
||||
cl++;
|
||||
if (cl > bl) {
|
||||
bs = cs;
|
||||
bl = cl;
|
||||
}
|
||||
}
|
||||
|
||||
if (bl < 2) {
|
||||
bs = -1;
|
||||
bl = 0;
|
||||
}
|
||||
|
||||
let parts = [], i = 0;
|
||||
while (i < 8) {
|
||||
if (i == bs) {
|
||||
push(parts, '');
|
||||
i += bl;
|
||||
continue;
|
||||
}
|
||||
push(parts, sprintf('%x', g[i]));
|
||||
i++;
|
||||
}
|
||||
|
||||
let out = join(':', parts);
|
||||
|
||||
if (bs == 0)
|
||||
out = ':' + out;
|
||||
if (bs >= 0 && bs + bl == 8)
|
||||
out = out + ':';
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function decode_name(buf, off) {
|
||||
let blen = length(buf), labels = [], total = 1;
|
||||
|
||||
while (true) {
|
||||
if (off >= blen)
|
||||
return { err: ERR.SHORT };
|
||||
|
||||
let len = ord(buf, off);
|
||||
|
||||
if ((len & LBL_MASK) == LBL_PTR)
|
||||
return { err: ERR.QPTR };
|
||||
if (len & LBL_MASK)
|
||||
return { err: ERR.LABEL };
|
||||
|
||||
off++;
|
||||
if (!len)
|
||||
break;
|
||||
|
||||
total += len + 1;
|
||||
if (total > LIM.name)
|
||||
return { err: ERR.NAMELEN };
|
||||
if (off + len > blen)
|
||||
return { err: ERR.SHORT };
|
||||
|
||||
let lbl = lc(substr(buf, off, len));
|
||||
for (let i = 0; i < len; i++) {
|
||||
let c = ord(lbl, i);
|
||||
if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) ||
|
||||
c == 0x2d || c == 0x5f)
|
||||
continue;
|
||||
return { err: ERR.CHARSET };
|
||||
}
|
||||
|
||||
push(labels, lbl);
|
||||
off += len;
|
||||
}
|
||||
|
||||
return { name: join('.', labels), next: off };
|
||||
};
|
||||
|
||||
export function skip_name(buf, off) {
|
||||
let blen = length(buf);
|
||||
|
||||
while (true) {
|
||||
if (off >= blen)
|
||||
return null;
|
||||
|
||||
let len = ord(buf, off);
|
||||
|
||||
if ((len & LBL_MASK) == LBL_PTR)
|
||||
return (off + 2 <= blen) ? off + 2 : null;
|
||||
if (len & LBL_MASK)
|
||||
return null;
|
||||
|
||||
off++;
|
||||
if (!len)
|
||||
return off;
|
||||
|
||||
off += len;
|
||||
if (off > blen)
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function parse(buf) {
|
||||
let blen = length(buf ?? '');
|
||||
|
||||
if (blen > LIM.msg)
|
||||
return { ok: false, err: ERR.MSGLEN };
|
||||
if (blen < HDR_LEN)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let flags = u16at(buf, 2);
|
||||
|
||||
if (!(flags & F_QR))
|
||||
return { ok: false, err: ERR.NOTRESP };
|
||||
if (flags & F_TC)
|
||||
return { ok: false, err: ERR.TRUNC };
|
||||
if (flags & M_RCODE)
|
||||
return { ok: false, err: ERR.RCODE };
|
||||
|
||||
if (u16at(buf, 4) != 1)
|
||||
return { ok: false, err: ERR.QDCOUNT };
|
||||
|
||||
let ancount = u16at(buf, 6);
|
||||
if (ancount > LIM.answers)
|
||||
return { ok: false, err: ERR.ANSMAX };
|
||||
|
||||
let q = decode_name(buf, HDR_LEN);
|
||||
if (q.err)
|
||||
return { ok: false, err: q.err };
|
||||
|
||||
let off = q.next;
|
||||
if (off + 4 > blen)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let qtype = u16at(buf, off);
|
||||
off += 4;
|
||||
|
||||
let a = [], aaaa = [];
|
||||
|
||||
for (let i = 0; i < ancount; i++) {
|
||||
off = skip_name(buf, off);
|
||||
if (off === null)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
if (off + RR_FIXED > blen)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let rtype = u16at(buf, off);
|
||||
let rdlen = u16at(buf, off + 8);
|
||||
off += RR_FIXED;
|
||||
|
||||
if (off + rdlen > blen)
|
||||
return { ok: false, err: ERR.RDLEN };
|
||||
|
||||
if (rtype == TYPE.A) {
|
||||
if (rdlen != 4)
|
||||
return { ok: false, err: ERR.RDLEN };
|
||||
push(a, fmt4(buf, off));
|
||||
}
|
||||
else if (rtype == TYPE.AAAA) {
|
||||
if (rdlen != 16)
|
||||
return { ok: false, err: ERR.RDLEN };
|
||||
push(aaaa, fmt6(buf, off));
|
||||
}
|
||||
|
||||
off += rdlen;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
id: u16at(buf, 0),
|
||||
qname: q.name,
|
||||
qtype,
|
||||
a,
|
||||
aaaa
|
||||
};
|
||||
};
|
||||
@@ -1,154 +0,0 @@
|
||||
// shunt - link layer decoder
|
||||
//
|
||||
// Ethernet, optional VLAN tag, IPv4/IPv6, UDP - down to the DNS payload.
|
||||
// Rejects anything malformed rather than guessing.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
// How far to walk before giving up: stacked VLAN tags, IPv6 extension headers.
|
||||
export const LIM = {
|
||||
vlan: 3,
|
||||
ext: 8
|
||||
};
|
||||
|
||||
// Decoder verdicts, contract like the parser's.
|
||||
export const ERR = {
|
||||
SHORT: 'E_SHORT',
|
||||
ETHER: 'E_ETHER',
|
||||
VLAN: 'E_VLAN',
|
||||
IPLEN: 'E_IPLEN',
|
||||
FRAG: 'E_FRAG',
|
||||
EXTHDR: 'E_EXTHDR',
|
||||
PROTO: 'E_PROTO',
|
||||
UDPLEN: 'E_UDPLEN'
|
||||
};
|
||||
|
||||
const ETH_HDR = 14;
|
||||
const ETYPE_OFF = 12;
|
||||
const VLAN_TAG = 4;
|
||||
|
||||
const ET_IPV4 = 0x0800;
|
||||
const ET_IPV6 = 0x86dd;
|
||||
const ET_VLAN = 0x8100;
|
||||
const ET_QINQ = 0x88a8;
|
||||
|
||||
const IP4_MIN = 20;
|
||||
const IP6_HDR = 40;
|
||||
const EXT_MIN = 8;
|
||||
const UDP_HDR = 8;
|
||||
|
||||
const IP_UDP = 17;
|
||||
const IP_FRAG = 44;
|
||||
const IP_AH = 51;
|
||||
|
||||
const IP4_FRAG_MASK = 0x3fff;
|
||||
|
||||
function u16(buf, off) {
|
||||
return (ord(buf, off) << 8) | ord(buf, off + 1);
|
||||
}
|
||||
|
||||
function is_ext(proto) {
|
||||
return proto == 0 || proto == 43 || proto == 60 || proto == IP_AH;
|
||||
}
|
||||
|
||||
export function decap(buf) {
|
||||
let len = length(buf ?? '');
|
||||
|
||||
if (len < ETH_HDR)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let off = ETYPE_OFF, et = u16(buf, off), tags = 0;
|
||||
|
||||
while (et == ET_VLAN || et == ET_QINQ) {
|
||||
if (++tags > LIM.vlan)
|
||||
return { ok: false, err: ERR.VLAN };
|
||||
|
||||
off += VLAN_TAG;
|
||||
if (off + 2 > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
et = u16(buf, off);
|
||||
}
|
||||
|
||||
off += 2;
|
||||
|
||||
let af, proto;
|
||||
|
||||
if (et == ET_IPV4) {
|
||||
if (off + IP4_MIN > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let ihl = (ord(buf, off) & 0x0f) * 4;
|
||||
if (ihl < IP4_MIN)
|
||||
return { ok: false, err: ERR.IPLEN };
|
||||
if (off + ihl > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let tot = u16(buf, off + 2);
|
||||
if (tot < ihl)
|
||||
return { ok: false, err: ERR.IPLEN };
|
||||
if (off + tot > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
len = off + tot;
|
||||
|
||||
if (u16(buf, off + 6) & IP4_FRAG_MASK)
|
||||
return { ok: false, err: ERR.FRAG };
|
||||
|
||||
proto = ord(buf, off + 9);
|
||||
af = 4;
|
||||
off += ihl;
|
||||
}
|
||||
else if (et == ET_IPV6) {
|
||||
if (off + IP6_HDR > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let plen = u16(buf, off + 4);
|
||||
if (off + IP6_HDR + plen > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
len = off + IP6_HDR + plen;
|
||||
|
||||
proto = ord(buf, off + 6);
|
||||
af = 6;
|
||||
off += IP6_HDR;
|
||||
|
||||
for (let i = 0; i < LIM.ext && is_ext(proto); i++) {
|
||||
if (off + EXT_MIN > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let hlen = (proto == IP_AH)
|
||||
? (ord(buf, off + 1) + 2) * 4
|
||||
: (ord(buf, off + 1) + 1) * 8;
|
||||
|
||||
proto = ord(buf, off);
|
||||
off += hlen;
|
||||
}
|
||||
|
||||
if (proto == IP_FRAG)
|
||||
return { ok: false, err: ERR.FRAG };
|
||||
if (is_ext(proto))
|
||||
return { ok: false, err: ERR.EXTHDR };
|
||||
}
|
||||
else
|
||||
return { ok: false, err: ERR.ETHER };
|
||||
|
||||
if (proto != IP_UDP)
|
||||
return { ok: false, err: ERR.PROTO };
|
||||
if (off + UDP_HDR > len)
|
||||
return { ok: false, err: ERR.SHORT };
|
||||
|
||||
let sport = u16(buf, off);
|
||||
let ulen = u16(buf, off + 4);
|
||||
|
||||
if (ulen < UDP_HDR || off + ulen > len)
|
||||
return { ok: false, err: ERR.UDPLEN };
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
af,
|
||||
sport,
|
||||
payload: substr(buf, off + UDP_HDR, ulen - UDP_HDR)
|
||||
};
|
||||
};
|
||||
@@ -1,129 +0,0 @@
|
||||
// shunt - domain matcher
|
||||
//
|
||||
// Compiles the policies' domain patterns into an exact and a wildcard map
|
||||
// and answers which policies claim a queried name.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
export const LIM = {
|
||||
name: 253,
|
||||
label: 63
|
||||
};
|
||||
|
||||
function ok_label(s) {
|
||||
if (!length(s) || length(s) > LIM.label)
|
||||
return false;
|
||||
|
||||
for (let i = 0; i < length(s); i++) {
|
||||
let c = ord(s, i);
|
||||
if ((c >= 0x61 && c <= 0x7a) || (c >= 0x30 && c <= 0x39) ||
|
||||
c == 0x2d || c == 0x5f)
|
||||
continue;
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function normalize(s) {
|
||||
s = lc(trim(s ?? ''));
|
||||
|
||||
while (length(s) && substr(s, -1) == '.')
|
||||
s = substr(s, 0, length(s) - 1);
|
||||
|
||||
return s;
|
||||
};
|
||||
|
||||
function validate(name) {
|
||||
if (!length(name))
|
||||
return 'empty';
|
||||
if (length(name) > LIM.name)
|
||||
return 'too long';
|
||||
|
||||
for (let l in split(name, '.'))
|
||||
if (!ok_label(l))
|
||||
return `bad label '${l}'`;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function compile(policies) {
|
||||
let exact = {}, wild = {}, issues = [];
|
||||
|
||||
function reject(policy, pattern, reason) {
|
||||
push(issues, { policy, pattern, reason });
|
||||
}
|
||||
|
||||
for (let pi = 0; pi < length(policies ?? []); pi++) {
|
||||
let p = policies[pi];
|
||||
let pname = p?.name ?? `#${pi}`;
|
||||
|
||||
for (let raw in (p?.domains ?? [])) {
|
||||
let pat = normalize(raw);
|
||||
let is_wild = false;
|
||||
|
||||
if (substr(pat, 0, 2) == '*.') {
|
||||
is_wild = true;
|
||||
pat = substr(pat, 2);
|
||||
}
|
||||
|
||||
if (index(pat, '*') >= 0) {
|
||||
reject(pname, raw, 'wildcard only allowed as leading *. label');
|
||||
continue;
|
||||
}
|
||||
|
||||
let bad = validate(pat);
|
||||
if (bad) {
|
||||
reject(pname, raw, bad);
|
||||
continue;
|
||||
}
|
||||
|
||||
let map = is_wild ? wild : exact;
|
||||
|
||||
if (!map[pat])
|
||||
map[pat] = [];
|
||||
|
||||
let dup = false;
|
||||
|
||||
for (let owner in map[pat])
|
||||
if (owner == pname)
|
||||
dup = true;
|
||||
|
||||
if (!dup)
|
||||
push(map[pat], pname);
|
||||
}
|
||||
}
|
||||
|
||||
// A list even for one element - a caller that has to distinguish shapes
|
||||
// gets it wrong exactly once, in the rare case, in production.
|
||||
function test(qname) {
|
||||
let q = normalize(qname);
|
||||
|
||||
if (!length(q) || length(q) > LIM.name)
|
||||
return null;
|
||||
|
||||
if (exists(exact, q))
|
||||
return exact[q];
|
||||
|
||||
let off = index(q, '.');
|
||||
|
||||
while (off >= 0) {
|
||||
let sfx = substr(q, off + 1);
|
||||
|
||||
if (exists(wild, sfx))
|
||||
return wild[sfx];
|
||||
|
||||
let nxt = index(sfx, '.');
|
||||
off = (nxt < 0) ? -1 : off + 1 + nxt;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
test,
|
||||
issues,
|
||||
size: { exact: length(keys(exact)), wild: length(keys(wild)) }
|
||||
};
|
||||
};
|
||||
@@ -1,64 +0,0 @@
|
||||
// shunt - interface resolution
|
||||
//
|
||||
// Turns a configured interface name into its device and gateways from a
|
||||
// netifd dump. Explicit config values always win.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
function nexthop(entry, fam) {
|
||||
let dflt = (fam == 4) ? '0.0.0.0' : '::';
|
||||
|
||||
for (let r in (entry?.route ?? []))
|
||||
if (r?.target == dflt && r?.mask == 0 && length(r?.nexthop ?? ''))
|
||||
return r.nexthop;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function resolve(policies, dump) {
|
||||
let entries = dump?.interface ?? [];
|
||||
let out = [];
|
||||
|
||||
for (let p in (policies ?? [])) {
|
||||
let device = null;
|
||||
|
||||
for (let e in entries) {
|
||||
if (e?.interface == p?.interface && length(e?.l3_device ?? '')) {
|
||||
device = e.l3_device;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (device == null)
|
||||
for (let e in entries)
|
||||
if (e?.l3_device == p?.interface) {
|
||||
device = p.interface;
|
||||
break;
|
||||
}
|
||||
|
||||
if (device == null) {
|
||||
push(out, p);
|
||||
continue;
|
||||
}
|
||||
|
||||
let gw4 = null, gw6 = null;
|
||||
|
||||
for (let e in entries) {
|
||||
if (e?.l3_device != device)
|
||||
continue;
|
||||
|
||||
gw4 ??= nexthop(e, 4);
|
||||
gw6 ??= nexthop(e, 6);
|
||||
}
|
||||
|
||||
push(out, {
|
||||
...p,
|
||||
interface: device,
|
||||
gw4: p.gw4 ?? gw4,
|
||||
gw6: p.gw6 ?? gw6
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
@@ -1,372 +0,0 @@
|
||||
// shunt - nftables renderer
|
||||
//
|
||||
// Renders the whole ruleset: one table, two chains, per policy sets and
|
||||
// marks. Pure string building, no kernel access.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
export const TABLE = 'inet shunt';
|
||||
|
||||
// The mask must stay a contiguous block: shift, capacity and mark are all
|
||||
// derived from it. That is why it is a constant and not a UCI option.
|
||||
export const DEFAULTS = {
|
||||
mask: 0xff000000,
|
||||
entry_ttl: 1200
|
||||
};
|
||||
|
||||
const RE_NAME = /^[A-Za-z0-9_]{1,24}$/;
|
||||
const RE_V4 = /^([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})\.([0-9]{1,3})(\/([0-9]{1,2}))?$/;
|
||||
const RE_V6 = /^[0-9A-Fa-f:]{2,45}(\/([0-9]{1,3}))?$/;
|
||||
|
||||
export function set_name(kind, family, policy) {
|
||||
return `${kind}${family}_${policy}`;
|
||||
};
|
||||
|
||||
function valid_name(s) {
|
||||
return type(s) == 'string' && match(s, RE_NAME) != null;
|
||||
}
|
||||
|
||||
export function mac_addr(s) {
|
||||
if (type(s) != 'string')
|
||||
return null;
|
||||
|
||||
let m = trim(lc(s));
|
||||
|
||||
return match(m, /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/) ? m : null;
|
||||
};
|
||||
|
||||
// A destination port or an inclusive range, normalised to nft syntax. Ports
|
||||
// are 1-65535; 0 is reserved and never a destination.
|
||||
export function port_spec(s) {
|
||||
let v = trim(`${s ?? ''}`);
|
||||
let m = match(v, /^([0-9]{1,5})(-([0-9]{1,5}))?$/);
|
||||
|
||||
if (!m)
|
||||
return null;
|
||||
|
||||
let lo = +m[1];
|
||||
let hi = m[3] != null ? +m[3] : lo;
|
||||
|
||||
if (lo < 1 || hi > 65535 || lo > hi)
|
||||
return null;
|
||||
|
||||
return lo == hi ? `${lo}` : `${lo}-${hi}`;
|
||||
};
|
||||
|
||||
// tcp or udp only - nothing else carries a destination port, and naming a
|
||||
// protocol that cannot be filtered by port is a configuration error worth
|
||||
// reporting rather than silently rendering.
|
||||
export function proto_name(s) {
|
||||
let v = lc(trim(`${s ?? ''}`));
|
||||
|
||||
return (v == 'tcp' || v == 'udp') ? v : null;
|
||||
};
|
||||
|
||||
export function addr_family(s) {
|
||||
if (type(s) != 'string')
|
||||
return null;
|
||||
|
||||
let m = match(s, RE_V4);
|
||||
if (m) {
|
||||
for (let i = 1; i <= 4; i++)
|
||||
if (+m[i] > 255)
|
||||
return null;
|
||||
if (m[6] != null && +m[6] > 32)
|
||||
return null;
|
||||
return 4;
|
||||
}
|
||||
|
||||
m = match(s, RE_V6);
|
||||
if (m) {
|
||||
let body = split(s, '/')[0];
|
||||
if (m[2] != null && +m[2] > 128)
|
||||
return null;
|
||||
if (index(body, ':::') >= 0)
|
||||
return null;
|
||||
if (length(split(body, '::')) > 2)
|
||||
return null;
|
||||
if (substr(body, 0, 1) == ':' && substr(body, 0, 2) != '::')
|
||||
return null;
|
||||
if (substr(body, -1) == ':' && substr(body, -2) != '::')
|
||||
return null;
|
||||
let groups = filter(split(body, ':'), (g) => g != '');
|
||||
if (length(groups) > 8 || (length(groups) == 8 && index(body, '::') >= 0))
|
||||
return null;
|
||||
for (let g in groups)
|
||||
if (length(g) > 4 || !match(g, /^[0-9A-Fa-f]+$/))
|
||||
return null;
|
||||
if (index(body, '::') < 0 && length(groups) != 8)
|
||||
return null;
|
||||
return 6;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
function mask_shift(mask) {
|
||||
let n = 0;
|
||||
while (n < 32 && !((mask >> n) & 1))
|
||||
n++;
|
||||
return n;
|
||||
}
|
||||
|
||||
export function compile(policies, opts) {
|
||||
let mask = opts?.mask ?? DEFAULTS.mask;
|
||||
let shift = mask_shift(mask);
|
||||
let capacity = mask >> shift;
|
||||
// Rule records, not strings: a MAC rule belongs in prerouting only, and
|
||||
// both chains must render from one ordered list or precedence breaks.
|
||||
let issues = [], marks = [], sets = [], rules4 = [], rules6 = [];
|
||||
let idx = 0;
|
||||
let learn = {};
|
||||
|
||||
function reject(policy, entry, reason) {
|
||||
push(issues, { policy, entry, reason });
|
||||
}
|
||||
|
||||
for (let pi = 0; pi < length(policies ?? []); pi++) {
|
||||
let p = policies[pi];
|
||||
let pname = p?.name;
|
||||
|
||||
if (!valid_name(pname)) {
|
||||
reject(pname ?? `#${pi}`, null,
|
||||
'invalid policy name - must match [A-Za-z0-9_]{1,24}');
|
||||
continue;
|
||||
}
|
||||
|
||||
let src = { '4': [], '6': [] }, dst = { '4': [], '6': [] };
|
||||
|
||||
for (let a in (p.src ?? [])) {
|
||||
let fam = addr_family(a);
|
||||
if (fam)
|
||||
push(src[sprintf('%d', fam)], a);
|
||||
else
|
||||
reject(pname, a, 'invalid src address');
|
||||
}
|
||||
|
||||
for (let a in (p.dst ?? [])) {
|
||||
let fam = addr_family(a);
|
||||
if (fam)
|
||||
push(dst[sprintf('%d', fam)], a);
|
||||
else
|
||||
reject(pname, a, 'invalid dst address');
|
||||
}
|
||||
|
||||
let macs = [];
|
||||
|
||||
for (let a in (p.src_mac ?? [])) {
|
||||
let m = mac_addr(a);
|
||||
if (m)
|
||||
push(macs, m);
|
||||
else
|
||||
reject(pname, a, 'invalid src_mac address');
|
||||
}
|
||||
|
||||
let ports = [], protos = [];
|
||||
|
||||
for (let v in (p.dport ?? [])) {
|
||||
let q = port_spec(v);
|
||||
if (q)
|
||||
push(ports, q);
|
||||
else
|
||||
reject(pname, v, 'invalid dport - expected 1-65535 or a range');
|
||||
}
|
||||
|
||||
for (let v in (p.proto ?? [])) {
|
||||
let q = proto_name(v);
|
||||
if (q)
|
||||
push(protos, q);
|
||||
else
|
||||
reject(pname, v, 'invalid proto - only tcp and udp carry ports');
|
||||
}
|
||||
|
||||
// A port with no protocol means both, as banIP does it: "port 443 of
|
||||
// this client" almost always includes QUIC, and requiring the
|
||||
// protocol would let it slip through unnoticed.
|
||||
if (length(ports) && !length(protos))
|
||||
protos = [ 'tcp', 'udp' ];
|
||||
|
||||
let has_dom = length(p.domains ?? []) > 0;
|
||||
let has_dst_any = length(dst['4']) || length(dst['6']);
|
||||
|
||||
// Ports and protocols were asked for and none survived validation.
|
||||
// Rendering the policy anyway would drop the narrowing and mark
|
||||
// everything the client sends - the same widening a mistyped client
|
||||
// selector gets refused for.
|
||||
if (length(p.dport ?? []) + length(p.proto ?? []) > 0 &&
|
||||
!length(ports) && !length(protos)) {
|
||||
reject(pname, null,
|
||||
'no usable port or protocol - policy skipped rather than widened to all traffic');
|
||||
continue;
|
||||
}
|
||||
let has_ipsrc = length(src['4']) || length(src['6']);
|
||||
let has_mac = length(macs) > 0;
|
||||
let has_src = has_ipsrc || has_mac;
|
||||
let has_any = has_src || length(dst['4']) || length(dst['6']) || has_dom;
|
||||
|
||||
if (!has_any) {
|
||||
reject(pname, null, 'policy selects nothing');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (length(p.src ?? []) + length(p.src_mac ?? []) > 0 && !has_src) {
|
||||
reject(pname, null,
|
||||
'no usable client selector - policy skipped rather than widened to every client');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (++idx > capacity) {
|
||||
reject(pname, null,
|
||||
sprintf('mark capacity exceeded (%d policies fit in mask 0x%08x)',
|
||||
capacity, mask));
|
||||
continue;
|
||||
}
|
||||
|
||||
let mark = idx << shift;
|
||||
// One transport term for all three rule shapes. `th dport` reads the
|
||||
// port at the transport header offset, which works for tcp and udp
|
||||
// alike, so a port without a protocol needs no rule per protocol.
|
||||
let l4 = '';
|
||||
|
||||
if (length(protos))
|
||||
l4 = length(protos) == 1
|
||||
? sprintf('meta l4proto %s ', protos[0])
|
||||
: sprintf('meta l4proto { %s } ', join(', ', protos));
|
||||
|
||||
if (length(ports))
|
||||
l4 += length(ports) == 1
|
||||
? sprintf('th dport %s ', ports[0])
|
||||
: sprintf('th dport { %s } ', join(', ', ports));
|
||||
|
||||
let stmt = sprintf('%smeta mark set (meta mark & 0x%08x) | 0x%08x counter return',
|
||||
l4, ~mask & 0xffffffff, mark);
|
||||
|
||||
push(marks, { name: pname, index: idx, mark,
|
||||
rt_table: 8000 + idx, rt_prio: 31000 + idx });
|
||||
|
||||
if (has_mac)
|
||||
push(sets, sprintf(
|
||||
'\tset %s { type ether_addr; counter; elements = { %s }; }',
|
||||
set_name('m', '', pname), join(', ', macs)));
|
||||
|
||||
for (let fam in [ '4', '6' ]) {
|
||||
let ip = (fam == '4') ? 'ip' : 'ip6';
|
||||
let rules = (fam == '4') ? rules4 : rules6;
|
||||
let atype = (fam == '4') ? 'ipv4_addr' : 'ipv6_addr';
|
||||
|
||||
if (has_dom)
|
||||
push(sets, sprintf(
|
||||
'\tset %s { type %s; flags timeout; counter; }',
|
||||
set_name('d', fam, pname), atype));
|
||||
|
||||
let prefixes = [];
|
||||
|
||||
if (length(src[fam]))
|
||||
prefixes = [ ...prefixes, {
|
||||
pre: sprintf('%s saddr @%s ', ip, set_name('c', fam, pname)),
|
||||
out: true, per_family: true
|
||||
} ];
|
||||
|
||||
if (has_mac)
|
||||
prefixes = [ ...prefixes, {
|
||||
pre: sprintf('ether saddr @%s ', set_name('m', '', pname)),
|
||||
out: false, per_family: false
|
||||
} ];
|
||||
|
||||
if (!has_src)
|
||||
prefixes = [ { pre: '', out: true, per_family: false } ];
|
||||
|
||||
if (!length(prefixes)) {
|
||||
if (length(dst[fam]) || has_dom)
|
||||
reject(pname, null, sprintf(
|
||||
'src has no v%s entry - v%s rules skipped to avoid over-marking',
|
||||
fam, fam));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (length(src[fam]))
|
||||
push(sets, sprintf(
|
||||
'\tset %s { type %s; flags interval; counter; elements = { %s }; }',
|
||||
set_name('c', fam, pname), atype,
|
||||
join(', ', src[fam])));
|
||||
|
||||
if (length(dst[fam]))
|
||||
push(sets, sprintf(
|
||||
'\tset %s { type %s; flags interval; counter; elements = { %s }; }',
|
||||
set_name('s', fam, pname), atype,
|
||||
join(', ', dst[fam])));
|
||||
|
||||
for (let px in prefixes)
|
||||
if (length(dst[fam]))
|
||||
push(rules, { out: px.out,
|
||||
text: sprintf('\t\t%s%s daddr @%s %s',
|
||||
px.pre, ip, set_name('s', fam, pname), stmt) });
|
||||
|
||||
if (has_dom)
|
||||
learn[set_name('d', fam, pname)] = true;
|
||||
|
||||
// Destinations exist, but all in the other family: nothing for
|
||||
// this one to route. A port or protocol alone does not have a
|
||||
// family, so it does not trigger this.
|
||||
if (!length(dst[fam]) && !has_dom && has_dst_any)
|
||||
continue;
|
||||
|
||||
for (let px in prefixes) {
|
||||
if (has_dom)
|
||||
push(rules, { out: px.out,
|
||||
text: sprintf('\t\t%s%s daddr @%s %s',
|
||||
px.pre, ip, set_name('d', fam, pname), stmt) });
|
||||
|
||||
if (!has_dst_any && !has_dom && (px.per_family || fam == '4'))
|
||||
push(rules, { out: px.out,
|
||||
text: sprintf('\t\t%s%s', px.pre, stmt) });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let setup = join('\n', [
|
||||
`destroy table ${TABLE}`,
|
||||
`table ${TABLE} {`,
|
||||
...sets,
|
||||
'\tchain prerouting {',
|
||||
'\t\ttype filter hook prerouting priority mangle; policy accept;',
|
||||
...map(rules4, (r) => r.text), ...map(rules6, (r) => r.text),
|
||||
'\t}',
|
||||
'\tchain output {',
|
||||
'\t\ttype route hook output priority mangle; policy accept;',
|
||||
...map(filter(rules4, (r) => r.out), (r) => r.text),
|
||||
...map(filter(rules6, (r) => r.out), (r) => r.text),
|
||||
'\t}',
|
||||
'}',
|
||||
''
|
||||
]);
|
||||
|
||||
return { setup, marks, issues, learn };
|
||||
};
|
||||
|
||||
export function refresh(writes, entry_ttl) {
|
||||
let ttl = entry_ttl ?? DEFAULTS.entry_ttl;
|
||||
let out = [], issues = [];
|
||||
|
||||
for (let w in (writes ?? [])) {
|
||||
let m = match(w?.set ?? '', /^[csd]([46])_[A-Za-z0-9_]{1,24}$/);
|
||||
let fam = addr_family(w?.addr ?? '');
|
||||
|
||||
if (!m || fam == null || sprintf('%d', fam) != m[1] ||
|
||||
index(w.addr, '/') >= 0) {
|
||||
push(issues, { entry: w, reason: 'rejected, not rendered' });
|
||||
continue;
|
||||
}
|
||||
|
||||
push(out, sprintf('destroy element %s %s { %s }', TABLE, w.set, w.addr));
|
||||
push(out, sprintf('add element %s %s { %s timeout %ds }',
|
||||
TABLE, w.set, w.addr, ttl));
|
||||
}
|
||||
|
||||
return { batch: length(out) ? join('\n', out) + '\n' : '', issues };
|
||||
};
|
||||
|
||||
export function teardown() {
|
||||
return `destroy table ${TABLE}\n`;
|
||||
};
|
||||
@@ -1,98 +0,0 @@
|
||||
// shunt - active resolution
|
||||
//
|
||||
// Collects the resolvable names from the policies and turns query results
|
||||
// into set writes, following CNAME chains from the queried name.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
import { normalize } from 'shunt.match';
|
||||
import { set_name } from 'shunt.nft';
|
||||
|
||||
export function names(policies) {
|
||||
let seen = {}, out = [];
|
||||
|
||||
for (let p in (policies ?? [])) {
|
||||
for (let raw in (p?.domains ?? [])) {
|
||||
let n = normalize(raw);
|
||||
|
||||
if (!length(n) || index(n, '*') >= 0)
|
||||
continue;
|
||||
if (seen[n])
|
||||
continue;
|
||||
|
||||
seen[n] = true;
|
||||
push(out, n);
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
};
|
||||
|
||||
const CHAIN_MAX = 8;
|
||||
|
||||
// resolv keys records by their own owner name, so a CNAME answer hides the
|
||||
// address under the canonical name. Walk from the name that was asked for.
|
||||
export function addresses(by_name, name) {
|
||||
let seen = {};
|
||||
let cur = normalize(name);
|
||||
let a = [], aaaa = [];
|
||||
|
||||
for (let hop = 0; hop < CHAIN_MAX; hop++) {
|
||||
if (!length(cur ?? '') || seen[cur])
|
||||
break;
|
||||
|
||||
seen[cur] = true;
|
||||
|
||||
let e = by_name[cur];
|
||||
if (!e)
|
||||
break;
|
||||
|
||||
for (let v in (e.A ?? []))
|
||||
push(a, v);
|
||||
for (let v in (e.AAAA ?? []))
|
||||
push(aaaa, v);
|
||||
|
||||
cur = normalize((e.CNAME ?? [])[0] ?? '');
|
||||
}
|
||||
|
||||
return { a, aaaa };
|
||||
};
|
||||
|
||||
export function index_results(results) {
|
||||
let by = {};
|
||||
|
||||
for (let k in (results ?? {})) {
|
||||
let n = normalize(k);
|
||||
|
||||
if (length(n))
|
||||
by[n] = results[k];
|
||||
}
|
||||
|
||||
return by;
|
||||
};
|
||||
|
||||
export function plan(results, matcher, names) {
|
||||
let writes = [];
|
||||
let by = index_results(results);
|
||||
|
||||
for (let raw in (names ?? [])) {
|
||||
let name = normalize(raw);
|
||||
|
||||
let policies = matcher.test(name);
|
||||
if (policies == null)
|
||||
continue;
|
||||
|
||||
let got = addresses(by, name);
|
||||
|
||||
for (let policy in policies) {
|
||||
for (let a in got.a)
|
||||
push(writes, { set: set_name('d', 4, policy), addr: a });
|
||||
|
||||
for (let a in got.aaaa)
|
||||
push(writes, { set: set_name('d', 6, policy), addr: a });
|
||||
}
|
||||
}
|
||||
|
||||
return writes;
|
||||
};
|
||||
@@ -1,97 +0,0 @@
|
||||
// shunt - ip rule and route renderer
|
||||
//
|
||||
// Renders the argv arrays for the policy routing tables and their rules.
|
||||
// Pure, like nft.uc - nothing here talks to the kernel.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
import { addr_family, DEFAULTS } from 'shunt.nft';
|
||||
|
||||
const RE_IFACE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,14}$/;
|
||||
|
||||
const BLACKHOLE_METRIC = 9999;
|
||||
|
||||
export function compile(policies, marks, opts) {
|
||||
let mask = opts?.mask ?? DEFAULTS.mask;
|
||||
let add = [], del = [], tables = [], issues = [];
|
||||
|
||||
let by_name = {};
|
||||
for (let m in (marks ?? []))
|
||||
by_name[m.name] = m;
|
||||
|
||||
function reject(policy, entry, reason) {
|
||||
push(issues, { policy, entry, reason });
|
||||
}
|
||||
|
||||
for (let p in (policies ?? [])) {
|
||||
let m = by_name[p?.name];
|
||||
if (!m)
|
||||
continue;
|
||||
|
||||
let iface = p.interface;
|
||||
if (type(iface) != 'string' || match(iface, RE_IFACE) == null) {
|
||||
reject(p.name, iface, 'invalid or missing interface');
|
||||
continue;
|
||||
}
|
||||
|
||||
let fb = p.fallback ?? 'main';
|
||||
if (fb != 'main' && fb != 'block') {
|
||||
reject(p.name, p.fallback, "fallback must be 'main' or 'block'");
|
||||
continue;
|
||||
}
|
||||
|
||||
let gw = { '4': null, '6': null };
|
||||
let gw_bad = false;
|
||||
|
||||
for (let fam in [ '4', '6' ]) {
|
||||
let g = p[`gw${fam}`];
|
||||
if (g == null)
|
||||
continue;
|
||||
if (sprintf('%d', addr_family(g)) == fam && index(g, '/') < 0)
|
||||
gw[fam] = g;
|
||||
else {
|
||||
reject(p.name, g, `invalid gw${fam}`);
|
||||
gw_bad = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (gw_bad)
|
||||
continue;
|
||||
|
||||
let fwmark = sprintf('0x%x/0x%x', m.mark, mask);
|
||||
let table = sprintf('%d', m.rt_table);
|
||||
let pref = sprintf('%d', m.rt_prio);
|
||||
|
||||
push(tables, sprintf('%d\tshunt_%s', m.rt_table, m.name));
|
||||
|
||||
for (let fam in [ '4', '6' ]) {
|
||||
let v = `-${fam}`;
|
||||
|
||||
let route = [ 'ip', v, 'route', 'replace', 'default' ];
|
||||
if (gw[fam])
|
||||
push(route, 'via', gw[fam]);
|
||||
push(route, 'dev', iface, 'table', table);
|
||||
push(add, route);
|
||||
|
||||
if (fb == 'block')
|
||||
push(add, [ 'ip', v, 'route', 'replace', 'blackhole',
|
||||
'default', 'metric',
|
||||
sprintf('%d', BLACKHOLE_METRIC),
|
||||
'table', table ]);
|
||||
|
||||
push(add, [ 'ip', v, 'rule', 'add', 'pref', pref,
|
||||
'fwmark', fwmark, 'lookup', table ]);
|
||||
|
||||
unshift(del, [ 'ip', v, 'route', 'flush', 'table', table ]);
|
||||
unshift(del, [ 'ip', v, 'rule', 'del', 'pref', pref ]);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
add,
|
||||
del,
|
||||
rt_tables: length(tables) ? join('\n', tables) + '\n' : '',
|
||||
issues
|
||||
};
|
||||
};
|
||||
@@ -1,75 +0,0 @@
|
||||
// shunt - passive DNS observer
|
||||
//
|
||||
// Opens an AF_PACKET socket with a BPF filter on DNS answers and turns a
|
||||
// captured frame into a verdict: which policies want it, or why not.
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken <dev@brenken.org>
|
||||
|
||||
import { decap } from 'shunt.frame';
|
||||
import { parse, TYPE } from 'shunt.dns';
|
||||
|
||||
export const RECV_LEN = 4160;
|
||||
|
||||
export function open(dev) {
|
||||
let sock, bpf;
|
||||
|
||||
try {
|
||||
sock = require('socket');
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false, err: 'socket module missing - install ucode-mod-socket' };
|
||||
}
|
||||
|
||||
try {
|
||||
bpf = require('shunt.snoop_bpf').BPF;
|
||||
}
|
||||
catch (e) {
|
||||
return { ok: false,
|
||||
err: 'shunt.snoop_bpf missing - reinstall the shunt package' };
|
||||
}
|
||||
|
||||
let s = sock.create(sock.AF_PACKET, sock.SOCK_RAW, 0);
|
||||
if (!s)
|
||||
return { ok: false, err: `create: ${sock.error()}` };
|
||||
|
||||
if (!s.setopt(sock.SOL_SOCKET, sock.SO_ATTACH_FILTER,
|
||||
{ len: length(bpf), filter: bpf })) {
|
||||
let err = `SO_ATTACH_FILTER: ${sock.error()}`;
|
||||
s.close();
|
||||
return { ok: false, err };
|
||||
}
|
||||
|
||||
if (!s.bind({ family: sock.AF_PACKET, interface: dev,
|
||||
protocol: 0x0003, address: '00:00:00:00:00:00' })) {
|
||||
let err = `bind: ${sock.error()}`;
|
||||
s.close();
|
||||
return { ok: false, err };
|
||||
}
|
||||
|
||||
return { ok: true, sock: s };
|
||||
};
|
||||
|
||||
// Returns { policies, qname, a, aaaa } or { drop: <verdict> }. The verdict
|
||||
// strings are contract; the fixtures compare them verbatim.
|
||||
export function observe(frame, matcher) {
|
||||
let f = decap(frame);
|
||||
if (!f.ok)
|
||||
return { drop: `frame:${f.err}` };
|
||||
|
||||
let r = parse(f.payload);
|
||||
if (!r.ok)
|
||||
return { drop: `dns:${r.err}` };
|
||||
|
||||
if (r.qtype != TYPE.A && r.qtype != TYPE.AAAA)
|
||||
return { drop: 'qtype' };
|
||||
|
||||
if (!length(r.a) && !length(r.aaaa))
|
||||
return { drop: 'noaddr' };
|
||||
|
||||
let policies = matcher.test(r.qname);
|
||||
if (policies == null)
|
||||
return { drop: 'nomatch' };
|
||||
|
||||
return { policies, qname: r.qname, a: r.a, aaaa: r.aaaa };
|
||||
};
|
||||
@@ -1,82 +0,0 @@
|
||||
// shunt - BPF program for the snoop socket
|
||||
//
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Copyright (c) 2026 Dirk Brenken (dev@brenken.org)
|
||||
//
|
||||
// GENERATED - do not edit.
|
||||
//
|
||||
// Expression: udp src port 53 or (vlan and udp src port 53)
|
||||
// Link type: EN10MB (br-lan)
|
||||
// Instructions: 52
|
||||
// tcpdump version 4.99.6
|
||||
// libpcap version 1.10.6 (64-bit time_t, with TPACKET_V3)
|
||||
// 64-bit build, 64-bit time_t
|
||||
//
|
||||
// Take it whole. The vlan primitive prefixes `ld #0; st M[0];
|
||||
// st M[1]` and the later branches read those scratch slots, so
|
||||
// dropping the preamble or splicing the two halves breaks the
|
||||
// tagged path silently. Without `vlan` the program would start
|
||||
// at `ldh [12]` and be 16 instructions instead of 52.
|
||||
//
|
||||
// Return style, not export style, and that is load bearing:
|
||||
// snoop.uc loads this with require() at open() time so the
|
||||
// module itself stays loadable without the constant, and
|
||||
// require() only accepts return style - export syntax fails to
|
||||
// compile outside an import.
|
||||
|
||||
return {
|
||||
BPF: [
|
||||
[ 0, 0, 0, 0 ],
|
||||
[ 2, 0, 0, 0 ],
|
||||
[ 2, 0, 0, 1 ],
|
||||
[ 40, 0, 0, 12 ],
|
||||
[ 21, 0, 4, 34525 ],
|
||||
[ 48, 0, 0, 20 ],
|
||||
[ 21, 0, 10, 17 ],
|
||||
[ 40, 0, 0, 54 ],
|
||||
[ 21, 41, 8, 53 ],
|
||||
[ 21, 0, 7, 2048 ],
|
||||
[ 48, 0, 0, 23 ],
|
||||
[ 21, 0, 5, 17 ],
|
||||
[ 40, 0, 0, 20 ],
|
||||
[ 69, 3, 0, 8191 ],
|
||||
[ 177, 0, 0, 14 ],
|
||||
[ 72, 0, 0, 14 ],
|
||||
[ 21, 33, 0, 53 ],
|
||||
[ 48, 0, 0, 4294963248 ],
|
||||
[ 21, 7, 0, 1 ],
|
||||
[ 0, 0, 0, 4 ],
|
||||
[ 2, 0, 0, 0 ],
|
||||
[ 2, 0, 0, 1 ],
|
||||
[ 40, 0, 0, 12 ],
|
||||
[ 21, 2, 0, 33024 ],
|
||||
[ 21, 1, 0, 34984 ],
|
||||
[ 21, 0, 25, 37120 ],
|
||||
[ 97, 0, 0, 1 ],
|
||||
[ 72, 0, 0, 12 ],
|
||||
[ 21, 0, 6, 34525 ],
|
||||
[ 97, 0, 0, 0 ],
|
||||
[ 80, 0, 0, 20 ],
|
||||
[ 21, 0, 19, 17 ],
|
||||
[ 97, 0, 0, 0 ],
|
||||
[ 72, 0, 0, 54 ],
|
||||
[ 21, 15, 16, 53 ],
|
||||
[ 21, 0, 15, 2048 ],
|
||||
[ 97, 0, 0, 0 ],
|
||||
[ 80, 0, 0, 23 ],
|
||||
[ 21, 0, 12, 17 ],
|
||||
[ 97, 0, 0, 0 ],
|
||||
[ 72, 0, 0, 20 ],
|
||||
[ 69, 9, 0, 8191 ],
|
||||
[ 97, 0, 0, 0 ],
|
||||
[ 80, 0, 0, 14 ],
|
||||
[ 84, 0, 0, 15 ],
|
||||
[ 100, 0, 0, 2 ],
|
||||
[ 12, 0, 0, 0 ],
|
||||
[ 7, 0, 0, 0 ],
|
||||
[ 72, 0, 0, 14 ],
|
||||
[ 21, 0, 1, 53 ],
|
||||
[ 6, 0, 0, 262144 ],
|
||||
[ 6, 0, 0, 0 ],
|
||||
]
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
#!/bin/sh
|
||||
# shunt has no version output by design: the runtime version comes from
|
||||
# rpc-sys packagelist via ubus, which is not available in the CI
|
||||
# container. The forced generic version check can therefore never match
|
||||
# PKG_VERSION in the output of the daemon.
|
||||
|
||||
[ "$1" = "shunt" ] || exit 1
|
||||
|
||||
exit 0
|
||||
@@ -1,10 +0,0 @@
|
||||
#!/bin/sh
|
||||
# compile and load with the shipped ucode.
|
||||
#
|
||||
|
||||
shunt 2>&1 | grep 'usage: shunt' || exit 1
|
||||
|
||||
ucode -e 'import * as a from "shunt.config"; import * as b from "shunt.nft";
|
||||
import * as c from "shunt.dns"; import * as d from "shunt.frame";
|
||||
import * as e from "shunt.match"; print("modules-ok\n");' |
|
||||
grep 'modules-ok'
|
||||
Reference in New Issue
Block a user