🎨 Sync 2026-11-12 18:00:39

This commit is contained in:
github-actions[bot] committed 2026-11-12 18:00:39 +08:00
1 parent 47f4b4d6ab
commit ed73e42a08
27 files changed
+1828 -1574

No files matched your search

+2 -2
View File
@@ -5,8 +5,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gately
PKG_VERSION:=0.6.5.5
PKG_RELEASE:=32
PKG_VERSION:=0.6.6
PKG_RELEASE:=33
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/wangwanjie/gately/tar.gz/v$(PKG_VERSION)?
+3 -3
View File
@@ -5,11 +5,11 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=honk
PKG_VERSION:=2026.11.09~6484888
PKG_RELEASE:=34
PKG_VERSION:=2026.11.12~a09d3bf
PKG_RELEASE:=35
HONK_SRC_URL:=https://github.com/daeuniverse/honk.git
HONK_SRC_VERSION:=64848881b7304e250e7a25422f4f1d86a5975eca
HONK_SRC_VERSION:=a09d3bfa272f3670d6606373452be41606bdc29e
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILES:=LICENSE
+2 -4
View File
@@ -10,10 +10,8 @@ LUCI_DEPENDS:=+fastnet +luci-lib-linkeaseauth +linkease-app-entry
# APK uses -rN; OPKG uses -N for runtime package releases.
LUCI_EXTRA_DEPENDS:=fastnet (>=0.8.9-$(if $(CONFIG_USE_APK),r)1)
LUCI_PKGARCH:=all
PKG_VERSION:=0.8.9-r1
PKG_RELEASE:=8
# Preserve LuCI translation version ordering when upgrading older git-* packages.
PKG_PO_VERSION:=git-26.313.00000-082
PKG_VERSION:=0.8.9-r2
PKG_RELEASE:=9
LUCI_MINIFY_CSS:=0
LUCI_MINIFY_JS:=0
@@ -490,42 +490,39 @@ function flagToBool(flag) {
function renderPayload(s, total, uciconfig) {
// common payload
let initPayload = function(o, n, key) {
const initPayload = function(o, n, key,
load_callback = (n, k, inst) => inst.getPayload(n)[k],
onchange_callback = (n, k, v, inst) => inst.setPayload(n, Object.fromEntries([[k, v]]))
) {
o.load = function(section_id) {
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key];
return load_callback(n, key, this.section.getOption('entry').instget(section_id));
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let n = this.option.match(/^payload(\d+)_/)[1];
let rule = new RulesEntry(UIEl.getValue()).setPayload(n, {factor: value});
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
this.section.getOption('entry').instset(section_id, L.bind(onchange_callback, this, n, key, value));
}
o.write = function() {};
o.rmempty = false;
o.modalonly = true;
}
let initDynamicPayload = function(o, n, key) {
const initDynamicPayload = function(o, n, key,
load_callback = (n, k, inst) => inst.getPayloads().slice(n).map(e => e[k] ?? ''),
onchange_callback = (n, k, v, inst) => {
const limit = inst.getPayloads().length;
v.forEach((val) => {
inst.setPayload(n, Object.fromEntries([[k, val]])); n++;
});
inst.setPayload(limit, Object.fromEntries([[k, null]]), limit);
}
) {
o.allowduplicates = true;
o.load = function(section_id) {
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayloads().slice(n).map(e => e[key] ?? '');
return load_callback(n, key, this.section.getOption('entry').instget(section_id));
}
o.validate = function(section_id, value) {
value = this.formvalue(section_id);
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue());
let n = this.option.match(/^payload(\d+)_/)[1];
let limit = rule.getPayloads().length;
value.forEach((val) => {
rule.setPayload(n, {factor: val}); n++;
});
rule.setPayload(limit, {factor: null}, limit);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
this.section.getOption('entry').instset(section_id, L.bind(onchange_callback, this, n, key, value));
return true;
}
@@ -535,6 +532,7 @@ function renderPayload(s, total, uciconfig) {
}
let o, prefix;
// StaticList payload
for (let n=0; n<total; n++) {
prefix = `payload${n}_`;
@@ -549,15 +547,6 @@ function renderPayload(s, total, uciconfig) {
o.depends('type', key);
})
initPayload(o, n, 'type');
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let n = this.option.match(/^payload(\d+)_/)[1];
let rule = new RulesEntry(UIEl.getValue()).setPayload(n, {type: value});
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
o = s.option(form.Value, prefix + 'general', _('Factor') + ` ${n+1}`);
if (n === 0) {
@@ -608,48 +597,40 @@ function renderPayload(s, total, uciconfig) {
if (n === 0)
o.depends('type', 'IN-TYPE');
o.depends(prefix + 'type', 'IN-TYPE');
initPayload(o, n, 'factor');
o.load = L.bind(function(n, key, uciconfig, section_id) {
return (new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key] || '')
.split('/');
}, o, n, 'factor', uciconfig)
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let n = this.option.match(/^payload(\d+)_/)[1];
let rule = new RulesEntry(UIEl.getValue()).setPayload(n, {factor: value.join('/')});
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
initPayload(o, n, 'factor',
(n, k, inst) => (inst.getPayload(n)[k] || '').split('/'),
(n, k, v, inst) => inst.setPayload(n, Object.fromEntries([[k, v.join('/')]]))
);
o = s.option(form.ListValue, prefix + 'inname', _('Factor') + ` ${n+1}`);
if (n === 0)
o.depends('type', 'IN-NAME');
o.depends(prefix + 'type', 'IN-NAME');
initPayload(o, n, 'factor');
o.load = L.bind(function(n, key, uciconfig, section_id) {
const inname_old_load = o.load;
o.load = function(section_id) {
hm.loadLabel.call(this, [
['', _('-- Please choose --')],
...hm.loadLabelValues(this.config, 'inbound')
], section_id);
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key];
}, o, n, 'factor', uciconfig)
return inname_old_load.apply(this, arguments);
}
o = s.option(form.ListValue, prefix + 'rematchname', _('Factor') + ` ${n+1}`);
if (n === 0)
o.depends('type', 'REMATCH-NAME');
o.depends(prefix + 'type', 'REMATCH-NAME');
initPayload(o, n, 'factor');
o.load = L.bind(function(n, key, uciconfig, section_id) {
const rematchname_old_load = o.load;
o.load = function(section_id) {
hm.loadLabel.call(this, [
['', _('-- Please choose --')],
...hm.loadLabelValues(this.config, 'rematch-name')
], section_id);
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key];
}, o, n, 'factor', uciconfig)
return rematchname_old_load.apply(this, arguments);
}
o = s.option(form.Value, prefix + 'uint', _('Factor') + ` ${n+1}`);
o.datatype = 'uinteger';
@@ -678,28 +659,20 @@ function renderPayload(s, total, uciconfig) {
o.depends('type', 'RULE-SET');
o.depends(prefix + 'type', 'RULE-SET');
initPayload(o, n, 'factor');
o.load = L.bind(function(n, key, uciconfig, section_id) {
const rule_set_old_load = o.load;
o.load = function(section_id) {
hm.loadLabel.call(this, hm.loadLabelValues(this.config, 'ruleset'), section_id);
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key];
}, o, n, 'factor', uciconfig)
return rule_set_old_load.apply(this, arguments);
}
o = s.option(form.Flag, prefix + 'NOT', _('NOT') + ` ${n+1}`);
o.default = o.disabled;
o.depends(Object.fromEntries([[prefix + 'type', /.+/]]));
initPayload(o, n, 'deny');
o.load = L.bind(function(n, key, uciconfig, section_id) {
return boolToFlag(new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayload(n)[key] ? true : false);
}, o, n, 'deny', uciconfig);
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let n = this.option.match(/^payload(\d+)_/)[1];
let rule = new RulesEntry(UIEl.getValue()).setPayload(n, {deny: flagToBool(value) || null});
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
initPayload(o, n, 'deny',
(n, k, inst) => boolToFlag(inst.getPayload(n)[k] ? true : false),
(n, k, v, inst) => inst.setPayload(n, Object.fromEntries([[k, flagToBool(v) || null]]))
);
}
// DynamicList payload
@@ -721,23 +694,15 @@ function renderPayload(s, total, uciconfig) {
extenbox[n].forEach((type) => {
o.depends('type', type);
})
initDynamicPayload(o, n, 'type');
o.validate = function(section_id, value) {
value = this.formvalue(section_id);
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue());
let n = this.option.match(/^payload(\d+)_/)[1];
value.forEach((val) => {
rule.setPayload(n, {type: val}); n++;
});
rule.setPayload(n, {type: null}, n);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
return true;
}
initDynamicPayload(o, n, 'type',
undefined,
(n, k, v, inst) => {
v.forEach((val) => {
inst.setPayload(n, Object.fromEntries([[k, val]])); n++;
});
inst.setPayload(n, Object.fromEntries([[k, null]]), n);
}
);
o = s.option(hm.GroupDynamicList, prefix + 'fused', _('Factor') + ' ++',
_('Content will not be verified, Please make sure you enter it correctly.'));
@@ -745,7 +710,8 @@ function renderPayload(s, total, uciconfig) {
o.depends(Object.fromEntries([['type', type], [prefix + 'type', /.+/]]));
})
initDynamicPayload(o, n, 'factor');
o.load = L.bind(function(n, key, uciconfig, section_id) {
const fused_old_load = o.load;
o.load = function(section_id) {
hm.loadLabel.call(this, [
...hm.inbound_type.map(e => [e, _('IN-TYPE')]), // Multi-values are not supported
...hm.loadLabelValues(this.config, 'inbound').map(e => [e, _('IN-NAME')]),
@@ -755,8 +721,8 @@ function renderPayload(s, total, uciconfig) {
...hm.loadLabelValues(this.config, 'ruleset').map(e => [e, _('RULE-SET')])
], section_id);
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayloads().slice(n).map(e => e[key] ?? '');
}, o, n, 'factor', uciconfig)
return fused_old_load.apply(this, arguments);
}
o = s.option(hm.StaticList, prefix + 'NOTs', _('NOT') + ' ++',
_('<code>0</code> or <code>1</code> only.'));
@@ -765,27 +731,16 @@ function renderPayload(s, total, uciconfig) {
extenbox[n].forEach((type) => {
o.depends(Object.fromEntries([['type', type], [prefix + 'type', /.+/]]));
})
initDynamicPayload(o, n, 'deny');
o.load = L.bind(function(n, key, uciconfig, section_id) {
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getPayloads().slice(n).map(e => boolToFlag(e[key] ? true : false));
}, o, n, 'deny', uciconfig);
o.validate = function(section_id, value) {
value = this.formvalue(section_id);
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue());
let n = this.option.match(/^payload(\d+)_/)[1];
let limit = rule.getPayloads().length;
value.forEach((value) => {
rule.setPayload(n, {deny: flagToBool(value) || null}); n++;
});
rule.setPayload(limit, {deny: null}, limit);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
return true;
}
initDynamicPayload(o, n, 'deny',
(n, k, inst) => inst.getPayloads().slice(n).map(e => boolToFlag(e[k] ? true : false)),
(n, k, v, inst) => {
const limit = inst.getPayloads().length;
v.forEach((val) => {
inst.setPayload(n, Object.fromEntries([[k, flagToBool(val) || null]])); n++;
});
inst.setPayload(limit, Object.fromEntries([[k, null]]), limit);
}
);
})
}
@@ -801,94 +756,90 @@ function renderRules(s, uciconfig) {
return El;
}
o.load = function(section_id) {
return form.DummyValue.prototype.load.call(this, section_id) || new RulesEntry().toString('json');
const value = this.super('load', section_id);
this[`${this.option}_${section_id}`] = new RulesEntry(value);
return value;
}
o.instget = function(section_id) {return this[`${this.option}_${section_id}`]}
o.instset = function(section_id, callback = inst => inst) {
const UIEl = this.getUIElement(section_id);
const inst = this[`${this.option}_${section_id}`];
callback(inst);
UIEl.node.previousSibling.innerText = inst.toString('mihomo');
UIEl.setValue(inst.toString('json'));
}
o.write = form.AbstractValue.prototype.write;
o.remove = form.AbstractValue.prototype.remove;
o.editable = true;
// common part
const initEntryOption = function(o, key,
load_callback = (k, inst) => inst[k],
onchange_callback = (k, v, inst) => inst.setKey(k, v)
) {
o.load = function(section_id) {
return load_callback(key, this.section.getOption('entry').instget(section_id));
}
o.onchange = function(ev, section_id, value) {
this.section.getOption('entry').instset(section_id, L.bind(onchange_callback, this, key, value));
}
o.write = function() {};
}
o = s.option(hm.GroupListValue, 'type', _('Type'));
o.default = hm.rules_type[0].types[0][0];
for (const {label, types} of hm.rules_type.concat(hm.rules_logical_type)) {
types.forEach((res) => o.value.call(o, res, label));
}
o.load = function(section_id) {
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).type;
}
initEntryOption(o, 'type');
o.validate = function(section_id, value) {
// params only available for types other than
if (hm.rules_type_allowparms.includes(value)) {
['no-resolve', 'src'].forEach((opt) => {
let UIEl = this.section.getUIElement(section_id, opt);
const params = ['no-resolve', 'src'];
for (let param of params) {
let UIEl = this.section.getUIElement(section_id, param);
if (hm.rules_type_allowparms.includes(value))
UIEl.node.querySelector('input').removeAttribute('disabled');
});
} else {
['no-resolve', 'src'].forEach((opt) => {
let UIEl = this.section.getUIElement(section_id, opt);
else {
UIEl.setValue('');
UIEl.node.querySelector('input').disabled = true;
});
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setParam('no-resolve').setParam('src');
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
this.section.getOption('entry').instset(section_id, inst => params.forEach(param => inst.setParam(param)));
}
}
return true;
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setKey('type', value);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
o.write = function() {};
o.rmempty = false;
o.modalonly = true;
renderPayload(s, Math.max(...Object.values(hm.rules_logical_payload_count).map(e => e.low)), uciconfig);
o = s.option(hm.ListValue, 'detour', _('Proxy group'));
initEntryOption(o, 'detour');
const detour_old_load = o.load;
o.load = function(section_id) {
hm.loadLabel.call(this, [
...hm.preset_outbound.full,
...hm.loadLabelValues(this.config, 'proxy_group')
], section_id);
return new RulesEntry(uci.get(uciconfig, section_id, 'entry')).detour;
return detour_old_load.apply(this, arguments);
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setKey('detour', value);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
o.write = function() {};
//o.depends('SUB-RULE', ''); // work on subrules not rules
o.editable = true;
o = s.option(form.Flag, 'src', _('src'),
_('Treat the <code>destination IP</code> as the <code>source IP</code>.'));
o.default = o.disabled;
o.load = function(section_id) {
return boolToFlag(new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getParam('src') ? true : false);
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setParam('src', flagToBool(value) || null);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
o.write = function() {};
initEntryOption(o, 'src',
(k, inst) => boolToFlag(inst.getParam(k) ? true : false),
(k, v, inst) => inst.setParam(k, flagToBool(v) || null)
);
o.depends('SUB-RULE', '');
o.modalonly = true;
@@ -896,18 +847,10 @@ function renderRules(s, uciconfig) {
_('Do not resolve the domain connection to IP for this match.</br>' +
'Only works for pure domain inbound connections without DNS resolution. e.g., socks5h'));
o.default = o.disabled;
o.load = function(section_id) {
return boolToFlag(new RulesEntry(uci.get(uciconfig, section_id, 'entry')).getParam('no-resolve') ? true : false);
}
o.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setParam('no-resolve', flagToBool(value) || null);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
o.write = function() {};
initEntryOption(o, 'no-resolve',
(k, inst) => boolToFlag(inst.getParam(k) ? true : false),
(k, v, inst) => inst.setParam(k, flagToBool(v) || null)
);
o.depends('SUB-RULE', '');
o.modalonly = true;
}
@@ -1421,7 +1364,10 @@ return view.extend({
...hm.loadLabelValues(this.config, 'subrule-group')
], section_id);
return new RulesEntry(uci.get(data[0], section_id, 'entry')).subrule || '';
return this.section.getOption('entry').instget(section_id).subrule || '';
}
so.onchange = function(ev, section_id, value) {
this.section.getOption('entry').instset(section_id, inst => inst.setKey('subrule', value));
}
so.validate = function(section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'detour');
@@ -1431,14 +1377,6 @@ return view.extend({
return true;
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'entry');
let rule = new RulesEntry(UIEl.getValue()).setKey('subrule', value);
UIEl.node.previousSibling.innerText = rule.toString('mihomo');
UIEl.setValue(rule.toString('json'));
}
so.write = function() {};
so.modalonly = true;
/* Routing rules END */
@@ -1642,85 +1580,84 @@ return view.extend({
so.editable = true;
so = ss.option(form.DummyValue, 'address', _('Address'));
so.load = function(section_id) {
const value = this.super('load', section_id);
this[`${this.option}_${section_id}`] = new DNSAddress(value);
return value;
}
so.instget = function(section_id) {return this[`${this.option}_${section_id}`]}
so.instset = function(section_id, callback = inst => inst) {
const UIEl = this.getUIElement(section_id);
const inst = this[`${this.option}_${section_id}`];
callback(inst);
UIEl.node.previousSibling.innerText = inst.toString();
UIEl.setValue(inst.toString());
}
so.write = form.AbstractValue.prototype.write;
so.remove = form.AbstractValue.prototype.remove;
so.editable = true;
so = ss.option(form.Value, 'addr', _('Address'));
so.load = function(section_id) {
return new DNSAddress(uci.get(data[0], section_id, 'address')).addr;
// common part
const initAddressOption = function(o, key,
load_callback = (k, inst) => boolToFlag(inst.parseParam(k) ? true : false),
onchange_callback = (k, v, inst) => inst.setParam(k, flagToBool(v) || null)
) {
o.load = function(section_id) {
return load_callback(key, this.section.getOption('address').instget(section_id));
}
o.onchange = function(ev, section_id, value) {
this.section.getOption('address').instset(section_id, L.bind(onchange_callback, this, key, value));
}
o.write = function() {};
}
so = ss.option(form.Value, 'addr', _('Address'));
initAddressOption(so, 'addr',
(k, inst) => inst[k],
(k, v, inst) => inst[k] = v
);
so.validate = function(section_id, value) {
if (value.match('#'))
return _('Expecting: %s').format(_('No add\'l params'));
// params only available on DoH
// https://github.com/muink/mihomo/blob/43f21c0b412b7a8701fe7a2ea6510c5b985a53d6/config/config.go#L1211C8-L1211C14
if (value.match(/^https?:\/\//)){
this.section.getUIElement(section_id, 'h3').node.querySelector('input').removeAttribute('disabled');
} else {
let UIEl = this.section.getUIElement(section_id, 'address');
let UIEl = this.section.getUIElement(section_id, 'h3');
if (value.match(/^https?:\/\//))
UIEl.node.querySelector('input').removeAttribute('disabled');
else {
this.section.getOption('address').instset(section_id, inst => inst.setParam('h3'));
let newvalue = new DNSAddress(UIEl.getValue()).setParam('h3').toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
['h3'].forEach((opt) => {
let UIEl = this.section.getUIElement(section_id, opt);
UIEl.setValue('');
UIEl.node.querySelector('input').disabled = true;
});
UIEl.setValue('');
UIEl.node.querySelector('input').disabled = true;
}
return true;
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = ('N' + UIEl.getValue()).replace(/^[^#]+/, value);
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
so.rmempty = false;
so.modalonly = true;
so = ss.option(hm.ListValue, 'detour', _('Proxy group'));
initAddressOption(so, 'detour',
(k, inst) => inst.parseParam(k),
(k, v, inst) => inst.setParam(k, v)
);
const detour_old_load = so.load;
so.load = function(section_id) {
hm.loadLabel.call(this, [
...hm.preset_outbound.dns,
...hm.loadLabelValues(this.config, 'proxy_group')
], section_id);
return new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('detour');
return detour_old_load.apply(this, arguments);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('detour', value).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
so.editable = true;
so = ss.option(form.Flag, 'h3', _('HTTP/3'));
so.default = so.disabled;
so.load = function(section_id) {
return boolToFlag(new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('h3') ? true : false);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('h3', flagToBool(value) || null).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'h3');
so.modalonly = true;
so = ss.option(form.Flag, 'skip-cert-verify', _('Skip cert verify'),
@@ -1728,101 +1665,42 @@ return view.extend({
'<br/>' +
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
so.default = so.disabled;
so.load = function(section_id) {
return boolToFlag(new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('skip-cert-verify') ? true : false);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('skip-cert-verify', flagToBool(value) || null).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'skip-cert-verify');
so.modalonly = true;
so = ss.option(form.Value, 'ecs', _('EDNS Client Subnet'));
so.datatype = 'cidr';
so.load = function(section_id) {
return new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('ecs');
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('ecs', value).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'ecs',
(k, inst) => inst.parseParam(k),
(k, v, inst) => inst.setParam(k, v)
);
so.modalonly = true;
so = ss.option(form.Flag, 'ecs-override', _('ECS override'),
_('Override the existing ECS in original request.'));
so.default = so.disabled;
so.load = function(section_id) {
return boolToFlag(new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('ecs-override') ? true : false);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('ecs-override', flagToBool(value) || null).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'ecs-override');
so.depends({'ecs': /.+/});
so.modalonly = true;
so = ss.option(form.Flag, 'disable-ipv4', _('Filter record: %s').format('A'));
so.default = so.disabled;
so.load = function(section_id) {
return boolToFlag(new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('disable-ipv4') ? true : false);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('disable-ipv4', flagToBool(value) || null).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
initAddressOption(so, 'disable-ipv4');
so.write = function() {};
so.modalonly = true;
so = ss.option(form.Flag, 'disable-ipv6', _('Filter record: %s').format('AAAA'));
so.default = so.disabled;
so.load = function(section_id) {
return boolToFlag(new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('disable-ipv6') ? true : false);
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('disable-ipv6', flagToBool(value) || null).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'disable-ipv6');
so.modalonly = true;
so = ss.option(form.DynamicList, 'disable-qtypes', _('Filter record type:'));
so.datatype = 'uinteger';
so.placeholder = '65';
so.load = function(section_id) {
return new DNSAddress(uci.get(data[0], section_id, 'address')).parseParam('disable-qtypes');
}
so.onchange = function(ev, section_id, value) {
let UIEl = this.section.getUIElement(section_id, 'address');
let newvalue = new DNSAddress(UIEl.getValue()).setParam('disable-qtypes', value).toString();
UIEl.node.previousSibling.innerText = newvalue;
UIEl.setValue(newvalue);
}
so.write = function() {};
initAddressOption(so, 'disable-qtypes',
(k, inst) => inst.parseParam(k),
(k, v, inst) => inst.setParam(k, v)
);
so.modalonly = true;
/* DNS server END */
+2 -2
View File
@@ -2,10 +2,10 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-flowproxy
PKG_VERSION:=1.0.3
PKG_RELEASE:=6
PKG_RELEASE:=7
LUCI_TITLE:=LuCI Support for FlowProxy Traffic Diversion
LUCI_DEPENDS:=+luci-base +nftables +ip-full
LUCI_DEPENDS:=+luci-base +nftables +ip-full +iputils-arping
LUCI_PKGARCH:=all
define Package/$(PKG_NAME)/conffiles
@@ -75,12 +75,21 @@ return L.view.extend({
return callGetStatus().then(L.bind(function(status) {
if (!status) return;
var isRunning = (status.running == 1);
var isDegraded = (status.degraded == 1);
var container = node || document;
var statusEl = container.querySelector('#service-status');
if (statusEl) {
var content = [];
if (isRunning) {
if (isRunning && isDegraded) {
content.push(E('span', { 'style': 'color: #FF9800; font-weight: bold;' }, [ _('degraded (direct fallback)') ]));
content.push(' ');
content.push(E('button', {
'class': 'btn cbi-button cbi-button-apply',
'style': 'margin-left: 10px;',
'click': ui.createHandlerFn(this, 'handleRestart')
}, [ _('Restart') ]));
} else if (isRunning) {
var chains = [];
if (status.nft && status.nft.tcp) chains.push('TCP');
if (status.nft && status.nft.udp) chains.push('UDP');
@@ -187,6 +196,9 @@ return L.view.extend({
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.Flag, 'monitor_enabled', _('Enable proxy server monitoring'));
o.default = '0';
o.rmempty = false;
o = s.taboption('settings', form.Value, 'proxy_server_ip_addr', _('Proxy server IP address'));
o.datatype = 'ip4addr'; o.rmempty = true;
@@ -226,3 +226,9 @@ msgstr ""
msgid "this will delete ALL current %s rules and generate default templates. are you sure?"
msgstr ""
msgid "Enable proxy server monitoring"
msgstr ""
msgid "degraded (direct fallback)"
msgstr ""
@@ -250,3 +250,9 @@ msgstr "RPC 错误: %s"
msgid "The proxy server IP address must be within the subnet of the selected interface (%s)."
msgstr "代理服务器 IP 地址必须在所选网络接口的子网网段之内 (%s)。"
msgid "Enable proxy server monitoring"
msgstr "启用代理服务器监控"
msgid "degraded (direct fallback)"
msgstr "已降级 (回退直连状态)"
@@ -8,6 +8,7 @@ config flowproxy 'global'
option routing_table '888'
option rule_priority '1000'
option dns_proxy_enabled '0'
option monitor_enabled '0'
option proxy_server_dns_port '5353'
option log_level 'info'
option log_size '1024'
+58 -271
View File
@@ -5,299 +5,86 @@ 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"
. /usr/share/flowproxy/common.sh
get_level_weight() {
case "$1" in
debug) echo 0 ;;
info) echo 1 ;;
warn) echo 2 ;;
error) echo 3 ;;
*) echo 1 ;;
esac
_stop_unlocked() {
log_msg info "Stopping FlowProxy..."
# 强制先移除标记,防止状态误报及旧守护进程继续动作
rm -f "$STATE_FILE" "$DEGRADED_FILE" "$GEN_FILE"
delete_route
delete_nft
cleanup_dns
rm -f "$NFT_CONF"
log_msg info "FlowProxy service stopped"
}
log_msg() {
local level="$1"; shift
local msg="$*"
local timestamp=$(date '+%Y-%m-%d %H:%M:%S')
_start_unlocked() {
local enabled=$(uci -q get "$UCI_CONFIG.global.enabled")
# 等级过滤
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
if [ "${enabled:-0}" != "1" ]; then
log_msg info "FlowProxy is disabled, ensuring service is stopped"
_stop_unlocked
return 0
fi
# 简单的日志截断逻辑,防止日志无限增长
# 读取配置的大小限制,默认 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
log_msg info "Starting FlowProxy..."
echo "[$timestamp] [$level] $msg" >> "$LOG_FILE"
}
check_env || return 1
check_config || return 1
apply_nft_rules || return 1
add_route || { delete_nft; return 1; }
setup_dns
check_env() {
# 检测核心二进制工具是否存在
if ! command -v nft >/dev/null 2>&1; then
log_msg error "nftables binary not found"
return 1
fi
return 0
}
rm -f "$DEGRADED_FILE"
touch "$STATE_FILE"
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
}
local gen="$(date +%s)-$$"
echo "$gen" > "$GEN_FILE"
apply_nft_rules() {
log_msg info "Generating nftables configuration..."
local conf_level=$(uci -q get "$UCI_CONFIG.global.log_level" || echo "info")
local monitor_enabled=$(uci -q get "$UCI_CONFIG.global.monitor_enabled" || echo 0)
if [ "$monitor_enabled" = "1" ]; then
procd_open_instance "watchdog"
procd_set_param command /usr/share/flowproxy/watchdog.sh "$gen"
procd_set_param respawn 3600 5 5
procd_close_instance
fi
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 (only restore if it originally existed, avoid polluting config)
if [ -n "$dns_redirect" ]; then
uci set dhcp.@dnsmasq[0].dns_redirect="$dns_redirect"
else
uci -q delete dhcp.@dnsmasq[0].dns_redirect
fi
# Restore servers correctly splitting comma-separated list
uci -q delete dhcp.@dnsmasq[0].server
if [ -n "$servers" ]; then
for s in $(echo "$servers" | tr ',' ' '); 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
log_msg info "FlowProxy service started successfully"
return 0
}
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"
acquire_lock
_start_unlocked
local ret=$?
release_lock
return $ret
}
stop_service() {
log_msg info "Stopping FlowProxy..."
# 强制先移除标记,防止状态误报
rm -f "$STATE_FILE"
delete_route
delete_nft
cleanup_dns
log_msg info "FlowProxy service stopped"
acquire_lock
_stop_unlocked
local ret=$?
release_lock
return $ret
}
restart_service() {
stop_service
start_service
acquire_lock
_stop_unlocked
_start_unlocked
local ret=$?
release_lock
return $ret
}
reload_service() {
restart_service
restart_service
}
service_triggers() {
# 监听自身配置改动
procd_add_reload_trigger "$UCI_CONFIG"
# 监听防火墙重载事件,防止规则被 fw4 清空后无法自动恢复
procd_add_reload_trigger "firewall"
# 监听自身配置改动
procd_add_reload_trigger "$UCI_CONFIG"
# 监听防火墙重载事件,防止规则被 fw4 清空后无法自动恢复
procd_add_reload_trigger "firewall"
}
+328
View File
@@ -0,0 +1,328 @@
#!/bin/sh
# FlowProxy shared runtime helpers
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"
DEGRADED_FILE="/var/run/flowproxy.degraded"
GEN_FILE="/var/run/flowproxy.gen"
MARK_FILE="/var/run/flowproxy.mark"
LOCK_FILE="/var/lock/flowproxy.lock"
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")
conf_level="${conf_level:-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")
case "$max_size" in
''|*[!0-9]*) max_size=1024 ;;
esac
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"
}
acquire_lock() {
[ "${FLOWPROXY_LOCKED:-0}" = "1" ] && return 0
mkdir -p "$(dirname "$LOCK_FILE")"
if command -v flock >/dev/null 2>&1; then
exec 9>"$LOCK_FILE"
flock -x 9
else
local wait_cnt=0
while ! mkdir "${LOCK_FILE}.d" 2>/dev/null; do
wait_cnt=$((wait_cnt + 1))
[ "$wait_cnt" -ge 50 ] && break
sleep 1
done
fi
FLOWPROXY_LOCKED=1
return 0
}
release_lock() {
[ "${FLOWPROXY_LOCKED:-0}" != "1" ] && return 0
if command -v flock >/dev/null 2>&1; then
flock -u 9 2>/dev/null
exec 9>&-
else
rmdir "${LOCK_FILE}.d" 2>/dev/null
fi
FLOWPROXY_LOCKED=0
return 0
}
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"
fi
rm -f "$err_file"
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
}
restore_nft_rules() {
if [ -s "$NFT_CONF" ]; then
log_msg info "Restoring nftables rules from cached $NFT_CONF"
nft delete table inet flowproxy 2>/dev/null
if nft -f "$NFT_CONF"; then
return 0
fi
log_msg warn "Cached nftables config failed, regenerating..."
fi
apply_nft_rules
}
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"); interface="${interface:-br-lan}"
local traffic_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark"); traffic_mark="${traffic_mark:-0x666}"
local routing_table=$(uci -q get "$UCI_CONFIG.global.routing_table"); routing_table="${routing_table:-888}"
local rule_priority=$(uci -q get "$UCI_CONFIG.global.rule_priority"); rule_priority="${rule_priority:-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" > "$MARK_FILE"
# 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
if ! err=$(ip route add default via "$proxy_server_ip_addr" dev "$interface" table "$routing_table" 2>&1); then
log_msg error "Failed to add default route: $err"
return 1
fi
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"
return 0
else
log_msg error "Failed to add ip rule: $err"
return 1
fi
}
delete_route() {
local quiet="$1"
# 1. 获取两组清理目标:旧值(记录文件) 和 新值(当前UCI)
local old_mark="" old_table="" old_prio=""
if [ -f "$MARK_FILE" ]; then
local saved_val=$(cat "$MARK_FILE")
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 "$MARK_FILE"
fi
local curr_mark=$(uci -q get "$UCI_CONFIG.global.traffic_mark"); curr_mark="${curr_mark:-0x666}"
local curr_table=$(uci -q get "$UCI_CONFIG.global.routing_table"); curr_table="${curr_table:-888}"
local curr_prio=$(uci -q get "$UCI_CONFIG.global.rule_priority"); curr_prio="${curr_prio:-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")
local dns_proxy_enabled=$(uci -q get "$UCI_CONFIG.global.dns_proxy_enabled")
local proxy_server_dns_port=$(uci -q get "$UCI_CONFIG.global.proxy_server_dns_port"); proxy_server_dns_port="${proxy_server_dns_port:-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:-0}" != "1" ] || [ "${dns_proxy_enabled:-0}" != "1" ]; 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 (only restore if it originally existed, avoid polluting config)
if [ -n "$dns_redirect" ]; then
uci set dhcp.@dnsmasq[0].dns_redirect="$dns_redirect"
else
uci -q delete dhcp.@dnsmasq[0].dns_redirect
fi
# Restore servers correctly splitting comma-separated list
uci -q delete dhcp.@dnsmasq[0].server
if [ -n "$servers" ]; then
for s in $(echo "$servers" | tr ',' ' '); 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
}
do_degrade() {
local proxy_ip=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
log_msg warn "Proxy server ${proxy_ip:-unknown} unreachable, degrading FlowProxy to direct mode..."
# 先写入降级标记,避免 RPCD 状态自愈误删 STATE_FILE
touch "$DEGRADED_FILE"
delete_route
delete_nft
cleanup_dns
log_msg warn "FlowProxy degraded to direct mode (monitoring remains active)"
}
do_recover() {
local proxy_ip=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
log_msg info "Proxy server ${proxy_ip:-unknown} is online, recovering FlowProxy..."
restore_nft_rules || return 1
add_route || { delete_nft; return 1; }
setup_dns
rm -f "$DEGRADED_FILE"
log_msg info "FlowProxy recovered to active proxy mode"
return 0
}
+171
View File
@@ -0,0 +1,171 @@
#!/bin/sh
# FlowProxy sidecar watchdog daemon (ARP + Ping health probe)
. /usr/share/flowproxy/common.sh
MY_GEN="$1"
[ -z "$MY_GEN" ] && exit 0
STOP_REQUESTED=0
SLEEP_PID=""
handle_signal() {
STOP_REQUESTED=1
[ -n "$SLEEP_PID" ] && kill "$SLEEP_PID" 2>/dev/null
}
trap 'handle_signal' TERM INT
interruptible_sleep() {
local secs="$1"
[ "$STOP_REQUESTED" -eq 1 ] && return 1
sleep "$secs" &
SLEEP_PID=$!
wait "$SLEEP_PID" 2>/dev/null
SLEEP_PID=""
[ "$STOP_REQUESTED" -eq 1 ] && return 1
return 0
}
resolve_probe_iface() {
local iface="$1"
if [ -L "/sys/class/net/$iface/master" ]; then
local master_path=$(readlink "/sys/class/net/$iface/master" 2>/dev/null)
local master_name=$(basename "$master_path" 2>/dev/null)
[ -n "$master_name" ] && { echo "$master_name"; return; }
fi
echo "$iface"
}
get_uint_or_default() {
local val="$1"
local def="$2"
local min="$3"
case "$val" in
''|*[!0-9]*) echo "$def"; return ;;
esac
[ "$val" -lt "$min" ] && echo "$min" || echo "$val"
}
is_context_valid() {
[ "$STOP_REQUESTED" -eq 0 ] || return 1
[ -f "$STATE_FILE" ] || return 1
[ "$(cat "$GEN_FILE" 2>/dev/null)" = "$MY_GEN" ] || return 1
[ "$(uci -q get "$UCI_CONFIG.global.enabled" || echo 0)" = "1" ] || return 1
[ "$(uci -q get "$UCI_CONFIG.global.monitor_enabled" || echo 0)" = "1" ] || return 1
return 0
}
check_proxy_alive() {
local ip="$1"
local iface="$2"
# 1. ICMP Ping (首次成功即毫秒级返回;失败则补测一次防单包抖动)
if ping -c 1 -W 1 -I "$iface" "$ip" >/dev/null 2>&1; then
return 0
fi
if [ "$STOP_REQUESTED" -eq 1 ]; then
return 0
fi
if ping -c 1 -W 1 -I "$iface" "$ip" >/dev/null 2>&1; then
return 0
fi
# 2. ARP 互补探测 (防止旁路由禁 Ping 误报)
if command -v arping >/dev/null 2>&1; then
if arping -f -q -c 2 -w 1 -I "$iface" "$ip" >/dev/null 2>&1; then
return 0
fi
else
ip neigh flush to "$ip" dev "$iface" >/dev/null 2>&1
ping -c 1 -W 1 -I "$iface" "$ip" >/dev/null 2>&1
if ip neigh show to "$ip" dev "$iface" 2>/dev/null | grep -qE "REACHABLE|DELAY|PROBE"; then
return 0
fi
fi
return 1
}
proxy_ip=$(uci -q get "$UCI_CONFIG.global.proxy_server_ip_addr")
conf_iface=$(uci -q get "$UCI_CONFIG.global.interface")
conf_iface="${conf_iface:-br-lan}"
probe_iface=$(resolve_probe_iface "$conf_iface")
interval=$(get_uint_or_default "$(uci -q get "$UCI_CONFIG.global.monitor_interval")" 5 2)
fail_threshold=$(get_uint_or_default "$(uci -q get "$UCI_CONFIG.global.monitor_fail_threshold")" 3 1)
recover_threshold=$(get_uint_or_default "$(uci -q get "$UCI_CONFIG.global.monitor_recover_threshold")" 2 1)
[ -z "$proxy_ip" ] && exit 0
log_msg info "Watchdog started (target=$proxy_ip, iface=$probe_iface, interval=${interval}s, fail=$fail_threshold, recover=$recover_threshold)"
# 启动后先等待一个探测周期,确保主服务完全进入稳定运行态
interruptible_sleep "$interval" || exit 0
[ -f "$DEGRADED_FILE" ] && state="degraded" || state="online"
fail_count=0
ok_count=0
while is_context_valid; do
if check_proxy_alive "$proxy_ip" "$probe_iface"; then
fail_count=0
if [ "$state" = "degraded" ]; then
ok_count=$((ok_count + 1))
log_msg debug "Watchdog: probe succeeded ($ok_count/$recover_threshold)"
if [ "$ok_count" -ge "$recover_threshold" ]; then
acquire_lock
if ! is_context_valid; then
release_lock
break
fi
if [ -f "$DEGRADED_FILE" ]; then
if do_recover; then
state="online"
ok_count=0
fail_count=0
else
log_msg error "Watchdog: recovery failed, staying in degraded mode"
fi
else
state="online"
ok_count=0
fi
release_lock
fi
else
ok_count=0
fi
else
[ "$STOP_REQUESTED" -eq 1 ] && break
ok_count=0
if [ "$state" = "online" ]; then
fail_count=$((fail_count + 1))
log_msg warn "Watchdog: probe failed ($fail_count/$fail_threshold) for $proxy_ip via $probe_iface"
if [ "$fail_count" -ge "$fail_threshold" ]; then
acquire_lock
if ! is_context_valid; then
release_lock
break
fi
if [ ! -f "$DEGRADED_FILE" ]; then
do_degrade
state="degraded"
fail_count=0
ok_count=0
else
state="degraded"
fail_count=0
fi
release_lock
fi
else
fail_count=0
fi
fi
interruptible_sleep "$interval" || break
done
log_msg info "Watchdog stopped"
exit 0
@@ -6,6 +6,7 @@ import { connect } from 'ubus';
const UCI_CONFIG = 'flowproxy';
const STATE_FILE = '/var/run/flowproxy.running';
const DEGRADED_FILE = '/var/run/flowproxy.degraded';
const IP_CACHE = '/tmp/flowproxy/lan_ip.cache';
const LOG_FILE = '/var/log/flowproxy.log';
const NFT_TABLE = 'flowproxy';
@@ -20,6 +21,7 @@ const methods = {
call: function() {
// 1. 快速探测
let is_running = access(STATE_FILE, 'f');
let is_degraded = access(DEGRADED_FILE, 'f');
// 2. 自愈:检查内核状态并同步标记文件
let tcp_active = (system('nft list chain inet ' + NFT_TABLE + ' LAN_MARKFLOW_TCP >/dev/null 2>&1') === 0);
@@ -28,7 +30,7 @@ const methods = {
if (real_running && !is_running) {
try { writefile(STATE_FILE, ''); } catch(e) {}
} else if (!real_running && is_running) {
} else if (!real_running && is_running && !is_degraded) {
try { system('rm -f ' + STATE_FILE); } catch(e) {}
}
@@ -67,11 +69,15 @@ const methods = {
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);
let monitor_enabled = int(uci_cursor.get(UCI_CONFIG, 'global', 'monitor_enabled') || '0');
let degraded_active = (is_degraded && is_running && !real_running) ? 1 : 0;
return {
running: real_running ? 1 : 0,
running: (real_running || degraded_active) ? 1 : 0,
degraded: degraded_active,
dns_running: dns_running,
enabled: int(uci_cursor.get(UCI_CONFIG, 'global', 'enabled') || '0'),
monitor_enabled: monitor_enabled,
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),
+2 -2
View File
@@ -1,8 +1,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-open-box
PKG_VERSION:=0.1.423
PKG_RELEASE:=73
PKG_VERSION:=0.1.425
PKG_RELEASE:=74
PKG_MAINTAINER:=liandu2024
PKG_LICENSE:=GPL-3.0-or-later
+9 -5
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-vantage
PKG_VERSION:=1.0.19
PKG_RELEASE:=34
PKG_VERSION:=1.0.20
PKG_RELEASE:=35
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage dashboard
@@ -20,15 +20,19 @@ LUCI_DESCRIPTION:=Live network dashboard (landing page under admin/dashboard): d
Read-only except for station names and stations set not to warn about \
their signal, which are kept in /etc/config/vantage.
# rpcd-mod-iwinfo provides the station lists and the radios' state
# (iwinfo info/assoclist/survey).
# (iwinfo info/assoclist).
# rpcd-mod-ucode runs the app's plugin (/usr/share/rpcd/ucode/luci.vantage:
# key-free wireless status, thermal zones, validated alias writes), which
# imports the uci, ubus and fs ucode modules; luci-base pulls all four in
# already, they are listed because the plugin needs them.
# already, they are listed because the plugin needs them. It also imports
# rtnl (the bridges' hosts, the default routes) and nl80211: it reads the
# radios' channel survey (the noise floor) through nl80211 itself, because
# rpcd's iwinfo survey crashes rpcd on drivers other than nl80211's (#124).
# Wi-Fi images already have both, as wifi-scripts needs them.
# Optional at runtime, degrade when absent: rpcd-mod-rrdns (reverse DNS
# names), umdns (mDNS names), hostapd ubus objects (WPS names, client
# capabilities).
LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-iwinfo +rpcd-mod-ucode +ucode-mod-uci +ucode-mod-ubus +ucode-mod-fs +ucode-mod-rtnl
LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-iwinfo +rpcd-mod-ucode +ucode-mod-uci +ucode-mod-ubus +ucode-mod-fs +ucode-mod-rtnl +ucode-mod-nl80211
LUCI_PKGARCH:=all
LUCI_MAINTAINER:=anothaDev <10740588+anothaDev@users.noreply.github.com>
LUCI_URL:=https://github.com/anothaDev/luci-theme-vantage
@@ -729,6 +729,13 @@ header.bg-primary {
font-size: var(--vt-fs-sm); color: var(--vt-text-2); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
/* a health card's reason in its parts (overview.js): each part stays
whole, and its dot sits in the .9em before it (" · " is .89em). The
parts start .9em further left, under the clip, so a part that begins
a line hides its dot (#122) */
.vt-app .vt-chip-reason > .vt-parts { display: block; margin-inline-start: -.9em; }
.vt-app .vt-chip-reason .vt-part { display: inline-block; }
.vt-app .vt-chip-reason .vt-part::before { content: "·"; display: inline-block; width: .9em; text-align: center; }
/* -------------------------------------------------- 5. hero: network path */
@@ -892,12 +899,24 @@ header.bg-primary {
.vt-app .vt-branch-ssids .vt-node { max-width: 260px; }
/* a network has no second column: its rates take that room */
.vt-app .vt-branch > .vt-node-net { grid-column: 1 / -1; justify-self: start; width: 100%; max-width: 260px; }
/* with subgrid every branch shares one radio column: 172px, or wider up
to 240px when the widest radio's own text needs it, so "Ch 128 · 160
MHz" in a wider font or a larger text size no longer ends in "…"
(#122). Without subgrid, the fixed column above. Narrower windows lay
the branches out as below. */
@supports (grid-template-columns: subgrid) {
.vt-app .vt-branches { display: grid; grid-template-columns: minmax(172px, max-content) minmax(0, 1fr); column-gap: 34px; row-gap: 18px; align-content: space-between; }
.vt-app .vt-branches > * { grid-column: 1 / -1; }
.vt-app .vt-branch { grid-template-columns: subgrid; }
.vt-app .vt-branch > .vt-node:first-child:not(.vt-node-net) { max-width: 240px; }
}
/* medium widths: the rates need their column, and the branches have room
to give; what a radio or the Wired node leads to goes under it, too
narrow beside it */
@container vthero (max-width: 1020px) {
.vt-app .vt-path { grid-template-columns: minmax(150px, 1.05fr) minmax(136px, .9fr) minmax(190px, 1.1fr) minmax(204px, 2fr); }
.vt-app .vt-branches { display: flex; }
.vt-app .vt-branch { grid-template-columns: minmax(0, 1fr); row-gap: 8px; }
.vt-app .vt-branch > .vt-node { justify-self: start; min-width: min(200px, 100%); }
.vt-app .vt-branch-ssids { padding-left: 34px; }
@@ -588,9 +588,10 @@ return baseclass.extend({
return out;
},
/* iwinfo survey [ { mhz, noise, ... } ] -> the noise floor (dBm) on the
channel at mhz, or null. Drivers send it as a signed byte, some as an
unsigned one (ath12k: 146 for -110 dBm), and 0 when they have none */
/* luci.vantage survey [ { mhz, noise } ] -> the noise floor (dBm) on
the channel at mhz, or null. A signed byte, as nl80211 carries it;
rpcd's iwinfo survey (1.0.19) gave some drivers' as an unsigned one
(ath12k: 146 for -110 dBm); 0 when the driver has none */
surveyNoise: function(entries, mhz) {
if (!mhz) return null;
var e = arr(entries).filter(function(x) { return x && num(x.mhz) === mhz; })[0];
@@ -38,8 +38,8 @@ var LOW_IDLE_POLL_MS = 30 * 1000;
IWINFO_MS, or at once when the interface's state moved (its radio up,
down, pending or reconfigured in luci.vantage wireless; its channel,
frequency, BSSID or SSID in hostapd's get_status, asked every poll).
The noise floor, which moves, comes every poll from iwinfo survey
(1.5-2 ms a radio), its entry for the radio's channel */
The noise floor, which moves, comes every poll from luci.vantage
survey (1.5-2.5 ms a radio), its entry for the radio's channel */
var IWINFO_MS = 30 * 1000;
/* luci-rpc getHostHints reads the DHCP leases and the ARP table: asked
again after HINTS_MS, and on each of the HINTS_HOT polls after a
@@ -70,7 +70,7 @@ var GONE_KEEP_MS = 5 * 60 * 1000; /* disconnected stations stay listed */
var TALKERS = 3; /* top talkers shown; the tile keeps room for all */
var SWITCH_MS = 30 * 1000; /* LuCI runs swconfig for each switch's link state: at most this often */
var STORE_KEY = 'vantage.hist.v1';
var VERSION = '1.0.19'; /* this package's, as its Makefile has it (tests/version.test.js) */
var VERSION = '1.0.20'; /* this package's, as its Makefile has it (tests/version.test.js) */
var SVGNS = 'http://www.w3.org/2000/svg';
var HOSTAPD_OBJ = /^[A-Za-z0-9_.-]{1,64}$/;
@@ -98,8 +98,11 @@ var callHints = rpc.declare({ object: 'luci-rpc', method: 'getHostHints', expect
var callIwInfo = rpc.declare({ object: 'iwinfo', method: 'info', params: [ 'device' ], expect: { '': {} } });
var callAssoc = rpc.declare({ object: 'iwinfo', method: 'assoclist', params: [ 'device' ], expect: { results: [] } });
/* the channels' survey: the noise floor of the radio's own (model.js
surveyNoise); null when the call fails */
var callSurvey = rpc.declare({ object: 'iwinfo', method: 'survey', params: [ 'device' ], expect: { results: [] } });
surveyNoise); null when the call fails. The plugin's, read through
nl80211, not iwinfo's: rpcd's iwinfo survey crashes rpcd on every
iwinfo backend but nl80211's (MediaTek's mtwifi in the mt798x builds,
#124) */
var callSurvey = rpc.declare({ object: 'luci.vantage', method: 'survey', params: [ 'device' ], expect: { results: [] } });
var callRrdns = rpc.declare({ object: 'network.rrdns', method: 'lookup', params: [ 'addrs', 'timeout', 'limit' ], expect: { '': {} } });
var callMdns = rpc.declare({ object: 'umdns', method: 'hosts' });
var callUciGet = rpc.declare({ object: 'uci', method: 'get', params: [ 'config' ], expect: { values: {} } });
@@ -2147,7 +2150,15 @@ return view.extend({
cell.check = c;
txt(cell.title, c.title);
txt(cell.value, c.value);
txt(cell.reason, c.reason);
/* the reason in its " · " parts: a part that does not fit goes
whole to the next line, without its dot (app.css), so "1 Gbit/s
link" never breaks after its "/" or inside a word (#122) */
var reason = c.reason == null ? '' : String(c.reason);
if (cell.reasonText !== reason) {
cell.reasonText = reason;
dom.content(cell.reason, reason === '' ? [] : E('span', { 'class': 'vt-parts' },
reason.split(' · ').map(function(p) { return E('span', { 'class': 'vt-part' }, [ p ]); })));
}
setTitle(cell.el, c.detail || c.reason);
patch(cell.viz, self.checkPicture(c));
});
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"luci-app-vantage": {
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal), the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard, and which switch ports a port's VLAN has), the link state of those switch ports (luci getSwconfigPortState) and the default route the kernel takes to the internet (luci.vantage routes: family, device and metric, no gateways)",
"description": "Vantage dashboard: read-only status of the device, uplink, radios and stations (wireless configuration without keys, through luci.vantage wireless), its temperature sensors (luci.vantage thermal), the wired hosts its bridges have learned (luci.vantage wired: MAC, port and when last heard, and which switch ports a port's VLAN has), the link state of those switch ports (luci getSwconfigPortState), the default route the kernel takes to the internet (luci.vantage routes: family, device and metric, no gateways) and each radio's noise floor (luci.vantage survey: the channel survey read through nl80211)",
"read": {
"file": {
"/proc/stat": [ "read" ]
@@ -12,8 +12,8 @@
"network.device": [ "status" ],
"luci-rpc": [ "getHostHints" ],
"luci": [ "getSwconfigPortState" ],
"luci.vantage": [ "routes", "thermal", "wired", "wireless" ],
"iwinfo": [ "assoclist", "info", "survey" ],
"luci.vantage": [ "routes", "survey", "thermal", "wired", "wireless" ],
"iwinfo": [ "assoclist", "info" ],
"hostapd.*": [ "get_clients", "get_status" ],
"umdns": [ "hosts" ],
"uci": [ "get" ]
@@ -43,6 +43,16 @@
// when a lookup could not be sent. One lookup a family, so
// the work does not grow with the routing table. Read group
// luci-app-vantage.
// survey { device }: the channel survey of one Wi-Fi interface, for
// the noise floor: { mhz, noise } (dBm) for each channel the
// driver gives a noise value, at most MAX_SURVEY: one
// read-only NL80211 survey dump. Not through rpcd's iwinfo
// survey: libiwinfo has a survey for its NL80211 backend
// only, and for any other (the MediaTek mtwifi backend of
// the mt798x builds) rpcd calls the missing function and
// dies (#124). Only an interface that cfg80211 drives (a
// phy80211 link in sysfs) is asked; any other device: no
// channels. Read group luci-app-vantage.
// set_alias { mac, name, icon }: name one station, or remove its name
// with name ''. Validated here, whatever the browser did, and
// written to /etc/config/vantage only. Write group
@@ -60,8 +70,9 @@
import { cursor } from 'uci';
import { connect } from 'ubus';
import { lsdir, readfile } from 'fs';
import { access, lsdir, readfile } from 'fs';
import * as rtnl from 'rtnl';
import * as nl80211 from 'nl80211';
const CONFIG = 'vantage';
const WIRELESS = 'wireless'; // read only, by wireless_from_config()
@@ -85,11 +96,13 @@ const NETWORK = 'network'; // read only, by switches(): switch_vlan secti
const BOARD = '/etc/board.json';
const BOARD_MAX = 65536; // bytes read of it
const MAX_SWITCH_VLANS = 64;
const MAX_SURVEY = 256; // channels in a survey reply (a tri-band wiphy has about 100)
const MAX_MHZ = 100000; // a channel's frequency, MHz (60 GHz is 70200)
// Patterns, compiled once: ucode compiles a regular expression literal each
// time the code reaches it, and a counted repeat ({1,64}) costs musl
// milliseconds to compile on a router (8.5 ms against 0.07 ms for the same
// test with the pattern compiled once, on an ARM access point). rpcd keeps
// test with the pattern compiled once, on an ARM Wi-Fi AP). rpcd keeps
// this module loaded, these with it, between calls.
const RE_MAC_COLON = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/;
const RE_MAC_DASH = /^[0-9a-f]{2}(-[0-9a-f]{2}){5}$/;
@@ -683,6 +696,39 @@ function routes() {
return out;
}
/* ------------------------------------------------------------- survey */
// The channel survey of one Wi-Fi interface: [ { mhz, noise } ] for each
// channel the driver reports with a noise value (dBm, a signed byte as
// NL80211 carries it; 0 is "none"), at most MAX_SURVEY. Asked of the
// kernel only for an interface cfg80211 drives (its phy80211 link): any
// other device would be refused by the kernel anyway, and is not asked.
// The reply of a dump is an array of messages; null when it had none or
// failed, false when it was cut short.
function survey(dev) {
let out = [];
if (!netdev(dev) || dev == '.' || dev == '..' || !access(NET + '/' + dev + '/phy80211'))
return out;
let res = nl80211.request(nl80211.const.NL80211_CMD_GET_SURVEY, nl80211.const.NLM_F_DUMP, { dev });
for (let m in ((type(res) == 'array') ? res : ((type(res) == 'object') ? [ res ] : []))) {
let s = (type(m) == 'object' && type(m.survey_info) == 'object') ? m.survey_info : null;
let mhz = s?.frequency, noise = s?.noise;
if (type(mhz) != 'int' || mhz <= 0 || mhz > MAX_MHZ || type(noise) != 'int' || noise == 0 || noise < -128 || noise > 127)
continue;
push(out, { mhz, noise });
if (length(out) >= MAX_SURVEY)
break;
}
return out;
}
// a switch port's number (0..63), or null
function switch_port(v) {
return (type(v) == 'int' && v >= 0 && v < 64) ? v : null;
@@ -842,6 +888,13 @@ const methods = {
}
},
survey: {
args: { device: 'device' },
call: function(request) {
return { results: survey(request.args?.device) };
}
},
set_alias: {
args: { mac: 'mac', name: 'name', icon: 'icon' },
call: function(request) {
+2 -2
View File
@@ -10,8 +10,8 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-theme-vantage
PKG_VERSION:=1.0.19
PKG_RELEASE:=43
PKG_VERSION:=1.0.20
PKG_RELEASE:=44
PKG_LICENSE:=GPL-3.0-or-later
LUCI_TITLE:=Vantage theme
@@ -56,7 +56,7 @@ const MOTION_EVENT = 'vantage:tick';
key, also what the dashboard reads under another theme) counts until
the router has one. */
/* this package's version, as its Makefile has it (tests/version.test.js) */
const VERSION = '1.0.19';
const VERSION = '1.0.20';
/* Check for updates (Appearance): when asked, never on its own, the browser
reads the project's 24.10 package list on GitHub Pages (the same release
as 25.12's), without cookies or a referrer. Only a version number passes;
@@ -1939,6 +1939,10 @@ div.right > .btn, div.right > .cbi-button, div.right > button { margin-left: 0;
.ifacebadge img { width: 16px; height: 16px; }
.ifacebadge.large { padding: 6px 10px; gap: 8px; font-size: var(--fs-base); }
.ifacebadge.large img { width: 20px; height: 20px; }
/* a network's status (Wireless, its dialog): the signal beside the details
keeps one width, wide enough for "-100/-110 dBm", so the details do not
move as the reading changes (#122) */
.ifacebadge.large > small:first-child { flex: none; min-width: 8.5em; text-align: center; }
.ifacebadge-active { border-color: var(--accent-line); box-shadow: inset 0 0 0 1px var(--accent-line); }
/* the device details tooltip (Interfaces): icon beside the details */
.cbi-tooltip.ifacebadge { display: inline-grid; grid-template-columns: auto minmax(0, 1fr); column-gap: 10px; }
@@ -2403,6 +2407,10 @@ body.modal-overlay-active #modal_overlay { display: block; }
#view .cbi-section > .cbi-title:has(+ div > .table:first-child),
#view > .cbi-section > h3:first-child:has(+ div > .table:first-child),
#view .cbi-section:not(.cbi-tblsection, .cbi-section .cbi-section) > h3:first-child:has(+ .table) { margin-bottom: 0; }
/* a table section whose table starts with its rows, no column titles
(Network -> Wireless): the band read as a taller first row (#122).
LuCI 25.12 adds a thead only for titles; 24.10 puts rows in the table */
#view .cbi-tblsection:not(.cbi-section .cbi-section) > h3:first-child:has(+ .table > :is(tbody, .tbody):first-child, + .table > .tr:first-child:not(.cbi-section-table-titles, .table-titles)) { margin-bottom: 0; }
#view > h2:not(:first-child), #view > h3:not(:first-child) { margin-top: var(--sp-6); }
.cbi-section > .table + h3, .cbi-section > .table + .cbi-title, .cbi-section > div + h3 { margin-top: var(--sp-6); }
.includes { margin-top: var(--sp-4); }
@@ -2757,18 +2765,23 @@ body[data-page="admin-statistics-graphs"] p.controls > .nowrap > .cbi-button { m
/* Network -> Wireless: radios as group headers, networks as rows */
.td[data-name="_badge"] > .center { text-align: left; }
/* the space between items is each item's own margin, not a gap: a
hidden " | " is still a flex item, and a gap around it indented a line
that starts with one (a network's second line, #122) */
.td[data-name="_stat"] > div, .td[data-name="_stat"] > div > div {
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 20px;
display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 0;
font-size: 0; /* hides the " | " separators */
}
.td[data-name="_stat"] > div > br, .td[data-name="_stat"] > div > div > br { display: none; }
.td[data-name="_stat"] .nowrap { display: inline-flex; align-items: baseline; gap: 6px; font-size: var(--fs-base); }
.td[data-name="_stat"] .nowrap { display: inline-flex; align-items: baseline; gap: 6px; margin-inline-end: 20px; font-size: var(--fs-base); }
.td[data-name="_stat"] > div > big { flex-basis: 100%; font-size: var(--fs-md); }
.td[data-name="_stat"] > div > div { flex-basis: 100%; }
.td[data-name="_stat"] > div > .nowrap:first-child { flex-basis: 100%; font-size: var(--fs-md); font-weight: 600; }
.td[data-name="_stat"] > div > .nowrap:first-child { flex-basis: 100%; margin-inline-end: 0; font-size: var(--fs-md); font-weight: 600; }
.td[data-name="_stat"] > div > .nowrap:first-child > strong { display: none; }
.cbi-section-table-row:has(> .td[data-name="_stat"] big) > .td { background: var(--s2); border-top: 1px solid var(--line-2); }
.cbi-section-table-row:has(> .td[data-name="_stat"] big):first-child > .td { border-top: 0; }
.cbi-section-table-row:has(> .td[data-name="_stat"] big) > .td { background: var(--s2); }
/* the darker line above a radio is the row above's own bottom border:
a top border of its own stacked under it as a double line (#122) */
.cbi-section-table-row:has(+ .cbi-section-table-row > .td[data-name="_stat"] big) > .td { border-bottom-color: var(--line-2); }
.cbi-section-table-row:not(:has(big)) > .td[data-name="_badge"] { padding-left: calc(var(--card-px) + 16px); }
/* progress bars (their text only exists in title): a thin track, the
-49
View File
@@ -1,49 +0,0 @@
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2025 ImmortalWrt.org
include $(TOPDIR)/rules.mk
PKG_NAME:=pcat-mgr
PKG_RELEASE:=4
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://gitlab.com/CoiaPrant/pcat-mgr.git
PKG_SOURCE_DATE:=2025-08-01
PKG_SOURCE_VERSION:=754b4f0b783a44d48d91e2dbd6c1dfca667a3049
PKG_MIRROR_HASH:=skip
PKG_LICENSE:=GPL-3.0-or-later
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
PKG_FLAGS:=nonshared
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=pcat-mgr
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/pcat-mgr
SECTION:=utils
CATEGORY:=Utilities
TITLE:=Photonicat Manager (Go Edition)
URL:=https://gitlab.com/CoiaPrant/pcat-mgr
DEPENDS:=$(GO_ARCH_DEPENDS) @TARGET_rockchip_armv8 +fuse-utils
CONFLICTS:=pcat-manager
endef
define Package/pcat-mgr/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/sbin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/pcat-mgr $(1)/usr/sbin/pcat-mgr
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) ./files/pcat-mgr.init $(1)/etc/init.d/pcat-mgr
endef
$(eval $(call GoBinPackage,pcat-mgr))
$(eval $(call BuildPackage,pcat-mgr))
-13
View File
@@ -1,13 +0,0 @@
#!/bin/sh /etc/rc.common
START=45
USE_PROCD=1
start_service() {
procd_open_instance
procd_set_param command /usr/sbin/pcat-mgr
procd_set_param respawn
procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}