v46.1-ui-mt76: Argon theme + hardened WG tunnel + Link Health dashboard

基于 v46 正式版(mt76)只恢复 UI 层:
- luci-theme-argon 2.4.3: local-background-wins 登录页 + bg1.jpg fallback
- luci-app-argon-config: 去 ui.changes.apply, ACL mutator 移 write
- luci-app-wgtunnel: rpcd ucode 后端(get/status/prepare/apply/rollback/reconnect),
  JSONMap 前端, 60s 一次性 token, 快照回滚, 无全局 network ACL
- luci-app-tr3000-status: 只读 rpcd ucode + 5s 轮询仪表盘
- tools/: audit_ui_packages.py + install_preview.sh + rollback_watchdog.sh
- docs/: 开发经历与翻车记录 + 固件哈希记录

固件本体(含烤入 WG 私钥/PSK)不入 git, 仅 K 盘保存.
kernel 成员与 v46 byte-identical; 尚未刷机.
This commit is contained in:
2026-08-19 15:04:02 +08:00
parent c6c90c1db8
commit 29cf67154a
92 changed files with 16036 additions and 0 deletions
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: GPL-3.0-only
include $(TOPDIR)/rules.mk
PKG_LICENSE:=GPL-3.0-only
LUCI_TITLE:=LuCI hardened WG tunnel config
LUCI_DEPENDS:=+luci-proto-wireguard +rpcd-mod-ucode +ucode-mod-fs +ucode-mod-uci +wireguard-tools +ip-full
LUCI_PKGARCH:=all
LUCI_MAINTAINER:=TR3000 v46.1 UI
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,175 @@
'use strict';
'require view';
'require rpc';
'require form';
'require ui';
'require poll';
'require dom';
var callGet = rpc.declare({ object: 'luci.wgtunnel', method: 'get', expect: { '': {} } });
var callStatus = rpc.declare({ object: 'luci.wgtunnel', method: 'status', expect: { '': {} } });
var callPrepare = rpc.declare({ object: 'luci.wgtunnel', method: 'prepare', params: [ 'input' ], expect: { '': {} } });
var callApply = rpc.declare({ object: 'luci.wgtunnel', method: 'apply', params: [ 'input' ], expect: { '': {} } });
var callReconnect = rpc.declare({ object: 'luci.wgtunnel', method: 'reconnect', expect: { '': {} } });
var callRollback = rpc.declare({ object: 'luci.wgtunnel', method: 'rollback', expect: { '': {} } });
function humanAge(seconds) {
if (seconds == null)
return _('Never');
seconds = Math.max(0, Number(seconds));
if (seconds < 60)
return _('%d seconds ago').format(seconds);
if (seconds < 3600)
return _('%d minutes ago').format(Math.floor(seconds / 60));
return _('%d hours ago').format(Math.floor(seconds / 3600));
}
function statusText(data) {
data = data || {};
var rt = data.runtime || {};
return '%s · %s · wg0:%s route:%s vxlan:%s'.format(data.state || _('unknown'), humanAge(data.handshake_age_seconds), rt.wg0 ? 'ok' : 'fail', rt.route ? 'ok' : 'fail', rt.vxlan ? 'ok' : 'fail');
}
function listValue(value) {
return Array.isArray(value) ? value : (value ? [ value ] : []);
}
function keyPlaceholder(configured) {
return configured ? _('<configured; leave blank to keep>') : _('<paste complete key>');
}
return view.extend({
load: function() {
return Promise.all([
L.resolveDefault(callGet(), {}),
L.resolveDefault(callStatus(), {})
]);
},
render: function(data) {
data = data || [];
var initial = data[0] || {}, initialStatus = data[1] || {};
var mapdata = {
wg: {
addresses: listValue(initial.addresses),
endpoint_host: initial.endpoint_host || '',
endpoint_port: initial.endpoint_port || '',
allowed_ips: listValue(initial.allowed_ips),
private_key: '',
public_key: '',
preshared_key: ''
}
};
var m = new form.JSONMap(mapdata, _('WG 隧道'), _('只编辑 wg0 到办公室的 WireGuard 参数。密钥不会从路由器读回浏览器;留空表示保持原值。'));
var s = m.section(form.NamedSection, 'wg', _('办公室隧道'));
s.addremove = false;
var o = s.option(form.DynamicList, 'addresses', _('本机隧道地址'));
o.placeholder = initial.required_address || '10.99.0.2/32';
o.rmempty = false;
o = s.option(form.Value, 'endpoint_host', _('办公室入口地址'));
o.datatype = 'host';
o.rmempty = false;
o = s.option(form.Value, 'endpoint_port', _('办公室入口端口'));
o.datatype = 'port';
o.rmempty = false;
o = s.option(form.DynamicList, 'allowed_ips', _('允许的隧道 IP'));
o.placeholder = initial.required_allowed_ip || '10.99.0.1/32';
o.rmempty = false;
o = s.option(form.Value, 'private_key', _('本机私钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.private_key_configured);
o.rmempty = true;
o = s.option(form.Value, 'public_key', _('办公室公钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.public_key_configured);
o.rmempty = true;
o = s.option(form.Value, 'preshared_key', _('预共享密钥'));
o.password = true;
o.placeholder = keyPlaceholder(initial.preshared_key_configured);
o.rmempty = true;
return m.render().then(L.bind(function(node) {
var statusNode = E('p', { 'class': 'cbi-section-descr' }, [ E('strong', _('当前状态:')), E('span', { 'id': 'wgtunnel-status' }, statusText(initialStatus)) ]);
var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'click': ui.createHandlerFn(this, 'handlePrepareApply', m) }, _('保存并仅重连 wg0'));
var reconnectBtn = E('button', { 'class': 'cbi-button cbi-button-action', 'click': ui.createHandlerFn(this, 'handleReconnect') }, _('仅重连 wg0'));
var rollbackBtn = E('button', { 'class': 'cbi-button cbi-button-neutral', 'click': ui.createHandlerFn(this, 'handleRollback') }, _('回滚上次快照'));
node.appendChild(E('hr'));
node.appendChild(statusNode);
node.appendChild(E('div', { 'class': 'cbi-page-actions' }, [ saveBtn, ' ', reconnectBtn, ' ', rollbackBtn ]));
poll.add(L.bind(function() {
return L.resolveDefault(callStatus(), {}).then(function(next) {
var el = document.getElementById('wgtunnel-status');
if (el)
dom.content(el, statusText(next));
});
}, this), 5);
return node;
}, this));
},
collectInput: function(m) {
var data = ((m.data || {}).wg || {});
return {
addresses: listValue(data.addresses),
endpoint_host: data.endpoint_host || '',
endpoint_port: data.endpoint_port || '',
allowed_ips: listValue(data.allowed_ips),
private_key: data.private_key || '',
public_key: data.public_key || '',
preshared_key: data.preshared_key || ''
};
},
handlePrepareApply: function(ev, m) {
return m.save(null, true).then(L.bind(function() {
return callPrepare(this.collectInput(m));
}, this)).then(function(prepared) {
if (!prepared.ok)
throw new Error(prepared.message || prepared.code || _('Prepare failed'));
var rows = (prepared.diff || []).map(function(d) { return E('li', {}, [ d.field, ': ', String(d.before), ' → ', String(d.after) ]); });
if (!rows.length)
rows.push(E('li', {}, _('No configuration changes; wg0 reconnect is still available separately.')));
return ui.showModal(_('确认 WG 配置变更'), [
E('p', _('将只写入 network.wg0 / network.wgpeer 的允许字段,并且只重连 wg0。不会重启 LAN、Wi-Fi 或防火墙。')),
E('ul', rows),
E('div', { 'class': 'right' }, [
E('button', { 'class': 'cbi-button', 'click': ui.hideModal }, _('Cancel')), ' ',
E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function() {
ui.hideModal();
return callApply({ token: prepared.token, confirm: true }).then(function(result) {
if (result.ok)
ui.addNotification(null, E('p', _('WG 配置已保存,wg0/VXLAN 检查通过。')), 'info');
else
ui.addNotification(null, E('p', _('应用失败,已尝试回滚:%s').format(result.code || 'unknown')), 'danger');
});
} }, _('Apply'))
])
]);
}).catch(function(e) {
ui.addNotification(null, E('p', _('WG 操作失败:%s').format(e.message || e)), 'danger');
});
},
handleReconnect: function() {
return L.resolveDefault(callReconnect(), {}).then(function(result) {
ui.addNotification(null, E('p', result.ok ? _('wg0 已重连。') : _('wg0 重连失败。')), result.ok ? 'info' : 'danger');
});
},
handleRollback: function() {
return L.resolveDefault(callRollback(), {}).then(function(result) {
ui.addNotification(null, E('p', result.ok ? _('已恢复上次 WG 配置快照并重连 wg0。') : _('没有可用快照或回滚失败。')), result.ok ? 'info' : 'danger');
});
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,13 @@
{
"admin/network/wgtunnel": {
"title": "WG 隧道",
"order": 35,
"action": {
"type": "view",
"path": "wgtunnel"
},
"depends": {
"acl": [ "luci-app-wgtunnel" ]
}
}
}
@@ -0,0 +1,15 @@
{
"luci-app-wgtunnel": {
"description": "Manage only the TR3000 wg0 office tunnel through luci.wgtunnel",
"read": {
"ubus": {
"luci.wgtunnel": [ "get", "status" ]
}
},
"write": {
"ubus": {
"luci.wgtunnel": [ "prepare", "apply", "rollback", "reconnect" ]
}
}
}
}
@@ -0,0 +1,367 @@
#!/usr/bin/ucode
/* SPDX-License-Identifier: GPL-3.0-only */
'use strict';
import { access, chmod, mkdir, popen, readfile, stat, unlink, writefile } from 'fs';
import { cursor } from 'uci';
const IFACE = 'wg0';
const PEER = 'wgpeer';
const STATE_DIR = '/tmp/luci-wgtunnel';
const TOKEN_FILE = STATE_DIR + '/prepare.json';
const SNAPSHOT = STATE_DIR + '/network.backup';
const LOCK = STATE_DIR + '/apply.lock';
const REQUIRED_ADDRESS = '10.99.0.2/32';
const REQUIRED_ALLOWED = '10.99.0.1/32';
let uci = null;
function sh(command) {
let fd = popen(command, 'r');
if (!fd)
return { ok: false, out: '' };
let out = fd.read('all') || '';
return { ok: fd.close() == 0, out };
}
function read_text(path) {
let value = readfile(path);
return value == null ? null : trim(value);
}
function uptime() {
let value = read_text('/proc/uptime') || '0';
return +split(value, /\s+/)[0];
}
function ensure_state_dir() {
mkdir(STATE_DIR, 0700);
chmod(STATE_DIR, 0700);
}
function fail(code, message) {
return { ok: false, code, message: message || code };
}
function arr(value) {
if (value == null)
return [];
return type(value) == 'array' ? value : [ value ];
}
function uniq(values) {
let result = [];
for (let value in arr(values)) {
value = trim(`${value}`);
if (value != '' && index(result, value) < 0)
push(result, value);
}
return result;
}
function clean_scalar(value) {
value = trim(`${value || ''}`);
return match(value, /[\x00-\x1f\x7f]/) ? null : value;
}
function ip4_to_int(ip) {
let parts = split(ip, '.');
if (length(parts) != 4)
return null;
let n = 0;
for (let part in parts) {
if (!match(part, /^[0-9]+$/))
return null;
let v = +part;
if (v < 0 || v > 255)
return null;
n = n * 256 + v;
}
return n;
}
function parse_cidr(value) {
value = clean_scalar(value);
if (value == null)
return null;
let m = match(value, /^([0-9]{1,3}(?:\.[0-9]{1,3}){3})\/([0-9]|[12][0-9]|3[0-2])$/);
if (!m)
return null;
let ip = ip4_to_int(m[1]);
let prefix = +m[2];
if (ip == null)
return null;
return { text: `${m[1]}/${prefix}`, ip, prefix };
}
function reject_bad_cidr(value, field) {
let cidr = parse_cidr(value);
if (cidr == null)
return fail('INVALID_CIDR', `${field}: ${value}`);
if (cidr.ip == 0 || cidr.prefix == 0)
return fail('DEFAULT_ROUTE_REJECTED', field);
let first = floor(cidr.ip / 16777216);
if (first >= 224 || first == 127 || first == 169)
return fail('RESERVED_RANGE_REJECTED', field);
let ip = cidr.text;
if (match(ip, /^192\.168\./) || match(ip, /^10\.(?!99\.)/) || match(ip, /^172\.(1[6-9]|2[0-9]|3[0-1])\./))
return fail('LAN_WAN_OVERLAP_REJECTED', field);
return null;
}
function validate_cidr_list(values, required, field) {
values = uniq(values);
if (index(values, required) < 0)
push(values, required);
for (let value in values) {
let err = reject_bad_cidr(value, field);
if (err)
return err;
}
return { ok: true, values };
}
function valid_port(value) {
value = clean_scalar(value);
return value != null && match(value, /^[0-9]+$/) && +value > 0 && +value <= 65535;
}
function valid_host(value) {
value = clean_scalar(value);
return value != null && value != '' && length(value) <= 253 && match(value, /^[A-Za-z0-9_.:-]+$/) && !match(value, /^-/) && !match(value, /\.\./);
}
function valid_wg_key(value) {
value = clean_scalar(value);
if (value == null || value == '')
return true;
if (!match(value, /^[A-Za-z0-9+/]{43}=$/))
return false;
let decoded = b64dec(value);
return decoded != null && length(decoded) == 32 && b64enc(decoded) == value;
}
function section_exists(name, type_name) {
return uci.get('network', name) != null && (type_name == null || uci.get('network', name, '.type') == type_name);
}
function generation() {
let s = stat('/etc/config/network');
return s == null ? 'missing' : `${s.mtime || 0}:${s.size || 0}`;
}
function read_config() {
let addresses = uniq(uci.get('network', IFACE, 'addresses'));
let allowed = uniq(uci.get('network', PEER, 'allowed_ips'));
return {
interface: IFACE,
peer_section: PEER,
addresses,
private_key_configured: !!uci.get('network', IFACE, 'private_key'),
endpoint_host: uci.get('network', PEER, 'endpoint_host') || '',
endpoint_port: uci.get('network', PEER, 'endpoint_port') || '',
public_key_configured: !!uci.get('network', PEER, 'public_key'),
preshared_key_configured: !!uci.get('network', PEER, 'preshared_key'),
allowed_ips: allowed,
required_address: REQUIRED_ADDRESS,
required_allowed_ip: REQUIRED_ALLOWED,
generation: generation()
};
}
function normalize_input(input) {
input ||= {};
let addresses = validate_cidr_list(input.addresses, REQUIRED_ADDRESS, 'addresses');
if (!addresses.ok)
return addresses;
let allowed = validate_cidr_list(input.allowed_ips, REQUIRED_ALLOWED, 'allowed_ips');
if (!allowed.ok)
return allowed;
let endpoint_host = clean_scalar(input.endpoint_host);
let endpoint_port = clean_scalar(input.endpoint_port);
if (!valid_host(endpoint_host))
return fail('INVALID_ENDPOINT_HOST');
if (!valid_port(endpoint_port))
return fail('INVALID_ENDPOINT_PORT');
for (let name in [ 'private_key', 'public_key', 'preshared_key' ])
if (!valid_wg_key(input[name]))
return fail('INVALID_WG_KEY', name);
return { ok: true, values: {
addresses: addresses.values,
endpoint_host,
endpoint_port,
allowed_ips: allowed.values,
private_key: clean_scalar(input.private_key) || null,
public_key: clean_scalar(input.public_key) || null,
preshared_key: clean_scalar(input.preshared_key) || null
} };
}
function diff_values(current, next) {
let diff = [];
function add(field, before, after, secret) {
if (json(before) != json(after))
push(diff, { field, before: secret ? (before ? '<configured>' : '<empty>') : before, after: secret ? (after ? '<new value>' : '<unchanged>') : after, secret: !!secret });
}
add('network.wg0.addresses', current.addresses, next.addresses, false);
add('network.wgpeer.endpoint_host', current.endpoint_host, next.endpoint_host, false);
add('network.wgpeer.endpoint_port', current.endpoint_port, next.endpoint_port, false);
add('network.wgpeer.allowed_ips', current.allowed_ips, next.allowed_ips, false);
add('network.wg0.private_key', current.private_key_configured, next.private_key != null, true);
add('network.wgpeer.public_key', current.public_key_configured, next.public_key != null, true);
add('network.wgpeer.preshared_key', current.preshared_key_configured, next.preshared_key != null, true);
return diff;
}
function write_token(state) {
ensure_state_dir();
writefile(TOKEN_FILE, json(state));
chmod(TOKEN_FILE, 0600);
}
function read_token() {
let text = read_text(TOKEN_FILE);
return text == null ? null : json(text);
}
function delete_token() {
unlink(TOKEN_FILE);
}
function set_list(section, option, values) {
uci.delete('network', section, option);
for (let value in values)
uci.add_list('network', section, option, value);
}
function apply_values(values) {
set_list(IFACE, 'addresses', values.addresses);
uci.set('network', PEER, 'endpoint_host', values.endpoint_host);
uci.set('network', PEER, 'endpoint_port', values.endpoint_port);
set_list(PEER, 'allowed_ips', values.allowed_ips);
if (values.private_key != null)
uci.set('network', IFACE, 'private_key', values.private_key);
if (values.public_key != null)
uci.set('network', PEER, 'public_key', values.public_key);
if (values.preshared_key != null)
uci.set('network', PEER, 'preshared_key', values.preshared_key);
uci.commit('network');
}
function snapshot() {
ensure_state_dir();
let copied = sh(`cp /etc/config/network '${SNAPSHOT}' && chmod 0600 '${SNAPSHOT}'`);
return copied.ok;
}
function restore_snapshot() {
if (!access(SNAPSHOT))
return false;
let restored = sh(`cp '${SNAPSHOT}' /etc/config/network && chmod 0600 /etc/config/network`);
return restored.ok;
}
function reconnect_wg0() {
return sh('/sbin/ifdown wg0 >/dev/null 2>&1; /sbin/ifup wg0 >/dev/null 2>&1').ok;
}
function check_runtime() {
let link = sh('/usr/sbin/ip link show dev wg0 >/dev/null 2>&1');
let route = sh('/usr/sbin/ip route show 10.99.0.1/32 2>/dev/null');
let vxlan = sh('/usr/sbin/ip -d link show dev vxlan0 2>/dev/null');
let ok_route = route.ok && index(route.out, 'dev wg0') >= 0;
let ok_vxlan = vxlan.ok && index(vxlan.out, 'vxlan id 10') >= 0 && index(vxlan.out, 'dstport 4789') >= 0 && index(vxlan.out, 'nolearning') >= 0 && index(vxlan.out, 'master br-lan') >= 0;
return { wg0: link.ok, route: ok_route, vxlan: ok_vxlan, ok: link.ok && ok_route && ok_vxlan };
}
function status() {
let hs = sh('/usr/bin/wg show wg0 latest-handshakes 2>/dev/null');
let latest = null, peers = 0;
if (hs.ok) {
for (let line in split(trim(hs.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) >= 2) {
peers++;
let t = +fields[1];
if (t > (latest || 0))
latest = t;
}
}
}
let now = timelocal(localtime());
let age = latest == null || latest == 0 ? null : max(0, now - latest);
let runtime = check_runtime();
return { available: hs.ok, peers, handshake_age_seconds: age, runtime, state: !hs.ok ? 'unavailable' : (peers == 0 ? 'no_peer' : (age == null ? 'never_handshaken' : (age <= 180 ? 'healthy' : 'stale'))) };
}
const methods = {
get: { call: function() {
uci = cursor();
return read_config();
} },
status: { call: function() {
return status();
} },
prepare: { call: function(input) {
uci = cursor();
if (!section_exists(IFACE, 'interface') || !section_exists(PEER, 'wireguard_wg0'))
return fail('REQUIRED_SECTION_MISSING');
let normalized = normalize_input(input || {});
if (!normalized.ok)
return normalized;
let current = read_config();
let diff = diff_values(current, normalized.values);
let token = trim(readfile('/proc/sys/kernel/random/uuid') || `${uptime()}`);
write_token({ token, expires: uptime() + 60, generation: current.generation, values: normalized.values });
return { ok: true, token, expires_in: 60, diff, requires_confirm: true };
} },
apply: { call: function(input) {
uci = cursor();
input ||= {};
let state = read_token();
if (state == null || input.token != state.token || input.confirm !== true)
return fail('INVALID_TOKEN');
if (uptime() > state.expires)
return fail('TOKEN_EXPIRED');
if (generation() != state.generation)
return fail('CONFIG_GENERATION_CHANGED');
if (!sh(`mkdir '${LOCK}' 2>/dev/null`).ok)
return fail('APPLY_LOCKED');
let runtime = null;
try {
delete_token();
if (!snapshot()) {
sh(`rmdir '${LOCK}' 2>/dev/null`);
return fail('SNAPSHOT_FAILED');
}
apply_values(state.values);
reconnect_wg0();
runtime = check_runtime();
if (!runtime.ok) {
restore_snapshot();
reconnect_wg0();
sh(`rmdir '${LOCK}' 2>/dev/null`);
return { ok: false, code: 'RUNTIME_CHECK_FAILED_ROLLED_BACK', runtime };
}
sh(`rmdir '${LOCK}' 2>/dev/null`);
return { ok: true, runtime };
} catch (e) {
restore_snapshot();
reconnect_wg0();
sh(`rmdir '${LOCK}' 2>/dev/null`);
return fail('APPLY_EXCEPTION_ROLLED_BACK');
}
} },
rollback: { call: function() {
let ok = restore_snapshot();
if (ok)
reconnect_wg0();
return { ok, runtime: check_runtime() };
} },
reconnect: { call: function() {
let ok = reconnect_wg0();
return { ok, runtime: check_runtime() };
} }
};
return { 'luci.wgtunnel': methods };