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 @@
{
"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 };