🍓 Sync 2026-09-15 02:13:06

This commit is contained in:
github-actions[bot] committed 2026-09-15 02:13:06 +08:00
1 parent 0e5393594c
commit 1dd96ac2bb
146 files changed
+26541 -508

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-flowproxy
PKG_VERSION:=1.0.3
PKG_RELEASE:=1
LUCI_TITLE:=LuCI Support for FlowProxy Traffic Diversion
LUCI_DEPENDS:=+luci-base
LUCI_PKGARCH:=all
define Package/$(PKG_NAME)/prerm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
/etc/init.d/flowproxy stop
/etc/init.d/flowproxy disable
fi
exit 0
endef
define Package/$(PKG_NAME)/postrm
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /tmp/flowproxy
rm -rf /usr/share/flowproxy
rm -f /var/log/flowproxy.log*
rm -f /var/run/flowproxy.*
fi
exit 0
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,560 @@
'use strict';
'require form';
'require uci';
'require rpc';
'require poll';
'require view';
'require dom';
'require fs';
'require ui';
'require network';
var callGetStatus = rpc.declare({
object: 'luci.flowproxy',
method: 'get_status'
});
var callGetLogs = rpc.declare({
object: 'luci.flowproxy',
method: 'get_logs',
params: ['lines']
});
var callClearLogs = rpc.declare({
object: 'luci.flowproxy',
method: 'clear_logs'
});
var callGenerateNftConfig = rpc.declare({
object: 'luci.flowproxy',
method: 'generate_nft_config'
});
var callGetRuntimeConfig = rpc.declare({
object: 'luci.flowproxy',
method: 'get_runtime_config'
});
var callRestartService = rpc.declare({
object: 'luci.flowproxy',
method: 'restart_service'
});
return L.view.extend({
load: function() {
return Promise.all([
uci.load('flowproxy').catch(function() { return {}; }),
network.getDevices(),
callGetStatus().catch(function() { return {}; }),
network.getNetworks().catch(function() { return []; })
]);
},
highlightNft: function(text) {
if (!text || text.trim() === '') return '<span style="color: #999;">' + _('(no content / table not loaded)') + '</span>';
var rules = [
{ rex: /#(.*)/g, cls: 'comment' },
{ rex: /\b(table|chain|set|elements|type)\b/g, cls: 'keyword' },
{ rex: /\b(ip|ip6|tcp|udp|ether|meta|meta nfproto)\b/g, cls: 'proto' },
{ rex: /\b(saddr|daddr|sport|dport|mark)\b/g, cls: 'match' },
{ rex: /\b(return|accept|drop|reject|counter|set)\b/g, cls: 'action' },
{ rex: /@[\w_]+/g, cls: 'variable' },
{ rex: /\{|\}/g, cls: 'bracket' }
];
var html = text.replace(/[&<>"']/g, function(m) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m];
});
rules.forEach(function(r) {
html = html.replace(r.rex, function(match) {
return '<span class="nft-' + r.cls + '">' + match + '</span>';
});
});
return html;
},
refreshStatus: function(map, node) {
return callGetStatus().then(L.bind(function(status) {
if (!status) return;
var isRunning = (status.running == 1);
var container = node || document;
var statusEl = container.querySelector('#service-status');
if (statusEl) {
var content = [];
if (isRunning) {
var chains = [];
if (status.nft && status.nft.tcp) chains.push('TCP');
if (status.nft && status.nft.udp) chains.push('UDP');
var ip = status.proxy_server_ip_addr || '-';
content.push(E('span', { 'style': 'color: green; font-weight: bold;' }, [ _('running') ]));
content.push(' (' + ip + ':');
content.push(E('span', { 'style': 'color: #FF9800; font-weight: bold;' }, [ chains.join('+') || '-' ]));
content.push(') ');
content.push(E('button', {
'class': 'btn cbi-button cbi-button-apply',
'style': 'margin-left: 10px;',
'click': ui.createHandlerFn(this, 'handleRestart')
}, [ _('Restart') ]));
} else {
content.push(E('span', { 'style': 'color: red; font-weight: bold;' }, [ _('stopped') ]));
}
L.dom.content(statusEl, content);
}
var dnsEl = container.querySelector('#dns-status');
if (dnsEl) {
var dcontent = [];
if (status.dns_running == 1) {
var ip = status.proxy_server_ip_addr || '-';
var port = status.proxy_server_dns_port || '5353';
dcontent.push(E('span', { 'style': 'color: green; font-weight: bold;' }, [ _('running') ]));
dcontent.push(' (' + ip + ':');
dcontent.push(E('span', { 'style': 'color: #FF9800; font-weight: bold;' }, [ port ]));
dcontent.push(')');
} else {
dcontent.push(E('span', { 'style': 'color: red; font-weight: bold;' }, [ _('stopped') ]));
}
L.dom.content(dnsEl, dcontent);
}
}, this));
},
refreshLogs: function() {
return callGetLogs(500).then(L.bind(function(data) {
var logEl = document.getElementById('log-content');
if (logEl) {
var logs = (data && Array.isArray(data.logs)) ? data.logs : [];
logEl.value = logs.length > 0 ? logs.join('\n') : _('no logs available');
if (logs.length > 0) logEl.scrollTop = logEl.scrollHeight;
}
}, this));
},
handleRestart: function() {
ui.showModal(null, [
E('p', { 'class': 'spinning' }, _('Restarting FlowProxy service...'))
]);
return callRestartService().then(L.bind(function(res) {
ui.hideModal();
if (res && res.result) {
ui.addNotification(null, E('p', _('FlowProxy service restarted successfully.')), 'info');
window.setTimeout(function() {
location.reload();
}, 1500);
} else {
ui.addNotification(null, E('p', _('Failed to restart FlowProxy service.')), 'error');
}
}, this)).catch(function(err) {
ui.hideModal();
ui.addNotification(null, E('p', _('RPC error: %s').format((err && (err.message || String(err))) || 'Unknown error')), 'error');
});
},
render: function(data) {
var self = this;
var devices = data[1];
var status = data[2];
var ifaces = data[3] || [];
var m, s, o;
m = new form.Map('flowproxy', _('FlowProxy'),
_('Traffic diversion based on nftables rules. The service will automatically start/stop when you click "Save & Apply".'));
if (!document.getElementById('flowproxy-style')) {
document.head.appendChild(E('style', { id: 'flowproxy-style' }, `
#log-content { width: 100% !important; height: 60vh; min-height: 400px; font-family: monospace; font-size: 12px; background: #f5f5f5 !important; color: #333 !important; border: 1px solid #ccc !important; padding: 10px; resize: vertical; border-radius: 4px; box-sizing: border-box; }
.nft-code-view { background: #f5f5f5 !important; color: #333333 !important; padding: 15px !important; font-family: monospace !important; font-size: 12px !important; overflow-x: auto !important; white-space: pre-wrap !important; width: 100% !important; border: 1px solid #cccccc !important; border-radius: 4px; box-sizing: border-box; max-height: 70vh; }
.nft-comment { color: #777777; font-style: italic; } .nft-keyword { color: #a626a1; font-weight: bold; } .nft-proto { color: #4078f2; } .nft-match { color: #986801; } .nft-action { color: #e45649; font-weight: bold; } .nft-variable { color: #50a14f; font-weight: bold; }
.cbi-section-table-titles th[data-sortable-row]::after, .cbi-section-table-titles th[data-sortable-row]::before { display: none !important; }
.cbi-section-table-titles th[data-sortable-row] { pointer-events: none !important; cursor: default !important; }
`));
}
s = m.section(form.NamedSection, 'global', 'flowproxy');
s.tab('settings', _('Settings'));
s.tab('rules', _('Rules'));
s.tab('lists', _('Lists'));
s.tab('preview', _('Preview'));
s.tab('logs', _('Logs'));
// --- Settings ---
o = s.taboption('settings', form.DummyValue, '_service_status', _('Current Status'));
o.rawhtml = true;
o.cfgvalue = function() { return '<div id="service-status" style="display:inline-block;"><em class="spinning">' + _('checking...') + '</em></div>'; };
o = s.taboption('settings', form.DummyValue, '_dns_status', _('DNS Redirection'));
o.rawhtml = true;
o.cfgvalue = function() { return '<div id="dns-status" style="display:inline-block;">-</div>'; };
s.taboption('settings', form.Flag, 'enabled', _('Enable FlowProxy')).rmempty = false;
s.taboption('settings', form.Flag, 'dns_proxy_enabled', _('Force upstream DNS to proxy server')).rmempty = false;
o = s.taboption('settings', form.Value, 'proxy_server_ip_addr', _('Proxy server IP address'));
o.datatype = 'ip4addr'; o.rmempty = false;
o.placeholder = '192.168.1.254';
o.validate = function(section_id, value) {
var ipInCidr = function(ipStr, cidrStr) {
if (!cidrStr || cidrStr.indexOf(':') !== -1 || !ipStr || ipStr.indexOf(':') !== -1) return false;
var parts = cidrStr.split('/');
var cidrIp = parts[0];
var maskBits = parseInt(parts[1] || '32', 10);
var ipToNum = function(s) {
var p = s.split('.');
if (p.length !== 4) return 0;
return (((parseInt(p[0], 10) << 24) |
(parseInt(p[1], 10) << 16) |
(parseInt(p[2], 10) << 8) |
parseInt(p[3], 10)) >>> 0);
};
var ipNum = ipToNum(ipStr);
var cidrNum = ipToNum(cidrIp);
if (maskBits === 0) return true;
var mask = (0xffffffff << (32 - maskBits)) >>> 0;
return (ipNum & mask) === (cidrNum & mask);
};
var selected_interface = s.formvalue(section_id, 'interface');
if (!value || !selected_interface) return true;
var target_iface = null;
for (var i = 0; i < ifaces.length; i++) {
var dev = ifaces[i].getL3Device() || ifaces[i].getDevice();
if ((dev && dev.getName() === selected_interface) || ifaces[i].getName() === selected_interface) {
target_iface = ifaces[i];
break;
}
}
if (!target_iface) return true;
var ips = target_iface.getIPAddrs();
if (!ips || ips.length === 0) return true;
var inSubnet = false;
for (var j = 0; j < ips.length; j++) {
if (ipInCidr(value, ips[j])) {
inSubnet = true;
break;
}
}
if (!inSubnet) {
return _('The proxy server IP address must be within the subnet of the selected interface (%s).').format(ips.join(', '));
}
return true;
};
o = s.taboption('settings', form.Value, 'proxy_server_dns_port', _('Proxy server DNS port'));
o.datatype = 'port'; o.default = '5353'; o.rmempty = false;
o = s.taboption('settings', form.ListValue, 'interface', _('Proxy server interface'));
devices.forEach(function(d) { o.value(d.getName(), d.getName()); });
o.default = 'br-lan';
s.taboption('settings', form.Value, 'traffic_mark', _('Traffic Mark')).datatype = 'or(uinteger, hex)';
s.taboption('settings', form.Value, 'routing_table', _('Routing Table ID')).datatype = 'and(uinteger,range(1, 4294967295))';
s.taboption('settings', form.Value, 'rule_priority', _('Rule Priority')).datatype = 'and(uinteger,range(0, 4294967295))';
// --- Rules ---
var nftsets = uci.sections('flowproxy', 'nftset').map(function(ss) { return '@' + ss['.name']; });
var setupRuleTable = function(type, title, switch_opt) {
var st = s.taboption('rules', form.SectionValue, '_tab_' + type, form.TableSection, type, title);
var ss = st.subsection;
ss.addremove = true; ss.anonymous = true; ss.sortable = true;
var refreshTable = function() {
return ss.render().then(function(newNode) {
var el = document.getElementById('flowproxy-table-' + type);
if (el && el.parentNode) {
el.parentNode.replaceChild(newNode, el);
}
});
};
var createTemplateButtons = function() {
var presets = {
'priv': { name: 'private (dst)', type: 'dst_ip', val: '@private_dst_ip_v4' },
'china': { name: 'china (dst)', type: 'dst_ip', val: '@chnroute_dst_ip_v4' },
'src_ip': { name: 'ip (src)', type: 'src_ip', val: '@no_proxy_src_ip_v4' },
'dst_ip': { name: 'ip (dst)', type: 'dst_ip', val: '@no_proxy_dst_ip_v4' },
'mac': { name: 'mac (src)', type: 'src_mac', val: '@no_proxy_src_mac' },
'ports': { name: 'ports (dst)', type: 'dst_port', val: (type === 'tcp_rule') ? '@no_proxy_dst_tcp_ports' : '@no_proxy_dst_udp_ports' }
};
var btnGroup = E('div', { 'style': 'display: flex; flex-wrap: wrap; gap: 4px; margin-top: 8px; align-items: center;' }, [ E('small', { 'style': 'margin-right: 5px; color: #888;' }, _('Quick add:')) ]);
Object.keys(presets).forEach(function(k) {
var p = presets[k];
var bgColors = {
'dst_ip': '#2196F3',
'src_ip': '#2196F3',
'custom': '#2196F3',
'src_mac': '#9C27B0',
'dst_port': '#FF9800'
};
var btnStyle = 'padding: 0 6px; font-size: 0.75rem; height: 22px; line-height: 22px; opacity: 0.9; margin-bottom: 4px; border: none; color: #fff; border-radius: 3px; cursor: pointer;';
var bgColor = bgColors[p.type] || '#607d8b';
btnStyle += ' background-color: ' + bgColor + ';';
btnGroup.appendChild(E('button', {
'style': btnStyle,
'title': p.val,
'click': ui.createHandlerFn(self, function() {
var sid = uci.add('flowproxy', type);
uci.set('flowproxy', sid, 'enabled', '1');
uci.set('flowproxy', sid, 'match_type', p.type);
uci.set('flowproxy', sid, 'match_value', p.val);
uci.set('flowproxy', sid, 'action', 'return');
uci.set('flowproxy', sid, 'counter', '0');
return Promise.resolve().then(function() {
return m.load().then(function() {
return refreshTable();
});
});
})
}, [ E('em', { 'class': 'icon-plus' }), ' ', p.name ]));
});
return btnGroup;
};
ss.render = function() {
return form.TableSection.prototype.render.apply(ss).then(function(node) {
node.id = 'flowproxy-table-' + type;
var titleEl = node.querySelector('h3');
if (titleEl) {
titleEl.style.display = 'block';
var headerRow = E('div', { 'style': 'display: flex; align-items: center; gap: 10px;' }, [
E('span', {}, title),
E('div', { 'style': 'font-size: 0.8em; font-weight: normal; display: inline-flex; align-items: center; gap: 5px; color: #666;' }, [
(function() {
var val = uci.get('flowproxy', 'global', switch_opt);
return E('input', { 'type': 'checkbox', 'style': 'width: 16px; height: 18px; cursor: pointer;', 'checked': (val !== '0') ? 'checked' : null,
'change': function(ev) { uci.set('flowproxy', 'global', switch_opt, ev.target.checked ? '1' : '0'); ui.addNotification(null, E('p', _('Master switch updated.')), 'info'); }
});
})(),
E('span', {}, _('enable protocol'))
])
]);
L.dom.content(titleEl, [ headerRow, createTemplateButtons() ]);
}
return node;
});
};
ss.renderSectionAdd = function(extra_class) {
var node = form.TableSection.prototype.renderSectionAdd.apply(this, [extra_class]);
var label = (type === 'tcp_rule') ? 'TCP' : 'UDP';
var addBtn = node.querySelector('.cbi-button-add'); if (addBtn) addBtn.innerText = _('Add rule');
var resetBtn = E('button', { 'class': 'cbi-button cbi-button-reset', 'style': 'margin-left: 10px; border: 1px solid #cc0000; color: #cc0000;',
'click': ui.createHandlerFn(self, function() {
if (confirm(_('this will delete ALL current %s rules and generate default templates. are you sure?').format(label))) {
uci.sections('flowproxy', type).forEach(function(r) { uci.remove('flowproxy', r['.name']); });
var defs = [ { t: 'src_mac', v: '@no_proxy_src_mac' }, { t: 'dst_ip', v: '@private_dst_ip_v4' }, { t: 'dst_ip', v: '@chnroute_dst_ip_v4' } ];
if (type === 'tcp_rule') defs.push({ t: 'dst_port', v: '@no_proxy_dst_tcp_ports' }); else if (type === 'udp_rule') defs.push({ t: 'dst_port', v: '@no_proxy_dst_udp_ports' });
defs.forEach(function(r) { var sid = uci.add('flowproxy', type); uci.set('flowproxy', sid, 'enabled', '1'); uci.set('flowproxy', sid, 'match_type', r.t); uci.set('flowproxy', sid, 'match_value', r.v); uci.set('flowproxy', sid, 'action', 'return'); uci.set('flowproxy', sid, 'counter', '0'); });
return Promise.resolve().then(function() {
return m.load().then(function() {
return refreshTable();
});
});
}
})
}, [ E('em', { 'class': 'icon-reload' }), ' ', _('reset %s templates').format(label) ]);
node.appendChild(resetBtn); return node;
};
ss.option(form.Flag, 'enabled', _('Enabled')).width = '8%';
var match_type = ss.option(form.ListValue, 'match_type', _('Match Type'));
match_type.value('dst_ip', 'dest ip'); match_type.value('src_ip', 'src ip'); match_type.value('src_mac', 'src mac');
match_type.value('dst_port', 'dest port'); match_type.value('src_port', 'src port'); match_type.value('custom', 'custom (raw)');
match_type.default = 'dst_ip'; match_type.rmempty = false; match_type.width = '15%';
var match_value = ss.option(form.Value, 'match_value', _('Match Value'));
match_value.rmempty = false; match_value.width = '45%';
nftsets.forEach(function(set) { match_value.value(set); });
match_value.validate = function(sid, val) {
if (!val || val === '') return _('Expecting: %s').format(_('Match Value'));
var type = match_type.formvalue(sid);
if (!type) {
var typeEl = document.querySelector('[name="cbid.flowproxy.' + sid + '.match_type"]');
if (typeEl && typeEl.value) type = typeEl.value;
}
if (!type) type = uci.get('flowproxy', sid, 'match_type');
if (val.match(/^@/)) {
var setName = val.substring(1);
// 仅当 section 确实存在时才尝试获取 type,否则不进行类型校验
var section = uci.get('flowproxy', setName);
var setType = (section && section['.type'] === 'nftset') ? uci.get('flowproxy', setName, 'type') : null;
if (!setType) return true;
var expectedType = '';
if (type === 'dst_ip' || type === 'src_ip') expectedType = 'ipv4_addr';
else if (type === 'src_mac') expectedType = 'ether_addr';
else if (type === 'dst_port' || type === 'src_port') expectedType = 'inet_service';
if (expectedType && setType !== expectedType && type !== 'custom') {
return _('Set type mismatch: expected %s, got %s').format(expectedType, setType);
}
return true;
}
switch (type) {
case 'dst_ip':
case 'src_ip':
if (!val.match(/^([0-9]{1,3}\.){3}[0-9]{1,3}(\/[0-9]{1,2})?$/)) return _('Invalid IPv4 address or CIDR');
break;
case 'src_mac':
if (!val.match(/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/)) return _('Invalid MAC address');
break;
case 'dst_port':
case 'src_port':
if (!val.match(/^[0-9]+(-[0-9]+)?$/)) return _('Invalid port or range');
break;
}
return true;
};
// 联动:当 match_type 改变时,强制触发 match_value 的校验
match_type.onchange = function(ev, sid, val) {
var value_input = m.lookupOption('match_value', sid)[0];
if (value_input) setTimeout(function() {
var el = document.getElementById(value_input.cbid(sid));
if (el) {
var inputEl = el.tagName === 'INPUT' ? el : el.querySelector('input');
if (inputEl) {
inputEl.dispatchEvent(new CustomEvent('change', { bubbles: true }));
inputEl.dispatchEvent(new CustomEvent('blur', { bubbles: true }));
inputEl.dispatchEvent(new CustomEvent('input', { bubbles: true }));
}
}
}, 0);
};
ss.option(form.Flag, 'counter', _('Counter')).width = '8%';
o = ss.option(form.ListValue, 'action', _('Action')); o.value('return', 'return'); o.value('accept', 'accept'); o.value('drop', 'drop'); o.default = 'return'; o.width = '12%';
};
setupRuleTable('tcp_rule', _('TCP Matching Rules'), 'tcp_enabled');
setupRuleTable('udp_rule', _('UDP Matching Rules'), 'udp_enabled');
// --- Lists ---
var predefined = [ { id: 'no_proxy_src_mac', name: _('no_proxy_src_mac'), type: 'macaddr' }, { id: 'no_proxy_src_ip_v4', name: _('no_proxy_src_ip_v4'), type: 'or(ip4addr, cidr4)' }, { id: 'no_proxy_dst_ip_v4', name: _('no_proxy_dst_ip_v4'), type: 'or(ip4addr, cidr4)' }, { id: 'no_proxy_dst_tcp_ports', name: _('no_proxy_dst_tcp_ports'), type: 'or(port, portrange)' }, { id: 'no_proxy_dst_udp_ports', name: _('no_proxy_dst_udp_ports'), type: 'or(port, portrange)' } ];
predefined.forEach(function(p) {
var sl = s.taboption('lists', form.SectionValue, '_list_' + p.id, form.NamedSection, p.id, 'nftset', p.name + ' (@' + p.id + ')');
sl.subsection.option(form.Flag, 'enabled', _('Enabled')).default = '1';
sl.subsection.option(form.DynamicList, 'elements', _('Elements')).datatype = p.type;
});
var spriv = s.taboption('lists', form.SectionValue, '_list_priv', form.NamedSection, 'private_dst_ip_v4', 'nftset', _('private_dst_ip_v4') + ' (@private_dst_ip_v4)');
spriv.subsection.option(form.Flag, 'enabled', _('Enabled')).default = '1';
spriv.subsection.option(form.Flag, 'auto_generate', _('auto_generate')).default = '1';
o = spriv.subsection.option(form.DynamicList, 'elements', _('elements')); o.datatype = 'cidr4'; o.depends('auto_generate', '0');
var sc = s.taboption('lists', form.SectionValue, '_list_chnroute', form.NamedSection, 'chnroute_dst_ip_v4', 'nftset', _('chnroute_dst_ip_v4') + ' (@chnroute_dst_ip_v4)');
sc.subsection.option(form.Flag, 'enabled', _('Enabled')).default = '1';
o = sc.subsection.option(form.Value, 'file_path', _('File Path')); o.default = '/usr/share/flowproxy/chnroute.txt';
o.render = function(sid) {
return form.Value.prototype.render.apply(this, arguments).then(function(node) {
var path = uci.get('flowproxy', sid, 'file_path') || '/usr/share/flowproxy/chnroute.txt';
var resNode = E('span', { 'style': 'margin-left: 10px; font-weight: bold; color: #444;', 'id': 'line-count-status' }, [ '...' ]);
var input = node.querySelector('input'); if (input) input.parentNode.appendChild(resNode);
fs.exec('/usr/bin/wc', ['-l', path]).then(function(res) { L.dom.content(resNode, (res.code === 0) ? [ _('(%d lines)').format(res.stdout.trim().split(' ')[0]) ] : [ _('(n/a)') ]); });
return node;
});
};
sc.subsection.option(form.Value, 'download_url', _('Download URL'));
o = sc.subsection.option(form.Button, '_download', _('Update Chnroute')); o.inputstyle = 'apply';
o.onclick = function(ev, sid) {
var urlOpt = m.lookupOption('download_url', sid)[0];
var url = urlOpt ? urlOpt.formvalue(sid) : uci.get('flowproxy', sid, 'download_url');
var pathOpt = m.lookupOption('file_path', sid)[0];
var path = pathOpt ? pathOpt.formvalue(sid) : uci.get('flowproxy', sid, 'file_path');
if (!path) path = '/usr/share/flowproxy/chnroute.txt';
if (!url) { ui.addNotification(null, E('p', _('Please set download_url first')), 'error'); return; }
ui.showModal(null, [ E('p', { 'class': 'spinning', 'id': 'download-msg' }, [ _('Downloading chnroute data...') ]) ]);
return fs.exec('/usr/bin/wget', ['-q', '-O', path, url, '--timeout=10', '--no-check-certificate']).then(function(res) {
var msgEl = document.getElementById('download-msg');
if (res.code === 0) {
if (msgEl) { msgEl.classList.remove('spinning'); L.dom.content(msgEl, [ _('Updated successfully.') ]); }
var cn = document.getElementById('line-count-status'); if (cn) fs.exec('/usr/bin/wc', ['-l', path]).then(function(r) { L.dom.content(cn, (r.code === 0) ? [ _('(%d lines)').format(r.stdout.trim().split(' ')[0]) ] : [ _('(n/a)') ]); });
setTimeout(ui.hideModal, 1500);
} else { ui.hideModal(); ui.addNotification(null, E('p', _('Download failed')), 'error'); }
}).catch(function(e) { ui.hideModal(); ui.addNotification(null, E('p', _('Error: %s').format(e.message)), 'error'); });
};
var sg = s.taboption('lists', form.SectionValue, '_list_custom', form.GridSection, 'nftset', _('Custom nftables sets'));
sg.subsection.addremove = true; sg.subsection.anonymous = false; sg.subsection.nodescription = true;
sg.subsection.filter = function(sid) { var pre = predefined.map(function(p){return p.id}); pre.push('private_dst_ip_v4','chnroute_dst_ip_v4'); return pre.indexOf(sid) === -1; };
sg.subsection.option(form.Flag, 'enabled', _('Enabled')).default = '1';
o = sg.subsection.option(form.ListValue, 'type', _('Element Type')); o.value('ipv4_addr', 'IPv4 Address/CIDR'); o.value('ether_addr', 'MAC Address'); o.value('inet_service', 'Port/Service'); o.default = 'ipv4_addr';
sg.subsection.option(form.DynamicList, 'elements', _('Elements'));
// --- Preview & Logs ---
var sp = s.taboption('preview', form.SectionValue, '_preview_section', form.NamedSection, 'global', 'flowproxy');
sp.subsection.tab('generated', _('Generated Config')); sp.subsection.tab('runtime', _('Live Runtime State'));
o = sp.subsection.taboption('generated', form.DummyValue, '_preview_gen');
o.render = function() {
var node = E('div', { 'style': 'width:100%; box-sizing:border-box; margin-top: 10px;' }, [
E('div', { 'style': 'margin-bottom: 10px' }, [
E('button', { 'class': 'cbi-button cbi-button-apply', 'click': function() {
var el = document.getElementById('gen-code');
if (el) { navigator.clipboard.writeText(el.innerText); ui.addNotification(null, E('p', _('Copied')), 'info'); }
} }, [ _('Copy Config') ])
]),
E('pre', { 'class': 'nft-code-view', 'id': 'gen-code' }, [ _('loading...') ])
]);
callGenerateNftConfig().then(function(res) {
var el = node.querySelector('#gen-code');
if (el) el.innerHTML = self.highlightNft(res.config || '');
});
return node;
};
o = sp.subsection.taboption('runtime', form.DummyValue, '_preview_run');
o.render = function() {
var node = E('div', { 'style': 'width:100%; box-sizing:border-box; margin-top: 10px;' }, [
E('div', { 'style': 'margin-bottom: 10px' }, [
E('button', { 'class': 'cbi-button cbi-button-refresh', 'click': function() { location.reload(); } }, [ _('Refresh Status') ])
]),
E('pre', { 'class': 'nft-code-view', 'id': 'run-code' }, [ _('loading...') ])
]);
callGetRuntimeConfig().then(function(res) {
var el = node.querySelector('#run-code');
if (el) el.innerHTML = self.highlightNft(res.runtime || '');
});
return node;
};
o = s.taboption('logs', form.ListValue, 'log_level', _('Log Level'));
o.value('debug'); o.value('info'); o.value('warn'); o.value('error'); o.default = 'info';
s.taboption('logs', form.Value, 'log_size', _('Log Size (KB)')).datatype = 'uinteger';
o = s.taboption('logs', form.DummyValue, '_log_view');
o.render = function() {
var node = E('div', { 'style': 'width:100%; box-sizing:border-box; margin-top: 15px;' }, [
E('div', { 'style': 'margin-bottom: 5px; text-align: left;' }, [
E('button', { 'class': 'cbi-button cbi-button-refresh', 'click': L.bind(self.refreshLogs, self) }, [ _('Refresh Logs') ]),
E('button', { 'class': 'cbi-button cbi-button-reset', 'style': 'margin-left: 10px;', 'click': L.bind(function() { if(confirm(_('Clear logs?'))) callClearLogs().then(L.bind(self.refreshLogs, self)); }, self) }, [ _('Clear Logs') ])
]),
E('textarea', { 'id': 'log-content', 'readonly': true }, [ _('loading logs...') ])
]);
callGetLogs(500).then(function(data) {
var logEl = node.querySelector('#log-content');
if (logEl) {
var logs = (data && Array.isArray(data.logs)) ? data.logs : [];
logEl.value = logs.length > 0 ? logs.join('\n') : _('no logs available');
if (logs.length > 0) logEl.scrollTop = logEl.scrollHeight;
}
});
return node;
};
return m.render().then(L.bind(function(node) {
this.refreshStatus(m, node);
poll.add(L.bind(function() { if (!document.body.contains(node)) return false; return this.refreshStatus(m, node); }, this), 5);
return node;
}, this));
}
});
@@ -0,0 +1,228 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: luci-app-flowproxy\n"
""
msgid "(%d lines)"
msgstr ""
msgid "(n/a)"
msgstr ""
msgid "(no content / table not loaded)"
msgstr ""
msgid "Action"
msgstr ""
msgid "Add rule"
msgstr ""
msgid "Clear Logs"
msgstr ""
msgid "Clear logs?"
msgstr ""
msgid "Copied"
msgstr ""
msgid "Copy Config"
msgstr ""
msgid "Counter"
msgstr ""
msgid "Current Status"
msgstr ""
msgid "Custom nftables sets"
msgstr ""
msgid "DNS Redirection"
msgstr ""
msgid "Download URL"
msgstr ""
msgid "Download failed"
msgstr ""
msgid "Downloading chnroute data..."
msgstr ""
msgid "Element Type"
msgstr ""
msgid "Elements"
msgstr ""
msgid "Enable FlowProxy"
msgstr ""
msgid "Enabled"
msgstr ""
msgid "Error: %s"
msgstr ""
msgid "Expecting: %s"
msgstr ""
msgid "File Path"
msgstr ""
msgid "FlowProxy"
msgstr ""
msgid "Force upstream DNS to proxy server"
msgstr ""
msgid "Generated Config"
msgstr ""
msgid "Invalid IPv4 address or CIDR"
msgstr ""
msgid "Invalid MAC address"
msgstr ""
msgid "Invalid port or range"
msgstr ""
msgid "Lists"
msgstr ""
msgid "Live Runtime State"
msgstr ""
msgid "Log Level"
msgstr ""
msgid "Log Size (KB)"
msgstr ""
msgid "Logs"
msgstr ""
msgid "LuCI Support for FlowProxy Traffic Diversion"
msgstr ""
msgid "Master switch updated."
msgstr ""
msgid "Match Type"
msgstr ""
msgid "Match Value"
msgstr ""
msgid "Network interface"
msgstr ""
msgid "Please set download_url first"
msgstr ""
msgid "Preview"
msgstr ""
msgid "Proxy server DNS port"
msgstr ""
msgid "Proxy server IP address"
msgstr ""
msgid "Quick add:"
msgstr ""
msgid "Refresh Logs"
msgstr ""
msgid "Refresh Status"
msgstr ""
msgid "Routing Table ID"
msgstr ""
msgid "Rules"
msgstr ""
msgid "Set type mismatch: expected %s, got %s"
msgstr ""
msgid "Settings"
msgstr ""
msgid "TCP Matching Rules"
msgstr ""
msgid "Traffic Mark"
msgstr ""
msgid "Traffic diversion based on nftables rules. The service will automatically start/stop when you click \"Save & Apply\"."
msgstr ""
msgid "UDP Matching Rules"
msgstr ""
msgid "Update Chnroute"
msgstr ""
msgid "Updated successfully."
msgstr ""
msgid "auto_generate"
msgstr ""
msgid "checking..."
msgstr ""
msgid "chnroute_dst_ip_v4"
msgstr ""
msgid "elements"
msgstr ""
msgid "enable protocol"
msgstr ""
msgid "loading logs..."
msgstr ""
msgid "loading..."
msgstr ""
msgid "no logs available"
msgstr ""
msgid "no_proxy_dst_ip_v4"
msgstr ""
msgid "no_proxy_dst_tcp_ports"
msgstr ""
msgid "no_proxy_dst_udp_ports"
msgstr ""
msgid "no_proxy_src_ip_v4"
msgstr ""
msgid "no_proxy_src_mac"
msgstr ""
msgid "private_dst_ip_v4"
msgstr ""
msgid "reset %s templates"
msgstr ""
msgid "running"
msgstr ""
msgid "stopped"
msgstr ""
msgid "this will delete ALL current %s rules and generate default templates. are you sure?"
msgstr ""
+1
View File
@@ -0,0 +1 @@
zh_Hans
+252
View File
@@ -0,0 +1,252 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: luci-app-flowproxy\n"
"Language: zh_Hans\n"
msgid "(%d lines)"
msgstr "(%d 行)"
msgid "(n/a)"
msgstr "(未知)"
msgid "(no content / table not loaded)"
msgstr "(无内容 / 列表未加载)"
msgid "Action"
msgstr "动作"
msgid "Add rule"
msgstr "添加规则"
msgid "Clear Logs"
msgstr "清空日志"
msgid "Clear logs?"
msgstr "确定要清空日志吗?"
msgid "Copied"
msgstr "已复制"
msgid "Copy Config"
msgstr "复制配置"
msgid "Counter"
msgstr "计数器"
msgid "Current Status"
msgstr "当前状态"
msgid "Custom nftables sets"
msgstr "自定义 nftables 集合"
msgid "DNS Redirection"
msgstr "DNS 转发"
msgid "Download URL"
msgstr "下载地址"
msgid "Download failed"
msgstr "下载失败"
msgid "Downloading chnroute data..."
msgstr "正在下载数据集..."
msgid "Element Type"
msgstr "元素类型"
msgid "Elements"
msgstr "元素列表"
msgid "Enable FlowProxy"
msgstr "启用代理分流"
msgid "Enabled"
msgstr "已启用"
msgid "Error: %s"
msgstr "错误: %s"
msgid "Expecting: %s"
msgstr "期望值: %s"
msgid "File Path"
msgstr "文件路径"
msgid "FlowProxy"
msgstr "代理分流"
msgid "Force upstream DNS to proxy server"
msgstr "强制上游 DNS 为代理服务器"
msgid "Generated Config"
msgstr "生成的配置"
msgid "Invalid IPv4 address or CIDR"
msgstr "无效的 IPv4 地址或 CIDR"
msgid "Invalid MAC address"
msgstr "无效的 MAC 地址"
msgid "Invalid port or range"
msgstr "无效的端口或端口范围"
msgid "Lists"
msgstr "名单"
msgid "Live Runtime State"
msgstr "实时运行状态"
msgid "Log Level"
msgstr "日志级别"
msgid "Log Size (KB)"
msgstr "日志文件大小 (KB)"
msgid "Logs"
msgstr "日志"
msgid "LuCI Support for FlowProxy Traffic Diversion"
msgstr "LuCI 支持 FlowProxy 流量分流"
msgid "Master switch updated."
msgstr "总开关状态已更新。"
msgid "Match Type"
msgstr "匹配类型"
msgid "Match Value"
msgstr "匹配值"
msgid "Network interface"
msgstr "网络接口"
msgid "Please set download_url first"
msgstr "请先设置下载 URL"
msgid "Preview"
msgstr "预览"
msgid "Proxy server DNS port"
msgstr "代理服务器 DNS 端口"
msgid "Proxy server IP address"
msgstr "代理服务器 IP 地址"
msgid "Proxy server interface"
msgstr "代理服务器所在接口"
msgid "Quick add:"
msgstr "快速添加预设:"
msgid "Refresh Logs"
msgstr "刷新日志"
msgid "Refresh Status"
msgstr "刷新实时状态"
msgid "Routing Table ID"
msgstr "路由表 ID"
msgid "Rule Priority"
msgstr "策略路由优先级"
msgid "Rules"
msgstr "规则"
msgid "Set type mismatch: expected %s, got %s"
msgstr "集合类型不匹配: 期望 %s, 实际为 %s"
msgid "Settings"
msgstr "设置"
msgid "TCP Matching Rules"
msgstr "TCP 匹配规则列表"
msgid "Traffic Mark"
msgstr "流量标记"
msgid "Traffic diversion based on nftables rules. The service will automatically start/stop when you click \"Save & Apply\"."
msgstr "基于 nftables 规则的流量分流。当你点击 “保存并应用” 后,服务将自动启动或停止。"
msgid "UDP Matching Rules"
msgstr "UDP 匹配规则列表"
msgid "Update Chnroute"
msgstr "立即更新数据集"
msgid "Updated successfully."
msgstr "数据集更新成功。"
msgid "auto_generate"
msgstr "自动生成"
msgid "checking..."
msgstr "检测中..."
msgid "chnroute_dst_ip_v4"
msgstr "中国大陆 IP"
msgid "elements"
msgstr "元素列表"
msgid "enable protocol"
msgstr "启用协议"
msgid "loading logs..."
msgstr "日志加载中..."
msgid "loading..."
msgstr "加载中..."
msgid "no logs available"
msgstr "暂无日志内容"
msgid "no_proxy_dst_ip_v4"
msgstr "不代理的目标 IP"
msgid "no_proxy_dst_tcp_ports"
msgstr "不代理的目标 TCP 端口"
msgid "no_proxy_dst_udp_ports"
msgstr "不代理的目标 UDP 端口"
msgid "no_proxy_src_ip_v4"
msgstr "不代理的源 IP"
msgid "no_proxy_src_mac"
msgstr "不代理的源 MAC"
msgid "private_dst_ip_v4"
msgstr "私有 IP"
msgid "reset %s templates"
msgstr "重置 %s 模版"
msgid "running"
msgstr "运行中"
msgid "stopped"
msgstr "已停止"
msgid "this will delete ALL current %s rules and generate default templates. are you sure?"
msgstr "这将删除当前所有 %s 规则并加载推荐的默认模版。确定吗?"
msgid "Restart"
msgstr "重启"
msgid "Restarting FlowProxy service..."
msgstr "正在重启 FlowProxy 服务..."
msgid "FlowProxy service restarted successfully."
msgstr "FlowProxy 服务重启成功。"
msgid "Failed to restart FlowProxy service."
msgstr "重启 FlowProxy 服务失败。"
msgid "RPC error: %s"
msgstr "RPC 错误: %s"
msgid "The proxy server IP address must be within the subnet of the selected interface (%s)."
msgstr "代理服务器 IP 地址必须在所选网络接口的子网网段之内 (%s)。"
@@ -0,0 +1,104 @@
config flowproxy 'global'
option enabled '0'
option tcp_enabled '1'
option udp_enabled '1'
option proxy_server_ip_addr ''
option interface 'br-lan'
option traffic_mark '0x666'
option routing_table '888'
option rule_priority '1000'
option dns_proxy_enabled '0'
option proxy_server_dns_port '5353'
option log_level 'info'
option log_size '1024'
option log_count '3'
# --- TCP 默认规则 ---
config tcp_rule
option enabled '1'
option match_type 'src_mac'
option match_value '@no_proxy_src_mac'
option action 'return'
option counter '0'
config tcp_rule
option enabled '1'
option match_type 'dst_ip'
option match_value '@private_dst_ip_v4'
option action 'return'
option counter '0'
config tcp_rule
option enabled '1'
option match_type 'dst_ip'
option match_value '@chnroute_dst_ip_v4'
option action 'return'
option counter '0'
config tcp_rule
option enabled '1'
option match_type 'dst_port'
option match_value '@no_proxy_dst_tcp_ports'
option action 'return'
option counter '0'
# --- UDP 默认规则 ---
config udp_rule
option enabled '1'
option match_type 'src_mac'
option match_value '@no_proxy_src_mac'
option action 'return'
option counter '0'
config udp_rule
option enabled '1'
option match_type 'dst_ip'
option match_value '@private_dst_ip_v4'
option action 'return'
option counter '0'
config udp_rule
option enabled '1'
option match_type 'dst_ip'
option match_value '@chnroute_dst_ip_v4'
option action 'return'
option counter '0'
config udp_rule
option enabled '1'
option match_type 'dst_port'
option match_value '@no_proxy_dst_udp_ports'
option action 'return'
option counter '0'
# nftables set 配置
config nftset 'no_proxy_src_mac'
option enabled '1'
option type 'ether_addr'
config nftset 'no_proxy_src_ip_v4'
option enabled '1'
option type 'ipv4_addr'
config nftset 'no_proxy_dst_ip_v4'
option enabled '1'
option type 'ipv4_addr'
config nftset 'private_dst_ip_v4'
option enabled '1'
option type 'ipv4_addr'
option auto_generate '1'
config nftset 'chnroute_dst_ip_v4'
option enabled '1'
option type 'ipv4_addr'
option file_path '/usr/share/flowproxy/chnroute.txt'
option download_url 'https://proxy.seckv.com/https://raw.githubusercontent.com/mayaxcn/china-ip-list/master/chnroute.txt'
config nftset 'no_proxy_dst_tcp_ports'
option enabled '1'
option type 'inet_service'
config nftset 'no_proxy_dst_udp_ports'
option enabled '1'
option type 'inet_service'
+299
View File
@@ -0,0 +1,299 @@
#!/bin/sh /etc/rc.common
# FlowProxy - Traffic Diversion Service
START=99
STOP=10
USE_PROCD=1
NAME="flowproxy"
UCI_CONFIG="flowproxy"
LOG_FILE="/var/log/flowproxy.log"
NFT_CONF="/tmp/flowproxy/nft.conf"
GENERATE_SCRIPT="/usr/share/flowproxy/generate_nft.sh"
STATE_FILE="/var/run/flowproxy.running"
get_level_weight() {
case "$1" in
debug) echo 0 ;;
info) echo 1 ;;
warn) echo 2 ;;
error) echo 3 ;;
*) echo 1 ;;
esac
}
log_msg() {
local level="$1"; shift
local msg="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
# 等级过滤
local conf_level=$(uci -q get "$UCI_CONFIG.global.log_level" || echo "info")
local conf_weight=$(get_level_weight "$conf_level")
local msg_weight=$(get_level_weight "$level")
[ "$msg_weight" -lt "$conf_weight" ] && return
# 简单的日志截断逻辑,防止日志无限增长
# 读取配置的大小限制,默认 1024KB
local max_size=$(uci -q get "$UCI_CONFIG.global.log_size" || echo 1024)
local current_size=$(du -k "$LOG_FILE" 2>/dev/null | cut -f1)
if [ "${current_size:-0}" -gt "$max_size" ]; then
mv "$LOG_FILE" "$LOG_FILE.old"
touch "$LOG_FILE"
fi
echo "[$timestamp] [$level] $msg" >> "$LOG_FILE"
}
check_env() {
# 检测核心二进制工具是否存在
if ! command -v nft >/dev/null 2>&1; then
log_msg error "nftables binary not found"
return 1
fi
return 0
}
check_config() {
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
[ -z "$proxy_server_ip_addr" ] && { log_msg error "proxy_server_ip_addr not configured"; return 1; }
return 0
}
apply_nft_rules() {
log_msg info "Generating nftables configuration..."
local conf_level=$(uci -q get "$UCI_CONFIG.global.log_level" || echo "info")
mkdir -p "$(dirname "$NFT_CONF")"
if [ -x "$GENERATE_SCRIPT" ]; then
# 传递日志等级并捕获 stderr 到临时文件
local err_file="/tmp/flowproxy/gen_err.tmp"
FLOWPROXY_LOG_LEVEL="$conf_level" "$GENERATE_SCRIPT" >/dev/null 2> "$err_file"
local ret=$?
# 将脚本的输出记录到日志
if [ -s "$err_file" ]; then
while read -r line; do
log_msg debug "Script: $line"
done < "$err_file"
rm -f "$err_file"
fi
if [ $ret -eq 0 ]; then
log_msg info "Applying nftables rules from $NFT_CONF"
# 确保内核支持 inet 表
nft delete table inet flowproxy 2>/dev/null
if nft -f "$NFT_CONF"; then
return 0
else
log_msg error "nftables rules application failed (syntax error)"
return 1
fi
else
log_msg error "Failed to generate nftables config"
return 1
fi
else
log_msg error "Generator script $GENERATE_SCRIPT not found or not executable"
return 1
fi
}
add_route() {
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
local interface=$(uci -q get "$UCI_CONFIG.global.interface" || echo "br-lan")
local traffic_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark" || echo "0x666")
local routing_table=$(uci -q get "$UCI_CONFIG.global.routing_table" || echo "888")
local rule_priority=$(uci -q get "$UCI_CONFIG.global.rule_priority" || echo "1000")
# 1. 先尝试用旧记录清理一次,防止由于配置变更导致的残留
delete_route "quiet"
log_msg info "Setting up routing table $routing_table and rule for mark $traffic_mark with priority $rule_priority..."
# 2. 记录当前使用的标记值、路由表 ID 和优先级
echo "$traffic_mark:$routing_table:$rule_priority" > "/var/run/flowproxy.mark"
# 3. 再次确保新标记和表的规则是干净的
while ip rule del fwmark "$traffic_mark" lookup "$routing_table" 2>/dev/null; do :; done
ip route flush table "$routing_table" 2>/dev/null
# 4. 添加路由和策略
local err
ip route add default via "$proxy_server_ip_addr" dev "$interface" table "$routing_table" 2>/dev/null
err=$(ip rule add fwmark "$traffic_mark" lookup "$routing_table" priority "$rule_priority" 2>&1)
if [ $? -eq 0 ]; then
log_msg info "Route added: via $proxy_server_ip_addr dev $interface table $routing_table for mark $traffic_mark with priority $rule_priority"
else
log_msg error "Failed to add ip rule: $err"
fi
}
delete_route() {
local traffic_mark routing_table rule_priority
local quiet="$1"
# 1. 获取两组清理目标:旧值(记录文件) 和 新值(当前UCI)
local old_mark="" old_table="" old_prio=""
if [ -f "/var/run/flowproxy.mark" ]; then
local saved_val=$(cat "/var/run/flowproxy.mark")
old_mark=$(echo "$saved_val" | cut -d':' -f1)
old_table=$(echo "$saved_val" | cut -d':' -f2)
old_prio=$(echo "$saved_val" | cut -d':' -f3)
[ "$quiet" != "quiet" ] && rm -f "/var/run/flowproxy.mark"
fi
local curr_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark" || echo "0x666")
local curr_table=$(uci -q get "$UCI_CONFIG.global.routing_table" || echo "888")
local curr_prio=$(uci -q get "$UCI_CONFIG.global.rule_priority" || echo "1000")
[ "$quiet" != "quiet" ] && log_msg info "Cleaning up FlowProxy routes (Atomic Mode)..."
# 2. 核心清理函数:只清理带 fwmark 且指向特定 table 的规则
safe_del_rule() {
local m="$1" t="$2" p="$3"
[ -z "$m" ] || [ -z "$t" ] && return
log_msg debug "Attempting cleanup: mark=$m, table=$t, priority=${p:-any}"
# 精准清理 (带优先级)
[ -n "$p" ] && while ip rule del fwmark "$m" lookup "$t" priority "$p" 2>/dev/null; do
log_msg debug "Removed stale ip rule: mark $m lookup $t priority $p"
done
# 模糊清理 (不带优先级,防止配置中途被手动改动)
while ip rule del fwmark "$m" lookup "$t" 2>/dev/null; do
log_msg debug "Removed stale ip rule (fuzzy): mark $m lookup $t"
done
# 清空路由表
ip route flush table "$t" 2>/dev/null
log_msg debug "Flushed routing table $t"
}
# 执行清理:先清旧值,再清当前值 (防止残留)
safe_del_rule "$old_mark" "$old_table" "$old_prio"
[ "$old_mark" != "$curr_mark" ] || [ "$old_table" != "$curr_table" ] && \
safe_del_rule "$curr_mark" "$curr_table" "$curr_prio"
}
delete_nft() {
nft delete table inet flowproxy 2>/dev/null
log_msg info "Removed nftables table 'flowproxy'"
}
setup_dns() {
local enabled=$(uci -q get "$UCI_CONFIG.global.enabled" || echo 0)
local dns_proxy_enabled=$(uci -q get "$UCI_CONFIG.global.dns_proxy_enabled" || echo 0)
local proxy_server_dns_port=$(uci -q get "$UCI_CONFIG.global.proxy_server_dns_port" || echo 5353)
local proxy_server_ip_addr=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
# Only apply DNS proxy if BOTH main service and DNS proxy are enabled
if [ "$enabled" -eq 0 ] || [ "$dns_proxy_enabled" -eq 0 ]; then
cleanup_dns
return 0
fi
[ -z "$proxy_server_ip_addr" ] && { log_msg error "proxy_server_ip_addr not configured, cannot setup DNS"; return 1; }
log_msg info "Setting up DNS redirection to $proxy_server_ip_addr#$proxy_server_dns_port..."
# Persistent backup in UCI
if ! uci -q get "$UCI_CONFIG.dns_backup" >/dev/null; then
local old_noresolv=$(uci -q get dhcp.@dnsmasq[0].noresolv)
local old_dns_redirect=$(uci -q get dhcp.@dnsmasq[0].dns_redirect)
local old_servers=$(uci -q get dhcp.@dnsmasq[0].server | tr '\n' ',' | sed 's/,$//')
log_msg debug "Backing up DNS: noresolv=${old_noresolv:-empty}, redirect=${old_dns_redirect:-empty}, servers=${old_servers:-empty}"
uci set "$UCI_CONFIG.dns_backup=dns_backup"
[ -n "$old_noresolv" ] && uci set "$UCI_CONFIG.dns_backup.noresolv=$old_noresolv"
[ -n "$old_dns_redirect" ] && uci set "$UCI_CONFIG.dns_backup.dns_redirect=$old_dns_redirect"
[ -n "$old_servers" ] && uci set "$UCI_CONFIG.dns_backup.servers=$old_servers"
uci commit "$UCI_CONFIG"
fi
log_msg debug "Applying new DNS: noresolv=1, redirect=0, server=$proxy_server_ip_addr#$proxy_server_dns_port"
uci set dhcp.@dnsmasq[0].noresolv='1'
uci set dhcp.@dnsmasq[0].dns_redirect='0'
# Delete current servers and add our proxy
uci -q delete dhcp.@dnsmasq[0].server
uci add_list dhcp.@dnsmasq[0].server="$proxy_server_ip_addr#$proxy_server_dns_port"
uci commit dhcp
/etc/init.d/dnsmasq restart
}
cleanup_dns() {
if uci -q get "$UCI_CONFIG.dns_backup" >/dev/null; then
log_msg info "Restoring original DNS configuration..."
local noresolv=$(uci -q get "$UCI_CONFIG.dns_backup.noresolv")
local dns_redirect=$(uci -q get "$UCI_CONFIG.dns_backup.dns_redirect")
local servers=$(uci -q get "$UCI_CONFIG.dns_backup.servers")
log_msg debug "Restoring DNS values: noresolv=${noresolv:-0}, redirect=${dns_redirect:-1}, servers=${servers:-empty}"
# Restore noresolv (default to 0 if not backed up)
[ -n "$noresolv" ] && uci set dhcp.@dnsmasq[0].noresolv="$noresolv" || uci set dhcp.@dnsmasq[0].noresolv="0"
# Restore dns_redirect (default to 1 as requested for OpenWrt typical modded builds)
[ -n "$dns_redirect" ] && uci set dhcp.@dnsmasq[0].dns_redirect="$dns_redirect" || uci set dhcp.@dnsmasq[0].dns_redirect="1"
# Restore servers
uci -q delete dhcp.@dnsmasq[0].server
if [ -n "$servers" ]; then
for s in $servers; do
[ -n "$s" ] && uci add_list dhcp.@dnsmasq[0].server="$s"
done
fi
uci commit dhcp
uci delete "$UCI_CONFIG.dns_backup"
uci commit "$UCI_CONFIG"
/etc/init.d/dnsmasq restart
fi
}
start_service() {
local enabled=$(uci -q get "$UCI_CONFIG.global.enabled" || echo 0)
if [ "$enabled" -eq 0 ]; then
log_msg info "FlowProxy is disabled, ensuring service is stopped"
stop_service
return 0
fi
log_msg info "Starting FlowProxy..."
check_env || return 1
check_config || return 1
apply_nft_rules || return 1
add_route
setup_dns
touch "$STATE_FILE"
log_msg info "FlowProxy service started successfully"
}
stop_service() {
log_msg info "Stopping FlowProxy..."
# 强制先移除标记,防止状态误报
rm -f "$STATE_FILE"
delete_route
delete_nft
cleanup_dns
log_msg info "FlowProxy service stopped"
}
restart_service() {
stop_service
start_service
}
service_triggers() {
# 监听自身配置改动
procd_add_reload_trigger "$UCI_CONFIG"
# 监听防火墙重载事件,防止规则被 fw4 清空后无法自动恢复
procd_add_reload_trigger "firewall"
}
reload_service() {
restart
}
File diff suppressed because it is too large. Load diff
+215
View File
@@ -0,0 +1,215 @@
#!/bin/sh
# Generate nftables configuration for flowproxy
. /lib/functions.sh
[ -n "$UCI_CONFIG_DIR" ] && export UCI_CONFIG_DIR
CONFIG="flowproxy"
NFT_TABLE="flowproxy"
OUTPUT_FILE="/tmp/flowproxy/nft.conf"
mkdir -p "$(dirname "$OUTPUT_FILE")"
ENABLED_SETS=" "
log_debug() {
[ "$FLOWPROXY_LOG_LEVEL" = "debug" ] && echo "DEBUG: $*" >&2
}
gen_set_definition() {
local section="$1"
local type enabled auto_gen file_path elems is_interval
enabled=$(uci -q get "$CONFIG.$section.enabled")
[ "$enabled" = "0" ] && { log_debug "Set $section is disabled, skipping"; return; }
log_debug "Generating set: $section"
ENABLED_SETS="${ENABLED_SETS}@${section} "
type=$(uci -q get "$CONFIG.$section.type")
[ -z "$type" ] && type="ipv4_addr"
if [ "$section" = "private_dst_ip_v4" ]; then
auto_gen=$(uci -q get "$CONFIG.$section.auto_generate")
if [ "$auto_gen" = "1" ] || [ -z "$auto_gen" ]; then
elems="10.0.0.0/8, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16"
fi
fi
local uci_elems=""
for e in $(uci -q get "$CONFIG.$section.elements"); do
[ -n "$uci_elems" ] && uci_elems="${uci_elems}, "
uci_elems="${uci_elems}${e}"
done
[ -n "$uci_elems" ] && { [ -n "$elems" ] && elems="${elems}, "; elems="${elems}${uci_elems}"; }
file_path=$(uci -q get "$CONFIG.$section.file_path")
download_url=$(uci -q get "$CONFIG.$section.download_url")
if [ -n "$download_url" ] && [ -n "$file_path" ]; then
if [ ! -f "$file_path" ]; then
echo "Downloading $section data from $download_url to $file_path..." >&2
wget -q -O "$file_path" "$download_url" --timeout=10 --no-check-certificate
[ $? -ne 0 ] && { echo "Failed to download $section data" >&2; rm -f "$file_path"; }
fi
fi
if [ -n "$file_path" ] && [ -f "$file_path" ]; then
local file_elems=$(get_file_elements "$file_path")
if [ -n "$file_elems" ]; then
[ -n "$elems" ] && elems="${elems}, "
elems="${elems}${file_elems}"
fi
fi
case "$type" in "ipv4_addr"|"inet_service"|"ether_addr") is_interval=1 ;; *) is_interval=0 ;; esac
[ "$is_interval" = "0" ] && { case "$elems" in */*|*-*) is_interval=1 ;; esac; }
printf "\tset %s {\n" "$section"
printf "\t\ttype %s\n" "$type"
[ "$is_interval" = "1" ] && printf "\t\tflags interval\n"
[ -n "$elems" ] && printf "\t\telements = { %s }\n" "$elems"
printf "\t}\n\n"
}
get_file_elements() {
local file_path="$1"
[ ! -f "$file_path" ] && return
if [ "$FLOWPROXY_PREVIEW" = "1" ]; then
local total=$(wc -l < "$file_path")
if [ "$total" -gt 100 ]; then
awk 'BEGIN { count=0 }
!/^($|#)/ {
if (count < 100) {
if (count > 0) {
printf ", "
if (count % 2 == 0) printf "\n\t\t\t "
}
printf "%s", $0
count++
}
}
END { printf ", ... (%d more items omitted for preview)", '"$total"'-100 }' "$file_path"
return
fi
fi
awk 'BEGIN { count=0 }
!/^($|#)/ {
if (count > 0) {
printf ", "
if (count % 2 == 0) printf "\n\t\t\t "
}
printf "%s", $0
count++
}
END { print "" }' "$file_path"
}
process_rule() {
local section="$1"; local proto="$2"
local enabled match_type match_value action counter
enabled=$(uci -q get "$CONFIG.$section.enabled"); [ "$enabled" = "0" ] && { log_debug "Rule $section is disabled, skipping"; return; }
match_type=$(uci -q get "$CONFIG.$section.match_type")
[ -z "$match_type" ] && match_type="dst_ip"
match_value=$(uci -q get "$CONFIG.$section.match_value")
log_debug "Processing $proto rule: $section ($match_type=$match_value)"
action=$(uci -q get "$CONFIG.$section.action"); [ -z "$action" ] && action="return"
counter=$(uci -q get "$CONFIG.$section.counter")
[ -z "$match_value" ] && return
case "$match_value" in
@*)
local set_ref=$(echo "$match_value" | cut -d' ' -f1)
if ! echo "$ENABLED_SETS" | grep -q " ${set_ref} "; then
log_debug "Skipping rule $section: set ${set_ref} is not defined or disabled"
return
fi
;;
esac
match_value=$(echo "$match_value" | sed -E 's/ (return|accept|drop)$//g')
local segment=""
case "$match_type" in
src_mac) segment="ether saddr $match_value ip protocol $proto" ;;
src_ip) segment="ip saddr $match_value ip protocol $proto" ;;
dst_ip) segment="ip daddr $match_value ip protocol $proto" ;;
src_port) segment="ip protocol $proto $proto sport $match_value" ;;
dst_port) segment="ip protocol $proto $proto dport $match_value" ;;
*) segment="$match_value ip protocol $proto" ;;
esac
local line="$segment"; [ "$counter" = "1" ] && line="$line counter"; line="$line $action"
[ -n "$line" ] && echo " $line"
}
SECTIONS_SET=$(uci -q show "$CONFIG" | grep "=nftset" | cut -d'.' -f2 | cut -d'=' -f1)
SECTIONS_TCP=$(uci -q show "$CONFIG" | grep "=tcp_rule" | cut -d'.' -f2 | cut -d'=' -f1)
SECTIONS_UDP=$(uci -q show "$CONFIG" | grep "=udp_rule" | cut -d'.' -f2 | cut -d'=' -f1)
if [ "$1" = "runtime" ]; then
TRAFFIC_MARK=$(uci -q get "$CONFIG.global.traffic_mark" || echo "0x666")
ROUTING_TABLE=$(uci -q get "$CONFIG.global.routing_table" || echo "888")
echo "--- [ nftables live rules ] ---"
nft list table inet "$CONFIG" 2>/dev/null | awk 'BEGIN { count=0; skip=0 }
/elements = \{/ { print $0; skip=1; next }
skip == 1 && /\}/ { printf "\t\t\t ... (items truncated for preview)\n"; print $0; skip=0; next }
skip == 1 { count++; if (count < 20) print $0; next }
{ print $0 }' || echo "(table not found)"
echo ""
echo "--- [ ip rule list ] ---"
ip rule show 2>/dev/null
echo ""
echo "--- [ ip route table $ROUTING_TABLE ] ---"
ip route show table "$ROUTING_TABLE" 2>/dev/null || echo "(table empty)"
exit 0
fi
cat > "$OUTPUT_FILE" << EOF
#!/usr/sbin/nft -f
table inet $NFT_TABLE
flush table inet $NFT_TABLE
table inet $NFT_TABLE {
EOF
for s in $SECTIONS_SET; do
gen_set_definition "$s" >> "$OUTPUT_FILE"
done
TCP_ENABLED=$(uci -q get "$CONFIG.global.tcp_enabled" || echo "1")
UDP_ENABLED=$(uci -q get "$CONFIG.global.udp_enabled" || echo "1")
TRAFFIC_MARK=$(uci -q get "$CONFIG.global.traffic_mark" || echo "0x666")
PROXY_SERVER_IP_ADDR=$(uci -q get "$CONFIG.global.proxy_server_ip_addr")
if [ "$TCP_ENABLED" = "1" ]; then
cat >> "$OUTPUT_FILE" << EOF
chain LAN_MARKFLOW_TCP {
type filter hook prerouting priority mangle; policy accept;
fib daddr type { unspec, local, anycast, multicast } counter return
EOF
if [ -n "$PROXY_SERVER_IP_ADDR" ]; then
echo " ip saddr $PROXY_SERVER_IP_ADDR ip protocol tcp counter return" >> "$OUTPUT_FILE"
fi
for s in $SECTIONS_TCP; do process_rule "$s" "tcp" >> "$OUTPUT_FILE"; done
echo " ip protocol tcp counter meta mark set $TRAFFIC_MARK" >> "$OUTPUT_FILE"
echo " }" >> "$OUTPUT_FILE"
fi
if [ "$UDP_ENABLED" = "1" ]; then
cat >> "$OUTPUT_FILE" << EOF
chain LAN_MARKFLOW_UDP {
type filter hook prerouting priority mangle; policy accept;
fib daddr type { unspec, local, anycast, multicast } counter return
EOF
if [ -n "$PROXY_SERVER_IP_ADDR" ]; then
echo " ip saddr $PROXY_SERVER_IP_ADDR ip protocol udp counter return" >> "$OUTPUT_FILE"
fi
for s in $SECTIONS_UDP; do process_rule "$s" "udp" >> "$OUTPUT_FILE"; done
echo " ip protocol udp counter meta mark set $TRAFFIC_MARK" >> "$OUTPUT_FILE"
echo " }" >> "$OUTPUT_FILE"
fi
echo "}" >> "$OUTPUT_FILE"
cat "$OUTPUT_FILE"
@@ -0,0 +1,11 @@
{
"admin/services/flowproxy": {
"title": "FlowProxy",
"description": "LuCI Support for FlowProxy Traffic Diversion",
"index": 50,
"action": {
"type": "view",
"path": "flowproxy/flowproxy"
}
}
}
@@ -0,0 +1,26 @@
{
"luci-app-flowproxy": {
"description": "Grant access to flowproxy configuration",
"read": {
"uci": [ "flowproxy" ],
"file": {
"/usr/share/flowproxy/*": [ "read" ]
},
"ubus": {
"luci.flowproxy": [ "*" ],
"network.interface": [ "dump" ]
}
},
"write": {
"uci": [ "flowproxy" ],
"file": {
"/usr/bin/wget": [ "exec" ],
"/usr/bin/wc": [ "exec" ],
"/usr/share/flowproxy/generate_nft.sh": [ "exec" ]
},
"ubus": {
"luci.flowproxy": [ "*" ]
}
}
}
}
+175
View File
@@ -0,0 +1,175 @@
'use strict';
import { access, readfile, writefile, stat } from 'fs';
import { cursor } from 'uci';
import { connect } from 'ubus';
const UCI_CONFIG = 'flowproxy';
const STATE_FILE = '/var/run/flowproxy.running';
const IP_CACHE = '/tmp/flowproxy/lan_ip.cache';
const LOG_FILE = '/var/log/flowproxy.log';
const NFT_TABLE = 'flowproxy';
// 辅助函数:安全 trim
function safe_trim(str) {
return (type(str) === 'string') ? trim(str) : '';
}
const methods = {
get_status: {
call: function() {
// 1. 快速探测
let is_running = access(STATE_FILE, 'f');
// 2. 自愈:检查内核状态并同步标记文件
let tcp_active = (system('nft list chain inet ' + NFT_TABLE + ' LAN_MARKFLOW_TCP >/dev/null 2>&1') === 0);
let udp_active = (system('nft list chain inet ' + NFT_TABLE + ' LAN_MARKFLOW_UDP >/dev/null 2>&1') === 0);
let real_running = (tcp_active || udp_active);
if (real_running && !is_running) {
try { writefile(STATE_FILE, ''); } catch(e) {}
} else if (!real_running && is_running) {
try { system('rm -f ' + STATE_FILE); } catch(e) {}
}
// 3. IP 获取 (带缓存)
let lan_ip = readfile(IP_CACHE) || '';
if (!lan_ip) {
try {
let ubus_conn = connect();
if (ubus_conn) {
let ifaces = ubus_conn.call('network.interface', 'dump');
if (type(ifaces?.interface) === 'array') {
// 优先寻找名为 'lan' 的接口,或者寻找拥有 ipv4 地址的第一个非回环接口
let fallback_ip = '';
for (let iface in ifaces.interface) {
if (iface.interface === 'lan' && iface['ipv4-address']?.[0]?.address) {
lan_ip = iface['ipv4-address'][0].address;
break;
}
if (!fallback_ip && iface.interface !== 'loopback' && iface['ipv4-address']?.[0]?.address) {
fallback_ip = iface['ipv4-address'][0].address;
}
}
if (!lan_ip) lan_ip = fallback_ip;
}
if (lan_ip) {
system('mkdir -p /tmp/flowproxy');
writefile(IP_CACHE, lan_ip);
}
ubus_conn.disconnect();
}
} catch (e) {}
}
let uci_cursor = cursor();
let proxy_server_ip_addr = uci_cursor.get(UCI_CONFIG, 'global', 'proxy_server_ip_addr') || '';
let dns_running = (uci_cursor.get(UCI_CONFIG, 'dns_backup') ? 1 : 0);
return {
running: real_running ? 1 : 0,
dns_running: dns_running,
enabled: int(uci_cursor.get(UCI_CONFIG, 'global', 'enabled') || '0'),
proxy_server_ip_addr: proxy_server_ip_addr,
proxy_server_dns_port: uci_cursor.get(UCI_CONFIG, 'global', 'proxy_server_dns_port') || '5353',
lan_ip: safe_trim(lan_ip),
interface: uci_cursor.get(UCI_CONFIG, 'global', 'interface') || 'br-lan',
traffic_mark: uci_cursor.get(UCI_CONFIG, 'global', 'traffic_mark') || '0x666',
nft: { tcp: tcp_active ? 1 : 0, udp: udp_active ? 1 : 0 }
};
}
},
get_logs: {
args: { lines: 100 },
call: function(request) {
let lines = request.args?.lines ?? 100;
let logs = [];
try {
if (access(LOG_FILE, 'r')) {
let log_stat = stat(LOG_FILE);
let content = '';
if (log_stat && log_stat.size > 65536) {
let fd = popen('tail -c 65536 ' + LOG_FILE);
if (fd) {
content = fd.read('all');
fd.close();
}
} else {
content = readfile(LOG_FILE);
}
if (content) {
let log_lines = split(content, "\n");
let total = length(log_lines);
let start = (total > lines) ? (total - lines) : 0;
for (let i = start; i < total; i++) {
if (log_lines[i]) push(logs, log_lines[i]);
}
}
}
} catch (e) {}
return { logs };
}
},
generate_nft_config: {
call: function() {
system('mkdir -p /tmp/flowproxy');
system('FLOWPROXY_PREVIEW=1 /usr/share/flowproxy/generate_nft.sh 2>&1');
return { config: safe_trim(readfile('/tmp/flowproxy/nft.conf') || '') };
}
},
get_runtime_config: {
call: function() {
let res_file = '/tmp/flowproxy/runtime.tmp';
let dns_file = '/tmp/flowproxy/dns_runtime.tmp';
system('mkdir -p /tmp/flowproxy');
system('/usr/share/flowproxy/generate_nft.sh runtime > ' + res_file + ' 2>&1');
let nft_runtime = readfile(res_file) || "(failed)";
system('rm -f ' + res_file);
system('uci show dhcp.@dnsmasq[0] | grep -E "noresolv|dns_redirect|server" | sed -E "s/^dhcp\\.[^.]*\\.([^=]*)=(.*)/\\1: \\2/; s/\\x27//g; s/#/:/g" > ' + dns_file + ' 2>&1');
let dns_out = readfile(dns_file);
system('rm -f ' + dns_file);
let dns_runtime = "\n--- [ DNSmasq Runtime Configuration ] ---\n";
dns_runtime += (dns_out || "(no active dnsmasq overrides)");
return { runtime: safe_trim(nft_runtime + dns_runtime) };
}
},
get_interfaces: {
call: function() {
let interfaces = [];
try {
let ubus_conn = connect();
if (ubus_conn) {
// 使用 network.device status 以获取所有物理设备 (包括 eth0 等 bridge 成员)
let dev_status = ubus_conn.call('network.device', 'status');
if (dev_status) {
for (let name in dev_status) {
push(interfaces, { name: name });
}
}
ubus_conn.disconnect();
}
} catch (e) {}
return { interfaces };
}
},
start_service: { call: function() { return { result: (system('/etc/init.d/flowproxy start >/dev/null 2>&1') === 0) }; } },
stop_service: { call: function() { return { result: (system('/etc/init.d/flowproxy stop >/dev/null 2>&1') === 0) }; } },
restart_service: { call: function() { return { result: (system('/etc/init.d/flowproxy restart >/dev/null 2>&1') === 0) }; } },
clear_logs: { call: function() { try { writefile(LOG_FILE, ''); return { result: true }; } catch(e) { return { result: false }; } } }
};
return { 'luci.flowproxy': methods };
reload_service() {
restart
}