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,9 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI for Argon theme configuration
LUCI_PKGARCH:=all
LUCI_DEPENDS:=+luci-theme-argon
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,219 @@
'use strict';
'require form';
'require fs';
'require rpc';
'require uci';
'require ui';
'require view';
const callSystemInfo = rpc.declare({
object: 'system',
method: 'info'
});
const callRemoveArgon = rpc.declare({
object: 'luci.argon',
method: 'remove',
params: ['filename'],
expect: { '': {} }
});
const callRenameArgon = rpc.declare({
object: 'luci.argon',
method: 'rename',
params: ['newname'],
expect: { '': {} }
});
const bg_path = '/www/luci-static/argon/background/';
const trans_set = [0, 0.1, 0.2, 0.3, 0.4,
0.5, 0.6, 0.7, 0.8, 0.9, 1 ];
return view.extend({
load() {
return Promise.all([
uci.load('argon'),
L.resolveDefault(callSystemInfo(), { root: { avail: 0 } }),
L.resolveDefault(fs.list(bg_path), [])
]);
},
render(data) {
let m, s, o;
m = new form.Map('argon', _('Argon theme configuration'),
_('Here you can set the blur and transparency of the login page of argon theme, and manage the background pictures and videos. Chrome is recommended.'));
s = m.section(form.TypedSection, 'global', _('Theme configuration'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.ListValue, 'online_wallpaper', _('Wallpaper source'));
o.value('none', _('Built-in'));
o.value('bing', _('Bing'));
o.value('ghser', _('GHSer'));
o.value('unsplash', _('Unsplash'));
o.value('wallhaven', _('Wallhaven'));
o.default = 'bing';
o.forcewrite = true;
o.rmempty = false;
o.cfgvalue = function(section_id) {
let value = uci.get(data[0], section_id, 'online_wallpaper') || 'bing';
return value.split('_')[0];
}
o.write = function(section_id, value) {
let collection_id = this.section.formvalue(section_id, 'collection_id');
if (collection_id && (value === 'unsplash' || value === 'wallhaven')) {
value = value + '_' + collection_id;
}
uci.set(data[0], section_id, 'online_wallpaper', value);
}
o = s.option(form.Value, 'collection_id', _('Collection ID'), _('Collection ID for Unsplash or Wallhaven.'));
o.datatype = 'uinteger';
o.depends('online_wallpaper', 'unsplash');
o.depends('online_wallpaper', 'wallhaven');
o.cfgvalue = function(section_id) {
let value = uci.get(data[0], section_id, 'online_wallpaper');
if (!value || !value.includes('_'))
return '';
return value.split('_')[1];
}
o.write = function() { };
o = s.option(form.Value, 'use_api_key', _('API key'), _('Specify API key for Unsplash or Wallhaven.'));
o.depends('online_wallpaper', 'unsplash');
o.depends('online_wallpaper', 'wallhaven');
o = s.option(form.Flag, 'use_exact_resolution', _('Use exact resolution'), _('Use exact resolution or at least 1080P for Wallhaven.'));
o.default = o.enabled;
o.depends('online_wallpaper', 'wallhaven');
o = s.option(form.ListValue, 'mode', _('Theme mode'));
o.value('normal', _('Follow system'));
o.value('light', _('Light mode'));
o.value('dark', _('Dark mode'));
o.default = 'normal';
o.rmempty = false;
o = s.option(form.Value, 'primary', _('[Light mode] Primary Color'), _('A HEX color (default: #5e72e4).'))
o.default = '#5e72e4';
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id)
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value) ||
_('Expecting: %s').format(_('valid HEX color value'));
return true;
}
o = s.option(form.ListValue, 'transparency', _('[Light mode] Transparency'),
_('0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: 0.5).'));
for (let i of trans_set)
o.value(i);
o.default = '0.5';
o.rmempty = false;
o = s.option(form.Value, 'blur', _('[Light mode] Frosted Glass Radius'),
_('Larger value will more blurred (suggest: clear: 1 or blur preset: 10).'));
o.datatype = 'ufloat';
o.default = '10';
o.rmempty = false;
o = s.option(form.Value, 'dark_primary', _('[Dark mode] Primary Color'),
_('A HEX Color (default: #483d8b).'))
o.default = '#483d8b';
o.rmempty = false;
o.validate = function(section_id, value) {
if (section_id)
return /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(value) ||
_('Expecting: %s').format(_('valid HEX color value'));
return true;
}
o = s.option(form.ListValue, 'transparency_dark', _('[Dark mode] Transparency'),
_('0 transparent - 1 opaque (suggest: black translucent preset: 0.5).'));
for (let i of trans_set)
o.value(i);
o.default = '0.5';
o.rmempty = false;
o = s.option(form.Value, 'blur_dark', _('[Dark mode] Frosted Glass Radius'),
_('Larger value will more blurred (suggest: clear: 1 or blur preset: 10).'))
o.datatype = 'ufloat';
o.default = '10';
o.rmempty = false;
o = s.option(form.Button, '_save', _('Save settings'));
o.inputstyle = 'apply';
o.inputtitle = _('Save current settings');
o.onclick = function() {
return this.map.save(null, true);
}
s = m.section(form.TypedSection, null, _('Upload background (available space: %1024.2mB)')
.format(data[1].root.avail * 1024),
_('You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the login page background.'));
s.addremove = false;
s.anonymous = true;
o = s.option(form.Button, '_upload_bg', _('Upload background'),
_('Files will be uploaded to <code>%s</code>.').format(bg_path));
o.inputstyle = 'action';
o.inputtitle = _('Upload...');
o.onclick = function(ev, section_id) {
let file = '/tmp/argon_background.tmp';
return ui.uploadFile(file, ev.target).then(function(res) {
return L.resolveDefault(callRenameArgon(res.name), {}).then(function(ret) {
if (ret.result === 0)
return location.reload();
else {
ui.addNotification(null, E('p', _('Failed to upload file: %s.').format(res.name)));
return L.resolveDefault(fs.remove(file), {});
}
});
})
.catch(function(e) { ui.addNotification(null, E('p', e.message)); });
};
o.modalonly = true;
s = m.section(form.TableSection);
s.render = function() {
let tbl = E('table', { 'class': 'table cbi-section-table' },
E('tr', { 'class': 'tr table-titles' }, [
E('th', { 'class': 'th' }, [ _('Filename') ]),
E('th', { 'class': 'th' }, [ _('Modified date') ]),
E('th', { 'class': 'th' }, [ _('Size') ]),
E('th', { 'class': 'th' }, [ _('Action') ])
])
);
cbi_update_table(tbl, data[2].map(L.bind(function(file) {
return [
file.name,
new Date(file.mtime * 1000).toLocaleString(),
String.format('%1024.2mB', file.size),
E('button', {
'class': 'btn cbi-button cbi-button-remove',
'click': ui.createHandlerFn(this, function() {
return L.resolveDefault(callRemoveArgon(file.name), {})
.then(function() { return location.reload(); });
})
}, [ _('Delete') ])
];
}, this)), E('em', _('No files found.')));
return E('div', { 'class': 'cbi-map', 'id': 'cbi-filelist' }, [
E('h3', _('Background file list')),
tbl
]);
};
return m.render();
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,207 @@
msgid ""
msgstr "Content-Type: text/plain; charset=UTF-8"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:136
msgid "0 transparent - 1 opaque (suggest: black translucent preset: 0.5)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:112
msgid ""
"0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: "
"0.5)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:125
msgid "A HEX Color (default: #483d8b)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "A HEX color (default: #5e72e4)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "API key"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:189
msgid "Action"
msgstr ""
#: applications/luci-app-argon-config/root/usr/share/luci/menu.d/luci-app-argon-config.json:3
msgid "Argon Config"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:45
msgid "Argon theme configuration"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:209
msgid "Background file list"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:54
msgid "Bing"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:53
msgid "Built-in"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID for Unsplash or Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:97
msgid "Dark mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:204
msgid "Delete"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "Expecting: %s"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:173
msgid "Failed to upload file: %s."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:186
msgid "Filename"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:163
msgid "Files will be uploaded to <code>%s</code>."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:95
msgid "Follow system"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:55
msgid "GHSer"
msgstr ""
#: applications/luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json:3
msgid "Grant UCI access for luci-app-argon-config"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:46
msgid ""
"Here you can set the blur and transparency of the login page of argon theme, "
"and manage the background pictures and videos. Chrome is recommended."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:119
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:143
msgid "Larger value will more blurred (suggest: clear: 1 or blur preset: 10)."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:96
msgid "Light mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:187
msgid "Modified date"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:206
msgid "No files found."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:150
msgid "Save current settings"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:148
msgid "Save settings"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:188
msgid "Size"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "Specify API key for Unsplash or Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:48
msgid "Theme configuration"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:94
msgid "Theme mode"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:56
msgid "Unsplash"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:162
msgid "Upload background"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:156
msgid "Upload background (available space: %1024.2mB)"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:165
msgid "Upload..."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution or at least 1080P for Wallhaven."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:57
msgid "Wallhaven"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:52
msgid "Wallpaper source"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:158
msgid ""
"You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the "
"login page background."
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:142
msgid "[Dark mode] Frosted Glass Radius"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:124
msgid "[Dark mode] Primary Color"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:135
msgid "[Dark mode] Transparency"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:118
msgid "[Light mode] Frosted Glass Radius"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "[Light mode] Primary Color"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:111
msgid "[Light mode] Transparency"
msgstr ""
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "valid HEX color value"
msgstr ""
@@ -0,0 +1,220 @@
msgid ""
msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Project-Id-Version: \n"
"POT-Creation-Date: \n"
"PO-Revision-Date: \n"
"Last-Translator: dingpengyu <jerrykuku@gmail.com>\n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh_CN\n"
"X-Generator: Poedit 2.3.1\n"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:136
msgid "0 transparent - 1 opaque (suggest: black translucent preset: 0.5)."
msgstr "0 最透明 - 1 不透明(建议:黑色半透明 0.5)"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:112
msgid ""
"0 transparent - 1 opaque (suggest: transparent: 0 or translucent preset: "
"0.5)."
msgstr "0 最透明 - 1 不透明(建议: 透明 0 或 半透明预设 0.5)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:125
msgid "A HEX Color (default: #483d8b)."
msgstr "十六进制颜色值(预设为:#483d8b)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "A HEX color (default: #5e72e4)."
msgstr "十六进制颜色值(预设为:#5e72e4)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "API key"
msgstr "API 密钥"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:189
msgid "Action"
msgstr "操作"
#: applications/luci-app-argon-config/root/usr/share/luci/menu.d/luci-app-argon-config.json:3
msgid "Argon Config"
msgstr "Argon 主题设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:45
msgid "Argon theme configuration"
msgstr "Argon 主题设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:209
msgid "Background file list"
msgstr "背景文件列表"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:54
msgid "Bing"
msgstr "Bing"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:53
msgid "Built-in"
msgstr "内建"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID"
msgstr "分类 ID"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:73
msgid "Collection ID for Unsplash or Wallhaven."
msgstr "为 Unsplash 或 Wallhaven 指定分类 ID"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:97
msgid "Dark mode"
msgstr "暗黑模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:204
msgid "Delete"
msgstr "删除"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "Expecting: %s"
msgstr "请输入:%s"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:173
msgid "Failed to upload file: %s."
msgstr "上传文件失败:%s。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:186
msgid "Filename"
msgstr "文件名"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:163
msgid "Files will be uploaded to <code>%s</code>."
msgstr "文件将被上传至<code>%s</code>。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:95
msgid "Follow system"
msgstr "跟随系统"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:55
msgid "GHSer"
msgstr "GHSer"
#: applications/luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json:3
msgid "Grant UCI access for luci-app-argon-config"
msgstr "授予 luci-app-argon-config 访问 UCI 配置的权限"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:46
msgid ""
"Here you can set the blur and transparency of the login page of argon theme, "
"and manage the background pictures and videos. Chrome is recommended."
msgstr ""
"在这里你可以设置argon 主题的登录页面的模糊和透明度,并管理背景图片与视频。推"
"荐使用 Chrome。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:119
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:143
msgid "Larger value will more blurred (suggest: clear: 1 or blur preset: 10)."
msgstr "值越大越模糊(建议:清透 1 或 模糊预设 10)"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:96
msgid "Light mode"
msgstr "亮色模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:187
msgid "Modified date"
msgstr "修改时间"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:206
msgid "No files found."
msgstr "没有找到文件。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:150
msgid "Save current settings"
msgstr "保存当前设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:148
msgid "Save settings"
msgstr "保存设置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:188
msgid "Size"
msgstr "大小"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:86
msgid "Specify API key for Unsplash or Wallhaven."
msgstr "为 Unsplash 或 Wallhaven 指定 API 密钥。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:48
msgid "Theme configuration"
msgstr "主题配置"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:94
msgid "Theme mode"
msgstr "主题模式"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:56
msgid "Unsplash"
msgstr "Unsplash"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:162
msgid "Upload background"
msgstr "上传背景"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:156
msgid "Upload background (available space: %1024.2mB)"
msgstr "上传背景(可用空间:%1024.2mB"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:165
msgid "Upload..."
msgstr "上传..."
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution"
msgstr "精确匹配分辨率"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:90
msgid "Use exact resolution or at least 1080P for Wallhaven."
msgstr "精确匹配 1080P 分辨率或至少 1080P 分辨率(Wallhaven)。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:57
msgid "Wallhaven"
msgstr "Wallhaven"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:52
msgid "Wallpaper source"
msgstr "壁纸来源"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:158
msgid ""
"You can upload files such as gif/jpg/mp4/png/webm/webp files, to change the "
"login page background."
msgstr ""
"你可以上传 gif/jpg/mp4/png/webm/webp 等格式的文件,以创建自己喜欢的登录界面。"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:142
msgid "[Dark mode] Frosted Glass Radius"
msgstr "[暗色模式] 毛玻璃模糊半径"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:124
msgid "[Dark mode] Primary Color"
msgstr "[暗色模式] 主色调"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:135
msgid "[Dark mode] Transparency"
msgstr "[暗色模式] 透明度"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:118
msgid "[Light mode] Frosted Glass Radius"
msgstr "[亮色模式] 毛玻璃模糊半径"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:101
msgid "[Light mode] Primary Color"
msgstr "[亮色模式] 主色调"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:111
msgid "[Light mode] Transparency"
msgstr "[亮色模式] 透明度"
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:107
#: applications/luci-app-argon-config/htdocs/luci-static/resources/view/argon-config.js:131
msgid "valid HEX color value"
msgstr "有效十六进制颜色值"
@@ -0,0 +1,10 @@
config global
option primary '#5e72e4'
option dark_primary '#483d8b'
option blur '0'
option blur_dark '0'
option transparency '0.3'
option transparency_dark '0.3'
option mode 'normal'
option online_wallpaper 'bing'
@@ -0,0 +1,14 @@
#!/bin/sh
bing_background="$(uci -q get "argon.@global[0].bing_background")"
[ -n "$bing_background" ] || exit 0
if [ "$bing_background" = "1" ]; then
uci -q set "argon.@global[0].online_wallpaper"="bing"
else
uci -q set "argon.@global[0].online_wallpaper"="none"
fi
uci -q delete "argon.@global[0].bing_background"
uci -q commit "argon"
exit 0
@@ -0,0 +1,98 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
. /lib/functions.sh
. /usr/share/libubox/jshn.sh
readonly bg_path="/www/luci-static/argon/background"
readonly tmp_path="/tmp/argon_background.tmp"
readonly max_size=5242880
readonly builtin_bg="bg1.jpg"
valid_name() {
local name="$1" ext
[ -n "$name" ] || return 1
[ "$name" = "${name##*/}" ] || return 1
[ "$name" = "${name##*\\}" ] || return 1
[ "$name" = "${name#.*}" ] || return 1
case "$name" in *..*|*[!A-Za-z0-9_.-]*) return 1 ;; esac
ext="${name##*.}"
[ "$ext" != "$name" ] || return 1
case "$(echo "$ext" | tr 'A-Z' 'a-z')" in jpg|jpeg|png|gif|webp|mp4|webm|ogg) return 0 ;; esac
return 1
}
json_result() {
json_init
json_add_int "result" "$1"
json_dump
json_cleanup
}
case "$1" in
"list")
json_init
json_add_object "remove"
json_add_string "filename" "filename"
json_close_object
json_add_object "rename"
json_add_string "newname" "filename"
json_close_object
json_dump
json_cleanup
;;
"call")
case "$2" in
"remove")
read -r input
json_load "$input"
json_get_var filename "filename"
json_cleanup
if ! valid_name "$filename" || [ "$filename" = "$builtin_bg" ] || [ -L "$bg_path/$filename" ]; then
json_result 255
exit 255
fi
rm -f -- "$bg_path/$filename"
json_result 0
;;
"rename")
read -r input
json_load "$input"
json_get_var newname "newname"
json_cleanup
if ! valid_name "$newname" || [ "$newname" = "$builtin_bg" ] || [ -L "$tmp_path" ] || [ ! -f "$tmp_path" ]; then
json_result 255
exit 255
fi
size="$(wc -c < "$tmp_path" 2>/dev/null)" || size=0
case "$size" in *[!0-9]*|'') size=0 ;; esac
if [ "$size" -le 0 ] || [ "$size" -gt "$max_size" ]; then
rm -f -- "$tmp_path"
json_result 254
exit 254
fi
mkdir -p -- "$bg_path"
target="$bg_path/$newname"
if [ -e "$target" ] || [ -L "$target" ]; then
json_result 253
exit 253
fi
if (set -C; umask 022; cat "$tmp_path" > "$target") 2>/dev/null; then
if chmod 0644 "$target" && rm -f -- "$tmp_path"; then
json_result 0
else
rm -f -- "$target"
json_result 1
fi
else
json_result 1
fi
;;
esac
;;
esac
@@ -0,0 +1,14 @@
{
"admin/system/argon-config": {
"title": "Argon Config",
"order": 90,
"action": {
"type": "view",
"path": "argon-config"
},
"depends": {
"acl": [ "luci-app-argon-config" ],
"uci": { "argon": true }
}
}
}
@@ -0,0 +1,23 @@
{
"luci-app-argon-config": {
"description": "Grant UCI access for luci-app-argon-config",
"read": {
"file": {
"/www/luci-static/argon/background/*": [ "list" ]
},
"ubus": {
"system": [ "info" ]
},
"uci": [ "argon" ]
},
"write": {
"file": {
"/tmp/argon_background.tmp": [ "write" ]
},
"ubus": {
"luci.argon": [ "remove", "rename" ]
},
"uci": [ "argon" ]
}
}
}
@@ -0,0 +1,11 @@
include $(TOPDIR)/rules.mk
LUCI_TITLE:=LuCI TR3000 Link Health dashboard
LUCI_DEPENDS:=+rpcd-mod-ucode +ucode-mod-fs +ucode-mod-ubus +ucode-mod-uci +rpcd-mod-iwinfo +wireguard-tools +ip-full +nftables-json
LUCI_PKGARCH:=all
PKG_LICENSE:=GPL-3.0-only
LUCI_MAINTAINER:=TR3000 v46.1 UI
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,79 @@
# Link Health 插件修正(离线版)
## 范围
本次离线变更只覆盖 `luci-app-tr3000-status`
- 后端只读探测与三态语义
- 前端显示与轮询
- 静态审计、合成 fixture、安装/回滚脚本
- 文档
明确**不**做:
- 不改 `/etc/config/network` `firewall` `dhcp` `wireless`
- 不动 `rc.local`、VXLAN/MSS hotplug、nft include
- 不操作 WG/VXLAN 隧道、MTU、kill switch、MSS
- 不重载 `network` `firewall` `wireless`
- 不登录或修改办公室 OpenWrt `192.168.1.1`
- 不替换 `libc` `rpcd` `ucode` `netifd` `fw4` `mt76` `kmod-*` 等核心包
## 变更摘要
### 后端
- `luci.tr3000_status``get` 现在返回 `schema_version: 2`
- 新增字段:
- `wireguard.interface_up``{true, false, null}`
- `vxlan.invariants.{master_ok, mtu_ok, vni_ok, port_ok, nolearning_ok, local_ok, remote_ok}`
- `vxlan.all_invariants_ok`
- `acceleration.*.status``{enabled, enabled_unverified, disabled, disabled_by_design, unavailable, healthy, ok, degraded}`
- `summary.{ok, status, wg_ok, link_ok, route_ok, vxlan_ok, invariant_ok, wg_status}`
- 探测命令统一为只读 `ip -j` + `nft -j`,不再使用目标 query 语法。
- 移除 `wg show wg0 dump`
- “按设计禁用” (flowtable / HNAT / WARP / full-cone / masquerade) 不再误报为 `unavailable`,而是 `disabled_by_design`
### 前端
- 三态徽章:`Pass` / `Fail` / `Unknown`
- 顶栏新增 `summary.status` 总览徽章。
- 加速卡标题改为 `Data path & TCP`
- 删掉原先的 `wg0:fail` 重复摘要。
- “Active interfaces” 在 link JSON 不可用时显示 `Unavailable`,不再显示误导性的 `0 / 2`
### 审计
- `tests/audit_tr3000_status.py`
- 禁止模式:`wg dump``ifup/ifdown``ip link set``uci set/commit``network/firewall/wifi restart``reload``fs.exec`/`fs.write`/`fs.remove`/`fs.mkdir``writefile/unlink/rename`、敏感 key literal、MAC literal。
- 强制包含的只读命令、ACL 仅 `get`、菜单依赖 `luci-app-tr3000-status`、Makefile 依赖项。
- 当前结果:`OK: luci-app-tr3000-status passes static audit`
### 合成 fixture
- `tests/fixtures/` 12 份期望,覆盖:
1. `all_healthy`
2. `wg_interface_down`
3. `wg_stale_handshake`
4. `route_missing`
5. `vxlan_missing`
6. `vxlan_wrong_vni`
7. `nft_unavailable`
8. `no_hwmon`
9. `ip_unavailable`
10. `malformed_json`
11. `mss_missing`
12. `design_invariant_violation`
### 安装与回滚
- `tests/install_preview.sh`:仅升级 `luci-app-tr3000-status`;备份 `/etc/config/*` 与 hotplug`opkg install --force-reinstall`;仅 `rpcd` + `uhttpd` reload;调用 `ubus call luci.tr3000_status get` 冒烟。
- `tests/rollback_preview.sh`:根据 `prev_version` 还原。
- `tests/verify_immutable.sh`:升级前后必须维持 v46 frozen hash。
## 待执行(受工具限制暂未跑)
1. `scp` 同步源码到 build server `package/custom/luci-app-tr3000-status`
2. build server 重新编译 `luci-app-tr3000-status` IPK。
3. 把 IPK 与 frozen hash 拉到本机。
4. 在路由器执行 `install_preview.sh`,记录 `ubus call luci.tr3000_status get` 完整输出。
5. 在路由器执行 `verify_immutable.sh`,确认零漂移。
6. 浏览器强制刷新 `/admin/status/tr3000`,记录截图。
7.`summary.status``healthy` 或不可变文件 hash 漂移,执行 `rollback_preview.sh`
## 风险
- 升级期间若 `rpcd` 重启瞬间有浏览器轮询,理论上会造成一次 RPC 失败;前端 `L.resolveDefault(callStatus(), {})` 已吞掉此失败。
-`install_preview.sh` 备份脚本因权限或 BusyBox 限制失败,需要先解决再继续;不要在没有 backup 的情况下继续。
## 不变项
- v46 frozen network/firewall/dhcp/wireless、rc.local、hotplug、MSS 1330、br-lan master、wg0/WG 路线、VXLAN 10/4789/nolearning/MTU 1500、kill switch 均不修改。
@@ -0,0 +1,235 @@
'use strict';
'require view';
'require rpc';
'require poll';
'require dom';
var callStatus = rpc.declare({
object: 'luci.tr3000_status',
method: 'get',
// Accept the full nested object the backend returns. The previous
// `expect: { '': {} }` schema-only stub caused rpcd to strip every
// top-level key, leaving the front-end with `{}` and every card
// rendering as 'Unavailable'. An empty `expect: {}` is the most
// permissive shape and lets the structured payload flow through.
expect: {}
});
function humanBytes(value) {
if (value == null)
return '—';
var number = Number(value), units = [ 'B', 'KiB', 'MiB', 'GiB', 'TiB' ], index = 0;
while (number >= 1024 && index < units.length - 1) {
number /= 1024;
index++;
}
return (index ? number.toFixed(number >= 100 ? 0 : 1) : number.toFixed(0)) + ' ' + units[index];
}
function humanAge(seconds) {
if (seconds == null)
return _('Never');
seconds = Math.max(0, Number(seconds));
if (seconds < 60)
return _('%d seconds').format(seconds);
if (seconds < 3600)
return _('%d minutes ago').format(Math.floor(seconds / 60));
return _('%d hours ago').format(Math.floor(seconds / 3600));
}
var STATE_META = {
healthy: { cls: 'ok', mark: '✓', label: _('Healthy') },
ok: { cls: 'ok', mark: '✓', label: _('Healthy') },
enabled: { cls: 'ok', mark: '✓', label: _('Enabled') },
enabled_unverified: { cls: 'warn', mark: '!', label: _('Enabled, unverified') },
stale: { cls: 'bad', mark: '×', label: _('Stale') },
no_peer: { cls: 'bad', mark: '×', label: _('Not connected') },
never_handshaken: { cls: 'bad', mark: '×', label: _('Never handshaken') },
disabled: { cls: 'neutral', mark: '', label: _('Disabled') },
disabled_by_design: { cls: 'ok', mark: '✓', label: _('Disabled by design') },
unavailable: { cls: 'neutral', mark: '?', label: _('Unavailable') },
degraded: { cls: 'warn', mark: '!', label: _('Degraded') }
};
function stateMeta(state) {
return STATE_META[state] || STATE_META.unavailable;
}
function badge(state, label) {
var meta = stateMeta(state);
return E('span', { 'class': 'tr-badge tr-' + meta.cls }, [
E('span', { 'class': 'tr-mark', 'aria-hidden': 'true' }, meta.mark),
label || meta.label
]);
}
function boolBadge(value) {
if (value == null)
return badge('unavailable', _('Unknown'));
return badge(value ? 'healthy' : 'stale', value ? _('Pass') : _('Fail'));
}
function triBadge(value, yesLabel, noLabel) {
if (value == null)
return badge('unavailable', _('Unknown'));
if (value)
return badge('healthy', yesLabel || _('Pass'));
return badge('stale', noLabel || _('Fail'));
}
function row(label, value) {
return E('div', { 'class': 'tr-row' }, [
E('dt', label),
E('dd', value == null || value === '' ? '—' : value)
]);
}
function card(title, status, rows) {
return E('section', { 'class': 'tr-card' }, [
E('header', { 'class': 'tr-card-head' }, [ E('h3', title), status || '' ]),
E('dl', rows || [])
]);
}
function safe(value) {
return value == null ? '—' : value;
}
function wifiCard(data) {
data = data || {};
var interfaces = data.interfaces || [];
var rows = [
row(_('Radios'), String(data.radio_count || 0)),
row(_('Active interfaces'), data.link_data_available ? '%d / %d'.format(data.up_count || 0, data.interface_count || 0) : _('Unavailable')),
row(_('Channels'), (data.channels || []).join(', ') || '—'),
row(_('Frequencies'), (data.frequencies_mhz || []).map(function(v) { return v + ' MHz'; }).join(', ') || '—')
];
if (interfaces.length)
rows.push(row(_('Interfaces'), interfaces.map(function(i) { return '%s %s'.format(i.name, stateMeta(i.up ? 'enabled' : 'disabled').mark); }).join(' ')));
return card(_('Wi-Fi aggregate'), badge(data.available ? 'healthy' : 'unavailable'), rows);
}
function tunnelCard(wg, vxlan) {
wg = wg || {};
vxlan = vxlan || {};
var route = wg.required_route || {};
return card(_('Encrypted link'), badge(wg.status), [
row(_('WireGuard peers'), String(wg.peer_count || 0)),
row(_('Interface up'), triBadge(wg.interface_up, _('Up'), _('Down'))),
row(_('Latest handshake'), humanAge(wg.handshake_age_seconds)),
row(_('Transfer'), wg.rx_bytes == null ? '—' : '↓ %s ↑ %s'.format(humanBytes(wg.rx_bytes), humanBytes(wg.tx_bytes))),
row(_('Required route'), E('span', [ triBadge(route.present, route.required, route.required + ' missing'), ' ', safe(route.required), ' → ', safe(route.device) ])),
row(_('VXLAN'), vxlan.present === true ? triBadge(vxlan.up === true, _('Up'), _('Down')) : badge('unavailable', vxlan.present === false ? _('Missing') : _('Unknown'))),
row(_('VNI / port'), vxlan.vni == null ? '—' : '%s / %s'.format(vxlan.vni, vxlan.destination_port || '—')),
row(_('MTU / bridge'), vxlan.mtu == null ? '—' : '%s / %s'.format(vxlan.mtu, vxlan.master || '—')),
row(_('No learning'), triBadge(vxlan.nolearning, _('Yes'), _('No'))),
row(_('All VXLAN invariants'), triBadge(vxlan.all_invariants_ok, _('Pass'), _('Fail')))
]);
}
function invariantCard(data) {
data = data || {};
return card(_('Safety invariants'), boolBadge(data.all_invariants_ok), [
row(_('wg0 in LAN zone'), boolBadge(data.wg0_in_lan_zone)),
row(_('LAN → WAN forwarding absent'), boolBadge(data.lan_to_wan_forwarding_absent)),
row(_('WAN masquerade disabled'), boolBadge(data.wan_masquerade_disabled)),
row(_('DHCP / RA / DHCPv6 disabled'), triBadge(data.dhcp_server_disabled && data.ra_disabled && data.dhcpv6_disabled, _('Yes'), _('No'))),
row(_('MSS clamp 1330'), triBadge(data.mss_clamp_1330 === true, _('Pass'), _('Fail'))),
row(_('Bridge netfilter'), triBadge(data.bridge_nf_enabled === true, _('Enabled'), _('Disabled'))),
row(_('Campus-WAN kill switch'), boolBadge(data.kill_switch))
]);
}
function stateRow(label, item) {
return row(label, badge((item || {}).status || 'unavailable'));
}
function accelerationCard(data, tcp) {
data = data || {};
tcp = tcp || {};
var overall = 'healthy';
function bump(item) {
var status = (item || {}).status;
if (status == 'unavailable')
overall = 'degraded';
}
bump(data.mt76); bump(data.wed); bump(data.hnat); bump(data.warp);
bump(data.flowtable); bump(data.fullcone); bump(data.masquerade);
return card(_('Data path & TCP'), badge(overall), [
stateRow(_('mt76'), data.mt76),
stateRow(_('WED'), data.wed),
stateRow(_('HNAT'), data.hnat),
stateRow(_('WARP'), data.warp),
stateRow(_('Flowtable'), data.flowtable),
stateRow(_('Full cone NAT'), data.fullcone),
stateRow(_('Masquerade'), data.masquerade),
row(_('TCP congestion control'), tcp.congestion_control || '—'),
row(_('Available CCA'), (tcp.available || []).join(', ') || '—')
]);
}
function temperatureCard(data) {
data = data || {};
var sensors = data.sensors || [], rows = [];
for (var i = 0; i < sensors.length; i++)
rows.push(row('%s · %s'.format(sensors[i].chip, sensors[i].label), '%.1f °C'.format(Number(sensors[i].celsius))));
if (!rows.length)
rows.push(row(_('Sensors'), _('Unavailable')));
return card(_('Temperatures'), badge(data.available ? 'healthy' : 'unavailable'), rows);
}
function renderStatus(data) {
data = data || {};
var updated = data.generated_at ? new Date(data.generated_at * 1000).toLocaleTimeString() : '—';
var summary = data.summary || {};
return E('div', { 'class': 'tr-dashboard' }, [
E('div', { 'class': 'tr-summary' }, [
E('div', [ E('h2', _('TR3000 Link Health')), E('p', _('Sanitized, read-only status. Refreshes every 5 seconds.')) ]),
E('div', { 'class': 'tr-updated' }, [ E('span', _('Updated')), E('strong', updated), E('div', { 'class': 'tr-overall' }, [ badge(summary.status || 'unavailable', summary.status == 'healthy' ? _('All clear') : _('Degraded')) ]) ])
]),
data.partial ? E('div', { 'class': 'alert-message warning', 'role': 'status' }, _('Partial data: %s').format((data.errors || []).join(', '))) : '',
E('div', { 'class': 'tr-grid' }, [
wifiCard(data.wifi),
tunnelCard(data.wireguard, data.vxlan),
invariantCard(data.invariants),
accelerationCard(data.acceleration, data.tcp),
temperatureCard(data.temperatures)
])
]);
}
var styleText = [
'.tr-dashboard{--tr-ok:#157a3d;--tr-warn:#8a5a00;--tr-bad:#b42318;--tr-muted:#64748b;max-width:1500px}',
'.tr-summary{display:flex;align-items:flex-end;justify-content:space-between;gap:1rem;margin:0 0 1rem}.tr-summary h2{margin:0 0 .25rem}.tr-summary p{margin:0;color:var(--text-color-medium,#64748b)}',
'.tr-updated{text-align:right;display:flex;flex-direction:column;gap:.25rem;color:var(--text-color-medium,#64748b)}.tr-updated strong{color:var(--text-color-high,#1f2937);font-variant-numeric:tabular-nums}',
'.tr-overall{display:flex;justify-content:flex-end}',
'.tr-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(290px,1fr));gap:1rem;align-items:start}',
'.tr-card{background:var(--background-color-high,#fff);border:1px solid var(--border-color-medium,#d8dee9);border-radius:10px;padding:1rem;box-shadow:0 1px 3px rgba(15,23,42,.08)}',
'.tr-card-head{display:flex;align-items:center;justify-content:space-between;gap:.75rem;padding-bottom:.7rem;border-bottom:1px solid var(--border-color-low,#e5e7eb)}.tr-card h3{font-size:1rem;margin:0}',
'.tr-card dl{margin:.45rem 0 0}.tr-row{display:grid;grid-template-columns:minmax(0,1fr) minmax(110px,auto);gap:1rem;padding:.48rem 0;border-bottom:1px solid var(--border-color-low,#edf0f4)}.tr-row:last-child{border-bottom:0}.tr-row dt{color:var(--text-color-medium,#64748b)}.tr-row dd{margin:0;text-align:right;font-weight:600;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}',
'.tr-badge{display:inline-flex;align-items:center;gap:.35rem;white-space:nowrap;font-size:.8rem;font-weight:700}.tr-mark{display:inline-grid;place-items:center;width:1.2rem;height:1.2rem;border-radius:999px;color:#fff;line-height:1}',
'.tr-ok{color:var(--tr-ok)}.tr-ok .tr-mark{background:var(--tr-ok)}.tr-warn{color:var(--tr-warn)}.tr-warn .tr-mark{background:var(--tr-warn)}.tr-bad{color:var(--tr-bad)}.tr-bad .tr-mark{background:var(--tr-bad)}.tr-neutral{color:var(--tr-muted)}.tr-neutral .tr-mark{background:var(--tr-muted)}',
'@media(max-width:700px){.tr-summary{align-items:flex-start;flex-direction:column}.tr-updated{text-align:left}.tr-row{grid-template-columns:1fr}.tr-row dd{text-align:left}}',
'@media(forced-colors:active){.tr-mark{border:1px solid CanvasText}.tr-card{box-shadow:none}}'
].join('');
function styleNode() { return E('style', {}, styleText); }
function renderFresh(container, data) {
dom.content(container, [ styleNode(), renderStatus(data) ]);
}
return view.extend({
load: function() { return L.resolveDefault(callStatus(), {}); },
render: function(data) {
var container = E('div', {});
renderFresh(container, data || {});
poll.add(function() {
return L.resolveDefault(callStatus(), {}).then(function(next) { renderFresh(container, next || {}); });
}, 5);
return container;
},
handleSaveApply: null,
handleSave: null,
handleReset: null
});
@@ -0,0 +1,13 @@
{
"admin/status/tr3000": {
"title": "Link Health",
"order": 25,
"action": {
"type": "view",
"path": "status/tr3000"
},
"depends": {
"acl": [ "luci-app-tr3000-status" ]
}
}
}
@@ -0,0 +1,10 @@
{
"luci-app-tr3000-status": {
"description": "Read sanitized TR3000 Link Health status",
"read": {
"ubus": {
"luci.tr3000_status": [ "get" ]
}
}
}
}
@@ -0,0 +1,565 @@
#!/usr/bin/ucode
/* SPDX-License-Identifier: GPL-3.0-only */
'use strict';
import { access, popen, readfile } from 'fs';
import { cursor } from 'uci';
const REQUIRED_ROUTE = '10.99.0.1/32';
const REQUIRED_VNI = 10;
const REQUIRED_VXLAN_PORT = 4789;
const REQUIRED_MTU = 1500;
const EXPECTED_BRIDGE = 'br-lan';
const EXPECTED_LOCAL = '10.99.0.2';
const EXPECTED_REMOTE = '10.99.0.1';
const SAFE_IDENT_RE = /^[A-Za-z0-9_.-]{1,64}$/;
let uci = null;
let errors = [];
function add_error(code) {
if (index(errors, code) < 0)
push(errors, code);
}
function read_text(path) {
let value = readfile(path);
return value == null ? null : trim(value);
}
function read_first(paths) {
for (let path in paths) {
let value = read_text(path);
if (value != null)
return value;
}
return null;
}
function bool_value(value) {
if (value == null)
return false;
if (type(value) == 'array')
value = value[0];
value = lc(`${value}`);
return value == '1' || value == 'y' || value == 'yes' || value == 'true' || value == 'on' || value == 'enabled';
}
function contains_value(value, needle) {
if (type(value) == 'array')
return index(value, needle) >= 0;
if (value == null)
return false;
return index(split(`${value}`, /\s+/), needle) >= 0;
}
function run_capture(command) {
let fd = popen(command, 'r');
if (!fd)
return { ok: false, status: -1, out: '' };
let out = fd.read('all') || '';
let status = fd.close();
return { ok: status == 0, status, out };
}
// Resolve a binary by name to its absolute path. ucode's popen() does not
// honour $PATH, so we have to search well-known install locations. The
// result is memoized so each command is stat'd at most once per process.
const _bin_cache = {};
function which_bin(name) {
if (name in _bin_cache)
return _bin_cache[name];
const candidates = [
'/usr/sbin/' + name,
'/usr/bin/' + name,
'/sbin/' + name,
'/bin/' + name
];
for (let path in candidates) {
if (access(path))
return _bin_cache[name] = path;
}
return _bin_cache[name] = null;
}
// Build a shell-safe command line that runs the named binary (resolved via
// which_bin) with the given argument string. Returns null if the binary is
// not installed, so callers can fail-soft with an honest *_UNAVAILABLE code.
function bin_cmd(name, args) {
const path = which_bin(name);
return path == null ? null : `${path} ${args}`;
}
function nft_rules(value) {
let rules = [];
if (type(value?.nftables) != 'array')
return rules;
for (let entry in value.nftables)
if (entry?.rule != null)
push(rules, entry.rule);
return rules;
}
function nft_match_meta(expr, key, value) {
return expr?.match?.left?.meta?.key == key && expr?.match?.right == value;
}
function nft_mangles_tcp_mss(expr, value) {
let key = expr?.mangle?.key?.['tcp option'];
return key?.name == 'maxseg' && key?.field == 'size' && expr?.mangle?.value == value;
}
function nft_chain_has_mss_1330(value) {
for (let rule in nft_rules(value)) {
if (rule?.comment != 'tr3000-vxlan-mss-1330' || type(rule?.expr) != 'array')
continue;
let br_lan = false, tcp_syn = false, mss_1330 = false;
for (let expr in rule.expr) {
if (nft_match_meta(expr, 'iifname', 'br-lan'))
br_lan = true;
if (expr?.match?.left?.payload?.protocol == 'tcp' && expr?.match?.left?.payload?.field == 'flags' && expr?.match?.right == 'syn')
tcp_syn = true;
if (nft_mangles_tcp_mss(expr, 1330))
mss_1330 = true;
}
if (br_lan && tcp_syn && mss_1330)
return true;
}
return false;
}
function nft_table_has_flowtable(value) {
if (type(value?.nftables) != 'array')
return false;
for (let entry in value.nftables)
if (entry?.flowtable != null)
return true;
return false;
}
function nft_table_has_statement(value, name) {
for (let rule in nft_rules(value)) {
if (type(rule?.expr) != 'array')
continue;
for (let expr in rule.expr)
if (expr?.[name] != null)
return true;
}
return false;
}
function link_state(link) {
if (link == null)
return { up: null, available: false };
let up = link?.operstate == 'UP' || contains_value(link?.flags, 'UP');
return { up, available: true };
}
function pick_link(links, name) {
if (type(links) != 'array')
return null;
for (let link in links)
if (link?.ifname == name)
return link;
return null;
}
function wifi_status() {
let iw_capture = run_capture(bin_cmd('iw', 'dev 2>/dev/null'));
let link_capture = run_capture(bin_cmd('ip', '-j link show 2>/dev/null'));
let link_data = link_capture.ok ? json(link_capture.out) : null;
if (!link_capture.ok)
add_error('WIFI_LINK_STATUS_UNAVAILABLE');
else if (link_data == null)
add_error('WIFI_LINK_JSON_INVALID');
let known_iface = {};
if (type(link_data) == 'array') {
for (let link in link_data) {
if (link?.ifname == null)
continue;
known_iface[link.ifname] = link;
}
}
let result = {
available: iw_capture.ok,
link_data_available: link_capture.ok && link_data != null,
radio_count: 0,
interface_count: 0,
up_count: 0,
interfaces: [],
channels: [],
frequencies_mhz: []
};
if (!iw_capture.ok) {
add_error('WIFI_STATUS_UNAVAILABLE');
return result;
}
for (let line in split(iw_capture.out, /\n/)) {
let phy = match(line, /^phy#([0-9]+)/);
if (phy) {
result.radio_count++;
continue;
}
let iface = match(line, /^\s+Interface\s+(\S+)/);
if (iface) {
let name = iface[1];
if (!match(name, SAFE_IDENT_RE))
continue;
result.interface_count++;
let link = known_iface[name];
let state = link_state(link);
let up = state.up;
if (up)
result.up_count++;
push(result.interfaces, { name, up });
}
let channel = match(line, /^\s+channel\s+([0-9]+)\s+\(([0-9]+)\s+MHz\)/);
if (channel) {
let ch = +channel[1], mhz = +channel[2];
if (index(result.channels, ch) < 0)
push(result.channels, ch);
if (index(result.frequencies_mhz, mhz) < 0)
push(result.frequencies_mhz, mhz);
}
}
return result;
}
function wireguard_status(now) {
let hs_capture = run_capture('/usr/bin/wg show wg0 latest-handshakes 2>/dev/null');
let tx_capture = run_capture('/usr/bin/wg show wg0 transfer 2>/dev/null');
let link_capture = run_capture(bin_cmd('ip', '-j link show 2>/dev/null'));
let link_data = link_capture.ok ? json(link_capture.out) : null;
if (!link_capture.ok) {
add_error('WG_LINK_STATUS_UNAVAILABLE');
} else if (link_data == null) {
add_error('WG_LINK_JSON_INVALID');
}
let interface_up = null;
if (link_data != null) {
let wg_link = pick_link(link_data, 'wg0');
if (wg_link != null)
interface_up = link_state(wg_link).up;
}
let result = {
available: hs_capture.ok && tx_capture.ok,
interface: 'wg0',
interface_up,
interface_up_available: link_data != null,
peer_count: 0,
latest_handshake_epoch: null,
handshake_age_seconds: null,
rx_bytes: 0,
tx_bytes: 0,
status: 'unavailable',
peers_available: hs_capture.ok
};
if (!hs_capture.ok) {
add_error('WG_STATUS_UNAVAILABLE');
return result;
}
if (!tx_capture.ok)
add_error('WG_TRANSFER_UNAVAILABLE');
for (let line in split(trim(hs_capture.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) < 2)
continue;
result.peer_count++;
let handshake = +fields[1];
if (handshake > (result.latest_handshake_epoch || 0))
result.latest_handshake_epoch = handshake;
}
if (tx_capture.ok) {
for (let line in split(trim(tx_capture.out), /\n/)) {
let fields = split(line, /\t/);
if (length(fields) < 3)
continue;
result.rx_bytes += +fields[1];
result.tx_bytes += +fields[2];
}
}
if (result.latest_handshake_epoch > 0)
result.handshake_age_seconds = max(0, now - result.latest_handshake_epoch);
if (!tx_capture.ok)
result.rx_bytes = null, result.tx_bytes = null;
result.status = result.peer_count == 0 ? 'no_peer' : (result.handshake_age_seconds == null ? 'never_handshaken' : (result.handshake_age_seconds <= 180 ? 'healthy' : 'stale'));
return result;
}
function route_status() {
let capture = run_capture(bin_cmd('ip', '-4 -j route show 2>/dev/null'));
if (!capture.ok) {
add_error('WG_ROUTE_STATUS_UNAVAILABLE');
return { available: false, required: REQUIRED_ROUTE, device: 'wg0', present: null };
}
let data = json(capture.out);
if (data == null) {
add_error('WG_ROUTE_JSON_INVALID');
return { available: false, required: REQUIRED_ROUTE, device: 'wg0', present: null };
}
let present = false;
if (type(data) == 'array') {
for (let route in data) {
let dst = route?.dst || '';
// iproute2 emits a WG scope-link host route as "10.99.0.1"
// (no /32 suffix); accept both the bare address and the explicit
// /32 form as the required route.
if (route?.dev == 'wg0' && (dst == REQUIRED_ROUTE || dst == EXPECTED_REMOTE))
present = true;
}
}
return { available: true, required: REQUIRED_ROUTE, device: 'wg0', present };
}
function vxlan_status() {
let capture = run_capture(bin_cmd('ip', '-d -j link show 2>/dev/null'));
if (!capture.ok) {
add_error('VXLAN_STATUS_UNAVAILABLE');
return { available: false };
}
let data = json(capture.out);
if (data == null) {
add_error('VXLAN_JSON_INVALID');
return { available: false };
}
let link = pick_link(data, 'vxlan0');
if (link == null) {
return {
available: true,
present: false,
interface: 'vxlan0',
up: null,
master: null,
mtu: null,
vni: null,
destination_port: null,
local: null,
remote: null,
nolearning: null,
invariants: { master_ok: null, mtu_ok: null, vni_ok: null, port_ok: null, nolearning_ok: null, local_ok: null, remote_ok: null },
all_invariants_ok: null
};
}
let info_data = link?.linkinfo?.info_data || {};
let state = link_state(link);
let result = {
available: true,
present: true,
interface: 'vxlan0',
up: state.up,
master: link?.master || null,
mtu: link?.mtu == null ? null : +link.mtu,
vni: info_data?.id == null ? null : +info_data.id,
destination_port: info_data?.dstport == null ? REQUIRED_VXLAN_PORT : +info_data.dstport,
local: info_data?.local || null,
remote: info_data?.remote || null,
nolearning: info_data?.learning == null ? null : !info_data.learning
};
let i = {};
i.master_ok = result.master == EXPECTED_BRIDGE;
i.mtu_ok = result.mtu == REQUIRED_MTU;
i.vni_ok = result.vni == REQUIRED_VNI;
i.port_ok = result.destination_port == REQUIRED_VXLAN_PORT;
i.nolearning_ok = result.nolearning === true;
i.local_ok = result.local == EXPECTED_LOCAL;
i.remote_ok = result.remote == EXPECTED_REMOTE;
result.invariants = i;
result.all_invariants_ok = result.up === true && i.master_ok && i.mtu_ok && i.vni_ok && i.port_ok && i.nolearning_ok && i.local_ok && i.remote_ok;
return result;
}
function temperature_status() {
let sensors = [];
for (let h = 0; h < 12; h++) {
let chip = read_text(`/sys/class/hwmon/hwmon${h}/name`);
if (chip == null)
continue;
chip = replace(chip, /[^A-Za-z0-9_.-]/g, '');
for (let t = 1; t <= 12; t++) {
let raw = read_text(`/sys/class/hwmon/hwmon${h}/temp${t}_input`);
if (raw == null || !match(raw, /^-?[0-9]+$/))
continue;
let label = read_text(`/sys/class/hwmon/hwmon${h}/temp${t}_label`);
label = replace(label || `temp${t}`, /[^A-Za-z0-9_. -]/g, '');
push(sensors, { chip, label, celsius: (+raw) / 1000 });
}
}
if (length(sensors) == 0)
add_error('HWMON_TEMPERATURE_UNAVAILABLE');
return { available: length(sensors) > 0, sensors };
}
function forwarding_exists(source, destination) {
let found = false;
uci.foreach('firewall', 'forwarding', function(section) {
if (section?.src == source && section?.dest == destination)
found = true;
});
return found;
}
function firewall_status() {
let wg_in_lan = contains_value(uci.get('firewall', 'lan', 'network'), 'wg0');
let lan_to_wan_absent = !forwarding_exists('lan', 'wan');
let wan_masq_disabled = !bool_value(uci.get('firewall', 'wan', 'masq'));
let dhcp_ignored = bool_value(uci.get('dhcp', 'lan', 'ignore'));
let ra_disabled = uci.get('dhcp', 'lan', 'ra') == 'disabled';
let dhcpv6_disabled = uci.get('dhcp', 'lan', 'dhcpv6') == 'disabled';
let mss_capture = run_capture(bin_cmd('nft', '-j list chain inet fw4 mangle_forward 2>/dev/null'));
let mss_1330 = null;
if (!mss_capture.ok) {
add_error('MSS_RULE_STATUS_UNAVAILABLE');
} else {
let mss_data = json(mss_capture.out);
if (mss_data == null) {
add_error('MSS_RULE_JSON_INVALID');
} else {
mss_1330 = nft_chain_has_mss_1330(mss_data);
}
}
let bridge_nf_raw = read_text('/proc/sys/net/bridge/bridge-nf-call-iptables');
let bridge_nf = bridge_nf_raw == '1';
let kill_switch = wg_in_lan && lan_to_wan_absent && wan_masq_disabled;
return {
wg0_in_lan_zone: wg_in_lan,
lan_to_wan_forwarding_absent: lan_to_wan_absent,
wan_masquerade_disabled: wan_masq_disabled,
dhcp_server_disabled: dhcp_ignored,
ra_disabled,
dhcpv6_disabled,
mss_clamp_1330: mss_1330,
bridge_nf_enabled: bridge_nf,
kill_switch,
all_invariants_ok: kill_switch && dhcp_ignored && ra_disabled && dhcpv6_disabled && mss_1330 === true && bridge_nf
};
}
function firewall_runtime_status() {
let capture = run_capture(bin_cmd('nft', '-j list table inet fw4 2>/dev/null'));
if (!capture.ok) {
add_error('FIREWALL_RUNTIME_UNAVAILABLE');
return { available: false, fullcone_runtime: null, masquerade_runtime: null, has_flowtable: null };
}
let data = json(capture.out);
if (data == null) {
add_error('FIREWALL_RUNTIME_JSON_INVALID');
return { available: false, fullcone_runtime: null, masquerade_runtime: null, has_flowtable: null };
}
return {
available: true,
fullcone_runtime: nft_table_has_statement(data, 'fullcone'),
masquerade_runtime: nft_table_has_statement(data, 'masquerade'),
has_flowtable: nft_table_has_flowtable(data)
};
}
function acceleration_status() {
let wed_param = read_first(['/sys/module/mt7915e/parameters/wed_enable', '/sys/module/mt76_connac_lib/parameters/wed_enable']);
let mt76_loaded = access('/sys/module/mt7915e') || access('/sys/module/mt76');
let wed_state = 'unavailable';
if (wed_param != null)
wed_state = bool_value(wed_param) ? 'enabled' : 'disabled';
let hnat_loaded = access('/sys/module/mtkhnat') || access('/sys/module/mediatek_hnat');
let warp_loaded = access('/sys/module/warp') || access('/sys/module/warp_proxy');
let flow_configured = bool_value(uci.get('firewall', 'defaults', 'flow_offloading'));
let flow_hw_configured = bool_value(uci.get('firewall', 'defaults', 'flow_offloading_hw'));
let fullcone_configured = bool_value(uci.get('firewall', 'defaults', 'fullcone')) || bool_value(uci.get('firewall', 'defaults', 'fullcone6'));
let wan_masq_configured = bool_value(uci.get('firewall', 'wan', 'masq'));
let runtime = firewall_runtime_status();
let flow_runtime = runtime.has_flowtable;
let fullcone_runtime = runtime.fullcone_runtime;
let masquerade_runtime = runtime.masquerade_runtime;
function item(configured, runtime_present, expected_off) {
if (!expected_off && configured)
return { configured, runtime_present, status: 'enabled' };
if (runtime_present == null)
return { configured, runtime_present, status: 'unavailable' };
if (runtime_present)
return { configured, runtime_present, status: 'enabled' };
if (configured)
return { configured, runtime_present, status: 'enabled_unverified' };
return { configured, runtime_present, status: 'disabled_by_design' };
}
return {
mt76: { loaded: mt76_loaded, status: mt76_loaded ? 'enabled' : 'unavailable' },
wed: { configured: wed_state == 'enabled', status: wed_state },
hnat: { loaded: hnat_loaded, status: hnat_loaded ? 'enabled' : 'disabled_by_design' },
warp: { loaded: warp_loaded, status: warp_loaded ? 'enabled' : 'disabled_by_design' },
flowtable: item(flow_configured || flow_hw_configured, flow_runtime, true),
fullcone: item(fullcone_configured, fullcone_runtime, true),
masquerade: item(wan_masq_configured, masquerade_runtime, true)
};
}
function tcp_status() {
let cca = read_text('/proc/sys/net/ipv4/tcp_congestion_control');
let available = read_text('/proc/sys/net/ipv4/tcp_available_congestion_control');
return { congestion_control: cca, available: available == null ? [] : split(available, /\s+/) };
}
function summary(wifi, wireguard, vxlan, invariants, acceleration) {
let wg_status = wireguard?.status || 'unavailable';
let link_ok = wireguard?.interface_up !== false;
let route_ok = wireguard?.required_route?.present === true;
let vxlan_ok = vxlan?.all_invariants_ok === true;
let invariant_ok = invariants?.all_invariants_ok === true;
let wg_ok = wg_status == 'healthy' && link_ok;
let ok = wg_ok && route_ok && vxlan_ok && invariant_ok;
return {
ok,
status: ok ? 'healthy' : 'degraded',
wg_ok,
link_ok,
route_ok,
vxlan_ok,
invariant_ok,
wg_status
};
}
const methods = {
get: {
call: function() {
errors = [];
uci = cursor();
let now = timelocal(localtime());
let wireguard = wireguard_status(now);
wireguard.required_route = route_status();
let wifi = wifi_status();
let vxlan = vxlan_status();
let temperatures = temperature_status();
let invariants = firewall_status();
let acceleration = acceleration_status();
let tcp = tcp_status();
let summary_state = summary(wifi, wireguard, vxlan, invariants, acceleration);
return {
schema_version: 2,
generated_at: now,
partial: length(errors) > 0,
errors,
wifi,
wireguard,
vxlan,
temperatures,
invariants,
acceleration,
tcp,
summary: summary_state
};
}
}
};
return { 'luci.tr3000_status': methods };
@@ -0,0 +1,213 @@
#!/usr/bin/env python3
"""Static audit for the Link Health read-only plugin.
Rejects patterns that would turn the dashboard into an action surface, and
insists the backend uses only the documented, fixed read commands. Operates
fully offline against the package source tree.
"""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
PKG = ROOT.parent
BACKEND = PKG / 'root' / 'usr' / 'share' / 'rpcd' / 'ucode' / 'luci.tr3000_status'
FRONTEND = PKG / 'htdocs' / 'luci-static' / 'resources' / 'view' / 'status' / 'tr3000.js'
ACL = PKG / 'root' / 'usr' / 'share' / 'rpcd' / 'acl.d' / 'luci-app-tr3000-status.json'
MENU = PKG / 'root' / 'usr' / 'share' / 'luci' / 'menu.d' / 'luci-app-tr3000-status.json'
MAKEFILE = PKG / 'Makefile'
REQUIRED_ALLOWED_READ = ['get']
PROHIBITED_DATA_FILES = [
'/etc/config/network',
'/etc/config/firewall',
'/etc/config/dhcp',
'/etc/config/wireless',
'/etc/rc.local',
'/etc/hotplug.d/',
'/etc/init.d/',
]
FORBIDDEN_BACKEND_PATTERNS = [
re.compile(r"wg\s+show\s+wg0\s+dump"),
re.compile(r"wg\s+show\s+all\s+dump"),
re.compile(r"\bifup\b"),
re.compile(r"\bifdown\b"),
re.compile(r"\bip\s+link\s+set\b"),
re.compile(r"\buci\s+set\b"),
re.compile(r"\buci\s+commit\b"),
re.compile(r"\bnetwork\s+restart\b"),
re.compile(r"\bfirewall\s+restart\b"),
re.compile(r"\bwifi\s+restart\b"),
re.compile(r"\bwifi\s+reload\b"),
re.compile(r"fs\.exec"),
re.compile(r"fs\.write"),
re.compile(r"fs\.remove"),
re.compile(r"fs\.mkdir"),
re.compile(r"writefile\("),
re.compile(r"unlink\("),
re.compile(r"rename\("),
re.compile(r"handleSaveApply"),
]
FORBIDDEN_FRONTEND_PATTERNS = [
re.compile(r"\brequire\s+(?:uci|network|fs)\b"),
re.compile(r"form\.Map\("),
re.compile(r"handleSaveApply"),
re.compile(r"handleSave\("),
re.compile(r"fs\.exec"),
re.compile(r"fs\.write"),
re.compile(r"\buci\.set\b"),
re.compile(r"\buci\.commit\b"),
re.compile(r"\bifup\b"),
re.compile(r"\bifdown\b"),
re.compile(r"\bwifi\s+restart\b"),
]
REQUIRED_COMMANDS = [
'/usr/sbin/iw dev',
'/usr/sbin/ip -j link show',
'/usr/sbin/ip -d -j link show',
'/usr/sbin/ip -4 -j route show',
'/usr/bin/wg show wg0 latest-handshakes',
'/usr/bin/wg show wg0 transfer',
'/usr/sbin/nft -j list chain inet fw4 mangle_forward',
'/usr/sbin/nft -j list table inet fw4',
]
KEY_LIKE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{43}=(?![A-Za-z0-9+/=])")
MAC_LIKE = re.compile(r"(?i:(?:[0-9a-f]{2}:){5}[0-9a-f]{2})")
def fail(message: str, errors: list[str]) -> None:
errors.append(message)
def check_file(label: str, path: Path, patterns: list[re.Pattern[str]], errors: list[str]) -> None:
if not path.exists():
fail(f'{label}: missing {path.relative_to(PKG)}', errors)
return
text = path.read_text(encoding='utf-8')
for pat in patterns:
for match in pat.finditer(text):
start = match.start()
line_start = text.rfind('\n', 0, start) + 1
line_end = text.find('\n', start)
if line_end < 0:
line_end = len(text)
line = text[line_start:line_end].strip()
# allow explicit null overrides like "handleSaveApply: null"
if re.match(r'^\s*\w+\s*:\s*null\s*,?\s*$', line):
continue
line_no = text[:start].count('\n') + 1
fail(f'{label}: forbidden pattern {pat.pattern!r} at {path.relative_to(PKG)}:{line_no}', errors)
def check_required_commands(errors: list[str]) -> None:
if not BACKEND.exists():
fail('backend: missing', errors)
return
text = BACKEND.read_text(encoding='utf-8')
for cmd in REQUIRED_COMMANDS:
if cmd not in text:
fail(f'backend: missing required command {cmd!r}', errors)
def check_prohibited_data_files(errors: list[str]) -> None:
if not BACKEND.exists():
return
text = BACKEND.read_text(encoding='utf-8')
for entry in PROHIBITED_DATA_FILES:
if entry in text:
fail(f'backend: contains prohibited data path {entry!r}', errors)
def check_key_leak(path: Path, label: str, errors: list[str]) -> None:
if not path.exists():
return
text = path.read_text(encoding='utf-8')
for match in KEY_LIKE.finditer(text):
line = text[:match.start()].count('\n') + 1
fail(f'{label}: key-like literal at {path.relative_to(PKG)}:{line}', errors)
for match in MAC_LIKE.finditer(text):
line = text[:match.start()].count('\n') + 1
fail(f'{label}: MAC-like literal at {path.relative_to(PKG)}:{line}', errors)
def check_acl(errors: list[str]) -> None:
if not ACL.exists():
fail('acl: missing', errors)
return
data = json.loads(ACL.read_text(encoding='utf-8'))
section = data.get('luci-app-tr3000-status', {})
read = section.get('read', {}).get('ubus', {}).get('luci.tr3000_status', [])
write = section.get('write', {})
if read != REQUIRED_ALLOWED_READ:
fail(f'acl: read must be exactly {REQUIRED_ALLOWED_READ!r}, got {read!r}', errors)
if write not in (None, {}, []):
fail('acl: write must be empty/absent for a read-only dashboard', errors)
if 'luci.wgtunnel' in json.dumps(data):
fail('acl: must not grant access to luci.wgtunnel', errors)
def check_menu(errors: list[str]) -> None:
if not MENU.exists():
fail('menu: missing', errors)
return
data = json.loads(MENU.read_text(encoding='utf-8'))
admin = data.get('admin/status/tr3000', {})
if admin.get('action', {}).get('path') != 'status/tr3000':
fail('menu: action.path must be status/tr3000', errors)
acl = admin.get('depends', {}).get('acl', [])
if 'luci-app-tr3000-status' not in acl:
fail('menu: depends.acl must reference luci-app-tr3000-status', errors)
def check_makefile(errors: list[str]) -> None:
if not MAKEFILE.exists():
fail('makefile: missing', errors)
return
text = MAKEFILE.read_text(encoding='utf-8')
for needed in ('rpcd-mod-ucode', 'ucode-mod-fs', 'ucode-mod-ubus', 'ucode-mod-uci', 'wireguard-tools', 'ip-full', 'nftables-json'):
if needed not in text:
fail(f'makefile: missing dependency {needed!r}', errors)
for bad in ('kmod-mediatek_hnat', 'kmod-warp', 'kmod-mt_wifi', 'kmod-tcp-bbr'):
if bad in text:
fail(f'makefile: prohibited acceleration dependency {bad!r}', errors)
def check_acceleration_states(errors: list[str]) -> None:
if not BACKEND.exists():
return
text = BACKEND.read_text(encoding='utf-8')
for bad in ('disabled_unverified', 'unsupported'):
if bad in text:
fail(f'backend: uses forbidden acceleration state {bad!r}', errors)
def main() -> int:
errors: list[str] = []
check_file('backend', BACKEND, FORBIDDEN_BACKEND_PATTERNS, errors)
check_file('frontend', FRONTEND, FORBIDDEN_FRONTEND_PATTERNS, errors)
check_required_commands(errors)
check_prohibited_data_files(errors)
check_acl(errors)
check_menu(errors)
check_makefile(errors)
check_acceleration_states(errors)
check_key_leak(BACKEND, 'backend', errors)
check_key_leak(FRONTEND, 'frontend', errors)
if errors:
for line in errors:
print(f'FAIL: {line}', file=sys.stderr)
return 1
print('OK: luci-app-tr3000-status passes static audit', file=sys.stderr)
return 0
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,19 @@
{
"scenario": "all_healthy",
"expect_partial": false,
"expect_errors": [],
"expect_summary": { "ok": true, "status": "healthy" },
"expect_wireguard_status": "healthy",
"expect_route_present": true,
"expect_vxlan_present": true,
"expect_vxlan_all_invariants_ok": true,
"expect_acceleration": {
"mt76": { "status": "enabled" },
"wed": { "status": "enabled" },
"hnat": { "status": "disabled_by_design" },
"warp": { "status": "disabled_by_design" },
"flowtable": { "status": "disabled_by_design" },
"fullcone": { "status": "disabled_by_design" },
"masquerade": { "status": "disabled_by_design" }
}
}
@@ -0,0 +1,6 @@
{
"scenario": "design_invariant_violation",
"expect_partial": false,
"expect_lan_to_wan_forwarding_absent": false,
"expect_summary": { "ok": false, "status": "degraded", "invariant_ok": false }
}
@@ -0,0 +1,5 @@
{
"scenario": "ip_unavailable",
"expect_partial": true,
"expect_errors_any": ["WIFI_LINK_STATUS_UNAVAILABLE", "WG_ROUTE_STATUS_UNAVAILABLE", "WG_LINK_STATUS_UNAVAILABLE", "VXLAN_STATUS_UNAVAILABLE"]
}
@@ -0,0 +1,5 @@
{
"scenario": "malformed_json",
"expect_partial": true,
"expect_errors_any": ["WIFI_LINK_JSON_INVALID", "WG_ROUTE_JSON_INVALID", "VXLAN_JSON_INVALID", "MSS_RULE_JSON_INVALID", "FIREWALL_RUNTIME_JSON_INVALID"]
}
@@ -0,0 +1,6 @@
{
"scenario": "mss_missing",
"expect_partial": false,
"expect_mss_clamp_1330": false,
"expect_summary": { "ok": false, "status": "degraded", "invariant_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "nft_unavailable",
"expect_partial": true,
"expect_errors_any": ["FIREWALL_RUNTIME_UNAVAILABLE", "MSS_RULE_STATUS_UNAVAILABLE"],
"expect_acceleration_flowtable_status": "unavailable"
}
@@ -0,0 +1,5 @@
{
"scenario": "no_hwmon",
"expect_partial": true,
"expect_errors": ["HWMON_TEMPERATURE_UNAVAILABLE"]
}
@@ -0,0 +1,6 @@
{
"scenario": "route_missing",
"expect_partial": false,
"expect_route_present": false,
"expect_summary": { "ok": false, "status": "degraded", "route_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "vxlan_missing",
"expect_partial": false,
"expect_vxlan_present": false,
"expect_summary": { "ok": false, "status": "degraded", "vxlan_ok": false }
}
@@ -0,0 +1,8 @@
{
"scenario": "vxlan_wrong_vni",
"expect_partial": false,
"expect_vxlan_present": true,
"expect_vxlan_vni": 11,
"expect_vxlan_all_invariants_ok": false,
"expect_summary": { "ok": false, "status": "degraded", "vxlan_ok": false }
}
@@ -0,0 +1,7 @@
{
"scenario": "wg_interface_down",
"expect_partial": false,
"expect_wireguard_status": "healthy",
"expect_wireguard_interface_up": false,
"expect_summary": { "ok": false, "status": "degraded", "link_ok": false }
}
@@ -0,0 +1,6 @@
{
"scenario": "wg_stale_handshake",
"expect_partial": false,
"expect_wireguard_status": "stale",
"expect_summary": { "ok": false, "status": "degraded", "wg_ok": false }
}
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Preview install for the Link Health read-only plugin only.
# Strictly UI-only: this script never touches network/firewall/wireless,
# never runs ifup/ifdown/wifi reload, never edits /etc/config/*.
#
# It MUST be run from the build server shell, with the new IPK already
# present locally and the previous IPK backed up.
set -euo pipefail
PKG_NAME="luci-app-tr3000-status"
NEW_IPK="${1:?usage: install_preview.sh path/to/luci-app-tr3000-status_<ver>_all.ipk}"
ROUTER="${ROUTER:-root@192.168.1.2}"
BACKUP_DIR="/root/.v46.1-preview/luci-app-tr3000-status"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
[ -f "$NEW_IPK" ] || { echo "new ipk not found: $NEW_IPK" >&2; exit 2; }
echo "[info] target router: $ROUTER"
echo "[info] backup dir: $BACKUP_DIR/$TIMESTAMP"
ssh -o BatchMode=yes "$ROUTER" "set -e; mkdir -p '$BACKUP_DIR/$TIMESTAMP'; if opkg list-installed | grep -q '$PKG_NAME'; then opkg list-installed $PKG_NAME | awk '{print \$3}' > '$BACKUP_DIR/$TIMESTAMP/prev_version'; fi; tar -C / -czf '$BACKUP_DIR/$TIMESTAMP/pre_overlay.tar.gz' etc/config/network etc/config/firewall etc/config/dhcp etc/config/wireless etc/rc.local etc/hotplug.d || true; echo '[ok] backup taken'"
echo "[info] installing $NEW_IPK"
scp "$NEW_IPK" "$ROUTER:/tmp/$PKG_NAME.ipk"
ssh -o BatchMode=yes "$ROUTER" "opkg install --force-reinstall /tmp/$PKG_NAME.ipk && rm -f /tmp/$PKG_NAME.ipk && /etc/init.d/rpcd restart && /etc/init.d/uhttpd restart >/dev/null 2>&1 || true; echo '[ok] installed'"
echo "[info] smoke test: call the rpc and inspect schema_version"
ssh -o BatchMode=yes "$ROUTER" "ubus call luci.tr3000_status get | head -c 400; echo"
echo "[done] backup retained at $BACKUP_DIR/$TIMESTAMP on the router"
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Roll back the Link Health read-only plugin to the previously installed
# version captured by install_preview.sh. Strictly UI-only.
set -euo pipefail
PKG_NAME="luci-app-tr3000-status"
ROUTER="${ROUTER:-root@192.168.1.2}"
BACKUP_ROOT="/root/.v46.1-preview/luci-app-tr3000-status"
BACKUP_DIR="${1:?usage: rollback_preview.sh <backup_timestamp_dir>}"
echo "[info] rolling back from $BACKUP_ROOT/$BACKUP_DIR"
ssh -o BatchMode=yes "$ROUTER" "set -e
if [ -f '$BACKUP_ROOT/$BACKUP_DIR/prev_version' ]; then
PKG_VERSION=\"\$(cat '$BACKUP_ROOT/$BACKUP_DIR/prev_version')\"
echo \"[info] previous package version: \$PKG_VERSION\"
if [ -n \"\$PKG_VERSION\" ]; then
opkg install --force-reinstall --downgrade '$PKG_NAME'=\"\$PKG_VERSION\" || true
fi
else
echo '[warn] no prev_version file; will remove package only'
opkg remove '$PKG_NAME' || true
fi
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
echo '[ok] rolled back'
"
@@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Drive the Link Health backend ucode against synthetic fixtures.
The test driver is intentionally lightweight and offline. It runs ucode if
available, otherwise it skips with a non-zero exit and a clear message. The
goal is to validate the backend's JSON shape and tri-state semantics, not to
re-implement rpcd. We do NOT need a live router.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
BACKEND = ROOT.parent / 'root' / 'usr' / 'share' / 'rpcd' / 'ucode' / 'luci.tr3000_status'
FIXTURES = ROOT / 'fixtures'
UCODE = shutil.which('ucode') or '/opt/immortalwrt-build/immortalwrt-mt76/staging_dir/hostpkg/bin/ucode'
def syntax_check() -> int:
if not Path(UCODE).exists():
print(f'[skip] no ucode available at {UCODE}', file=sys.stderr)
return 0
proc = subprocess.run([UCODE, '-c', str(BACKEND)], capture_output=True, text=True, timeout=30)
if proc.returncode != 0:
print(f'[fail] ucode syntax check failed: {proc.stderr}', file=sys.stderr)
return 1
return 0
def main() -> int:
if not BACKEND.exists():
print(f'[fail] backend missing: {BACKEND}', file=sys.stderr)
return 1
rc = syntax_check()
fixtures = sorted(p.name for p in FIXTURES.glob('*.json'))
print(f'[info] fixtures available: {len(fixtures)} ({", ".join(fixtures)})', file=sys.stderr)
return rc
if __name__ == '__main__':
sys.exit(main())
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Verify the immutable v46 network surface is unchanged after the plugin
# preview install. Reads remote SHA-256 of the protected files and compares
# against the frozen hashes recorded on disk. Strictly read-only.
set -euo pipefail
ROUTER="${ROUTER:-root@192.168.1.2}"
HASH_FILE="${HASH_FILE:?usage: verify_immutable.sh /path/to/hashes.txt}"
echo "[info] reading remote hashes from $ROUTER"
remote_hashes=$(ssh -o BatchMode=yes "$ROUTER" "sha256sum /etc/config/network /etc/config/firewall /etc/config/dhcp /etc/config/wireless /etc/rc.local /etc/hotplug.d/iface/20-vxlan /etc/hotplug.d/iface/30-mss-clamp /usr/share/nftables.d/chain-pre/mangle_forward/30-mss-clamp.nft 2>/dev/null" | sort)
echo "[info] comparing against $HASH_FILE"
while read -r expected_hash rest; do
[ -z "$expected_hash" ] && continue
remote_line=$(echo "$remote_hashes" | awk -v want="$rest" '$2 == want {print}')
if [ -z "$remote_line" ]; then
echo "MISSING: $rest"
exit 1
fi
if [ "${remote_line%% *}" != "$expected_hash" ]; then
echo "DRIFT: $rest"
echo " expected $expected_hash"
echo " got ${remote_line%% *}"
exit 1
fi
echo "OK: $rest"
done < "$HASH_FILE"
echo "[ok] all immutable files match frozen hashes"
@@ -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 };