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; 尚未刷机.
@@ -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
|
||||
|
After Width: | Height: | Size: 9.4 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
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>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
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 |
|
After Width: | Height: | Size: 156 KiB |
|
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');
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"
|
||||