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 };
@@ -0,0 +1,24 @@
#
# Copyright (C) 2008-2019 Jerrykuku
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
include $(TOPDIR)/rules.mk
LUCI_TITLE:=Argon Theme
LUCI_DEPENDS:=+wget +jsonfilter
PKG_VERSION:=2.4.3
PKG_RELEASE:=20250722
LUCI_MINIFY_CSS:=0
define Package/luci-theme-argon/conffiles
/etc/config/argon
/www/luci-static/argon/background/
endef
include $(TOPDIR)/feeds/luci/luci.mk
# call BuildPackage - OpenWrt buildroot signature
@@ -0,0 +1,2 @@
Drop background here!
accept jpg png gif mp4 webm
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#adaeaf" d="m8,10.033663l-6.898535,-6.013274l-1.060688,0.972974l7.959223,6.986249l7.959223,-6.986249l-1.060688,-0.972974l-6.898535,6.013274z"/>
</svg>

After

Width:  |  Height:  |  Size: 225 B

@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<browserconfig><msapplication><tile><square70x70logo src="/ms-icon-70x70.png"/><square150x150logo src="/ms-icon-150x150.png"/><square310x310logo src="/ms-icon-310x310.png"/><TileColor>#ffffff</TileColor></tile></msapplication></browserconfig>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

@@ -0,0 +1,41 @@
{
"name": "Openwrt",
"icons": [
{
"src": "\/android-icon-36x36.png",
"sizes": "36x36",
"type": "image\/png",
"density": "0.75"
},
{
"src": "\/android-icon-48x48.png",
"sizes": "48x48",
"type": "image\/png",
"density": "1.0"
},
{
"src": "\/android-icon-72x72.png",
"sizes": "72x72",
"type": "image\/png",
"density": "1.5"
},
{
"src": "\/android-icon-96x96.png",
"sizes": "96x96",
"type": "image\/png",
"density": "2.0"
},
{
"src": "\/android-icon-144x144.png",
"sizes": "144x144",
"type": "image\/png",
"density": "3.0"
},
{
"src": "\/android-icon-192x192.png",
"sizes": "192x192",
"type": "image\/png",
"density": "4.0"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16">
<path fill="#888" d="M8,0c-4.355,0-7.898,3.481-7.998,7.812,0.092-3.779,2.966-6.812,6.498-6.812,3.59,0,6.5,3.134,6.5,7,0,0.828,0.672,1.5,1.5,1.5s1.5-0.672,1.5-1.5c0-4.418-3.582-8-8-8zM8,16c4.355,0,7.898-3.481,7.998-7.812-0.092,3.779-2.966,6.812-6.498,6.812-3.59,0-6.5-3.134-6.5-7,0-0.828-0.672-1.5-1.5-1.5s-1.5,0.672-1.5,1.5c0,4.418,3.582,8,8,8z"/>
</svg>

After

Width:  |  Height:  |  Size: 417 B

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 27.5.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="图层_1" xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 256 256"
style="enable-background:new 0 0 256 256;" xml:space="preserve">
<style type="text/css">
.st0{fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_2_00000009581766544743910510000007087157279682564742_);}
.st1{fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_3_00000013155245276689480680000010334395393893521599_);}
.st2{fill:#FFFFFF;}
</style>
<g>
<g id="svg_1">
<linearGradient id="svg_2_00000043442590260727270070000016472210641679865270_" gradientUnits="userSpaceOnUse" x1="11.1563" y1="247.3437" x2="245.4437" y2="13.0563" gradientTransform="matrix(1 0 0 -1 0 258)">
<stop offset="0" style="stop-color:#5E72E4"/>
<stop offset="1" style="stop-color:#778AFF"/>
</linearGradient>
<path id="svg_2" style="fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_2_00000043442590260727270070000016472210641679865270_);" d="
M36.4,0.2h183.8c19.7,0,35.7,16,35.7,35.7v183.8c0,19.7-16,35.7-35.7,35.7H36.4c-19.7,0-35.7-16-35.7-35.7V35.9
C0.7,16.2,16.7,0.2,36.4,0.2z"/>
<linearGradient id="svg_3_00000010280352489557108120000000938545297310085033_" gradientUnits="userSpaceOnUse" x1="0.7" y1="257.8" x2="0.7" y2="257.8" gradientTransform="matrix(1 0 0 -1 0 258)">
<stop offset="0" style="stop-color:#5E72E4"/>
<stop offset="1" style="stop-color:#778AFF"/>
</linearGradient>
<path id="svg_3" style="fill-rule:evenodd;clip-rule:evenodd;fill:url(#svg_3_00000010280352489557108120000000938545297310085033_);" d="
M0.7,0.2"/>
</g>
<path id="svg_4" class="st2" d="M128.3,45.4c-46.7,0-84.4,37.8-84.4,84.4c0,32.2,18.1,60.2,44.6,74.4c6.8,3.7,15.3-0.2,17.2-7.7
l4.3-17.6c1.5-6.2-1-12.6-6.1-16.4c-10-7.4-16.4-19.3-16.4-32.7c0-22.5,18.3-40.7,40.7-40.7c22.5,0,40.7,18.3,40.7,40.7
c0,13.4-6.4,25.2-16.4,32.7c-5.1,3.8-7.6,10.2-6.1,16.5l4.4,17.6c1.9,7.5,10.3,11.4,17.2,7.7c26.6-14.2,44.6-42.2,44.6-74.5
C212.8,83.3,174.9,45.4,128.3,45.4L128.3,45.4z"/>
</g>
<circle class="st2" cx="128.3" cy="131.6" r="18.3"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 938 B

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024">
<path fill="#fff" d="M484.430769 51.2 236.307692 354.461538H118.153846c-43.323077 0-78.769231 35.446154-78.769231 78.769231v157.538462c0 43.323077 35.446154 78.769231 78.769231 78.769231h118.153846L484.430769 972.8c25.6 25.6 66.953846 7.876923 66.953846-27.569231V78.769231c0-35.446154-43.323077-53.169231-66.953846-27.569231zm354.461539 120.123077c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-27.569231 27.569231c-7.876923 7.876923-7.876923 21.661538 0 27.56923C858.584615 299.323077 905.846154 399.753846 905.846154 512c0 112.246154-47.261538 212.676923-122.092308 285.538462-7.876923 7.876923-7.876923 19.692308 0 27.56923l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.569231 0C927.507692 768 984.615385 645.907692 984.615385 512s-55.138462-256-145.723077-340.676923zM714.830769 297.353846c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-27.56923 27.569231c-7.876923 7.876923-7.876923 19.692308 0 27.569231C703.015385 391.876923 728.615385 448.984616 728.615385 512c0 63.015385-27.569231 120.123077-70.892308 159.507692-7.876923 7.876923-7.876923 19.692308 0 27.569231l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.56923 0 57.107692-53.169231 94.523077-129.969231 94.523077-216.615385 0-82.707692-35.446154-159.507692-92.553846-212.676923z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" class="icon" viewBox="0 0 1024 1024">
<path fill="#fff" d="M484.430769 51.2 236.307692 354.461538H118.153846c-43.323077 0-78.769231 35.446154-78.769231 78.769231v157.538462c0 43.323077 35.446154 78.769231 78.769231 78.769231h118.153846L484.430769 972.8c25.6 25.6 66.953846 7.876923 66.953846-27.569231V78.769231c0-35.446154-43.323077-53.169231-66.953846-27.569231zM882.215385 512l96.492307-96.492308c7.876923-7.876923 7.876923-19.692308 0-27.56923l-27.56923-27.569231c-7.876923-7.876923-19.692308-7.876923-27.569231 0l-96.492308 96.492307-96.492308-96.492307c-7.876923-7.876923-19.692308-7.876923-27.56923 0l-27.569231 27.569231c-7.876923 7.876923-7.876923 19.692308 0 27.56923L771.938462 512l-96.492308 96.492308c-7.876923 7.876923-7.876923 19.692308 0 27.56923l27.569231 27.569231c7.876923 7.876923 19.692308 7.876923 27.56923 0l96.492308-96.492307 96.492308 96.492307c7.876923 7.876923 19.692308 7.876923 27.569231 0l27.56923-27.569231c7.876923-7.876923 7.876923-19.692308 0-27.56923L882.215385 512z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

@@ -0,0 +1,448 @@
'use strict';
'require baseclass';
'require ui';
/**
* Native JavaScript slide animation utilities
* Replaces jQuery slideUp/slideDown functionality with better performance
*/
const SlideAnimations = {
/**
* Animation durations in milliseconds
*/
durations: {
fast: 200,
normal: 400,
slow: 600
},
/**
* Map to track running animations and their cleanup functions
*/
runningAnimations: new WeakMap(),
/**
* Slide element down (show) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideDown: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideDown: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds
const animDuration = typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal);
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Set initial state for animation
element.style.display = 'block';
element.style.overflow = 'hidden';
element.style.height = '0px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Get the target height
const targetHeight = element.scrollHeight;
// Animate to full height
element.style.height = targetHeight + 'px';
// Set up cleanup function
const cleanup = () => {
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Slide element up (hide) with animation
* @param {Element} element - DOM element to animate
* @param {string|number} duration - Animation duration ('fast', 'normal', 'slow' or milliseconds)
* @param {function} callback - Optional callback function when animation completes
*/
slideUp: function(element, duration, callback) {
if (!element) {
console.warn('SlideAnimations.slideUp: No element provided');
return;
}
// Stop any existing animation on this element
this.stop(element);
// Convert duration string to milliseconds
const animDuration = typeof duration === 'string' ?
this.durations[duration] || this.durations.normal :
(duration || this.durations.normal);
// Store original styles
const originalStyles = {
display: element.style.display,
overflow: element.style.overflow,
height: element.style.height,
transition: element.style.transition
};
// Get current height before hiding
const currentHeight = element.scrollHeight;
// Set initial state for animation
element.style.overflow = 'hidden';
element.style.height = currentHeight + 'px';
element.style.transition = `height ${animDuration}ms ease-out`;
// Force reflow to ensure initial state is applied
element.offsetHeight;
// Animate to zero height
element.style.height = '0px';
// Set up cleanup function
const cleanup = () => {
element.style.display = 'none';
element.style.height = originalStyles.height || '';
element.style.overflow = originalStyles.overflow || '';
element.style.transition = originalStyles.transition || '';
// Remove from running animations map
this.runningAnimations.delete(element);
if (callback && typeof callback === 'function') {
try {
callback.call(element);
} catch (e) {
console.error('SlideAnimations callback error:', e);
}
}
};
// Store cleanup function for potential cancellation
const timeoutId = setTimeout(cleanup, animDuration);
this.runningAnimations.set(element, { timeoutId, cleanup });
},
/**
* Stop all running animations on an element
* @param {Element} element - DOM element to stop animations on
*/
stop: function(element) {
if (!element) return;
const animationData = this.runningAnimations.get(element);
if (animationData) {
// Clear the timeout
clearTimeout(animationData.timeoutId);
// Run cleanup immediately
animationData.cleanup();
}
// Clear transition to immediately stop any CSS animation
element.style.transition = '';
// Force reflow to apply changes immediately
element.offsetHeight;
},
/**
* Check if element has running animation
* @param {Element} element - DOM element to check
* @returns {boolean} - True if element has running animation
*/
isAnimating: function(element) {
return this.runningAnimations.has(element);
}
};
/**
* Argon Theme Menu Module
* Handles rendering and interaction of the main navigation menu and sidebar
*/
return baseclass.extend({
/**
* Initialize the menu module
* Load menu data and trigger rendering
*/
__init__: function () {
ui.menu.load().then(L.bind(this.render, this));
},
/**
* Main render function for the menu system
* @param {Object} tree - Menu tree structure from LuCI
*/
render: function (tree) {
var node = tree,
url = '',
children = ui.menu.getChildren(tree);
// Find and render the active main menu item
for (var i = 0; i < children.length; i++) {
var isActive = (L.env.requestpath.length ? children[i].name == L.env.requestpath[0] : i == 0);
if (isActive) {
this.renderMainMenu(children[i], children[i].name);
}
}
// Render tab menu if we're deep enough in the navigation hierarchy
if (L.env.dispatchpath.length >= 3) {
for (var i = 0; i < 3 && node; i++) {
node = node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node) {
this.renderTabMenu(node, url);
}
}
// Attach event listeners for sidebar toggle functionality
var sidebarToggle = document.querySelector('a.showSide');
var darkMask = document.querySelector('.darkMask');
if (sidebarToggle) {
sidebarToggle.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
if (darkMask) {
darkMask.addEventListener('click', ui.createHandlerFn(this, 'handleSidebarToggle'));
}
},
/**
* Handle menu expand/collapse functionality
* Manages the sliding animation and active states of menu items
* @param {Event} ev - Click event from menu item
*/
handleMenuExpand: function (ev) {
var target = ev.target;
var slide = target.parentNode;
var slideMenu = target.nextElementSibling;
var shouldCollapse = false;
// Close all currently active submenus
var activeMenus = document.querySelectorAll('.main .main-left .nav > li > ul.active');
activeMenus.forEach(function (ul) {
// Stop any running animations and slide up
SlideAnimations.stop(ul);
// Remove active classes immediately when starting slideUp animation
ul.classList.remove('active');
ul.previousElementSibling.classList.remove('active');
SlideAnimations.slideUp(ul, 'fast');
// Check if we're clicking on an already open menu (should collapse it)
if (!shouldCollapse && ul === slideMenu) {
shouldCollapse = true;
}
});
// Exit if there's no submenu to show
if (!slideMenu) {
return;
}
// Open the submenu if it's not already open
if (!shouldCollapse) {
// Find the slide menu within the slide element
var slideMenuElement = slide.querySelector(".slide-menu");
if (slideMenuElement) {
// Add active classes immediately when starting slideDown animation
slideMenu.classList.add('active');
target.classList.add('active');
SlideAnimations.slideDown(slideMenuElement, 'fast');
}
target.blur(); // Remove focus from the clicked element
}
// Prevent default link behavior and event bubbling
ev.preventDefault();
ev.stopPropagation();
},
/**
* Render the main navigation menu
* Creates hierarchical menu structure with active states and click handlers
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for menu items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated menu element
*/
renderMainMenu: function (tree, url, level) {
var currentLevel = (level || 0) + 1;
var menuContainer = E('ul', { 'class': level ? 'slide-menu' : 'nav' });
var children = ui.menu.getChildren(tree);
// Don't render empty menus or menus deeper than 2 levels
if (children.length === 0 || currentLevel > 2) {
return E([]);
}
// Generate menu items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (
(L.env.dispatchpath[currentLevel] === child.name) &&
(L.env.dispatchpath[currentLevel - 1] === tree.name)
);
// Recursively render submenu
var submenu = this.renderMainMenu(child, url + '/' + child.name, currentLevel);
var hasChildren = submenu.children.length > 0;
// Determine CSS classes based on state
var slideClass = hasChildren ? 'slide' : null;
var menuClass = hasChildren ? 'menu' : 'food';
if (isActive) {
menuContainer.classList.add('active');
slideClass += " active";
menuClass += " active";
}
// Create menu item with link and submenu
var menuItem = E('li', { 'class': slideClass }, [
E('a', {
'href': L.url(url, child.name),
'click': (currentLevel === 1) ? ui.createHandlerFn(this, 'handleMenuExpand') : null,
'class': menuClass,
'data-title': child.title.replace(/ /g, "_"), // More robust space replacement
}, [_(child.title)]),
submenu
]);
menuContainer.appendChild(menuItem);
}
// Append to main menu container if this is the top level
if (currentLevel === 1) {
var mainMenuElement = document.querySelector('#mainmenu');
if (mainMenuElement) {
mainMenuElement.appendChild(menuContainer);
mainMenuElement.style.display = '';
}
}
return menuContainer;
},
/**
* Render tab navigation menu
* Creates horizontal tab menu for deeper navigation levels
* @param {Object} tree - Menu tree node to render
* @param {string} url - Base URL for tab items
* @param {number} level - Current nesting level (0-based)
* @returns {Element} - Generated tab menu element
*/
renderTabMenu: function (tree, url, level) {
var container = document.querySelector('#tabmenu');
var currentLevel = (level || 0) + 1;
var tabContainer = E('ul', { 'class': 'tabs' });
var children = ui.menu.getChildren(tree);
var activeNode = null;
// Don't render empty tab menus
if (children.length === 0) {
return E([]);
}
// Generate tab items for each child
for (var i = 0; i < children.length; i++) {
var child = children[i];
var isActive = (L.env.dispatchpath[currentLevel + 2] === child.name);
var activeClass = isActive ? ' active' : '';
var className = 'tabmenu-item-%s %s'.format(child.name, activeClass);
var tabItem = E('li', { 'class': className }, [
E('a', { 'href': L.url(url, child.name) }, [_(child.title)])
]);
tabContainer.appendChild(tabItem);
// Store reference to active node for recursive rendering
if (isActive) {
activeNode = child;
}
}
// Append tab container to main tab menu element
if (container) {
container.appendChild(tabContainer);
container.style.display = '';
// Recursively render nested tab menus if there's an active node
if (activeNode) {
var nestedTabs = this.renderTabMenu(activeNode, url + '/' + activeNode.name, currentLevel);
if (nestedTabs.children.length > 0) {
container.appendChild(nestedTabs);
}
}
}
return tabContainer;
},
/**
* Handle sidebar toggle functionality
* Toggles the mobile/responsive sidebar menu visibility
* @param {Event} ev - Click event from sidebar toggle button or dark mask
*/
handleSidebarToggle: function (ev) {
var showSideButton = document.querySelector('a.showSide');
var sidebar = document.querySelector('#mainmenu');
var darkMask = document.querySelector('.darkMask');
var scrollbarArea = document.querySelector('.main-right');
// Check if any required elements are missing
if (!showSideButton || !sidebar || !darkMask || !scrollbarArea) {
console.warn('Sidebar toggle elements not found');
return;
}
// Toggle sidebar visibility and related states
if (showSideButton.classList.contains('active')) {
// Close sidebar
showSideButton.classList.remove('active');
sidebar.classList.remove('active');
scrollbarArea.classList.remove('active');
darkMask.classList.remove('active');
} else {
// Open sidebar
showSideButton.classList.add('active');
sidebar.classList.add('active');
scrollbarArea.classList.add('active');
darkMask.classList.add('active');
}
}
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
// out: false
// ==========================================================================
// CSS Custom Properties for Font Management
// ==========================================================================
@font-face {
font-family: 'Google Sans';
src: local('Google Sans'),
local('GoogleSans-Regular'),
url('../fonts/GoogleSans-Regular.woff2') format('woff2'),
url('../fonts/GoogleSans-Regular.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Logo Font */
@font-face {
font-family: 'TypoGraphica';
src: local('TypoGraphica'),
url('../fonts/TypoGraphica.woff2') format('woff2'),
url('../fonts/TypoGraphica.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* Icon Font */
@font-face {
font-family: 'argon';
src: url('../fonts/argon.woff2') format('woff2'),
url('../fonts/argon.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
@@ -0,0 +1,605 @@
// out: false
// ==========================================================================
// Layout Styles
// Main layout components for LuCI admin interface
// ==========================================================================
// 1. Main Container Layout
// ==========================================================================
.main {
position: relative;
width: 100%;
height: 100%;
overflow-y: auto;
display: flex;
flex-direction: row;
}
// 2. Left Sidebar Layout
// ==========================================================================
.main-left {
width: 15rem;
height: 100%;
flex-shrink: 0;
z-index: 100;
overflow-x: auto;
word-break: break-word;
background-color: var(--menu-bg-color);
box-shadow: rgba(0, 0, 0, 0.75) 0px 0px 15px -5px;
transition: all 0.2s;
// Sidebar Header
.sidenav-header {
padding: 1.5rem 0.5rem;
text-align: center;
.brand {
display: block;
margin: 0 2rem;
font-size: 1.8rem;
font-family: "TypoGraphica";
color: var(--primary);
text-decoration: none;
text-align: center;
cursor: default;
.logo {
max-width: 100%;
height: auto;
}
}
}
// Navigation Menu
.nav {
margin-top: 0.5rem;
// Top-level navigation links
&>li>a:first-child {
display: block;
position: relative;
margin: 0.1rem 0.5rem;
padding: 0.675rem 0 0.675rem 2.5rem;
font-size: 1rem;
text-decoration: none;
border-radius: 0.25rem;
cursor: default;
transition: all 0.2s;
// Active state
&.active {
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
&::after {
transform: rotate(90deg);
color: #fff !important;
}
}
// Hover state
&:hover {
cursor: pointer;
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
}
// Icon before text
&::before {
position: absolute;
left: 0.8rem;
padding-top: 3px;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e915";
color: var(--primary);
transition: all 0.3s;
}
}
&>.slide>.menu::before {
transition: transform .1s ease-in-out;
}
&>.slide>.menu.active::before {
transition: transform .2s ease-in-out;
}
// Navigation list items
li {
padding: 0;
cursor: pointer;
user-select: none;
a {
display: block;
color: var(--menu-color);
}
// Collapsible menu items
&.slide {
padding: 0;
ul {
display: none;
overflow: hidden;
}
&:hover {
background: none;
}
// Submenu container
.slide-menu {
margin: 0 0.5rem 0 2.5rem;
padding: 0rem 0.5rem;
&.active {
display: block;
}
li {
position: relative;
margin: 0;
border-radius: 0.25rem;
background: none;
list-style: none;
a {
padding: 0.5rem 0rem;
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Hover indicator line
&::after {
position: absolute;
left: 0;
bottom: 0;
width: 0;
height: 2px;
content: "";
background-color: var(--primary);
transition: all 0.2s;
}
&:hover {
background: none;
&::after {
width: 100%;
}
}
}
// Active submenu item
.active {
background: none;
color: var(--menu-color);
a {
color: var(--menu-color);
}
&::after {
position: absolute;
left: 0;
bottom: 0;
width: 100%;
height: 2px;
content: "";
background-color: var(--primary);
transition: all 0.2s;
}
&:hover {
background: none;
&::after {
width: 100%;
}
}
}
}
}
// Main menu items
.menu {
display: block;
position: relative;
margin: 0.1rem 0.5rem;
padding: 0.675rem 0 0.675rem 2.5rem;
font-size: 1rem;
text-decoration: none;
border-radius: 0.25rem;
cursor: default;
transition: all 0.2s;
// Active state
&.active {
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
&::after {
transform: rotate(90deg);
color: #fff !important;
}
}
// Hover state
&:hover {
cursor: pointer;
color: #fff;
background: var(--primary);
&::before {
color: #fff !important;
}
}
// Icon before text
&::before {
position: absolute;
left: 0.8rem;
padding-top: 3px;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e915";
color: var(--primary);
transition: all 0.3s;
}
// Dropdown arrow
&::after {
position: absolute;
right: 0.5rem;
top: 0.8rem;
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: '\e90f';
color: #ced4da;
text-rendering: auto;
transition: all 0.3s;
}
}
// Menu-specific icons and colors
.menu[data-title="Status"]:before {
content: "\e906";
color: var(--primary);
}
.menu[data-title="System"]:before {
content: "\e90a";
color: #fb6340;
}
.menu[data-title="Services"]:before {
content: "\e909";
color: #11cdef;
}
.menu[data-title="NAS"]:before {
content: "\e90c";
color: #f3a4b5;
}
.menu[data-title="VPN"]:before {
content: "\e90b";
color: #8965e0;
}
.menu[data-title="Network"]:before {
content: "\e908";
color: #8965e0;
}
.menu[data-title="Bandwidth_Monitor"]:before {
content: "\e90d";
color: #2dce89;
}
.menu[data-title="Docker"]:before {
content: "\e911";
color: #6699ff;
}
.menu[data-title="Statistics"]:before {
content: "\e913";
color: #8965e0;
}
.menu[data-title="Control"]:before {
content: "\e912";
color: var(--primary);
}
.menu[data-title="Asterisk"]:before {
content: "\e914";
color: #fb6340;
}
// Logout link styling
a[data-title="Log_out"],
.food[data-title="Log_out"] {
&::before {
content: "\e907";
color: #adb5bd;
}
}
}
}
&[style*="overflow: hidden"]>.nav>.slide>.menu::before {
display: none;
}
// Scrollbar Styling
&::-webkit-scrollbar {
width: 5px;
height: 1px;
}
&::-webkit-scrollbar-thumb {
background-color: #f6f9fc;
}
&::-webkit-scrollbar-track {
background-color: #fff;
}
}
// 3. Right Content Area Layout
// ==========================================================================
.main-right {
height: 100%;
flex-grow: 1;
overflow-x: hidden;
overflow-y: auto;
display: flex;
flex-direction: column;
transition: all 0.2s;
// Main Content Container
&>#maincontent {
position: relative;
z-index: 50;
flex: 1;
display: flex;
flex-direction: column;
&>.container {
margin: 0 1.25rem 1rem 1.25rem;
flex-grow: 1;
display: flex;
flex-direction: column;
gap: 1rem;
}
// Dashboard Specific Styles
.Dashboard {
color: var(--gray-dark) !important;
h3 {
color: var(--gray-dark);
}
p {
margin-top: 3px;
margin-bottom: 3px;
}
hr {
border-top: 1px solid rgba(0, 0, 0, 1);
}
.dashboard-bg {
background-color: #fff;
}
.settings-info {
padding-top: 1em;
padding-bottom: 1em;
p span:nth-child(2) {
max-height: 18.5px;
top: 4px;
}
.label {
font-size: 0.7rem;
padding: 0.2rem 0.6rem;
}
}
}
}
}
// 4. Header Layout
// ==========================================================================
header {
position: relative;
width: 100%;
padding: 0;
color: var(--header-color);
// Primary background modifier
&.bg-primary {
background-color: var(--primary) !important;
}
// Header extension background
&::after {
content: "";
position: absolute;
width: 100%;
height: 2rem;
background-color: var(--primary) !important;
}
// Header Content Container
.fill {
padding: 0.8rem 0;
display: flex;
border-bottom: 0px solid rgba(255, 255, 255, 0.08) !important;
.container {
width: 100%;
height: 2rem;
padding: 0 1.25rem;
display: flex;
align-items: center;
// Left section (brand and menu toggle)
.flex1 {
flex: 1;
.showSide {
display: none;
font-size: 1.4rem;
color: #fff;
&:hover {
text-decoration: none;
}
}
.brand {
display: none;
padding-left: 1rem;
font-size: 1.5rem;
font-family: "TypoGraphica";
color: #fff;
text-decoration: none;
cursor: default;
vertical-align: text-bottom;
}
}
// Right section (status indicators)
.pull-right {
margin-top: 0rem;
float: right;
display: flex;
}
}
// Status Indicators
.status {
span {
display: inline-block;
margin: 0 0.25rem;
padding: 0.3rem 0.8rem;
font-size: 0.875rem;
font-weight: bold;
white-space: nowrap;
text-decoration: none;
text-transform: uppercase;
text-shadow: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
&:last-child {
margin-right: 0;
}
}
span[data-indicator="poll-status"] {
color: #fff;
}
span[data-style="active"] {
background-color: var(--green);
}
span[data-style="inactive"] {
color: #ffffff !important;
background-color: #32325d;
}
}
}
}
// 5. Footer Layout
// ==========================================================================
footer {
padding: 1rem;
font-size: 0.875rem;
color: #aaa;
text-align: right;
white-space: nowrap;
overflow: hidden;
>a {
color: #aaa;
text-decoration: none;
}
}
// 6. View
// ==========================================================================
#view {
border-radius: 0.25rem;
display: flex;
flex-direction: column;
gap: 1rem;
&>.spinning {
position: fixed;
top: 50%;
left: 50%;
transform: translateX(-50%) translateY(-50%);
padding: 1rem;
border-radius: 0.5rem;
background: #ffffff;
box-shadow: 0 0 1rem 0 rgba(136, 152, 170, .15);
}
&>div:first-child {
display: flex;
flex-direction: column;
gap: 0.875rem;
}
}
@@ -0,0 +1,407 @@
// out: false
// ==========================================================================
// Normalize.less
// Based on normalize.css for style reset and standardization
// ==========================================================================
// 1. Global Reset
// ==========================================================================
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
// 2. Document Root Elements
// ==========================================================================
html {
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
font-family: var(--font-family-sans-serif);
}
body {
background-color: var(--background-color);
color: var(--gray-dark);
-webkit-tap-highlight-color: transparent;
font-size: 0.875rem;
}
// 3. Semantic Elements
// ==========================================================================
main {
display: block;
}
// 4. Heading Elements
// ==========================================================================
.h1,
.h2,
.h3,
.h4,
.h5,
.h6,
h1,
h2,
h3,
h4,
h5,
h6 {
font-family: inherit;
font-weight: normal;
line-height: 1.1 !important;
color: inherit;
}
h1 {
font-size: 2rem;
margin: 0.67em 0;
padding-bottom: 0.5rem;
border-bottom: thin solid var(--lighter);
}
h2 {
padding: 1rem 1.25rem;
font-size: 1.25rem;
font-weight: bold;
color: var(--gray-dark);
border-radius: 0.25rem;
background: var(--white);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.03);
}
h3 {
margin: 0;
padding: 0.8755rem 1.25rem;
font-size: 1.1rem;
font-weight: bold;
line-height: 1;
color: var(--gray-dark);
border-radius: 0.25rem;
background: var(--white);
display: block;
width: 100%;
}
h4 {
margin: 0;
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
font-weight: bold;
color: var(--gray-dark-400);
em {
padding: 0 0.5rem;
}
}
h5 {
margin: 2rem 0 0 0;
padding-bottom: 0.5rem;
font-size: 1rem;
}
// 5. Text Elements
// ==========================================================================
a {
background-color: transparent;
}
abbr {
cursor: help;
text-decoration: underline;
color: var(--primary);
}
abbr[title] {
border-bottom: none;
text-decoration: underline;
-webkit-text-decoration: underline dotted;
text-decoration: underline dotted;
}
b,
strong {
font-weight: bolder;
}
code,
kbd,
samp {
font-family: inherit;
font-size: inherit;
padding: 1px 3px;
color: var(--dark);
border-radius: 0.5rem;
background: var(--lighter);
}
small {
font-size: 90%;
line-height: 1.42857143;
white-space: normal;
}
sub,
sup {
font-size: 75%;
line-height: 0;
position: relative;
vertical-align: baseline;
}
sub {
bottom: -0.25em;
}
sup {
top: -0.5em;
}
// 6. Horizontal Rules and Preformatted Text
// ==========================================================================
hr {
box-sizing: content-box;
height: 0;
margin: 1rem 0;
overflow: visible;
opacity: 0.1;
border-color: var(--lighter);
}
pre {
font-family: monospace, monospace;
font-size: 1em;
overflow: auto;
}
// 7. Media Elements
// ==========================================================================
img {
border-style: none;
}
// 8. Form Elements
// ==========================================================================
button,
input,
optgroup,
select,
textarea {
font-family: inherit;
font-size: 100%;
line-height: 1.15;
margin: 0;
}
button,
input {
overflow: visible;
}
button,
select {
text-transform: none;
}
// Button Styles
[type=button],
[type=reset],
[type=submit],
button {
-webkit-appearance: button;
appearance: button;
}
[type=button]::-moz-focus-inner,
[type=reset]::-moz-focus-inner,
[type=submit]::-moz-focus-inner,
button::-moz-focus-inner {
border-style: none;
padding: 0;
}
[type=button]:-moz-focusring,
[type=reset]:-moz-focusring,
[type=submit]:-moz-focusring,
button:-moz-focusring {
outline: 1px dotted ButtonText;
}
// Input Styles
[type=checkbox],
[type=radio] {
box-sizing: border-box;
padding: 0;
}
[type=number]::-webkit-inner-spin-button,
[type=number]::-webkit-outer-spin-button {
height: auto;
}
[type=search] {
-webkit-appearance: textfield;
appearance: textfield;
outline-offset: -2px;
}
[type=search]::-webkit-search-decoration {
-webkit-appearance: none;
appearance: none;
}
::-webkit-file-upload-button {
-webkit-appearance: button;
appearance: button;
font: inherit;
}
// checkbox style
input[type="checkbox"] {
appearance: none !important;
-webkit-appearance: none !important;
border: 1px solid var(--primary);
width: 1rem !important;
height: 1rem !important;
padding: 0;
cursor: pointer;
transition: all 0.2s;
}
input[type="checkbox"]:checked {
border: 1px solid var(--primary);
background-image: url('data:image/svg+xml,%3csvg xmlns=\'http://www.w3.org/2000/svg\' viewBox=\'0 0 8 8\'%3e%3cpath fill=\'%23fff\' d=\'M6.564.75l-3.59 3.612-1.538-1.55L0 4.26 2.974 7.25 8 2.193z\'/%3e%3c/svg%3e') !important;
background-color: var(--primary);
background-size: 70%;
background-repeat: no-repeat;
background-position: center;
}
// Select Styles
select {
padding: 0.36rem 0.8rem;
color: var(--gray-dark);
border: thin solid var(--lighter);
background-color: var(--white);
background-image: none;
}
// Textarea Styles
textarea {
padding: 0.25rem;
overflow: auto;
&:focus-visible {
outline: none;
border: 1px solid var(--primary);
}
}
// Fieldset Styles
fieldset {
padding: 0.35em 0.75em 0.625em;
}
legend {
box-sizing: border-box;
color: inherit;
display: table;
max-width: 100%;
padding: 0;
white-space: normal;
}
// 9. Interactive Elements
// ==========================================================================
details {
display: block;
}
summary {
display: list-item;
}
template {
display: none;
}
progress {
vertical-align: baseline;
}
// 10. List Elements
// ==========================================================================
ul {
line-height: normal;
}
li {
list-style-type: none;
}
// 11. State Styles
// ==========================================================================
[disabled="disabled"] {
pointer-events: none;
}
::selection {
background-color: var(--primary);
color: var(--white);
}
::placeholder {
color: var(--lighter);
}
a:link,
a:visited,
a:active {
color: var(--primary);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
// 12 tools
// ==========================================================================
.pull-right {
float: right;
}
.pull-left {
float: left;
}
.nowrap:not(.td) {
white-space: nowrap;
}
div[style="width:100%;height:300px;border:1px solid #000;background:#fff"] {
border: 0 !important;
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,245 @@
// out: false
/**
Pure v3.0.0
Copyright 2013 Yahoo!
Licensed under the BSD License.
https://github.com/pure-css/pure/blob/master/LICENSE
*/
/**
normalize.css v | MIT License | https://necolas.github.io/normalize.css/
Copyright (c) Nicolas Gallagher and Jonathan Neal
*/
/** normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
.pure-g {
display: flex;
flex-flow: row wrap;
align-content: flex-start
}
.pure-u {
display: inline-block;
vertical-align: top
}
.pure-u-1,
.pure-u-1-1,
.pure-u-1-12,
.pure-u-1-2,
.pure-u-1-24,
.pure-u-1-3,
.pure-u-1-4,
.pure-u-1-5,
.pure-u-1-6,
.pure-u-1-8,
.pure-u-10-24,
.pure-u-11-12,
.pure-u-11-24,
.pure-u-12-24,
.pure-u-13-24,
.pure-u-14-24,
.pure-u-15-24,
.pure-u-16-24,
.pure-u-17-24,
.pure-u-18-24,
.pure-u-19-24,
.pure-u-2-24,
.pure-u-2-3,
.pure-u-2-5,
.pure-u-20-24,
.pure-u-21-24,
.pure-u-22-24,
.pure-u-23-24,
.pure-u-24-24,
.pure-u-3-24,
.pure-u-3-4,
.pure-u-3-5,
.pure-u-3-8,
.pure-u-4-24,
.pure-u-4-5,
.pure-u-5-12,
.pure-u-5-24,
.pure-u-5-5,
.pure-u-5-6,
.pure-u-5-8,
.pure-u-6-24,
.pure-u-7-12,
.pure-u-7-24,
.pure-u-7-8,
.pure-u-8-24,
.pure-u-9-24 {
display: inline-block;
letter-spacing: normal;
word-spacing: normal;
vertical-align: top;
text-rendering: auto
}
.pure-u-1-24 {
width: 4.1667%
}
.pure-u-1-12,
.pure-u-2-24 {
width: 8.3333%
}
.pure-u-1-8,
.pure-u-3-24 {
width: 12.5%
}
.pure-u-1-6,
.pure-u-4-24 {
width: 16.6667%
}
.pure-u-1-5 {
width: 20%
}
.pure-u-5-24 {
width: 20.8333%
}
.pure-u-1-4,
.pure-u-6-24 {
width: 25%
}
.pure-u-7-24 {
width: 29.1667%
}
.pure-u-1-3,
.pure-u-8-24 {
width: 33.3333%
}
.pure-u-3-8,
.pure-u-9-24 {
width: 37.5%
}
.pure-u-2-5 {
width: 40%
}
.pure-u-10-24,
.pure-u-5-12 {
width: 41.6667%
}
.pure-u-11-24 {
width: 45.8333%
}
.pure-u-1-2,
.pure-u-12-24 {
width: 50%
}
.pure-u-13-24 {
width: 54.1667%
}
.pure-u-14-24,
.pure-u-7-12 {
width: 58.3333%
}
.pure-u-3-5 {
width: 60%
}
.pure-u-15-24,
.pure-u-5-8 {
width: 62.5%
}
.pure-u-16-24,
.pure-u-2-3 {
width: 66.6667%
}
.pure-u-17-24 {
width: 70.8333%
}
.pure-u-18-24,
.pure-u-3-4 {
width: 75%
}
.pure-u-19-24 {
width: 79.1667%
}
.pure-u-4-5 {
width: 80%
}
.pure-u-20-24,
.pure-u-5-6 {
width: 83.3333%
}
.pure-u-21-24,
.pure-u-7-8 {
width: 87.5%
}
.pure-u-11-12,
.pure-u-22-24 {
width: 91.6667%
}
.pure-u-23-24 {
width: 95.8333%
}
.pure-u-1,
.pure-u-1-1,
.pure-u-24-24,
.pure-u-5-5 {
width: 100%
}
.col-1 {
flex: 1 1 30px !important;
}
.col-2 {
flex: 2 2 60px !important;
}
.col-3 {
flex: 3 3 90px !important;
}
.col-4 {
flex: 4 4 120px !important;
}
.col-5 {
flex: 5 5 150px !important;
}
.col-6 {
flex: 6 6 180px !important;
}
.col-7 {
flex: 7 7 210px !important;
}
.col-8 {
flex: 8 8 240px !important;
}
.col-9 {
flex: 9 9 270px !important;
}
.col-10 {
flex: 10 10 300px !important;
}
@@ -0,0 +1,610 @@
// out: false
// ==========================================================================
// Responsive Styles
// Mobile-first responsive design for LuCI admin interface
// ==========================================================================
// 1. Browser-Specific Fixes
// ==========================================================================
// Internet Explorer Specific Fixes
@media all and (-ms-high-contrast: none) {
.main>.main-left>.nav>.slide>.menu::before {
top: 30.25%;
}
.main>.main-left>.nav>li:last-child::before {
top: 20%;
}
.showSide::before {
top: -12px;
}
}
// 2. Large Desktop Screens (1600px and below)
// ==========================================================================
@media screen and (max-width: 1600px) {
// Header adjustments
header>.fill>.container>#logo {
margin: 0 2.5rem 0 0.5rem;
}
// Sidebar width
.main-left {
width: calc(0% + 13rem);
}
// Button and label sizing
.btn:not(button),
.label {
padding: 0.5rem 0.75rem;
}
// Form field sizing
.cbi-value-title {
width: 15rem;
padding-right: 0.6rem;
}
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"],
.cbi-value input[type="password"],
.cbi-value textarea {
min-width: 18rem;
}
// Specific component adjustments
#cbi-firewall-zone .cbi-input-select {
min-width: 9rem;
}
.cbi-input-textarea {
font-size: small;
}
.node-admin-status>.main fieldset li>a {
padding: 0.3rem 0.6rem;
}
}
// 3. Standard Desktop Screens (1366px and below)
// ==========================================================================
@media screen and (max-width: 1366px) {
// Header adjustments
header>.fill>.container {
cursor: default;
}
// Sidebar width
.main-left {
width: calc(0% + 13rem);
}
// Tab styling
.tabs>li>a,
.cbi-tabmenu>li>a {
padding: 0.2rem 0.8rem;
}
// Panel and table adjustments
.panel-title {
font-size: 1.1rem;
padding-bottom: 1rem;
}
table {
width: 100% !important;
}
.table .cbi-input-text {
width: 100%;
}
// Form field sizing
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"],
.cbi-value input[type="password"] {
min-width: 16rem;
}
#cbi-firewall-zone .cbi-input-select {
min-width: 5.5rem;
}
// Navigation font sizes
.main>.main-left>.nav>li,
.main>.main-left>.nav>li>a,
.main .main-left .nav>li>a:first-child,
.main>.main-left>.nav>.slide>.menu,
.main>.main-left>.nav>li>[data-title="Log_out"] {
font-size: 0.9rem;
}
.main>.main-left>.nav>.slide>.slide-menu>li>a {
font-size: 0.875rem;
}
// Modal adjustments
#modal_overlay {
top: 0rem;
}
// Page-specific table fixes
[data-page="admin-network-firewall-forwards"] .table:not(.cbi-section-table) {
display: block;
}
[data-page="admin-network-firewall-forwards"] .table:not(.cbi-section-table),
[data-page="admin-network-firewall-rules"] .table:not(.cbi-section-table),
[data-page="admin-network-hosts"] .table,
[data-page="admin-network-routes"] .table {
overflow-y: visible;
}
// Button sizing
.btn:not(button),
.cbi-button {
font-size: 0.875rem;
}
}
// 4. Small Desktop/Large Tablet (1152px and below)
// ==========================================================================
@media screen and (max-width: 1152px) {
// Header adjustments
header>.fill>.container>#logo {
display: none;
}
header>.fill>.container>.brand {
position: relative;
}
// Layout adjustments
html,
.main {
overflow-y: visible;
}
.main>.loading>span {
top: 25%;
}
.main-left {
width: calc(0% + 13rem);
}
// Login page specific
body:not(.logged-in) .showSide {
visibility: hidden;
width: 0;
margin: 0;
}
.node-main-login>.main .cbi-value-title {
text-align: left;
}
// Form field adjustments
.cbi-value-title {
width: 12rem;
padding-right: 1rem;
}
.cbi-value-field .cbi-dropdown,
.cbi-value-field .cbi-input-select,
.cbi-value input[type="text"] {
width: 16rem;
min-width: 16rem;
}
// Password field specific sizing
.cbi-value input[name^="pw"],
.cbi-value input[data-update="change"]:nth-child(2) {
width: 13rem !important;
min-width: 13rem;
}
// Code output adjustments
#diag-rc-output>pre,
#command-rc-output>pre,
[data-page="admin-services-wol"] .notice code {
font-size: 1rem;
}
// Table responsive behavior
.table {
display: block;
}
.Interfaces .table {
overflow-x: hidden;
}
#packages.table {
display: grid;
}
// Table row adjustments
.tr {
display: flex;
flex-direction: row;
flex-wrap: wrap;
}
.Overview .table[width="100%"]>.tr {
flex-wrap: nowrap;
}
.tr.placeholder {
border-bottom: thin solid #ddd;
}
.tr.placeholder>.td,
#cbi-firewall .tr>.td,
#cbi-network .tr:nth-child(2)>.td,
.cbi-section #wifi_assoclist_table .tr>.td {
border-top: 0;
}
// Table cell adjustments
.th,
.td {
display: inline-block;
align-self: flex-start;
flex: 2 2 10%;
text-overflow: ellipsis;
word-wrap: break-word;
}
.td select,
.td input[type="text"] {
width: 100%;
word-wrap: normal;
}
.td [data-dynlist]>input,
.td input.cbi-input-password {
width: calc(100% - 1.5rem);
}
.td[data-type="button"],
.td[data-type="fvalue"] {
flex: 1 1 12.5%;
text-align: left;
}
.th.cbi-value-field,
.td.cbi-value-field,
.th.cbi-section-table-cell,
.td.cbi-section-table-cell {
flex-basis: auto;
padding-top: 1rem;
}
// Section table adjustments
.cbi-section-table-row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
.td.cbi-value-field,
.cbi-section-table-cell {
display: inline-block;
flex: 10 10 auto;
flex-basis: 50%;
text-align: center;
}
.td.cbi-section-actions {
vertical-align: bottom;
}
// Hide table headers on mobile
.tr.table-titles,
.tr.cbi-section-table-titles,
.tr.cbi-section-table-descr {
display: none;
}
.tr[data-title]::before,
.tr.cbi-section-table-titles.named::before {
font-size: 0.9rem;
display: block;
flex: 1 1 100%;
border-bottom: thin solid rgba(0, 0, 0, 0.26);
background: #e9ecef;
}
.td[data-title],
[data-page^="admin-status-realtime"] .td[id] {
text-align: left;
}
.td[data-title]::before {
display: block;
}
// Button spacing
.cbi-button+.cbi-button {
margin-left: 0;
}
.td.cbi-section-actions>*>*,
.td.cbi-section-actions>*>form>* {
margin: 2.1px 3px;
}
// Firewall form adjustments
.Firewall form {
position: static !important;
margin: 0 0 2rem 0;
padding: 2rem;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
input {
width: 100% !important;
margin: 0;
margin-top: 1rem;
}
}
.Firewall .center,
.Firewall .center::before {
text-align: left !important;
}
// Button sizing
.btn:not(button),
.cbi-button {
font-size: 0.875rem;
}
}
// 5. Tablet Portrait (768px and below)
// ==========================================================================
@media screen and (max-width: 768px) {
// Base font size
body {
font-size: 0.875rem;
}
// Progress bar adjustments
.cbi-progressbar::after {
font-size: 0.6rem;
}
// Mobile sidebar behavior
.main-left {
position: fixed;
z-index: 100;
width: 0;
&.active {
width: 13rem;
}
}
.main-right {
width: 100%;
&.active {
overflow-y: hidden;
}
}
.darkMask.active {
display: block;
}
// Mobile menu toggle
.showSide {
padding: 0.1rem;
position: relative;
z-index: 99;
display: inline-block !important;
&::before {
font-family: 'argon' !important;
font-style: normal !important;
font-weight: normal !important;
font-variant: normal !important;
text-transform: none !important;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
content: "\e20e";
font-size: 1.7rem;
}
}
// Brand visibility
header>.fill>.container>.flex1>.brand {
display: inline-block;
}
// Navigation font size
.main>.main-left>.nav>.slide>.slide-menu>li>a {
font-size: 0.875rem;
}
}
// 6. Mobile Portrait (600px and below)
// ==========================================================================
@media screen and (max-width: 600px) {
// Hide mobile elements
.mobile-hide {
display: none;
}
// Content margins
#maincontent>.container {
margin: 0 1rem 1rem 1rem;
}
// Form adjustments
.cbi-value-title {
text-align: left;
}
.cbi-dynlist p {
padding: 0.5rem 1rem;
}
// Prevent horizontal scroll
body {
overflow-x: hidden;
}
// Login page adjustments
.node-main-login .main .main-right #maincontent .container .cbi-map .cbi-section .cbi-section-node .cbi-value .cbi-value-field {
width: 16rem;
}
.node-main-login footer {
display: none;
}
// Tab scrollbar hiding
.tabs,
.cbi-tabmenu {
&::-webkit-scrollbar {
width: 0px;
height: 0px;
}
}
// Form field layout
.cbi-value-field,
.cbi-value-description {
display: block !important;
padding-left: 0 !important;
padding-right: 0 !important;
}
[data-page="admin-system-admin-password"] .cbi-value-field {
display: table-cell !important;
}
// Modal adjustments
.modal.cbi-modal {
max-width: 100%;
max-height: none;
}
.modal {
display: flex;
align-items: center;
flex-wrap: wrap;
width: 100%;
min-width: 270px;
max-width: 600px;
min-height: 32px;
margin: 5em auto;
padding: 1em;
border-radius: 3px !important;
background: #fff;
box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.16), 0 0 2px 0 rgba(0, 0, 0, 0.12);
}
// Dropdown adjustments
.cbi-dropdown[open]>ul.dropdown {
margin-bottom: 1rem;
}
// Login page footer
.login-page .login-container footer {
display: none;
}
}
// 7. Small Mobile (480px and below)
// ==========================================================================
@media screen and (max-width: 480px) {
// Hide mobile elements
.mobile-hide {
display: none;
}
// Interface box adjustments for overview page
div[style*="display:grid;grid-template-columns:repeat"] {
.ifacebox {
flex-basis: 80px;
.ifacebox-body {
padding: 0.875rem 0.5rem;
font-size: 0.6rem;
}
}
}
// Login page mobile adjustments
.login-page .login-container {
margin-left: 0rem !important;
width: 100%;
.login-form {
.form-login {
.input-group {
&::before {
color: #525461;
}
input {
color: #525461;
border-bottom: white 1px solid;
border-bottom: var(--white) 1px solid;
border-radius: 0;
}
}
}
}
}
}
// 8. Desktop Scrollbar Styling (600px and above)
// ==========================================================================
@media screen and (min-width: 600px) {
::-webkit-scrollbar {
width: 10px;
height: 10px;
}
::-webkit-scrollbar,
::-webkit-scrollbar-corner {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--primary);
border-radius: 10px;
&:hover {
background: var(--primary);
}
&:active {
background: var(--primary);
}
}
}
@@ -0,0 +1,257 @@
// out: false
// ==========================================================================
// Login Page Styles
// Optimized with LESS best practices
// ==========================================================================
// Mixins for common patterns
.flex-center() {
display: flex;
align-items: center;
justify-content: center;
}
.absolute-full() {
position: absolute;
top: 0;
left: 0;
height: 100vh;
width: 100vw;
}
.icon-base() {
font-family: 'argon' !important;
font-style: normal;
font-weight: normal;
font-variant: normal;
text-transform: none;
line-height: 1;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.login-page {
display: flex;
height: 100%;
flex-direction: column;
// Background video container
.video {
.absolute-full();
.flex-center();
background-color: var(--darker);
overflow: hidden;
video {
width: 100%;
height: auto;
}
}
// Volume control button
.volume-control {
position: fixed;
top: 1rem;
right: 1rem;
width: 1.5rem;
height: 1.5rem;
z-index: 5000;
cursor: pointer;
background: url(../img/volume_high.svg) no-repeat center/contain;
&.mute {
background-image: url(../img/volume_off.svg);
}
}
// Main background image
.main-bg {
.absolute-full();
background: url(../img/blank.png) no-repeat center/cover;
transition: all 0.5s ease;
}
// Login container
.login-container {
z-index: 10;
margin-left: 5%;
display: flex;
height: 100vh;
width: 26rem;
flex-direction: column;
background-color: var(--white);
backdrop-filter: blur(var(--blur-radius));
background-color: rgba(244, 245, 247, var(--blur-opacity));
box-shadow: rgba(0, 0, 0, 0.75) 0 0 35px -5px;
// Login form
.login-form {
.absolute-full();
display: flex;
flex-direction: column;
align-items: center;
max-width: 26rem;
// Brand/Logo section
.brand {
display: flex;
align-items: center;
justify-content: center;
margin: 50px auto 100px 50px;
color: var(--default);
text-decoration: none;
.icon {
width: 50px;
height: auto;
margin-right: 25px;
}
.brand-text {
margin-right: 45px;
font-size: 1.25rem;
font-weight: 700;
font-family: "TypoGraphica";
word-break: break-word;
}
&:hover {
text-decoration: none;
}
}
// Form container
.form-login {
width: 100%;
padding: 20px 50px;
box-sizing: border-box;
.errorbox {
padding-bottom: 2rem;
text-align: center;
color: var(--warning);
}
// Input group styling
.input-group {
position: relative;
margin-bottom: 1.25rem;
// Icon styling
&::before {
.icon-base();
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
font-size: 1.5rem;
color: var(--default);
}
// Animated border
.border {
position: absolute;
bottom: 0;
width: 100%;
height: 1px;
border-bottom: 1px solid var(--primary);
transform: scaleX(0);
transition: transform 0.3s ease;
}
// Input field
input {
display: block;
width: 100%;
margin: 0.825rem 0;
padding: 0.5rem 0.75rem 0.5rem 3rem;
font-size: 1rem;
line-height: 1.5em;
color: var(--default);
background-color: transparent;
background-clip: padding-box;
border: 0;
border-bottom: 1px solid var(--white);
border-radius: 0;
outline: none;
box-sizing: border-box;
box-shadow: 0 3px 2px rgba(233, 236, 239, 0.05);
transition: all 0.3s cubic-bezier(0.68, -0.55, 0.265, 1.55);
&:focus+.border {
transform: scaleX(1);
}
}
.cbi-input-password {
position: relative;
margin-bottom: 2rem;
}
// Icon content
&.user-icon::before {
content: "\e971";
}
&.pass-icon::before {
content: "\e910";
}
}
}
// Submit button
.cbi-button-apply {
width: 100% !important;
min-height: 50px;
margin: 30px 0 100px;
padding: 10px 0;
font-size: 15px;
font-weight: 600;
color: var(--white);
text-align: center;
letter-spacing: 0.8rem;
background-color: var(--primary) !important;
border: none;
border-radius: 6px;
outline: none;
cursor: pointer;
box-shadow: rgba(0, 0, 0, 0.1) 0 0 50px 0;
transition: all 0.3s ease !important;
&:hover,
&:focus {
opacity: 0.9;
}
}
}
// Footer
footer {
position: absolute;
bottom: 0;
z-index: 10;
display: flex;
justify-content: space-evenly;
width: 100%;
margin-top: auto;
padding: 0 0 30px;
color: var(--gray);
text-align: center;
line-height: 1.6rem;
font-size: 0.75rem;
.ftc {
position: absolute;
bottom: 30px;
width: 100%;
}
.luci-link {
display: block;
}
}
}
}
@@ -0,0 +1,12 @@
#!/bin/sh
if [ "$PKG_UPGRADE" != 1 ]; then
uci get luci.themes.Argon >/dev/null 2>&1 || \
uci batch <<-EOF
set luci.themes.Argon=/luci-static/argon
set luci.main.mediaurlbase=/luci-static/argon
commit luci
EOF
fi
exit 0
@@ -0,0 +1,108 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-3.0-only
. /usr/share/libubox/jshn.sh
src="$(uci -q get argon.@global[0].online_wallpaper || echo bing)"
case "$src" in
bing|none|ghser|unsplash|wallhaven) WEB_PIC_SRC="$src" ;;
unsplash_[0-9]*|wallhaven_[0-9]*) WEB_PIC_SRC="$src" ;;
*) WEB_PIC_SRC="bing" ;;
esac
API_KEY="$(uci -q get argon.@global[0].use_api_key)"
EXACT_RESO="$(uci -q get argon.@global[0].use_exact_resolution || echo '1')"
case "$API_KEY" in *[!A-Za-z0-9_.-]*|????????????????????????????????????????????????????????????????*) API_KEY="" ;; esac
case "$EXACT_RESO" in 1) ;; *) EXACT_RESO=0 ;; esac
cache_key="$(echo "$WEB_PIC_SRC" | tr -c 'A-Za-z0-9_-' '_')"
CACHE="/var/run/argon_${cache_key}.url"
WRLOCK="/var/lock/argon_${cache_key}.lock"
valid_url() {
case "$1" in
https://www.bing.com/*|//www.bing.com/*|https://api.vvhan.com/*|https://images.unsplash.com/*|https://wallhaven.cc/*) return 0 ;;
esac
return 1
}
fetch_pic_url() {
case "$WEB_PIC_SRC" in
bing)
local picpath="$(wget -T3 -qO- "https://www.bing.com/HPImageArchive.aspx?format=js&n=1" | jsonfilter -qe '@.images[0].url' | sed 's/1920x1080/UHD/g')"
[ -n "$picpath" ] && echo "//www.bing.com${picpath}"
;;
ghser)
echo "https://api.vvhan.com/api/wallpaper/acg"
;;
unsplash)
if [ -z "$API_KEY" ]; then
local pic_id="$(wget -T3 --spider "https://source.unsplash.com/1920x1080/daily?wallpapers" 2>&1 | grep -Eo "photo-\w+-\w+" | head -n1)"
[ -n "$pic_id" ] && echo "https://images.unsplash.com/${pic_id}?fm=jpg&fit=crop&w=1920&h=1080"
else
wget -T3 -qO- "https://api.unsplash.com/photos/random?client_id=${API_KEY}" | jsonfilter -qe '@["urls"]["regular"]'
fi
;;
unsplash_*)
local collection_id="${WEB_PIC_SRC#unsplash_}"
if [ -z "$API_KEY" ]; then
local pic_id="$(wget -T3 --spider "https://source.unsplash.com/collection/${collection_id}/1920x1080" 2>&1 | grep -Eo "photo-\w+-\w+" | head -n1)"
[ -n "$pic_id" ] && echo "https://images.unsplash.com/${pic_id}?fm=jpg&fit=crop&w=1920&h=1080"
else
wget -T3 -qO- "https://api.unsplash.com/photos/random?client_id=${API_KEY}&collections=${collection_id}" | jsonfilter -qe '@["urls"]["regular"]'
fi
;;
wallhaven)
wget -T3 -qO- "https://wallhaven.cc/api/v1/search?resolutions=1920x1080&sorting=random" | jsonfilter -qe '@.data[0].path'
;;
wallhaven_*)
local tag_id="${WEB_PIC_SRC#wallhaven_}"
local use_reso="resolutions"
[ "$EXACT_RESO" -eq 1 ] || use_reso="atleast"
[ -z "$API_KEY" ] || API_KEY="apikey=$API_KEY&"
wget -T3 -qO- "https://wallhaven.cc/api/v1/search?${API_KEY}q=id%3A${tag_id}&${use_reso}=1920x1080&sorting=random" | jsonfilter -qe '@.data[0].path'
;;
esac
}
try_update() {
exec 200>"$WRLOCK"
if flock -n 200 >/dev/null 2>&1; then
local picurl="$(fetch_pic_url)"
if valid_url "$picurl"; then
printf '%s\n' "$picurl" > "$CACHE"
printf '%s\n' "$picurl"
elif [ -s "$CACHE" ]; then
cat "$CACHE"
else
: > "$CACHE"
fi
flock -u 200 >/dev/null 2>&1
elif [ -s "$CACHE" ]; then
cat "$CACHE"
fi
}
case "$1" in
"list")
json_init; json_add_object "get_url"; json_close_object; json_dump; json_cleanup
;;
"call")
case "$2" in
"get_url")
read -r input
if [ -f "$CACHE" ]; then
idle_t="$(($(date '+%s') - $(date -r "$CACHE" '+%s' 2>/dev/null || echo '0')))"
if [ -s "$CACHE" ] && [ "$idle_t" -le 43200 ]; then
url="$(cat "$CACHE")"
valid_url "$url" || url=""
json_init; json_add_string "url" "$url"; json_dump; json_cleanup; return 0
elif [ ! -s "$CACHE" ] && [ "$idle_t" -le 120 ]; then
echo '{ "url": "" }'; return 1
fi
fi
json_init; json_add_string "url" "$(try_update)"; json_dump; json_cleanup; return 0
;;
esac
;;
esac
@@ -0,0 +1,8 @@
{
"luci-theme-argon": {
"description": "Grant UCI access for luci-theme-argon",
"read": {
"uci": [ "argon" ]
}
}
}
@@ -0,0 +1,55 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Agron Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
</div>
<footer class="mobile-hide" style="text-wrap: auto">
<div class="footer-content" style="display: flex; flex-wrap: wrap; gap: 0.5em; justify-content: end;">
<a class="luci-link" href="https://github.com/openwrt/luci" target="_blank">Powered by {{ version.luciname }} ({{ version.luciversion }})</a>
<span class="footer-separator">|</span>
<a href="https://github.com/jerrykuku/luci-theme-argon" target="_blank">ArgonTheme {# vPKG_VERSION #}</a>
<span class="footer-separator">|</span>
<a class="luci-link" href="{{ version.disturl }}" target="_blank">{{ version.distname }} {{ version.distversion }}-{{ version.distrevision }}</a>
<ul class="breadcrumb pull-right" id="modemenu" style="display:none"></ul>
</div>
</footer>
</div>
</div>
<script>
// thanks for Jo-Philipp Wich <jow@openwrt.org>
var luciLocation = {{ ctx.path }};
var winHeight = window.innerHeight;
window.addEventListener('resize', function () {
var winWidth = window.innerWidth;
if(winWidth < 600){
var newHeight = window.innerHeight;
var keyboradHeight = newHeight - winHeight;
var ftcElement = document.querySelector(".ftc");
if (ftcElement) {
ftcElement.style.bottom = (keyboradHeight + 30) + "px";
}
}
});
</script>
<script type="text/javascript">L.require('menu-argon')</script>
</body>
</html>
@@ -0,0 +1,47 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Agron Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
</div>
<!-- added style="text-wrap: auto" but on this page it is still not the best solution. I will take it to consideration next time :) -->
<footer style="text-wrap: auto">
<div>
<a class="luci-link" href="https://github.com/openwrt/luci" target="_blank">Powered by {{ version.luciname }} ({{ version.luciversion }})</a>
<a href="https://github.com/jerrykuku/luci-theme-argon" target="_blank">ArgonTheme {# vPKG_VERSION #}</a>
<a class="luci-link" href="{{ version.disturl }}" target="_blank">{{ version.distname }} {{ version.distversion }}-{{ version.distrevision }}</a>
</div>
</footer>
</div>
</div>
<script>
// thanks for Jo-Philipp Wich <jow@openwrt.org>
var luciLocation = {{ ctx.path }};
var winHeight = window.innerHeight;
window.addEventListener('resize', function () {
var winWidth = window.innerWidth;
if(winWidth < 600){
var newHeight = window.innerHeight;
var keyboradHeight = newHeight - winHeight;
var ftcElement = document.querySelector(".ftc");
if (ftcElement) {
ftcElement.style.bottom = (keyboradHeight + 30) + "px";
}
}
});
</script>
</body>
</html>
@@ -0,0 +1,181 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{%
import { readfile, access } from 'fs';
import { cursor } from 'uci';
import { srand } from 'math';
import { getuid, getspnam } from 'luci.core';
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
let cfg = cursor();
//send as HTML5
http.prepare_content('text/html; charset=UTF-8');
srand(+substr(reverse(time() + ""), 0, 8));
//Custom settings
let mode = 'normal';
let bar_color = '#5e72e4';
let primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark;
const dark_css = readfile('/www/luci-static/argon/css/dark.css');
if (access('/etc/config/argon')) {
primary = cfg.get_first('argon', 'global', 'primary');
dark_primary = cfg.get_first('argon', 'global', 'dark_primary');
blur_radius = cfg.get_first('argon', 'global', 'blur');
blur_radius_dark = cfg.get_first('argon', 'global', 'blur_dark');
blur_opacity = cfg.get_first('argon', 'global', 'transparency');
blur_opacity_dark = cfg.get_first('argon', 'global', 'transparency_dark');
mode = cfg.get_first('argon', 'global', 'mode');
bar_color = (mode == 'dark') ? dark_primary : primary;
}
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ hostname }}{{ node?.title ? ` - ${striptags(node.title)}` : '' }} - LuCI</title>
<!-- SEO and Description -->
<meta name="robots" content="noindex, nofollow">
<!-- Security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<!-- Mobile and PWA -->
<meta name="format-detection" content="telephone=no, email=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ hostname }} - LuCI">
<!-- Theme and Colors -->
<meta name="theme-color" content="{{ bar_color }}">
<meta name="msapplication-TileColor" content="{{ bar_color }}">
<meta name="msapplication-TileImage" content="{{ media }}/icon/ms-icon-144x144.png">
<meta name="application-name" content="{{ hostname }} - LuCI">
<!-- Icons and Manifest -->
<link rel="icon" type="image/x-icon" href="{{ media }}/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="{{ media }}/icon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ media }}/icon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="{{ media }}/icon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="192x192" href="{{ media }}/icon/android-icon-192x192.png">
<link rel="apple-touch-icon" sizes="60x60" href="{{ media }}/icon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="{{ media }}/icon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="144x144" href="{{ media }}/icon/apple-icon-144x144.png">
<link rel="manifest" href="{{ media }}/icon/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="{{ media }}/css/cascade.css{# ?v=PKG_VERSION #}">
<style title="text/css">
{% if (mode == 'normal'): %}
@media (prefers-color-scheme: dark) {
{{ dark_css }}
}
{% endif %}
{% if (mode == 'dark'): %}
{{ dark_css }}
{% endif -%}
{% if (access('/etc/config/argon')): %}
:root {
--primary: {{ primary }};
--dark-primary: {{ dark_primary }};
--blur-radius:{{ blur_radius }}px;
--blur-opacity:{{ blur_opacity }};
--blur-radius-dark:{{ blur_radius_dark }}px;
--blur-opacity-dark:{{ blur_opacity_dark }};
}
{% endif -%}
</style>
<!-- Stylesheets -->
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css}}">
{% endif -%}
{% if (css): %}
<style title="text/css">
{{ css }}
</style>
{% endif -%}
<script src="{{ dispatcher.build_url('admin/translations', dispatcher.lang) }}?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/cbi.js?v={{ version.luciversion }}"></script>
<script src="{{ resource }}/luci.js?v={{ version.luciversion }}"></script>
</head>
<body
class="lang_{{ dispatcher.lang }} {{ node?.title ? ` - ${striptags(node.title)}` : '' }} {% if (ctx.authsession): %}logged-in{% endif %}"
data-page="{{ entityencode(join('-', ctx.request_path), true) }}">
<div class="main">
<div class="main-left" id="mainmenu" style="display:none">
<div class="sidenav-header d-flex align-items-center">
<a class="brand" href="#">{{ hostname }}</a>
<div class="ml-auto">
<!-- Sidenav toggler -->
<div class="sidenav-toggler d-none d-xl-block active" data-action="sidenav-unpin"
data-target="#sidenav-main">
<div class="sidenav-toggler-inner">
<i class="sidenav-toggler-line"></i>
<i class="sidenav-toggler-line"></i>
<i class="sidenav-toggler-line"></i>
</div>
</div>
</div>
</div>
</div>
<div class="main-right">
<header class="bg-primary">
<div class="fill">
<div class="container">
<div class="flex1">
<a class="showSide"></a>
<a class="brand" href="#">{{ hostname }}</a>
</div>
<div class="status" id="indicators"></div>
</div>
</div>
</header>
<div class="darkMask"></div>
<div id="maincontent">
<div class="container">
{% if (getuid() == 0 && getspnam('root')?.pwdp === ''): %}
<div class="alert-message error">
<h4>{{ _('No password set!') }}</h4>
<p>{{ _('There is no password set on this router. Please configure a root password to protect the web interface.') }}</p>
{% if (dispatcher.lookup("admin/system/admin")): %}
<div class="right"><a class="btn" href="{{ dispatcher.build_url("admin/system/admin") }}">{{ _('Go to password configuration...') }}</a></div>
{% endif %}
</div>
{% endif %}
<noscript>
<div class="alert-message error">
<h4>{{ _('JavaScript required!') }}</h4>
<p>{{ _('You must enable JavaScript in your browser or LuCI will not work properly.') }}</p>
</div>
</noscript>
<div id="tabmenu" style="display:none"></div>
@@ -0,0 +1,125 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-material Argon Template
luci-theme-argon
Copyright 2020 Jerrykuku <jerrykuku@qq.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-material:
Copyright 2015 Lutty Yang <lutty@wcan.in>
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{%
import { readfile, access } from 'fs';
import { cursor } from 'uci';
import { srand } from 'math';
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
let cfg = cursor();
//send as HTML5
http.prepare_content('text/html; charset=UTF-8');
srand(+substr(reverse(time() + ""), 0, 8));
//Custom settings
let mode = 'normal';
let bar_color = '#5e72e4';
let primary, dark_primary, blur_radius, blur_radius_dark, blur_opacity, blur_opacity_dark;
const dark_css = readfile('/www/luci-static/argon/css/dark.css');
if (access('/etc/config/argon')) {
primary = cfg.get_first('argon', 'global', 'primary');
dark_primary = cfg.get_first('argon', 'global', 'dark_primary');
blur_radius = cfg.get_first('argon', 'global', 'blur');
blur_radius_dark = cfg.get_first('argon', 'global', 'blur_dark');
blur_opacity = cfg.get_first('argon', 'global', 'transparency');
blur_opacity_dark = cfg.get_first('argon', 'global', 'transparency_dark');
mode = cfg.get_first('argon', 'global', 'mode');
bar_color = (mode == 'dark') ? dark_primary : primary;
}
-%}
<!DOCTYPE html>
<html lang="{{ dispatcher.lang }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>{{ hostname }}{{ node?.title ? ` - ${striptags(node.title)}` : '' }} - LuCI</title>
<!-- SEO and Description -->
<meta name="robots" content="noindex, nofollow">
<!-- Security -->
<meta http-equiv="X-Content-Type-Options" content="nosniff">
<meta http-equiv="X-Frame-Options" content="SAMEORIGIN">
<meta http-equiv="X-XSS-Protection" content="1; mode=block">
<meta http-equiv="Referrer-Policy" content="strict-origin-when-cross-origin">
<!-- Mobile and PWA -->
<meta name="format-detection" content="telephone=no, email=no">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="{{ hostname }} - LuCI">
<!-- Theme and Colors -->
<meta name="theme-color" content="{{ bar_color }}">
<meta name="msapplication-TileColor" content="{{ bar_color }}">
<meta name="msapplication-TileImage" content="{{ media }}/icon/ms-icon-144x144.png">
<meta name="application-name" content="{{ hostname }} - LuCI">
<!-- Icons and Manifest -->
<link rel="icon" type="image/x-icon" href="{{ media }}/favicon.ico">
<link rel="icon" type="image/png" sizes="16x16" href="{{ media }}/icon/favicon-16x16.png">
<link rel="icon" type="image/png" sizes="32x32" href="{{ media }}/icon/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="96x96" href="{{ media }}/icon/favicon-96x96.png">
<link rel="icon" type="image/png" sizes="192x192" href="{{ media }}/icon/android-icon-192x192.png">
<link rel="apple-touch-icon" sizes="60x60" href="{{ media }}/icon/apple-icon-60x60.png">
<link rel="apple-touch-icon" sizes="72x72" href="{{ media }}/icon/apple-icon-72x72.png">
<link rel="apple-touch-icon" sizes="144x144" href="{{ media }}/icon/apple-icon-144x144.png">
<link rel="manifest" href="{{ media }}/icon/manifest.json" crossorigin="use-credentials">
<link rel="stylesheet" href="{{ media }}/css/cascade.css{# ?v=PKG_VERSION #}">
<style title="text/css">
{% if (mode == 'normal'): %}
@media (prefers-color-scheme: dark) {
{{ dark_css }}
}
{% endif %}
{% if (mode == 'dark'): %}
{{ dark_css }}
{% endif -%}
{% if (access('/etc/config/argon')): %}
:root {
--primary: {{ primary }};
--dark-primary: {{ dark_primary }};
--blur-radius:{{ blur_radius }}px;
--blur-opacity:{{ blur_opacity }};
--blur-radius-dark:{{ blur_radius_dark }}px;
--blur-opacity-dark:{{ blur_opacity_dark }};
}
{% endif -%}
</style>
<link rel="shortcut icon" href="{{ media }}/favicon.ico">
{% if (node?.css): %}
<link rel="stylesheet" href="{{ resource }}/{{ node.css}}">
{% endif -%}
{% if (css): %}
<style title="text/css">
{{ css }}
</style>
{% endif -%}
</head>
<body>
@@ -0,0 +1,12 @@
{#
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2019 Jo-Philipp Wich <jo@mein.io>
Licensed to the public under the Apache License 2.0.
-#}
{%
if (!ctx.template_header_sent) {
include("themes/" + theme + "/header_login");
ctx.template_header_sent = true;
}
%}
@@ -0,0 +1,165 @@
{#
Argon is a clean HTML5 theme for LuCI. It is based on luci-theme-bootstrap and MUI and Argon Template
luci-theme-argon
Copyright 2020 Jerryk <jerrykuku@gmail.com>
Have a bug? Please create an issue here on GitHub!
https://github.com/jerrykuku/luci-theme-argon/issues
luci-theme-bootstrap:
Copyright 2008 Steven Barth <steven@midlink.org>
Copyright 2008-2016 Jo-Philipp Wich <jow@openwrt.org>
Copyright 2012 David Menting <david@nut-bolt.nl>
MUI:
https://github.com/muicss/mui
Argon Theme
https://demos.creative-tim.com/argon-dashboard/index.html
Licensed to the public under the Apache License 2.0
-#}
{% include("themes/" + theme + "/out_header_login") %}
{%
import * as fs from 'fs';
import { cursor } from 'uci';
import { rand } from 'math';
let cfg = cursor();
//Fetch Local Background Media
const imageTypes = " jpg jpeg png gif webp ";
const videoTypes = " mp4 webm ";
const allTypes = imageTypes + videoTypes;
function fetchMedia(path, themeDir) {
let backgroundTable = [];
for (f in (fs.lsdir(path))) {
let ext = lc(split(f, '.')?.[1]);
if (ext && index(allTypes, " " + ext + " ") != -1) {
let bg = {};
bg.type = ext;
bg.url = themeDir + f;
push(backgroundTable, bg);
}
}
return backgroundTable;
}
function selectBackground(themeDir) {
let bgUrl = media + "/img/bg1.jpg";
let remoteBgUrl = "";
let backgroundType = "Image";
let mimeType = "";
if (fs.access("/etc/config/argon")) {
let online_wallpaper = cfg.get_first('argon', 'global', 'online_wallpaper') ?? (cfg.get_first('argon', 'global', 'bing_background') == '1' ? 'bing' : null);
if (online_wallpaper && online_wallpaper != "none") {
const picurl = ubus.call("luci.argon_wallpaper", "get_url") ?? {};
if (picurl?.url && match(picurl.url, /^(https?:\/\/|\/\/)www\.bing\.com\/|^(https?:\/\/)api\.vvhan\.com\/|^(https?:\/\/)images\.unsplash\.com\/|^(https?:\/\/)wallhaven\.cc\//))
remoteBgUrl = picurl.url;
}
}
let background = fetchMedia("/www" + themeDir, themeDir);
// Priority: local uploaded backgrounds win; only fall through to
// the remote Bing/preload URL when no local files exist.
if (length(background) > 0) {
let currentBg = background[(rand() % length(background))];
bgUrl = currentBg.url;
if (index(videoTypes, " " + currentBg.type + " ") != -1) {
backgroundType = "Video";
mimeType = "video/" + currentBg.type;
}
}
return {bgUrl, remoteBgUrl, backgroundType, mimeType};
}
const boardinfo = ubus.call("system", "board");
const hostname = striptags(boardinfo?.hostname ?? '?');
const themeDir = media + "/background/";
const bgArry = selectBackground(themeDir);
%}
<!-- Login Page Start -->
<div class="login-page">
{% if ( bgArry.backgroundType == "Video" ): %}
<!-- Video Player Start -->
<div class="video">
<video autoplay loop muted id="video">
<source src="{{ bgArry.bgUrl }}" type="{{ bgArry.mimeType }}">
</video>
</div>
<div class="volume-control mute"></div>
<script>
document.querySelector(".volume-control").addEventListener("click", function(){
if(this.classList.contains("mute")){
this.classList.remove("mute");
document.getElementById("video").muted = false;
}else{
this.classList.add("mute");
document.getElementById("video").muted = true;
}
});
</script>
<!-- Video Player End -->
{% else %}
<!-- Image Background Start -->
<div class="main-bg" id="main-bg" style="background-image:url({{ bgArry.bgUrl }})"></div>
<script type="text/javascript">//<![CDATA[
(function() {
var remote = "{{ bgArry.remoteBgUrl }}";
if (!remote)
return;
var img = new Image();
img.onload = function() {
var el = document.getElementById("main-bg");
if (el)
el.style.backgroundImage = "url(" + remote + ")";
};
img.onerror = function() {};
img.src = remote;
})();
//]]></script>
<!-- Image Background End -->
{% endif %}
<!-- Login Container Start -->
<div class="login-container">
<div class="login-form">
<!-- Logo Start -->
<a class="brand" href="/"><img src="{{ media }}/img/argon.svg" class="icon">
<span class="brand-text">{{ hostname }}</span>
</a>
<!-- Logo End -->
<!-- Login Form Start -->
<form class="form-login" method="post" action="{{ http.getenv("REQUEST_URI") }}">
{%- if (fuser): %}
<div class="errorbox">{{ _('Invalid username and/or password! Please try again.') }}</div>
{% endif -%}
<div class="input-container">
<div class="input-group user-icon">
<input class="cbi-input-user" id="cbi-input-user" type="text" name="luci_username" value="{{ entityencode(duser, true) }}" />
<label class="border" for="cbi-input-user"></label>
</div>
<div class="input-group pass-icon">
<input class="cbi-input-password" id="cbi-input-password" type="password" name="luci_password" />
<label class="border" for="cbi-input-password"></label>
</div>
</div>
<div>
<input type="submit" value="{{ _('Log in') }}" class="cbi-button cbi-button-apply" />
</div>
</form>
<!-- Login Form End -->
<script type="text/javascript">//<![CDATA[
var input = document.getElementsByName('luci_password')[0];
if (input)
input.focus();
//]]></script>
{% include("themes/" + theme + "/footer_login") %}
@@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""Static safety gates for the v46.1 UI-only LuCI packages."""
from __future__ import annotations
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
EXPECTED_PACKAGES = {
"luci-theme-argon",
"luci-app-argon-config",
"luci-app-wgtunnel",
"luci-app-tr3000-status",
}
FORBIDDEN_PACKAGES = {
"luci-app-turboacc-mtk",
"luci-app-wrtbwmon",
"kmod-mediatek_hnat",
"kmod-warp",
"kmod-mt_wifi",
"kmod-tcp-bbr",
}
GLOBAL_FORBIDDEN = {
"ifup lan": "must never bounce the management LAN",
"wifi restart": "must never reset Wi-Fi from a UI package",
"/etc/init.d/network restart": "must never restart the global network",
"/etc/init.d/firewall restart": "must never restart fw4",
}
FRONTEND_FORBIDDEN = {
"handleSaveApply(": "WG UI must not invoke LuCI global apply",
"fs.exec": "browser code must not execute programs",
"uci.load('network')": "WG UI must not receive generic network UCI access",
'uci.load("network")': "WG UI must not receive generic network UCI access",
"new form.Map('network'": "WG UI must use JSONMap instead of network UCI",
'new form.Map("network"': "WG UI must use JSONMap instead of network UCI",
}
KEY_LIKE = re.compile(r"(?<![A-Za-z0-9+/])[A-Za-z0-9+/]{43}=(?![A-Za-z0-9+/=])")
def fail(message: str) -> None:
print(f"FAIL: {message}")
raise SystemExit(1)
def text_files(package: Path):
for path in sorted(package.rglob("*")):
if not path.is_file():
continue
# Repo-only documentation (not shipped in the IPK) may legitimately
# name forbidden patterns when describing them; only scan shipped/code
# files for the forbidden-content gates.
if path.suffix == ".md" or path.name.upper().startswith("README"):
continue
# Test harnesses (not shipped) legitimately reference forbidden package
# names when asserting they are absent; skip them too.
if "tests" in path.relative_to(package).parts:
continue
try:
yield path, path.read_text(encoding="utf-8")
except UnicodeDecodeError:
continue
def load_json(path: Path) -> dict:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
fail(f"invalid JSON {path.relative_to(ROOT)}: {exc}")
def sole_acl(path: Path, name: str) -> dict:
data = load_json(path)
if set(data) != {name}:
fail(f"{path.relative_to(ROOT)} must contain only ACL {name}")
return data[name]
def assert_ubus_only(block: dict, obj: str, methods: set[str]) -> None:
if set(block) != {"ubus"}:
fail(f"ACL block for {obj} must grant ubus only, got {sorted(block)}")
ubus = block["ubus"]
if set(ubus) != {obj} or set(ubus[obj]) != methods:
fail(f"ACL for {obj} expected methods {sorted(methods)}, got {ubus}")
def check_wgtunnel() -> None:
package = ROOT / "luci-app-wgtunnel"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-wgtunnel.json",
"luci-app-wgtunnel",
)
assert_ubus_only(acl.get("read", {}), "luci.wgtunnel", {"get", "status"})
assert_ubus_only(
acl.get("write", {}),
"luci.wgtunnel",
{"prepare", "apply", "rollback", "reconnect"},
)
frontend = package / "htdocs/luci-static/resources/view/wgtunnel.js"
source = frontend.read_text(encoding="utf-8")
for needle, reason in FRONTEND_FORBIDDEN.items():
if needle in source:
fail(f"{frontend.relative_to(ROOT)} contains {needle!r}: {reason}")
for required in ("form.JSONMap", "luci.wgtunnel", "prepare", "apply"):
if required not in source:
fail(f"{frontend.relative_to(ROOT)} lacks {required!r}")
backend = package / "root/usr/share/rpcd/ucode/luci.wgtunnel"
if not backend.is_file():
fail(f"missing {backend.relative_to(ROOT)}")
source = backend.read_text(encoding="utf-8")
for method in ("get", "status", "prepare", "apply", "rollback", "reconnect"):
if re.search(rf"\b{re.escape(method)}\s*:", source) is None:
fail(f"WG backend lacks method {method}")
for required in ("10.99.0.2/32", "10.99.0.1/32", "wg0", "vxlan0"):
if required not in source:
fail(f"WG backend lacks topology fuse {required}")
def check_status() -> None:
package = ROOT / "luci-app-tr3000-status"
acl = sole_acl(
package / "root/usr/share/rpcd/acl.d/luci-app-tr3000-status.json",
"luci-app-tr3000-status",
)
assert_ubus_only(acl.get("read", {}), "luci.tr3000_status", {"get"})
if "write" in acl:
fail("Link Health ACL must not contain a write block")
backend = package / "root/usr/share/rpcd/ucode/luci.tr3000_status"
frontend = package / "htdocs/luci-static/resources/view/status/tr3000.js"
if not backend.is_file() or not frontend.is_file():
fail("Link Health backend/frontend is missing")
for path in (backend, frontend):
source = path.read_text(encoding="utf-8")
for needle in ("private_key", "preshared_key", "wifi-key", "wireless key"):
if needle in source.lower():
fail(f"{path.relative_to(ROOT)} references secret field {needle!r}")
def check_argon_acl() -> None:
path = ROOT / "luci-app-argon-config/root/usr/share/rpcd/acl.d/luci-app-argon-config.json"
acl = sole_acl(path, "luci-app-argon-config")
read_ubus = acl.get("read", {}).get("ubus", {})
write_ubus = acl.get("write", {}).get("ubus", {})
mutators = {"remove", "rename"}
if mutators & set(read_ubus.get("luci.argon", [])):
fail("Argon remove/rename methods must not be granted in read ACL")
if not mutators <= set(write_ubus.get("luci.argon", [])):
fail("Argon write ACL must grant remove and rename")
def check_all_sources() -> None:
packages = {path.name for path in ROOT.iterdir() if path.is_dir() and path.name.startswith("luci-")}
missing = EXPECTED_PACKAGES - packages
if missing:
fail(f"missing package directories: {sorted(missing)}")
for package_name in sorted(EXPECTED_PACKAGES):
package = ROOT / package_name
for path, source in text_files(package):
rel = path.relative_to(ROOT)
for needle, reason in GLOBAL_FORBIDDEN.items():
if needle in source:
fail(f"{rel} contains {needle!r}: {reason}")
for forbidden in FORBIDDEN_PACKAGES:
if forbidden in source:
fail(f"{rel} pulls forbidden package {forbidden}")
if KEY_LIKE.search(source):
fail(f"{rel} contains a possible WireGuard key literal")
if path.suffix == ".json":
load_json(path)
def main() -> int:
check_all_sources()
check_argon_acl()
check_wgtunnel()
check_status()
print("PASS: v46.1 UI-only static safety gates")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,86 @@
#!/bin/sh
# v46.1 UI-only live install helper
# - Only installs the 5 audited IPKs (NO opkg update, NO network/firewall/wifi changes)
# - Pre-checks live immutable hashes, then installs, then post-checks
# - All operations are idempotent; safe to re-run after a partial failure
set -eu
BASE=/tmp/v46.1-ui
mkdir -p "$BASE"
cd "$BASE"
PKGS="
luci-theme-argon_2.4.3-r20250722_all.ipk
luci-app-argon-config_26.187.07912~668cdc6_all.ipk
luci-i18n-argon-config-zh-cn_26.187.07912~668cdc6_all.ipk
luci-app-wgtunnel_0_all.ipk
luci-app-tr3000-status_0_all.ipk
"
IMMU="
/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
"
sha256() { sha256sum "$1" 2>/dev/null | awk '{print $1}'; }
echo "=== pre-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > pre.sha256
cat pre.sha256
missing=""
for p in $PKGS; do
if [ ! -f "$BASE/$p" ]; then
missing="$missing $p"
fi
done
if [ -n "$missing" ]; then
echo "FAIL: missing IPK files in $BASE:$missing"
exit 1
fi
for p in $PKGS; do
echo "=== opkg install --force-reinstall $p ==="
opkg install --force-reinstall --noaction "$p" || true
done
for p in $PKGS; do
opkg install --force-reinstall "$p"
done
# Keep the active theme on Bootstrap until admin chooses Argon.
# To activate Argon:
# uci set luci.main.mediaurlbase=/luci-static/argon
# uci commit luci
# /etc/init.d/rpcd restart; /etc/init.d/uhttpd restart
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
/etc/init.d/rpcd restart
/etc/init.d/uhttpd restart
echo "=== post-install snapshot ==="
{
for f in $IMMU; do
s=$(sha256 "$f" 2>/dev/null) || s=missing
printf "%s %s\n" "$s" "$f"
done
} > post.sha256
cat post.sha256
if cmp -s pre.sha256 post.sha256; then
echo "PASS: immutable files unchanged"
else
echo "FAIL: immutable files differ; diff:"
diff -u pre.sha256 post.sha256 || true
fi
@@ -0,0 +1,48 @@
#!/bin/sh
# v46.1 UI-only rollback watchdog
# - Removes ONLY the preview IPKs (luci-theme-argon, luci-app-argon-config, luci-i18n-argon-config-zh-cn, luci-app-wgtunnel, luci-app-tr3000-status)
# - Restores /luci-static/bootstrap as the active theme
# - Clears /tmp/luci-cache and the WG apply state directory
# - Restarts rpcd + uhttpd only. Does NOT touch network / firewall / Wi-Fi / WG / VXLAN.
# - Does NOT call opkg update or fetch anything from the network. Bootstrap is
# already part of the v46 image; we only flip mediaurlbase to it.
set -eu
PREVIEW_LUCI="
luci-theme-argon
luci-app-argon-config
luci-i18n-argon-config-zh-cn
luci-app-wgtunnel
luci-app-tr3000-status
"
# 1. Switch active theme to bootstrap (already present in the v46 image)
uci set luci.main.mediaurlbase=/luci-static/bootstrap
uci commit luci
# 2. Drop any in-memory Argon config so the admin does not "see" an empty form
uci -q delete argon.@global[0] 2>/dev/null || true
uci commit argon 2>/dev/null || true
# 3. Clean LuCI cache
rm -rf /tmp/luci-cache /tmp/luci-indexcache 2>/dev/null || true
rm -rf /www/luci-static/argon/background 2>/dev/null || true
mkdir -p /www/luci-static/argon/background
chmod 0755 /www/luci-static/argon/background
# 4. Clear the WG apply state directory (token / snapshot / lock). It is a
# DIRECTORY created by luci.wgtunnel, not a file.
rm -rf /tmp/luci-wgtunnel 2>/dev/null || true
# 5. Remove preview IPKs only
for pkg in $PREVIEW_LUCI; do
if opkg list-installed | awk '{print $1}' | grep -qx "$pkg"; then
opkg remove --autoremove "$pkg" || true
fi
done
# 6. Restart only web/rpcd - never network, never firewall, never wifi
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
echo "v46.1 UI rollback watchdog: done; theme bootstrap, preview IPKs removed, web stack restarted"