diff --git a/luci-app-shunt/Makefile b/luci-app-shunt/Makefile deleted file mode 100644 index 50201edd..00000000 --- a/luci-app-shunt/Makefile +++ /dev/null @@ -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 - -include $(TOPDIR)/feeds/luci/luci.mk - -# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js deleted file mode 100644 index 6f714d78..00000000 --- a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logread.js +++ /dev/null @@ -1,4 +0,0 @@ -'use strict'; -'require view.shunt.logtemplate as LogTemplate'; - -return LogTemplate.Logview(/\bshunt(\[\d+\])?:/, 'shunt'); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js deleted file mode 100644 index 70e876c6..00000000 --- a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/logtemplate.js +++ /dev/null @@ -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 }); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js deleted file mode 100644 index 7ae3d260..00000000 --- a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/overview.js +++ /dev/null @@ -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(`${_('online documentation')}`)); - 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 -}); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js deleted file mode 100644 index 95beb139..00000000 --- a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/policies.js +++ /dev/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 -}); diff --git a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js b/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js deleted file mode 100644 index f4422361..00000000 --- a/luci-app-shunt/htdocs/luci-static/resources/view/shunt/setreport.js +++ /dev/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 -}); diff --git a/luci-app-shunt/po/templates/shunt.pot b/luci-app-shunt/po/templates/shunt.pot deleted file mode 100644 index ef1a74ae..00000000 --- a/luci-app-shunt/po/templates/shunt.pot +++ /dev/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 "" diff --git a/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping b/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping deleted file mode 100644 index e6e19f9f..00000000 --- a/luci-app-shunt/root/etc/uci-defaults/95-luci-app-shunt-housekeeping +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -rm -f /var/luci-indexcache.*.json -[ -x "/etc/init.d/rpcd" ] && /etc/init.d/rpcd reload -exit 0 diff --git a/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json b/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json deleted file mode 100644 index db0664cb..00000000 --- a/luci-app-shunt/root/usr/share/luci/menu.d/luci-app-shunt.json +++ /dev/null @@ -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" - } - } -} diff --git a/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json b/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json deleted file mode 100644 index 87b404a5..00000000 --- a/luci-app-shunt/root/usr/share/rpcd/acl.d/luci-app-shunt.json +++ /dev/null @@ -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" - ] - } - } -} diff --git a/luci-app-wwand/Makefile b/luci-app-wwand/Makefile index 71a1e0fc..7269d549 100644 --- a/luci-app-wwand/Makefile +++ b/luci-app-wwand/Makefile @@ -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 diff --git a/shunt/Makefile b/shunt/Makefile deleted file mode 100644 index c2862e7a..00000000 --- a/shunt/Makefile +++ /dev/null @@ -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 - -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)) diff --git a/shunt/README.md b/shunt/README.md deleted file mode 100644 index 5f8c4eb1..00000000 --- a/shunt/README.md +++ /dev/null @@ -1,777 +0,0 @@ - - -# 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) - - -## 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). - - -## 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). - - -## 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 - - -## 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. - - -### 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 table `. -* **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..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/` 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. - - -## 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 - - -## 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 # 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. - - -## 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 dev ` when a gateway is known, -`default dev ` 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. - - -## 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. - - -### 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. - - -## 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' -``` - - -### 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. - - -## 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_ / d6_ learned, flags timeout, per-element counter - set s4_ / s6_ static dst, flags interval, counter - set c4_ / c6_ client src selectors, interval, counter - set m_ client MACs, no family digit, counter - -fwmark << 24, mask 0xff000000 -ip rule pref 31000 + -routing table 8000 + -/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 ` 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. - - -## 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. - - -## 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 host # 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 mark 0x1000000 -ip route get -``` - -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 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. - - -## 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. - - -## 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. - - -## 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. - - -## 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 diff --git a/shunt/files/shunt.config b/shunt/files/shunt.config deleted file mode 100644 index bfd376b9..00000000 --- a/shunt/files/shunt.config +++ /dev/null @@ -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' diff --git a/shunt/files/shunt.init b/shunt/files/shunt.init deleted file mode 100644 index b5fa2927..00000000 --- a/shunt/files/shunt.init +++ /dev/null @@ -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 -} diff --git a/shunt/files/shunt.rpcd b/shunt/files/shunt.rpcd deleted file mode 100644 index c4d51179..00000000 --- a/shunt/files/shunt.rpcd +++ /dev/null @@ -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 - -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, ), -// 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 }; - } - } - } -}; diff --git a/shunt/files/shunt.uc b/shunt/files/shunt.uc deleted file mode 100755 index 9444d052..00000000 --- a/shunt/files/shunt.uc +++ /dev/null @@ -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 - -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.), 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/ 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); -} diff --git a/shunt/src/config.uc b/shunt/src/config.uc deleted file mode 100644 index b3c40264..00000000 --- a/shunt/src/config.uc +++ /dev/null @@ -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 - -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 }; -}; diff --git a/shunt/src/dedupe.uc b/shunt/src/dedupe.uc deleted file mode 100644 index 5b515bae..00000000 --- a/shunt/src/dedupe.uc +++ /dev/null @@ -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 - -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 }; -}; diff --git a/shunt/src/dns.uc b/shunt/src/dns.uc deleted file mode 100644 index 517cb817..00000000 --- a/shunt/src/dns.uc +++ /dev/null @@ -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 - -// 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 - }; -}; diff --git a/shunt/src/frame.uc b/shunt/src/frame.uc deleted file mode 100644 index 34bcfb1f..00000000 --- a/shunt/src/frame.uc +++ /dev/null @@ -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 - -// 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) - }; -}; diff --git a/shunt/src/match.uc b/shunt/src/match.uc deleted file mode 100644 index 165c8b6b..00000000 --- a/shunt/src/match.uc +++ /dev/null @@ -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 - -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)) } - }; -}; diff --git a/shunt/src/netifd.uc b/shunt/src/netifd.uc deleted file mode 100644 index f4d758d2..00000000 --- a/shunt/src/netifd.uc +++ /dev/null @@ -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 - -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; -}; diff --git a/shunt/src/nft.uc b/shunt/src/nft.uc deleted file mode 100644 index 86dc18bb..00000000 --- a/shunt/src/nft.uc +++ /dev/null @@ -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 - -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`; -}; diff --git a/shunt/src/poll.uc b/shunt/src/poll.uc deleted file mode 100644 index 83677918..00000000 --- a/shunt/src/poll.uc +++ /dev/null @@ -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 - -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; -}; diff --git a/shunt/src/route.uc b/shunt/src/route.uc deleted file mode 100644 index f2b03b27..00000000 --- a/shunt/src/route.uc +++ /dev/null @@ -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 - -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 - }; -}; diff --git a/shunt/src/snoop.uc b/shunt/src/snoop.uc deleted file mode 100644 index 5d8a3857..00000000 --- a/shunt/src/snoop.uc +++ /dev/null @@ -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 - -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: }. 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 }; -}; diff --git a/shunt/src/snoop_bpf.uc b/shunt/src/snoop_bpf.uc deleted file mode 100644 index b31ffdb9..00000000 --- a/shunt/src/snoop_bpf.uc +++ /dev/null @@ -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 ], - ] -}; diff --git a/shunt/test-version.sh b/shunt/test-version.sh deleted file mode 100755 index d34b27b2..00000000 --- a/shunt/test-version.sh +++ /dev/null @@ -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 diff --git a/shunt/test.sh b/shunt/test.sh deleted file mode 100755 index 394ae21e..00000000 --- a/shunt/test.sh +++ /dev/null @@ -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'