diff --git a/filebrowser-q/Makefile b/filebrowser-q/Makefile index f02f92cf9..1d22382b4 100644 --- a/filebrowser-q/Makefile +++ b/filebrowser-q/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=filebrowser-q PKG_VERSION:=1.5.6-stable -PKG_RELEASE:=5 +PKG_RELEASE:=6 PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)? diff --git a/filebrowser-q/files/filebrowser.init b/filebrowser-q/files/filebrowser.init index 89182565e..482d2113b 100644 --- a/filebrowser-q/files/filebrowser.init +++ b/filebrowser-q/files/filebrowser.init @@ -1,25 +1,24 @@ #!/bin/sh /etc/rc.common +USE_PROCD=1 START=99 -STOP=10 CONF="filebrowser-q" PROG="/usr/bin/filebrowser-q" CONF_PATH="/etc/filebrowser-q/config.yaml" -PID_FILE="/var/run/filebrowser-q.pid" +DB_PATH="/etc/filebrowser-q/database.db" -start() { - config_load "$CONF" +start_service() { + config_load "$CONF" - local enabled - config_get_bool enabled "config" "enabled" "0" - [ "$enabled" -eq "1" ] || return 1 + local enabled + config_get_bool enabled "config" "enabled" "0" + [ "$enabled" -eq "1" ] || return 1 - local listen_port root_path root_name + local listen_port root_path root_name config_get listen_port "config" "listen_port" "8787" config_get root_path "config" "root_path" "/" - # 路径为 / 时 name 设为 root,否则留空 root_name="" [ "$root_path" = "/" ] && root_name="root" @@ -28,7 +27,7 @@ start() { cat < "$CONF_PATH" server: port: $listen_port - database: "/etc/filebrowser-q/database.db" + database: "$DB_PATH" sources: - path: "$root_path" name: "$root_name" @@ -49,12 +48,20 @@ EOF -i "$CONF_PATH" fi - echo "Starting filebrowser..." - start-stop-daemon -S -q -b -m -p "$PID_FILE" -x "$PROG" -- -c "$CONF_PATH" + procd_open_instance + procd_set_param command "$PROG" + procd_append_param command -c "$CONF_PATH" + + procd_set_param limits core="unlimited" + procd_set_param limits nofile="1000000 1000000" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param respawn + + procd_close_instance + } -stop() { - kill -9 `pidof filebrowser-q | sed "s/$$//g"` 2>/dev/null - rm -f "$PID_FILE" - echo "filebrowser stopped" +service_triggers() { + procd_add_reload_trigger "$CONF" } diff --git a/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js b/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js index 0aec6c308..710ff2b80 100644 --- a/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js +++ b/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js @@ -6,9 +6,21 @@ 'require view'; 'require fs'; +const callServiceList = rpc.declare({ + object: 'service', + method: 'list', + params: ['name'], + expect: { '': {} } +}); + function getServiceStatus() { + return L.resolveDefault(callServiceList('filebrowser-q'), {}).then(function(res) { let isRunning = false; + try { + isRunning = res['filebrowser-q']['instances']['instance1']['running']; + } catch (e) { } return isRunning; + }); } function renderStatus(isRunning, port) { @@ -26,16 +38,8 @@ function renderStatus(isRunning, port) { } return view.extend({ - load: async function () { - const promises = await Promise.all([ - L.resolveDefault(fs.stat('/var/run/filebrowser-q.pid'), null), - uci.load('filebrowser-q') - ]); - const data = { - isRunning: promises[0], - conf: promises[1] - }; - return data; + load() { + return uci.load('filebrowser-q'); }, render(data) { @@ -49,24 +53,17 @@ return view.extend({ s = m.section(form.TypedSection); s.anonymous = true; s.render = function() { - poll.add(function() { - return fs.stat('/var/run/filebrowser-q.pid').then(function(stat) { - let view = document.getElementById('service_status'); - if (view) { - view.innerHTML = renderStatus(stat, webport); - } - }).catch(function() { - let view = document.getElementById('service_status'); - if (view) { - view.innerHTML = renderStatus(null, webport); - } - }); - }); + poll.add(function() { + return L.resolveDefault(getServiceStatus()).then(function(res) { + let view = document.getElementById('service_status'); + view.innerHTML = renderStatus(res, webport); + }); + }); - return E('div', { class: 'cbi-section', id: 'status_bar' }, [ - E('p', { id: 'service_status' }, _('Collecting data...')) - ]); -}; + return E('div', { class: 'cbi-section', id: 'status_bar' }, [ + E('p', { id: 'service_status' }, _('Collecting data...')) + ]); + } s = m.section(form.NamedSection, 'config', 'filebrowser'); diff --git a/luci-app-mosdns/Makefile b/luci-app-mosdns/Makefile index 70eae4a46..e7ea0e88d 100644 --- a/luci-app-mosdns/Makefile +++ b/luci-app-mosdns/Makefile @@ -2,7 +2,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-mosdns PKG_VERSION:=1.7.13 -PKG_RELEASE:=21 +PKG_RELEASE:=22 LUCI_TITLE:=LuCI Support for mosdns LUCI_PKGARCH:=all diff --git a/luci-app-mosdns/root/etc/hotplug.d/iface/99-mosdns b/luci-app-mosdns/root/etc/hotplug.d/iface/99-mosdns index 9f19dbdbf..48fea3623 100755 --- a/luci-app-mosdns/root/etc/hotplug.d/iface/99-mosdns +++ b/luci-app-mosdns/root/etc/hotplug.d/iface/99-mosdns @@ -1,2 +1,3 @@ #!/bin/sh -[[ "$ACTION" = ifup && "$(uci -q get mosdns.mosdns.enabled)" == 1 ]] && /etc/init.d/mosdns restart +[ "$ACTION" = "ifup" ] || exit 0 +[ "$INTERFACE" = "wan" ] && /etc/init.d/mosdns reload diff --git a/luci-app-sysctl/Makefile b/luci-app-sysctl/Makefile new file mode 100644 index 000000000..4065c46ae --- /dev/null +++ b/luci-app-sysctl/Makefile @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: Apache-2.0 +# +# Copyright (C) 2026 luci-app-sysctl contributors +# +# LuCI application: manage kernel sysctl parameters from the web UI. +# +# Build inside OpenWrt 24.10 buildroot/SDK: +# - copy this directory to package/luci-app-sysctl (needs luci feed installed), or +# - copy to feeds/luci/applications/luci-app-sysctl and re-run feeds update/install +# then: make menuconfig -> LuCI -> Applications -> luci-app-sysctl +# make package/luci-app-sysctl/compile V=s + +include $(TOPDIR)/rules.mk + +PKG_NAME:=luci-app-sysctl +PKG_VERSION:=1.5.9 +PKG_RELEASE:=1 + +PKG_LICENSE:=Apache-2.0 +PKG_MAINTAINER:=luci-app-sysctl contributors + +LUCI_TITLE:=LuCI application to manage kernel sysctl parameters +LUCI_DESCRIPTION:=View live kernel parameters and manage custom sysctl entries \ + stored in /etc/sysctl.d/99-luci-sysctl.conf from the LuCI web interface. \ + Supports browsing /proc/sys, searching parameters, one-click apply and \ + immediate runtime activation of single parameters. +LUCI_DEPENDS:=+luci-base +rpcd +rpcd-mod-ucode +LUCI_CONFFILES:=/etc/sysctl.d/99-luci-sysctl.conf +LUCI_PKGARCH:=all + +include $(TOPDIR)/feeds/luci/luci.mk + +# call BuildPackage - OpenWrt buildroot signature diff --git a/luci-app-sysctl/build-ipk.sh b/luci-app-sysctl/build-ipk.sh new file mode 100644 index 000000000..41088197a --- /dev/null +++ b/luci-app-sysctl/build-ipk.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: Apache-2.0 +# +# build-ipk.sh - pack luci-app-sysctl into an opkg-installable .ipk +# without requiring the OpenWrt SDK/buildroot. +# +# Usage: +# ./build-ipk.sh [output-directory] +# +# The produced .ipk targets OpenWrt 24.10 (opkg, "all" architecture): +# opkg install luci-app-sysctl_-_all.ipk + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PKG_DIR="$SCRIPT_DIR" +OUT_DIR="${1:-$PKG_DIR}" + +# --- read package identity from the OpenWrt Makefile ----------------------- +get_var() { + sed -n "s/^$1:=//p" "$PKG_DIR/Makefile" | head -1 +} + +PKG_NAME="$(get_var PKG_NAME)" +PKG_VERSION="$(get_var PKG_VERSION)" +PKG_RELEASE="$(get_var PKG_RELEASE)" + +for v in PKG_NAME PKG_VERSION PKG_RELEASE; do + if [ -z "$(eval "echo \$$v")" ]; then + echo "ERROR: $v not found in $PKG_DIR/Makefile" >&2 + exit 1 + fi +done + +IPK_NAME="${PKG_NAME}_${PKG_VERSION}-${PKG_RELEASE}_all.ipk" + +# --- sanity checks ---------------------------------------------------------- +for f in \ + "$PKG_DIR/htdocs/luci-static/resources/view/sysctl.js" \ + "$PKG_DIR/root/usr/share/rpcd/ucode/luci.sysctl" \ + "$PKG_DIR/root/usr/share/luci/menu.d/$PKG_NAME.json" \ + "$PKG_DIR/root/usr/share/rpcd/acl.d/$PKG_NAME.json" \ + "$PKG_DIR/root/etc/sysctl.d/99-luci-sysctl.conf" +do + if [ ! -f "$f" ]; then + echo "ERROR: required file missing: $f" >&2 + exit 1 + fi +done + +# --- staging ---------------------------------------------------------------- +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +DATA="$WORK/data" +CTRL="$WORK/control" + +mkdir -p "$DATA" "$CTRL" + +# root/ maps to / +cp -a "$PKG_DIR/root/." "$DATA/" + +# htdocs/ maps to /www +mkdir -p "$DATA/www" +cp -a "$PKG_DIR/htdocs/." "$DATA/www/" + +# uniform ownership/permissions; rpcd refuses world-writable ucode plugins +find "$DATA" -type d -exec chmod 755 {} + +find "$DATA" -type f -exec chmod 644 {} + + +# --- control metadata --------------------------------------------------------- +cat > "$CTRL/control" < "$CTRL/conffiles" <<'EOF' +/etc/sysctl.d/99-luci-sysctl.conf +EOF + +cat > "$CTRL/postinst" <<'EOF' +#!/bin/sh +# register the new ubus object and invalidate the LuCI menu cache +if [ -x /etc/init.d/rpcd ]; then + /etc/init.d/rpcd restart +fi +rm -f /tmp/luci-indexcache* 2>/dev/null +exit 0 +EOF + +chmod 755 "$CTRL/postinst" +chmod 644 "$CTRL/control" "$CTRL/conffiles" + +# --- archive ---------------------------------------------------------------- +TAR_FLAGS=(--owner=0 --group=0 --numeric-owner) + +tar "${TAR_FLAGS[@]}" -czf "$WORK/data.tar.gz" -C "$DATA" ./etc ./usr ./www +tar "${TAR_FLAGS[@]}" -czf "$WORK/control.tar.gz" -C "$CTRL" ./control ./conffiles ./postinst + +printf '2.0\n' > "$WORK/debian-binary" + +IPK_PATH="$OUT_DIR/$IPK_NAME" +mkdir -p "$OUT_DIR" +rm -f "$IPK_PATH" +tar -czf "$IPK_PATH" -C "$WORK" debian-binary control.tar.gz data.tar.gz + +echo "OK: $IPK_PATH" diff --git a/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js b/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js new file mode 100644 index 000000000..4ca9a7d3a --- /dev/null +++ b/luci-app-sysctl/htdocs/luci-static/resources/view/sysctl.js @@ -0,0 +1,1383 @@ +/* SPDX-License-Identifier: Apache-2.0 + * + * luci-app-sysctl - LuCI view for kernel sysctl parameters (OpenWrt 24.10) + * v1.4.0: modal-free architecture - all views, editors, confirmations and + * results render inline on the page (robust against theme quirks on + * third-party firmwares where ui.showModal may be invisible). + * + * Section 1: custom parameters (/etc/sysctl.d/99-luci-sysctl.conf) + * Section 2: online preset (/etc/sysctl.d/98-online-preset.conf) + * Section 3: browse/search live kernel parameters from /proc/sys + */ + +'use strict'; +'require view'; +'require rpc'; +'require ui'; +'require dom'; + +var callList = rpc.declare({ object: 'luci.sysctl', method: 'list' }); +var callStatus = rpc.declare({ object: 'luci.sysctl', method: 'status' }); +var callBrowse = rpc.declare({ object: 'luci.sysctl', method: 'browse', params: [ 'prefix' ] }); +var callSearch = rpc.declare({ object: 'luci.sysctl', method: 'search', params: [ 'query', 'limit' ] }); +var callSet = rpc.declare({ object: 'luci.sysctl', method: 'set', params: [ 'key', 'value', 'disabled', 'apply' ] }); +var callRemove = rpc.declare({ object: 'luci.sysctl', method: 'remove', params: [ 'key' ] }); +var callApply = rpc.declare({ object: 'luci.sysctl', method: 'apply' }); +var callPresetStatus = rpc.declare({ object: 'luci.sysctl', method: 'preset_status' }); +var callPresetFetch = rpc.declare({ object: 'luci.sysctl', method: 'preset_fetch', params: [ 'url', 'prefix' ] }); +var callPresetImport = rpc.declare({ object: 'luci.sysctl', method: 'preset_import', params: [ 'url', 'prefix' ] }); +var callPresetCheck = rpc.declare({ object: 'luci.sysctl', method: 'preset_check' }); +var callPresetRemove = rpc.declare({ + object: 'luci.sysctl', + method: 'preset_remove' +}); + +var callPresetList = rpc.declare({ + object: 'luci.sysctl', + method: 'preset_list', + params: [ 'url', 'prefix' ] +}); +var callFileView = rpc.declare({ object: 'luci.sysctl', method: 'file_view', params: [ 'path' ] }); +var callFileSet = rpc.declare({ object: 'luci.sysctl', method: 'file_set', params: [ 'path', 'key', 'value', 'disabled' ] }); +var callFileDelete = rpc.declare({ object: 'luci.sysctl', method: 'file_delete', params: [ 'path', 'key' ] }); + +var KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*$/; + +function badge(text, kind) { + return E('span', { 'class': 'lsc-badge lsc-badge-' + kind }, text); +} + +var LSC_CSS = [ + '.lsc-root { font-size: 15px; }', + '.lsc-root .lsc-chips { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin: 6px 0 2px; }', + '.lsc-root .lsc-chip { appearance: none; border: 1px solid #d0d7de; background: #fff; color: #24292f; border-radius: 999px; padding: 4px 12px; font-size: 12.5px; line-height: 1.5; cursor: pointer; text-transform: none; font-weight: 500; transition: background .15s, color .15s, border-color .15s, box-shadow .15s; box-shadow: 0 1px 2px rgba(16,24,40,.04); }', + '.lsc-root .lsc-chip:hover { border-color: #0969da; color: #0969da; background: #f3f8ff; }', + '.lsc-root .lsc-chip.lsc-chip-active { background: #0969da; border-color: #0969da; color: #fff; }', + '.lsc-root .lsc-tablewrap { border: 1px solid #e4e7ec; border-radius: 8px; overflow: auto; background: #fff; margin: 8px 0; }', + '.lsc-root table.lsc-table { width: 100%; border-collapse: collapse; font-size: 15px; margin: 0; }', + '.lsc-root .lsc-table thead th { background: #f6f8fa; text-align: center !important; font-weight: 600; color: #57606a; padding: 10px 12px; border-bottom: 1px solid #e4e7ec; white-space: nowrap; font-size: 15px; }', + '.lsc-root .lsc-table tbody td { padding: 10px 12px; border-bottom: 1px solid #f0f2f4; vertical-align: middle; text-align: center !important; }', + '.lsc-root .lsc-table tbody td .cbi-button { margin: 2px 3px; }', + '.lsc-root .lsc-table tbody tr:last-child td { border-bottom: none; }', + '.lsc-root .lsc-table tbody tr:nth-child(even) td { background: #fafbfc; }', + '.lsc-root .lsc-table tbody tr:hover td { background: #f0f6ff; }', + '.lsc-root .lsc-badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 13px; font-weight: 500; line-height: 1.6; white-space: nowrap; }', + '.lsc-root .lsc-badge-ok { background: #e6f6ec; color: #18794e; }', + '.lsc-root .lsc-badge-warn { background: #fff3e0; color: #a35a00; }', + '.lsc-root .lsc-badge-err { background: #fdecec; color: #b3261e; }', + '.lsc-root .lsc-badge-off { background: #eef0f2; color: #6e7781; }', + '.lsc-root .lsc-badge-main { background: #eef4fb; color: #34618e; }', + '.lsc-root .lsc-card { border: 1px solid #e4e7ec; border-radius: 10px; padding: 14px 16px; background: #fff; box-shadow: 0 1px 3px rgba(16,24,40,.06); margin: 10px 0; }', + '.lsc-root .lsc-card h3 { margin: 0 0 8px; font-size: 15px; font-weight: 600; }', + '.lsc-root .lsc-card h4 { margin: 10px 0 4px; font-size: 13.5px; color: #57606a; }', + '.lsc-root code { background: #f6f8fa; border: 1px solid #eef0f2; border-radius: 4px; padding: 1px 6px; font-size: 1em; word-break: break-all; }', + '.lsc-root .cbi-button { border-radius: 6px; transition: filter .15s, box-shadow .15s, background .15s; text-transform: none; }', + '.lsc-root .cbi-button:hover { filter: brightness(1.06); }', + '.lsc-root h2, .lsc-root h3, .lsc-root h4, .lsc-root th, .lsc-root .lsc-badge, .lsc-root code, .lsc-root .lsc-muted, .lsc-root .cbi-section-descr, .lsc-root .lsc-card * { text-transform: none !important; }', + '.lsc-root .lsc-muted { color: #777; }', + '.lsc-root .cbi-page-actions { float: none !important; text-align: center !important; }' +].join('\n'); + +return view.extend({ + ensureStyle: function() { + if (document.getElementById('luci-app-sysctl-style') != null) + return; + + document.head.appendChild(E('style', { 'id': 'luci-app-sysctl-style' }, [ LSC_CSS ])); + }, + + load: function() { + return Promise.all([ callList(), callStatus(), callPresetStatus() ]); + }, + + render: function(data) { + this.ensureStyle(); + + var list = (data[0] != null) ? data[0] : {}; + var status = (data[1] != null) ? data[1] : {}; + var preset = (data[2] != null) ? data[2] : {}; + + this.customEntries = list.custom || []; + this.mainEntries = list.main || []; + this.statusData = status; + this.presetData = preset; + this.browsePrefix = ''; + this.searchTimer = null; + this.editing = null; + this.fileViewPath = null; + this.fileEditing = null; + this._armTimers = {}; + + var root = E('div', { 'class': 'cbi-map lsc-root' }, [ + E('h2', {}, _('内核参数(Sysctl)管理')), + E('div', { 'class': 'cbi-map-descr' }, _( + '自定义参数保存在 %s,开机时由系统自动加载,也可随时点击“应用配置”立即生效。').format('/etc/sysctl.d/99-luci-sysctl.conf')), + this.renderCustomSection(), + this.renderPresetSection(), + this.renderBrowseSection() + ]); + + this.refreshCustomTable(); + this.refreshSourceChips(); + this.loadBrowse(''); + + return root; + }, + + /* ---------- shared helpers ---------- */ + + errorBox: function(msg, hint) { + var children = [ E('p', { 'style': 'margin:2px 0' }, msg) ]; + + if (hint) + children.push(E('p', { 'style': 'margin:2px 0;color:#666' }, hint)); + + return E('div', { 'class': 'alert-message error', 'style': 'margin:6px 0' }, children); + }, + + backendErrorHint: function(msg) { + if (/not found/i.test(msg || '')) + return _('后端缺少该方法,通常是 rpcd 尚未重新加载插件所致。请在 SSH 执行:%s 后刷新页面。').format('/etc/init.d/rpcd restart'); + + return _('可在 SSH 用 %s 验证后端。').format('ubus call luci.sysctl status'); + }, + + /* Two-step inline confirmation: first click arms the button, + * second click (within 4s) runs the action. */ + armButton: function(btn, confirmText, action) { + if (btn.getAttribute('data-armed') == '1') { + btn.setAttribute('data-armed', '0'); + btn.textContent = btn.getAttribute('data-orig'); + action(); + return; + } + + btn.setAttribute('data-armed', '1'); + btn.setAttribute('data-orig', btn.textContent); + btn.textContent = confirmText; + + this._armTimers[btn.getAttribute('data-arm-id')] = window.setTimeout(function() { + if (btn.getAttribute('data-armed') == '1') { + btn.setAttribute('data-armed', '0'); + btn.textContent = btn.getAttribute('data-orig'); + } + }, 4000); + }, + + nextArmId: function() { + this._armSeq = (this._armSeq || 0) + 1; + return String(this._armSeq); + }, + + /* ---------- section 1: custom parameters ---------- */ + + renderCustomSection: function() { + this.customTableBody = E('tbody', {}); + this.tableWrapBox = E('div', { 'class': 'lsc-tablewrap' }); + this.customEmptyBox = E('div', { + 'style': 'display:none;text-align:center;color:#777;padding:10px 0' + }, _('暂无自定义参数,点击下方“添加参数”开始。')); + this.editFormBox = E('div', { 'style': 'display:none' }); + this.fileViewBox = E('div', { 'style': 'display:none' }); + this.applyResultBox = E('div', { 'style': 'display:none' }); + this.sourceChipsBox = E('div', { 'class': 'lsc-chips' }); + + var descr = [ + E('p', { 'style': 'margin:4px 0' }, [ + _('配置源(点击文件名可在下方查看/编辑,按顺序应用,后面的文件覆盖前面的同名参数):'), + this.sourceChipsBox + ]) + ]; + + var table = E('table', { 'class': 'lsc-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, _('参数名')), + E('th', {}, _('配置值')), + E('th', {}, _('当前值')), + E('th', {}, _('状态')), + E('th', {}, _('操作')) + ])), + this.customTableBody + ]); + + this.tableWrapBox.appendChild(table); + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('自定义参数')), + E('div', { 'class': 'cbi-section-descr' }, descr), + this.editFormBox, + this.tableWrapBox, + this.customEmptyBox, + this.fileViewBox, + this.applyResultBox, + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn(this, 'showEditForm', null) + }, _('添加参数')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-apply', + 'click': ui.createHandlerFn(this, 'applyConfig') + }, _('应用配置')), + ' ', + E('button', { + 'class': 'cbi-button cbi-button-negative', + 'click': ui.createHandlerFn(this, 'reloadList') + }, _('刷新')) + ]) + ]); + }, + + refreshCustomTable: function() { + var rows = []; + var i, e; + + /* Main-config entries (/etc/sysctl.conf) are intentionally NOT listed + * here anymore: they are reachable through the source chips above, + * showing them twice was redundant. */ + + if (this.customEntries.length == 0) { + /* hide the whole (empty) table incl. header, show a one-liner */ + this.tableWrapBox.style.display = 'none'; + this.customEmptyBox.style.display = ''; + dom.content(this.customTableBody, rows); + return; + } + + this.tableWrapBox.style.display = ''; + this.customEmptyBox.style.display = 'none'; + + for (i = 0; i < this.customEntries.length; i++) { + e = this.customEntries[i]; + + var badgeEl; + + if (e.disabled) + badgeEl = badge(_('已禁用'), 'off'); + else if (!e.exists) + badgeEl = badge(_('无效参数'), 'err'); + else if (e.match) + badgeEl = badge(_('已生效'), 'ok'); + else + badgeEl = badge(_('未应用'), 'warn'); + + var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除')); + + rows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, e.key) ]), + E('td', {}, [ E('code', {}, e.value) ]), + E('td', {}, [ E('code', {}, (e.current == null) ? '—' : e.current) ]), + E('td', {}, badgeEl), + E('td', { 'style': 'white-space:nowrap' }, [ + E('button', { + 'class': 'cbi-button cbi-button-edit', + 'click': ui.createHandlerFn(this, 'showEditForm', { isNew: false, entry: e }) + }, _('编辑')), + ' ', + e.disabled + ? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'toggleEntry', e, false) }, _('启用')) + : E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(this, 'toggleEntry', e, true) }, _('禁用')), + ' ', + delBtn + ]) + ])); + + (function(view, entry, btn) { + btn.addEventListener('click', function() { + view.armButton(btn, _('确认删除?'), function() { view.removeEntry(entry); }); + }); + })(this, e, delBtn); + } + + dom.content(this.customTableBody, rows); + }, + + showEditForm: function(entry) { + this.editing = (entry != null) ? entry : null; + this.editFromBrowse = (entry != null && entry.fromBrowse === true); + this.renderEditForm(); + + if (this.editing != null && this.editFormBox.scrollIntoView) + this.editFormBox.scrollIntoView({ block: 'nearest' }); + }, + + renderEditForm: function() { + var self = this; + var st = this.editing; + + if (st == null) { + this.editFormBox.style.display = 'none'; + dom.content(this.editFormBox, []); + return; + } + + this.editFormBox.style.display = ''; + + var isNew = (st.isNew === true); + var key = (st.entry != null) ? st.entry.key : (st.key || ''); + + var keyInput = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%', + 'placeholder': 'net.ipv4.tcp_fastopen', 'value': key + }); + + var valInput = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%', + 'placeholder': '3', 'value': (st.entry != null) ? st.entry.value : (st.value || '') + }); + + var applyChk = E('input', { 'type': 'checkbox' }); + applyChk.checked = true; + + var disabledChk = E('input', { 'type': 'checkbox' }); + disabledChk.checked = (!isNew && st.entry.disabled == true); + + var errBox = this.errorBox('', ''); + errBox.style.display = 'none'; + + var title = isNew + ? (st.fromMain ? _('自定义覆盖主配置:%s').format(key) : _('添加内核参数')) + : _('编辑内核参数:%s').format(key); + + var note = isNew + ? (st.fromMain + ? _('将把该参数写入自定义配置以覆盖主配置取值,主配置文件本身保持不变。') + : _('若参数在当前内核中不存在(例如模块未加载),仍可保存,但会标记为无效。')) + : _('修改参数名保存后将替换原条目(先写入新参数名,再删除旧参数名)。'); + + var saveBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('保存')); + + saveBtn.addEventListener('click', function() { self.saveEditForm(keyInput, valInput, applyChk, disabledChk, errBox); }); + + dom.content(this.editFormBox, [ + E('div', { 'class': 'lsc-card' }, [ + E('strong', {}, title), + E('div', { 'class': 'cbi-value', 'style': 'margin-top:8px' }, [ + E('label', { 'class': 'cbi-value-title' }, _('参数名')), + E('div', { 'class': 'cbi-value-field' }, keyInput) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('值')), + E('div', { 'class': 'cbi-value-field' }, valInput) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, ' '), + E('div', { 'class': 'cbi-value-field' }, [ + E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px' }, [ applyChk, _('立即写入内核(运行时生效)') ]), + E('label', { 'style': 'display:inline-flex;align-items:center;gap:6px;margin-left:16px' }, [ disabledChk, _('禁用该条目(写入配置但注释掉)') ]) + ]) + ]), + E('div', { 'style': 'color:#666;margin-top:4px' }, note), + errBox, + E('div', { 'style': 'margin-top:8px' }, [ + saveBtn, ' ', + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideEditForm') }, _('取消')) + ]) + ]) + ]); + }, + + hideEditForm: function() { + this.editing = null; + this.renderEditForm(); + + /* edit was started from the browse section: scroll back there so the + * user does not get lost at the top of the page */ + if (this.editFromBrowse) { + this.editFromBrowse = false; + + if (this.browseSectionBox != null && this.browseSectionBox.scrollIntoView) + this.browseSectionBox.scrollIntoView({ block: 'start' }); + } + }, + + saveEditForm: function(keyInput, valInput, applyChk, disabledChk, errBox) { + var self = this; + var st = this.editing; + + if (st == null) + return; + + var isNew = (st.isNew === true); + var origKey = (st.entry != null) ? st.entry.key : null; + var key = (keyInput.value || '').trim(); + var val = (valInput.value || '').trim(); + var disabled = disabledChk.checked; + var applyNow = applyChk.checked && !disabled; + + errBox.style.display = 'none'; + + if (!KEY_RE.test(key)) { + errBox.textContent = ''; + errBox.appendChild(E('p', {}, _('参数名格式无效,仅允许字母、数字、点、下划线和连字符。'))); + errBox.style.display = ''; + return; + } + + if (val == '') { + errBox.textContent = ''; + errBox.appendChild(E('p', {}, _('值不能为空。'))); + errBox.style.display = ''; + return; + } + + var p = callSet(key, val, disabled, applyNow); + + if (!isNew && key != origKey) { + p = p.then(function(res) { + if (res == null || res.code != 0) + return res; + + return callRemove(origKey).then(function() { + return res; + }).catch(function() { + res.rename_leftover = origKey; + return res; + }); + }); + } + + p.then(function(res) { + if (res == null || res.code != 0) { + errBox.textContent = ''; + errBox.appendChild(self.errorBox(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误')))); + errBox.style.display = ''; + return; + } + + var notes = []; + + if (res.rename_leftover) + notes.push(E('p', { 'style': 'color:#a80' }, _('旧参数 %s 删除失败,请在本页手动删除该条目。').format(res.rename_leftover))); + + if (!res.exists) + notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 在当前内核中不存在(可能模块未加载),配置已保存但不会生效。').format(key))); + else if (applyNow && res.applied === 'readonly') + notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 为只读,无法在运行时修改(配置已保存,重启后尝试应用)。').format(key))); + else if (applyNow && res.applied === false) + notes.push(E('p', { 'style': 'color:#a80' }, _('参数 %s 已写入配置,但运行时校验不一致,请检查取值格式。').format(key))); + + if (notes.length == 0) + notes.push(E('p', {}, (st.fromMain && isNew) ? _('已创建覆盖条目:%s。') : _('参数 %s 已保存。').format(key))); + + self.hideEditForm(); + self.reloadList(); + + dom.content(self.applyResultBox, [ E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, notes) ]); + self.applyResultBox.style.display = ''; + }).catch(function(e) { + errBox.textContent = ''; + errBox.appendChild(self.errorBox(_('保存失败:%s').format(e.message), self.backendErrorHint(e.message))); + errBox.style.display = ''; + }); + }, + + reloadList: function() { + var self = this; + + return Promise.all([ callList(), callStatus(), callPresetStatus() ]).then(function(data) { + var list = (data[0] != null) ? data[0] : {}; + + self.customEntries = list.custom || []; + self.mainEntries = list.main || []; + self.statusData = (data[1] != null) ? data[1] : {}; + self.presetData = (data[2] != null) ? data[2] : {}; + self.refreshCustomTable(); + self.refreshPresetStatus(); + self.refreshSourceChips(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('加载失败:%s').format(e.message)), 'error'); + }); + }, + + refreshSourceChips: function() { + var self = this; + var files = (this.statusData != null && this.statusData.files != null) ? this.statusData.files : []; + + if (this.sourceChipsBox == null) + return; + + var chips = []; + + /* Plugin-managed files (98-online-preset.conf, 99-luci-sysctl.conf) + * are managed through their dedicated sections; opening them via the + * file panel is refused by the backend, so hide their chips. */ + var pluginManaged = { + '/etc/sysctl.d/99-luci-sysctl.conf': true, + '/etc/sysctl.d/98-online-preset.conf': true + }; + + for (var i = 0; i < files.length; i++) { + var f = files[i]; + + if (pluginManaged[f.path]) + continue; + + chips.push(E('button', { + 'class': 'lsc-chip' + (this.fileViewPath == f.path ? ' lsc-chip-active' : ''), + 'title': _('点击查看并可编辑该文件内的参数'), + 'click': ui.createHandlerFn(self, 'showFileView', f.path) + }, '%s (%d)'.format(f.path.replace('/etc/', ''), f.count))); + } + + dom.content(this.sourceChipsBox, chips); + }, + + toggleEntry: function(entry, disabled) { + var self = this; + + return callSet(entry.key, entry.value, disabled, false).then(function(res) { + if (res == null || res.code != 0) { + ui.addNotification(null, + E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : 'unknown error')), + 'error'); + } + + self.reloadList(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error'); + }); + }, + + removeEntry: function(entry) { + var self = this; + + return callRemove(entry.key).then(function(res) { + if (res == null || res.code != 0) { + ui.addNotification(null, + E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')), + 'error'); + } + + self.reloadList(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error'); + }); + }, + + applyConfig: function() { + var self = this; + + dom.content(this.applyResultBox, [ E('p', { 'style': 'color:#777;padding:6px' }, _('正在应用全部配置,请稍候…')) ]); + this.applyResultBox.style.display = ''; + + return callApply().then(function(res) { + var errors = (res != null && res.errors != null) ? res.errors : []; + var content; + + if (res == null || res.code != 0) { + content = self.errorBox(_('应用失败:%s').format((res != null && res.error) ? res.error : 'unknown error')); + } + else if (errors.length == 0) { + content = E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [ + E('p', {}, _('全部配置已成功应用。')) + ]); + } + else { + var items = []; + + for (var i = 0; i < errors.length; i++) + items.push(E('li', {}, [ + E('strong', {}, errors[i].file), + E('pre', { 'style': 'white-space:pre-wrap;margin:4px 0' }, errors[i].output || '') + ])); + + content = E('div', { 'class': 'alert-message warning', 'style': 'margin:6px 0' }, [ + E('p', {}, _('应用完成,以下文件存在报错(常见原因:参数不存在、只读或权限不足):')), + E('ul', {}, items) + ]); + } + + dom.content(self.applyResultBox, [ content ]); + self.reloadList(); + }).catch(function(e) { + dom.content(self.applyResultBox, [ self.errorBox(_('应用失败:%s').format(e.message), self.backendErrorHint(e.message)) ]); + }); + }, + + /* ---------- per-file viewing / editing (inline) ---------- */ + + showFileView: function(path) { + this.fileViewPath = (this.fileViewPath == path) ? null : path; + this.fileEditing = null; + this.refreshSourceChips(); + this.renderFilePanel(); + }, + + renderFilePanel: function() { + var self = this; + var path = this.fileViewPath; + var box = this.fileViewBox; + + if (path == null) { + box.style.display = 'none'; + dom.content(box, []); + return; + } + + box.style.display = ''; + dom.content(box, [ + E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)), + E('p', { 'style': 'color:#777;text-align:center' }, _('加载中…')) + ]); + + callFileView(path).then(function(res) { + if (self.fileViewPath != path) + return; + + if (res == null || res.code != 0) { + dom.content(box, [ + E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)), + self.errorBox((res != null && res.error) ? res.error : _('未知错误'), self.backendErrorHint(res != null ? res.error : '')) + ]); + return; + } + + var rows = []; + + for (var i = 0; i < (res.entries || []).length; i++) { + var e = res.entries[i]; + var editing = (self.fileEditing != null && self.fileEditing.key == e.key); + + if (editing) { + rows.push(self.renderFileEditRow(res.path, e)); + continue; + } + + var ops; + + if (res.editable) { + var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('删除')); + + ops = [ + E('button', { 'class': 'cbi-button cbi-button-edit', 'click': ui.createHandlerFn(self, 'editFileEntry', path, e) }, _('编辑')), + ' ', + e.disabled + ? E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, false) }, _('启用')) + : E('button', { 'class': 'cbi-button cbi-button-remove', 'click': ui.createHandlerFn(self, 'fileToggle', path, e, true) }, _('禁用')), + ' ', + delBtn + ]; + + (function(view, p, entry, btn) { + btn.addEventListener('click', function() { + view.armButton(btn, _('确认删除?'), function() { view.fileDelete(p, entry); }); + }); + })(self, res.path, e, delBtn); + } + else { + ops = [ E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: e.key, value: e.value, fromMain: true }) + }, _('自定义')) ]; + } + + rows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, (e.disabled ? '# ' : '') + e.key) ]), + E('td', {}, [ E('code', {}, e.value) ]), + E('td', {}, [ E('code', {}, (e.disabled || e.current == null) ? '—' : e.current) ]), + E('td', { 'style': 'white-space:nowrap' }, ops) + ])); + } + + if (rows.length == 0) + rows.push(E('tr', {}, E('td', { 'colspan': 4, 'style': 'text-align:center;color:#777;padding:12px' }, + _('(空文件)')))); + + var note = res.editable + ? _('此文件中的参数可直接编辑/禁用/删除,保存即写入 %s;点击"应用配置"后对内核生效。').format(res.path) + : _('此文件由插件管理,不支持直接编辑:在线预设请在下方"在线预设"区更新,自定义参数请在上方表格中管理。'); + + dom.content(box, [ + E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(res.path)), + E('p', { 'style': 'margin:4px 0;color:#666;text-align:center' }, note), + E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, _('参数')), + E('th', {}, _('配置值')), + E('th', {}, _('当前值')), + E('th', {}, _('操作')) + ])), + E('tbody', rows) + ])), + E('div', { 'style': 'margin-top:8px;text-align:center' }, [ + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'hideFileView') }, _('收起')) + ]) + ]); + }).catch(function(e) { + if (self.fileViewPath != path) + return; + + dom.content(box, [ + E('h3', { 'style': 'text-align:center' }, _('文件内容:%s').format(path)), + self.errorBox(_('读取失败:%s').format(e.message), self.backendErrorHint(e.message)) + ]); + }); + }, + + renderFileEditRow: function(path, entry) { + var self = this; + + var valInput = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:95%', 'value': entry.value + }); + + var disabledChk = E('input', { 'type': 'checkbox' }); + disabledChk.checked = (entry.disabled == true); + + var saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive important' }, _('保存')); + + saveBtn.addEventListener('click', function() { + var val = (valInput.value || '').trim(); + + if (val == '') { + alert(_('值不能为空。')); + return; + } + + callFileSet(path, entry.key, val, disabledChk.checked).then(function(res) { + if (res == null || res.code != 0) { + alert(_('保存失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))); + return; + } + + self.fileEditing = null; + self.renderFilePanel(); + self.reloadList(); + }).catch(function(e) { + alert(_('保存失败:%s').format(e.message)); + }); + }); + + return E('tr', { 'style': 'background:#fafafa' }, [ + E('td', {}, [ E('code', {}, entry.key) ]), + E('td', { 'colspan': 2 }, [ + valInput, ' ', + E('label', { 'style': 'display:inline-flex;align-items:center;gap:4px' }, [ disabledChk, _('禁用') ]) + ]), + E('td', { 'style': 'white-space:nowrap' }, [ + saveBtn, ' ', + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(self, 'cancelFileEdit') }, _('取消')) + ]) + ]); + }, + + editFileEntry: function(path, entry) { + this.fileEditing = entry; + this.renderFilePanel(); + }, + + cancelFileEdit: function() { + this.fileEditing = null; + this.renderFilePanel(); + }, + + fileToggle: function(path, entry, disabled) { + var self = this; + + return callFileSet(path, entry.key, entry.value, disabled).then(function(res) { + if (res == null || res.code != 0) { + ui.addNotification(null, + E('p', {}, _('操作失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))), + 'error'); + } + + self.renderFilePanel(); + self.reloadList(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('操作失败:%s').format(e.message)), 'error'); + }); + }, + + fileDelete: function(path, entry) { + var self = this; + + return callFileDelete(path, entry.key).then(function(res) { + if (res == null || res.code != 0) { + ui.addNotification(null, + E('p', {}, _('删除失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))), + 'error'); + } + + self.renderFilePanel(); + self.reloadList(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('删除失败:%s').format(e.message)), 'error'); + }); + }, + + hideFileView: function() { + this.fileViewPath = null; + this.fileEditing = null; + this.refreshSourceChips(); + this.renderFilePanel(); + }, + + /* ---------- section 2: online preset ---------- */ + + renderPresetSection: function() { + var self = this; + + this.presetStatusLine = E('div', { 'style': 'margin:6px 0;padding:6px 10px;background:#f5f5f5;border-radius:3px' }); + this.presetResultBox = E('div', { 'style': 'display:none' }); + + this.presetUrlInput = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:100%', + 'placeholder': 'https://github.com/user/repo/blob/main/sysctl.conf(也支持 raw 地址)' + }); + + this.presetPrefixInput = E('input', { + 'type': 'text', 'class': 'cbi-input-text', 'style': 'width:260px', + 'placeholder': 'https://gh-proxy.org/' + }); + + var delBtn = E('button', { 'class': 'cbi-button cbi-button-remove' }, _('移除预设')); + + delBtn.addEventListener('click', function() { + self.armButton(delBtn, _('确认移除?'), function() { self.presetRemove(); }); + }); + + return E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('在线预设')), + E('div', { 'class': 'cbi-section-descr' }, [ + E('p', { 'style': 'margin:4px 0' }, _( + '从在线源(GitHub 等)获取现成的 sysctl 优化配置,预览确认后一键导入。')), + E('p', { 'style': 'margin:4px 0' }, _( + '预设保存于 %s,由本插件整体管理(更新时全量替换)。如需覆盖个别条目,请在上方"自定义参数"中添加,其加载顺序更靠后、优先级更高。').format('/etc/sysctl.d/98-online-preset.conf')) + ]), + this.presetStatusLine, + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('配置源 URL')), + E('div', { 'class': 'cbi-value-field' }, this.presetUrlInput) + ]), + E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title' }, _('镜像前缀(可选)')), + E('div', { 'class': 'cbi-value-field' }, [ + this.presetPrefixInput, + E('div', { 'style': 'color:#777;margin-top:2px' }, + _('直连 GitHub 不可用时填写镜像前缀,最终地址为 前缀 + 原始 URL。')) + ]) + ]), + E('div', { 'class': 'cbi-page-actions' }, [ + E('button', { 'class': 'cbi-button cbi-button-apply', 'click': ui.createHandlerFn(this, 'presetFetchPreview') }, _('获取并预览')), + ' ', + E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'presetCheckUpdates') }, _('检查更新')), + ' ', + delBtn + ]), + this.presetResultBox + ]); + }, + + refreshPresetStatus: function() { + var p = this.presetData || {}; + var line; + + if (p.present) { + line = [ E('strong', {}, _('已导入') + ':'), _('%d 条参数').format(p.count || 0) ]; + + if (p.source) { + line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('来源') + ' ')); + line.push(E('span', { 'style': 'word-break:break-all' }, p.source)); + } + + if (p.fetched) { + line.push(E('span', { 'style': 'color:#777' }, ' · ' + _('更新于') + ' ')); + line.push(this.formatEpoch(p.fetched)); + } + } + else { + line = [ E('span', { 'style': 'color:#777' }, _('尚未导入在线预设。')) ]; + } + + dom.content(this.presetStatusLine, line); + }, + + formatEpoch: function(s) { + var n = Number(s); + + if (s == null || s === '' || isNaN(n) || n <= 0) + return '—'; + + try { + return new Date(n * 1000).toLocaleString(); + } catch (e) { + return String(s); + } + }, + + presetFetchPreview: function() { + var self = this; + var url = (this.presetUrlInput.value || '').trim(); + + if (url == '') { + this.showPresetResult(this.errorBox(_('请先填写配置源 URL。'))); + return; + } + + this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在获取配置源信息,请稍候…'))); + + return callPresetList(url, (this.presetPrefixInput.value || '').trim()).then(function(lres) { + if (lres != null && lres.is_dir) { + self.renderPresetDirList(lres); + return; + } + + return self.fetchAndPreviewPresetFile(url); + }).catch(function(e) { + self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message))); + }); + }, + + showPresetResult: function(content) { + this.presetResultBox.style.display = ''; + dom.content(this.presetResultBox, [ content ]); + }, + + fetchAndPreviewPresetFile: function(url) { + var self = this; + var prefix = (this.presetPrefixInput.value || '').trim(); + + this.showPresetResult(E('p', { 'style': 'color:#777;padding:6px' }, _('正在下载并解析预设,请稍候…'))); + + return callPresetFetch(url, prefix).then(function(res) { + if (res == null || res.code != 0) { + self.showPresetResult(self.errorBox( + (res != null && res.error) ? res.error : _('未知错误'), + self.backendErrorHint(res != null ? res.error : ''))); + return; + } + + self.renderPresetPreview(res); + }).catch(function(e) { + self.showPresetResult(self.errorBox(_('获取失败:%s').format(e.message), self.backendErrorHint(e.message))); + }); + }, + + renderPresetDirList: function(res) { + var self = this; + var rows = []; + + for (var i = 0; i < (res.files || []).length; i++) { + (function(f) { + var btn = E('button', { + 'class': 'lsc-chip', + 'style': 'font-size:13px;padding:5px 14px' + }, f.name + (f.size > 0 ? ' · ' + (f.size / 1024).toFixed(1) + ' KB' : '')); + + btn.addEventListener('click', function() { self.fetchAndPreviewPresetFile(f.url); }); + rows.push(E('li', { 'style': 'margin:2px 0' }, btn)); + })(res.files[i]); + } + + this.presetResultBox.style.display = ''; + dom.content(this.presetResultBox, [ + E('h3', {}, _('预设目录')), + E('p', { 'style': 'margin:4px 0;color:#666' }, + _('该地址是一个目录,发现 %d 个预设文件。点击要导入的文件(下载时同样使用镜像前缀):').format(res.files.length)), + E('ul', { 'style': 'margin:8px 0;padding-left:4px;list-style:none' }, rows), + E('div', { 'style': 'margin-top:10px' }, [ + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('收起')) + ]) + ]); + }, + + renderPresetPreview: function(res) { + var self = this; + var rows = []; + var notes = []; + var i; + + var cmap = {}; + + for (i = 0; i < (res.conflicts || []).length; i++) + cmap[res.conflicts[i].key] = res.conflicts[i].file; + + for (i = 0; i < (res.params || []).length; i++) { + var it = res.params[i]; + var conflictFile = cmap[it.key] || ''; + + rows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, it.key) ]), + E('td', {}, [ E('code', {}, it.value) ]), + E('td', { 'style': 'color:#a80' }, conflictFile ? _('覆盖本地 %s').format(conflictFile.replace('/etc/', '')) : '') + ])); + } + + notes.push(E('p', {}, _('共解析出 %d 条参数,来源:%s。').format(res.params.length, res.url))); + + if ((res.conflicts || []).length > 0) + notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' }, + _('%d 条参数与本地现有配置同名,导入后将覆盖其取值。').format(res.conflicts.length))); + + if ((res.invalid || []).length > 0) + notes.push(E('p', { 'class': 'alert-message warning', 'style': 'margin:6px 0' }, + _('%d 行无法解析已忽略(常见为说明文字或格式错误的行)。').format(res.invalid.length))); + + var importBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('导入到路由器')); + + importBtn.addEventListener('click', function() { self.presetImport(res.url); }); + + dom.content(this.presetResultBox, [ + E('h3', {}, _('预设预览')), + E('div', {}, notes), + E('div', { 'style': 'max-height:320px;overflow:auto' }, E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [ + E('thead', {}, E('tr', {}, [ E('th', {}, _('参数')), E('th', {}, _('值')), E('th', {}, _('冲突')) ])), + E('tbody', rows) + ]))), + E('div', { 'style': 'margin-top:10px' }, [ + importBtn, ' ', + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消')) + ]) + ]); + }, + + presetImport: function(url) { + var self = this; + + return callPresetImport(url, '').then(function(res) { + if (res == null || res.code != 0) { + dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format((res != null && res.error) ? res.error : _('未知错误'))) ]); + return; + } + + dom.content(self.presetResultBox, [ + E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [ + E('p', {}, _('已导入 %d 条预设参数到 %s。点击"应用配置"可立即生效。').format(res.count, res.path)) + ]) + ]); + + self.reloadList(); + }).catch(function(e) { + dom.content(self.presetResultBox, [ self.errorBox(_('导入失败:%s').format(e.message), self.backendErrorHint(e.message)) ]); + }); + }, + + presetCheckUpdates: function() { + var self = this; + + dom.content(this.presetResultBox, [ E('p', { 'style': 'color:#777;padding:6px' }, _('正在对比在线版本与本地预设,请稍候…')) ]); + this.presetResultBox.style.display = ''; + + return callPresetCheck().then(function(res) { + if (res == null || res.code != 0) { + dom.content(self.presetResultBox, [ self.errorBox( + (res != null && res.error) ? res.error : _('未知错误'), + self.backendErrorHint(res != null ? res.error : '')) ]); + return; + } + + if ((res.added || []).length == 0 && (res.changed || []).length == 0 && (res.removed || []).length == 0) { + dom.content(self.presetResultBox, [ + E('div', { 'class': 'alert-message', 'style': 'margin:6px 0' }, [ + E('p', {}, _('在线预设已是最新,共 %d 条参数。').format(res.remote_count)) + ]) + ]); + return; + } + + self.renderPresetDiff(res); + }).catch(function(e) { + dom.content(self.presetResultBox, [ self.errorBox(_('检查更新失败:%s').format(e.message), self.backendErrorHint(e.message)) ]); + }); + }, + + renderPresetDiff: function(res) { + var self = this; + var blocks = []; + var i; + + var mkTable = function(headers, rows) { + return E('div', { 'class': 'lsc-tablewrap' }, E('table', { 'class': 'lsc-table' }, [ + E('thead', {}, E('tr', {}, headers.map(function(h) { return E('th', {}, h); }))), + E('tbody', rows) + ])); + }; + + if ((res.added || []).length > 0) { + var addRows = []; + + for (i = 0; i < res.added.length; i++) + addRows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, res.added[i].key) ]), + E('td', {}, [ E('code', {}, res.added[i].value) ]) + ])); + + blocks.push(E('div', {}, [ + E('h4', {}, _('新增 %d 条').format(res.added.length)), + mkTable([ _('参数'), _('值') ], addRows) + ])); + } + + if ((res.changed || []).length > 0) { + var chgRows = []; + + for (i = 0; i < res.changed.length; i++) + chgRows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, res.changed[i].key) ]), + E('td', {}, [ E('code', {}, res.changed[i].from) ]), + E('td', {}, [ E('code', {}, res.changed[i].to) ]) + ])); + + blocks.push(E('div', {}, [ + E('h4', {}, _('变更 %d 条').format(res.changed.length)), + mkTable([ _('参数'), _('当前值'), _('新值') ], chgRows) + ])); + } + + if ((res.removed || []).length > 0) { + var rmRows = []; + + for (i = 0; i < res.removed.length; i++) + rmRows.push(E('tr', {}, [ + E('td', {}, [ E('code', {}, res.removed[i].key) ]), + E('td', {}, [ E('code', {}, res.removed[i].old) ]) + ])); + + blocks.push(E('div', {}, [ + E('h4', {}, _('移除 %d 条').format(res.removed.length)), + mkTable([ _('参数'), _('当前值') ], rmRows) + ])); + } + + var applyBtn = E('button', { 'class': 'btn cbi-button-positive important' }, _('应用更新')); + + applyBtn.addEventListener('click', function() { self.presetImport(res.url); }); + + dom.content(this.presetResultBox, [ + E('h3', {}, _('发现在线预设更新')), + E('p', {}, _('在线版本共 %d 条,本地 %d 条。更新会整体替换 %s。').format(res.remote_count, res.local_count, '/etc/sysctl.d/98-online-preset.conf')), + E('div', { 'style': 'max-height:380px;overflow:auto' }, blocks), + E('div', { 'style': 'margin-top:10px' }, [ + applyBtn, ' ', + E('button', { 'class': 'btn', 'click': ui.createHandlerFn(this, 'hidePresetResult') }, _('取消')) + ]) + ]); + }, + + hidePresetResult: function() { + this.presetResultBox.style.display = 'none'; + dom.content(this.presetResultBox, []); + }, + + presetRemove: function() { + var self = this; + + return callPresetRemove().then(function(res) { + if (res == null || res.code != 0) { + ui.addNotification(null, + E('p', {}, _('移除失败:%s').format((res != null && res.error) ? res.error : 'unknown error')), + 'error'); + } + + self.reloadList(); + }).catch(function(e) { + ui.addNotification(null, E('p', {}, _('移除失败:%s').format(e.message)), 'error'); + }); + }, + + /* ---------- section 3: browse / search ---------- */ + + renderBrowseSection: function() { + var self = this; + + this.browseTableBody = E('tbody', {}); + this.crumbs = E('div', { 'style': 'margin:6px 0' }); + this.searchInput = E('input', { + 'type': 'text', + 'class': 'cbi-input-text', + 'style': 'width:280px;display:inline-block', + 'placeholder': _('搜索参数名或值(至少 2 个字符)') + }); + + this.searchInput.addEventListener('input', function() { self.onSearchInput(); }); + + var searchBtn = E('button', { 'class': 'cbi-button cbi-button-find', 'click': ui.createHandlerFn(this, 'doSearch') }, _('搜索')); + + var table = E('table', { 'class': 'lsc-table' }, [ + E('thead', {}, E('tr', {}, [ + E('th', {}, _('参数')), + E('th', {}, _('当前值')), + E('th', {}, _('操作')) + ])), + this.browseTableBody + ]); + + var box = E('div', { 'class': 'cbi-section' }, [ + E('h3', {}, _('浏览内核参数')), + E('div', { 'class': 'cbi-section-descr' }, + _('浏览 /proc/sys 下的实时参数。点击目录进入子级,也可直接搜索;点击“自定义”可将参数加入上方自定义列表。')), + E('div', { 'style': 'margin:8px 0' }, [ this.searchInput, ' ', searchBtn ]), + this.crumbs, + E('div', { 'class': 'lsc-tablewrap' }, table) + ]); + + this.browseSectionBox = box; + + return box; + }, + + onSearchInput: function() { + var self = this; + + if (this.searchTimer != null) + window.clearTimeout(this.searchTimer); + + this.searchTimer = window.setTimeout(function() { self.doSearch(); }, 500); + }, + + doSearch: function() { + var self = this; + var q = (this.searchInput.value || '').trim(); + + if (this.searchTimer != null) { + window.clearTimeout(this.searchTimer); + this.searchTimer = null; + } + + if (q.length < 2) { + this.browsePrefix = ''; + this.loadBrowse(''); + return; + } + + this.browsePrefix = null; + this.crumbsUpdate(); + + dom.content(this.browseTableBody, E('tr', {}, + E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('搜索中…')))); + + callSearch(q, 500).then(function(res) { + var items = (res != null && res.items != null) ? res.items : []; + + self.renderSearchResults(items, (res != null && res.truncated == true), q); + }).catch(function(e) { + dom.content(self.browseTableBody, E('tr', {}, + E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('搜索失败:%s').format(e.message)))); + }); + }, + + renderSearchResults: function(items, truncated, q) { + var rows = []; + + items.sort(function(a, b) { return (a.key < b.key) ? -1 : (a.key > b.key) ? 1 : 0; }); + + for (var i = 0; i < items.length; i++) + rows.push(this.renderParamRow(items[i].key, items[i].value)); + + if (rows.length == 0) + rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, + _('没有匹配“%s”的参数。').format(q)))); + + if (truncated) + rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'color:#e80' }, + _('结果过多已截断,请输入更精确的关键词。')))); + + dom.content(this.browseTableBody, rows); + }, + + renderParamRow: function(key, value) { + var self = this; + + return E('tr', { 'class': 'cbi-section-table-row' }, [ + E('td', {}, [ E('code', {}, key) ]), + E('td', {}, [ E('code', {}, value) ]), + E('td', {}, E('button', { + 'class': 'cbi-button cbi-button-add', + 'click': ui.createHandlerFn(self, 'showEditForm', { isNew: true, key: key, value: value, fromBrowse: true }) + }, _('自定义'))) + ]); + }, + + loadBrowse: function(prefix) { + var self = this; + + dom.content(this.browseTableBody, E('tr', {}, + E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, _('加载中…')))); + + this.crumbsUpdate(); + + callBrowse(prefix).then(function(res) { + var items = (res != null && res.items != null) ? res.items : []; + var rows = []; + + for (var i = 0; i < items.length; i++) { + var it = items[i]; + + if (it.type == 'group') { + rows.push(E('tr', { 'class': 'cbi-section-table-row' }, [ + E('td', {}, E('a', { + 'href': '#', + 'click': ui.createHandlerFn(self, 'browseInto', it.prefix) + }, [ E('strong', {}, it.name + '/') ])), + E('td', {}, E('span', { 'style': 'color:#999' }, _('目录'))), + E('td', {}, E('a', { + 'href': '#', + 'style': 'text-decoration:none', + 'click': ui.createHandlerFn(self, 'browseInto', it.prefix) + }, _('进入'))) + ])); + } + else { + rows.push(self.renderParamRow(it.key, it.value)); + } + } + + if (rows.length == 0) + rows.push(E('tr', {}, E('td', { 'colspan': 3, 'style': 'text-align:center;color:#777;padding:12px' }, + _('(空)')))); + + dom.content(self.browseTableBody, rows); + }).catch(function(e) { + dom.content(self.browseTableBody, E('tr', {}, + E('td', { 'colspan': 3, 'class': 'alert-message error' }, _('加载失败:%s').format(e.message)))); + }); + }, + + browseInto: function(prefix) { + this.browsePrefix = prefix; + this.loadBrowse(prefix); + }, + + crumbsUpdate: function() { + var self = this; + var parts = [ E('a', { + 'href': '#', + 'click': ui.createHandlerFn(this, 'browseInto', '') + }, _('根目录')) ]; + + if (this.browsePrefix == null) { + parts.push(E('span', { 'style': 'color:#777' }, ' › ' + _('搜索') + ': ' + this.searchInput.value)); + } + else if (this.browsePrefix != '') { + var segs = this.browsePrefix.replace(/\.$/, '').split('.'); + var acc = ''; + + for (var i = 0; i < segs.length; i++) { + acc += segs[i] + '.'; + + parts.push(E('span', { 'style': 'color:#777' }, ' › ')); + parts.push(E('a', { + 'href': '#', + 'click': ui.createHandlerFn(this, 'browseInto', acc) + }, segs[i])); + } + + /* one-click step back to the parent directory */ + var parentPrefix = (segs.length > 1) + ? segs.slice(0, segs.length - 1).join('.') + '.' + : ''; + + parts.push(E('span', { 'style': 'color:#777' }, ' ')); + parts.push(E('a', { + 'href': '#', + 'style': 'margin-left:12px', + 'click': ui.createHandlerFn(this, 'browseInto', parentPrefix) + }, _('← 返回上一级'))); + } + + dom.content(this.crumbs, parts); + } +}); + diff --git a/luci-app-sysctl/root/etc/sysctl.d/99-luci-sysctl.conf b/luci-app-sysctl/root/etc/sysctl.d/99-luci-sysctl.conf new file mode 100644 index 000000000..d0d4b6f24 --- /dev/null +++ b/luci-app-sysctl/root/etc/sysctl.d/99-luci-sysctl.conf @@ -0,0 +1,10 @@ +# Custom kernel parameters managed by luci-app-sysctl. +# +# Entries added or modified through the LuCI web interface +# (System -> 内核参数 / Kernel Parameters) are stored in this file. +# +# A line prefixed with "#" means the entry is disabled, e.g.: +# # net.core.somaxconn = 4096 +# +# This file is loaded at boot time by /etc/init.d/sysctl and can be +# re-applied at runtime from the LuCI page ("应用配置" button). diff --git a/luci-app-sysctl/root/usr/share/luci/menu.d/luci-app-sysctl.json b/luci-app-sysctl/root/usr/share/luci/menu.d/luci-app-sysctl.json new file mode 100644 index 000000000..d9f48e0fa --- /dev/null +++ b/luci-app-sysctl/root/usr/share/luci/menu.d/luci-app-sysctl.json @@ -0,0 +1,13 @@ +{ + "admin/system/sysctl": { + "title": "内核参数", + "order": 65, + "action": { + "type": "view", + "path": "sysctl" + }, + "depends": { + "acl": [ "luci-app-sysctl" ] + } + } +} diff --git a/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json b/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json new file mode 100644 index 000000000..307873a88 --- /dev/null +++ b/luci-app-sysctl/root/usr/share/rpcd/acl.d/luci-app-sysctl.json @@ -0,0 +1,33 @@ +{ + "luci-app-sysctl": { + "description": "Grant access to kernel sysctl parameters", + "read": { + "ubus": { + "luci.sysctl": [ + "status", + "list", + "browse", + "search", + "preset_status", + "preset_fetch", + "preset_check", + "file_view", + "preset_list" + ] + } + }, + "write": { + "ubus": { + "luci.sysctl": [ + "set", + "remove", + "apply", + "preset_import", + "preset_remove", + "file_set", + "file_delete" + ] + } + } + } +} diff --git a/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl b/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl new file mode 100644 index 000000000..ce0a57d2a --- /dev/null +++ b/luci-app-sysctl/root/usr/share/rpcd/ucode/luci.sysctl @@ -0,0 +1,1018 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// luci.sysctl - rpcd ucode plugin (rpcd-mod-ucode) +// +// Provides the ubus object "luci.sysctl" used by the LuCI application +// "luci-app-sysctl" to view and manage kernel sysctl parameters. +// +// rpcd loads every regular file below /usr/share/rpcd/ucode/, executes it +// with an embedded ucode VM and uses the value returned at the top level +// as the ubus object signature: +// +// { "object-name": { "method-name": { "args": {...}, "call": fn } } } +// +// Method callbacks receive a request object exposing "request.args" (the +// ubus call arguments) and "request.info" (caller/ACL information). The +// plain object returned by the callback is sent back as ubus reply. +// +// Methods: +// status() -> list of config files, sanity info +// list() -> custom + /etc/sysctl.conf entries +// browse(prefix) -> children of /proc/sys subtree +// search(query, limit) -> substring search over all params +// set(key, value, disabled, apply) -> upsert entry in 99-luci-sysctl.conf +// remove(key) -> delete entry from 99-luci-sysctl.conf +// apply() -> run "sysctl -e -p" over all confs +// +// Online preset (wholesale-managed /etc/sysctl.d/98-online-preset.conf): +// preset_status() -> imported preset state + entries +// preset_fetch(url, prefix) -> download + parse + conflict check +// preset_import(url, prefix) -> download + write 98-online-preset.conf +// preset_check() -> re-download and diff against current +// preset_remove() -> remove the preset file +// preset_list(url, prefix) -> list .conf files in a github.com +// directory ("tree") URL +// +// Per-file viewing/editing of user-visible config sources: +// file_view(path) -> entries of one config file +// file_set(path, key, value, dis) -> rewrite a single line in place +// file_delete(path, key) -> remove matching lines +// +// Editable paths: /etc/sysctl.conf and /etc/sysctl.d/*.conf, EXCEPT the two +// plugin-managed files (98-online-preset.conf, 99-luci-sysctl.conf) which +// must be managed through their dedicated UI sections. + +'use strict'; + +import { access, lsdir, popen, readfile, stat, unlink, writefile } from 'fs'; + +const MAIN_CONF = '/etc/sysctl.conf'; +const SYSCTL_D = '/etc/sysctl.d'; +const CUSTOM_CONF = SYSCTL_D + '/99-luci-sysctl.conf'; +const PRESET_CONF = SYSCTL_D + '/98-online-preset.conf'; +const PROC_SYS = '/proc/sys'; +const SYSCTL_BIN = '/sbin/sysctl'; + +// valid sysctl key: components of [A-Za-z0-9_-], joined by dots ("/" allowed +// inside components as an alternative separator, as accepted by busybox sysctl) +const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\/-]*$/; + +function trim(s) { + if (s == null) + return s; + + return replace(replace(s, /^[ \t\r\n]+/, ''), /[ \t\r\n]+$/, ''); +} + +// fs.access() returns true when the path exists and null otherwise; +// coerce the result to a real boolean for API responses +function path_exists(path) { + return (access(path) != null); +} + +function shquote(s) { + return "'" + replace(s, /'/g, "'\\''") + "'"; +} + +// Map "net.ipv4.ip_forward" -> "/proc/sys/net/ipv4/ip_forward" +// (note: ucode's join() takes the separator first) +function proc_file(key) { + return PROC_SYS + '/' + join('/', split(key, '.')); +} + +function read_current(key) { + let v = readfile(proc_file(key)); + return (v == null) ? null : trim(v); +} + +// Parse a sysctl configuration file into an array of entries. +// A line of the form "# key = value" is treated as a disabled entry, +// plain comments ("# ...") are skipped. +function parse_conf(path) { + let entries = []; + let data = readfile(path); + + if (data == null || length(data) == 0) + return entries; + + let lines = split(data, '\n'); + + for (let i = 0; i < length(lines); i++) { + let line = trim(lines[i]); + let disabled = false; + + if (line == null || length(line) == 0) + continue; + + if (substr(line, 0, 1) == '#') { + let stripped = trim(substr(line, 1)); + + // only treat commented-out assignments as disabled entries + if (match(stripped, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/) == null) + continue; + + disabled = true; + line = stripped; + } + + let m = match(line, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=[ \t]*(.*)$/); + + if (m == null) + continue; + + push(entries, { + key: m[1], + value: (m[2] != null) ? trim(m[2]) : '', + line: i + 1, + disabled: disabled + }); + } + + return entries; +} + +function render_conf(entries) { + let buf = '# Custom kernel parameters managed by luci-app-sysctl.\n' + + '# A line prefixed with "#" means the entry is disabled.\n\n'; + + for (let i = 0; i < length(entries); i++) { + let e = entries[i]; + + buf += (e.disabled ? '# ' : '') + e.key + ' = ' + e.value + '\n'; + } + + return buf; +} + +// Merge live /proc/sys values into parsed entries +function decorate(entries) { + let out = []; + + for (let i = 0; i < length(entries); i++) { + let e = entries[i]; + let cur = read_current(e.key); + + push(out, { + key: e.key, + value: e.value, + line: e.line, + disabled: e.disabled, + exists: path_exists(proc_file(e.key)), + current: (e.disabled || cur == null) ? null : cur, + match: (e.disabled || cur == null) ? null : (cur == e.value) + }); + } + + return out; +} + +// Config files in application order: /etc/sysctl.conf first, +// then /etc/sysctl.d/*.conf in lexicographic order. +function collect_confs() { + let paths = []; + + if (access(MAIN_CONF)) + push(paths, MAIN_CONF); + + if (access(SYSCTL_D)) { + let names = sort(lsdir(SYSCTL_D) || []); + + for (let i = 0; i < length(names); i++) + if (match(names[i], /\.conf$/) != null) + push(paths, SYSCTL_D + '/' + names[i]); + } + + return paths; +} + +// Recursively read all parameters below `dir` into `out` (key -> value) +function walk(dir, prefix, out) { + let names = lsdir(dir); + + if (names == null) + return; + + names = sort(names); + + for (let i = 0; i < length(names); i++) { + let name = names[i]; + let full = dir + '/' + name; + let st = stat(full); + + if (st == null) + continue; + + if (st.type == 'directory') { + walk(full, prefix + name + '.', out); + } + else { + let v = readfile(full); + out[prefix + name] = (v == null) ? '' : trim(v); + } + } +} + +// Write a value directly to /proc/sys, returning a status string on failure +// or a boolean on success. +function write_proc(key, value) { + let p = proc_file(key); + + if (access(p) == null) + return 'missing'; + + if (writefile(p, value + '\n') == null) + return 'readonly'; + + return (read_current(key) == value); +} + +// ------------------------- online preset helpers --------------------------- + +// Normalize a user-supplied URL: strip whitespace and convert a github.com +// blob page URL to its raw.githubusercontent.com equivalent. Returns null +// when the input is not a usable http(s) URL. +function norm_url(url) { + url = trim(url); + + if (url == null || length(url) == 0) + return null; + + if (substr(url, 0, 8) != 'https://' && substr(url, 0, 7) != 'http://') + return null; + + let m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/blob\/(.+)$/); + + if (m != null) + url = 'https://raw.githubusercontent.com/' + m[1] + '/' + m[2] + '/' + m[3]; + + return url; +} + +// Prepend an optional mirror prefix (e.g. "gh-proxy.org"); the scheme is +// auto-completed and a trailing slash is ensured. +function apply_prefix(url, prefix) { + prefix = trim(prefix); + + if (prefix == null || length(prefix) == 0) + return url; + + if (substr(prefix, 0, 8) != 'https://' && substr(prefix, 0, 7) != 'http://') + prefix = 'https://' + prefix; + + if (substr(prefix, length(prefix) - 1, 1) != '/') + prefix += '/'; + + return prefix + url; +} + +// Download a URL using whichever fetcher is available on the target system. +// Tries curl, then wget (busybox/uclient), then uclient-fetch. Returns the +// response body or null when all attempts fail. +function http_get(url) { + let cmds = [ + 'curl -fsSL --max-time 15 ' + shquote(url) + ' 2>/dev/null', + 'wget -q -T 15 -O - ' + shquote(url) + ' 2>/dev/null', + 'uclient-fetch -q -T 15 -O - ' + shquote(url) + ' 2>/dev/null' + ]; + + for (let i = 0; i < length(cmds); i++) { + let p = popen(cmds[i], 'r'); + let out = (p != null) ? p.read('all') : null; + let rc = (p != null) ? p.close() : -1; + + if (rc == 0 && out != null && length(out) > 0) + return out; + } + + return null; +} + +// Parse downloaded preset content: comments/blank lines skipped, invalid +// lines collected for reporting. Returns { params, invalid }. +function parse_preset_content(data) { + let res = { params: [], invalid: [] }; + + if (data == null) + return res; + + let stripped = trim(data); + + // guard against HTML error pages + if (length(stripped) == 0 || substr(stripped, 0, 1) == '<') + return res; + + let lines = split(data, '\n'); + + for (let i = 0; i < length(lines); i++) { + let line = trim(lines[i]); + + if (line == null || length(line) == 0 || substr(line, 0, 1) == '#') + continue; + + let m = match(line, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=[ \t]*(.*)$/); + + if (m == null) { + push(res.invalid, line); + continue; + } + + push(res.params, { key: m[1], value: (m[2] != null) ? trim(m[2]) : '' }); + } + + return res; +} + +// Index of keys already defined elsewhere (used for conflict reporting); +// the preset file itself is excluded. +function existing_keys() { + let idx = {}; + let confs = collect_confs(); + + for (let i = 0; i < length(confs); i++) { + let p = confs[i]; + + if (p == PRESET_CONF) + continue; + + let entries = parse_conf(p); + + for (let j = 0; j < length(entries); j++) + if (!entries[j].disabled && idx[entries[j].key] == null) + idx[entries[j].key] = p; + } + + return idx; +} + +// Read preset metadata recorded in the file header comments. +function preset_meta() { + let data = readfile(PRESET_CONF); + + if (data == null) + return { present: false }; + + let source = null; + let fetched = null; + let lines = split(data, '\n'); + + for (let i = 0; i < length(lines); i++) { + let line = trim(lines[i]); + + if (line == null) + continue; + + if (substr(line, 0, 10) == '# source: ') + source = trim(substr(line, 10)); + else if (substr(line, 0, 11) == '# fetched: ') + fetched = trim(substr(line, 11)); + } + + let entries = parse_conf(PRESET_CONF); + let count = 0; + + for (let i = 0; i < length(entries); i++) + if (!entries[i].disabled) + count++; + + return { present: true, count: count, source: source, fetched: fetched }; +} + +function render_preset(url, params) { + let buf = '# Online preset managed by luci-app-sysctl\n' + + '# source: ' + url + '\n' + + '# fetched: ' + time() + '\n' + + '# This file is replaced wholesale on update. To override a\n' + + '# single entry, add it to 99-luci-sysctl.conf instead.\n\n'; + + for (let i = 0; i < length(params); i++) + buf += params[i].key + ' = ' + params[i].value + '\n'; + + return buf; +} + +// Download + parse + conflict report, shared by fetch/import. +function preset_download(url, prefix) { + let norm = norm_url(url); + + if (norm == null) + return { code: 1, error: 'Invalid URL, expected an http(s) URL' }; + + let final_url = apply_prefix(norm, prefix); + let data = http_get(final_url); + + if (data == null) + return { code: 2, error: 'Download failed: ' + final_url, url: final_url }; + + let parsed = parse_preset_content(data); + + if (length(parsed.params) == 0) + return { code: 3, error: 'No valid sysctl entries found at ' + final_url, url: final_url, invalid: parsed.invalid }; + + let idx = existing_keys(); + let conflicts = []; + + for (let i = 0; i < length(parsed.params); i++) { + let k = parsed.params[i].key; + + if (idx[k] != null) + push(conflicts, { key: k, file: idx[k] }); + } + + return { code: 0, url: final_url, parsed: parsed, conflicts: conflicts }; +} + +// ----------------------- per-file viewing/editing --------------------------- + +// Recognize a github.com directory ("tree") URL and convert it to the +// matching GitHub contents API endpoint. Returns null when the URL is not +// a tree URL (e.g. a plain file URL). +// https://github.com/u/r/tree/main/dir -> .../contents/dir?ref=main +// (note: ucode's regex engine has no non-capturing groups, so the branch +// and the sub-path are split manually) +function github_tree_to_api(url) { + let m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/tree\/(.+)$/); + + if (m == null) + return null; + + let rest = m[3]; + let slash = index(rest, '/'); + let branch = (slash > 0) ? substr(rest, 0, slash) : rest; + let path = (slash > 0) ? substr(rest, slash + 1) : ''; + + let api = 'https://api.github.com/repos/' + m[1] + '/' + m[2] + '/contents'; + + if (length(path) > 0) + api += '/' + path; + + return api + '?ref=' + branch; +} + +// Whitelist of user-editable config sources. The plugin-managed files +// (PRESET_CONF / CUSTOM_CONF) are deliberately excluded: they are managed +// through their dedicated UI sections and rewriting them here would bypass +// the plugin's own state. +function editable_path(p) { + if (p == null || length(p) == 0) + return false; + + if (p == MAIN_CONF) + return true; + + if (p == CUSTOM_CONF || p == PRESET_CONF) + return false; + + if (substr(p, 0, length(SYSCTL_D) + 1) == SYSCTL_D + '/') { + let base = substr(p, length(SYSCTL_D) + 1); + + // plain filename only (no traversal), must end in .conf + if (match(base, /^[A-Za-z0-9_.-]+\.conf$/) != null) + return true; + } + + return false; +} + +// True when the raw line assigns `key` (enabled or commented-out form) +function line_matches_key(raw, key) { + let t = trim(raw); + + if (t == null || length(t) == 0) + return false; + + if (substr(t, 0, 1) == '#') { + let m = match(trim(substr(t, 1)), /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/); + return (m != null && m[1] == key); + } + + let m = match(t, /^([A-Za-z0-9_][A-Za-z0-9_.\/-]*)[ \t]*=/); + return (m != null && m[1] == key); +} + +// Replace the first line assigning `key` with a new value, keeping every +// other line (comments, ordering, formatting) untouched. +function file_rewrite(path, key, value, disabled) { + let data = readfile(path); + + if (data == null) + return { code: 2, error: 'Cannot read ' + path }; + + let lines = split(data, '\n'); + let out = []; + let replaced = false; + + for (let i = 0; i < length(lines); i++) { + let raw = lines[i]; + + if (!replaced && line_matches_key(raw, key)) { + push(out, (disabled ? '# ' : '') + key + ' = ' + value); + replaced = true; + } + else { + push(out, raw); + } + } + + if (!replaced) + return { code: 3, error: 'Key not found in ' + path }; + + if (writefile(path, join('\n', out)) == null) + return { code: 4, error: 'Failed to write ' + path }; + + return { code: 0 }; +} + +// Remove all lines assigning `key` from the file. +function file_delete_key(path, key) { + let data = readfile(path); + + if (data == null) + return { code: 2, error: 'Cannot read ' + path }; + + let lines = split(data, '\n'); + let out = []; + let removed = 0; + + for (let i = 0; i < length(lines); i++) { + if (line_matches_key(lines[i], key)) + removed++; + else + push(out, lines[i]); + } + + if (removed == 0) + return { code: 3, error: 'Key not found in ' + path }; + + if (writefile(path, join('\n', out)) == null) + return { code: 4, error: 'Failed to write ' + path }; + + return { code: 0, removed: removed }; +} + +const methods = { + status: { + args: {}, + call: function(request) { + let confs = collect_confs(); + let files = []; + + for (let i = 0; i < length(confs); i++) { + push(files, { + path: confs[i], + count: length(parse_conf(confs[i])), + managed: (confs[i] == CUSTOM_CONF) + }); + } + + return { + files: files, + custom_path: CUSTOM_CONF, + sysctl_bin: path_exists(SYSCTL_BIN), + initd: path_exists('/etc/init.d/sysctl') + }; + } + }, + + list: { + args: {}, + call: function(request) { + return { + custom: (access(CUSTOM_CONF)) ? decorate(parse_conf(CUSTOM_CONF)) : [], + main: (access(MAIN_CONF)) ? decorate(parse_conf(MAIN_CONF)) : [] + }; + } + }, + + browse: { + args: { prefix: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let prefix = (a.prefix != null) ? a.prefix : ''; + + if (prefix != '' && match(prefix, /^[A-Za-z0-9_]+([.][A-Za-z0-9_]+)*\.$/) == null) + return { code: 1, error: 'Invalid prefix', items: [] }; + + let dir = PROC_SYS; + let base = ''; + + if (prefix != '') { + base = prefix; + dir = PROC_SYS + '/' + join('/', split(substr(prefix, 0, length(prefix) - 1), '.')); + } + + let names = sort(lsdir(dir) || []); + let items = []; + + for (let i = 0; i < length(names); i++) { + let name = names[i]; + let full = dir + '/' + name; + let st = stat(full); + + if (st == null) + continue; + + if (st.type == 'directory') { + push(items, { name: name, type: 'group', prefix: base + name + '.' }); + } + else { + let v = readfile(full); + + push(items, { + name: name, + type: 'param', + key: base + name, + value: (v == null) ? '' : trim(v) + }); + } + } + + return { items: items }; + } + }, + + search: { + args: { query: '', limit: 100 }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let q = (a.query != null) ? trim(a.query) : ''; + let limit = 100; + + if (a.limit != null) { + let n = int(a.limit, 100); + + if (n > 0 && n <= 1000) + limit = n; + } + + if (q == null || length(q) < 2) + return { items: [], truncated: false }; + + let all = {}; + walk(PROC_SYS, '', all); + + let ql = lc(q); + let keylist = sort(keys(all)); + let items = []; + let truncated = false; + + for (let i = 0; i < length(keylist); i++) { + let key = keylist[i]; + + // match on key name (>= 2 chars), or on value (>= 3 chars) + if (index(lc(key), ql) < 0 && + (length(q) < 3 || index(all[key], q) < 0)) + continue; + + push(items, { key: key, value: all[key] }); + + if (length(items) >= limit) { + truncated = true; + break; + } + } + + return { items: items, truncated: truncated }; + } + }, + + set: { + args: { key: '', value: '', disabled: false, apply: false }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let key = trim(a.key); + let value = (a.value == null) ? '' : trim(a.value); + let disabled = (a.disabled == true); + let apply = (a.apply == true); + + if (key == null || length(key) == 0 || match(key, KEY_RE) == null) + return { code: 1, error: 'Invalid key' }; + + if (match(value, /[\r\n]/) != null) + return { code: 1, error: 'Invalid value' }; + + let exists = path_exists(proc_file(key)); + let entries = (access(CUSTOM_CONF)) ? parse_conf(CUSTOM_CONF) : []; + let found = false; + + for (let i = 0; i < length(entries); i++) { + if (entries[i].key == key) { + entries[i].value = value; + entries[i].disabled = disabled; + found = true; + break; + } + } + + if (!found) + push(entries, { key: key, value: value, line: 0, disabled: disabled }); + + if (writefile(CUSTOM_CONF, render_conf(entries)) == null) + return { code: 2, error: 'Failed to write ' + CUSTOM_CONF }; + + let applied = null; + + if (apply && !disabled) + applied = write_proc(key, value); + + return { + code: 0, + exists: exists, + applied: applied, + current: (apply && !disabled) ? read_current(key) : null + }; + } + }, + + remove: { + args: { key: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let key = trim(a.key); + + if (key == null || length(key) == 0) + return { code: 1, error: 'Missing key' }; + + let entries = (access(CUSTOM_CONF)) ? parse_conf(CUSTOM_CONF) : []; + let out = []; + + for (let i = 0; i < length(entries); i++) + if (entries[i].key != key) + push(out, entries[i]); + + if (length(out) == length(entries)) + return { code: 3, error: 'Key not found in ' + CUSTOM_CONF }; + + if (writefile(CUSTOM_CONF, render_conf(out)) == null) + return { code: 2, error: 'Failed to write ' + CUSTOM_CONF }; + + return { code: 0 }; + } + }, + + apply: { + args: {}, + call: function(request) { + let confs = collect_confs(); + let errors = []; + + if (!path_exists(SYSCTL_BIN)) + return { code: 4, error: 'sysctl binary not found', errors: errors }; + + for (let i = 0; i < length(confs); i++) { + let p = popen(SYSCTL_BIN + ' -e -p ' + shquote(confs[i]) + ' 2>&1', 'r'); + let out = (p != null) ? p.read('all') : null; + let rc = (p != null) ? p.close() : -1; + + out = (out == null) ? '' : trim(out); + + if (rc != 0 || length(out) > 0) + push(errors, { file: confs[i], code: rc, output: out }); + } + + return { code: 0, errors: errors }; + } + }, + + preset_status: { + args: {}, + call: function(request) { + let meta = preset_meta(); + + return { + present: meta.present, + path: PRESET_CONF, + count: (meta.present) ? meta.count : 0, + source: meta.source, + fetched: meta.fetched, + params: (meta.present) ? decorate(parse_conf(PRESET_CONF)) : [] + }; + } + }, + + preset_fetch: { + args: { url: '', prefix: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let dl = preset_download(a.url, a.prefix); + + if (dl.code != 0) + return { code: dl.code, error: dl.error, url: dl.url, invalid: dl.invalid }; + + return { + code: 0, + url: dl.url, + params: dl.parsed.params, + invalid: dl.parsed.invalid, + conflicts: dl.conflicts + }; + } + }, + + preset_import: { + args: { url: '', prefix: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let dl = preset_download(a.url, a.prefix); + + if (dl.code != 0) + return { code: dl.code, error: dl.error, url: dl.url, invalid: dl.invalid }; + + if (writefile(PRESET_CONF, render_preset(dl.url, dl.parsed.params)) == null) + return { code: 4, error: 'Failed to write ' + PRESET_CONF }; + + return { + code: 0, + count: length(dl.parsed.params), + path: PRESET_CONF, + url: dl.url + }; + } + }, + + preset_check: { + args: {}, + call: function(request) { + let meta = preset_meta(); + + if (!meta.present || meta.source == null) + return { code: 5, error: 'No online preset imported yet' }; + + let data = http_get(meta.source); + + if (data == null) + return { code: 2, error: 'Download failed: ' + meta.source, url: meta.source }; + + let parsed = parse_preset_content(data); + + if (length(parsed.params) == 0) + return { code: 3, error: 'No valid sysctl entries found at ' + meta.source, url: meta.source, invalid: parsed.invalid }; + + // remote state (last definition wins, order preserved) + let remote = {}; + let order = []; + + for (let i = 0; i < length(parsed.params); i++) { + if (remote[parsed.params[i].key] == null) + push(order, parsed.params[i].key); + + remote[parsed.params[i].key] = parsed.params[i].value; + } + + // local state (preset file contains no disabled entries) + let local = {}; + let entries = parse_conf(PRESET_CONF); + + for (let i = 0; i < length(entries); i++) + if (!entries[i].disabled) + local[entries[i].key] = entries[i].value; + + let added = []; + let changed = []; + let removed = []; + + for (let i = 0; i < length(order); i++) { + let k = order[i]; + + if (local[k] == null) + push(added, { key: k, value: remote[k] }); + else if (local[k] != remote[k]) + push(changed, { key: k, from: local[k], to: remote[k] }); + } + + let lkeys = sort(keys(local)); + + for (let i = 0; i < length(lkeys); i++) + if (remote[lkeys[i]] == null) + push(removed, { key: lkeys[i], old: local[lkeys[i]] }); + + return { + code: 0, + url: meta.source, + remote_count: length(parsed.params), + local_count: length(local), + added: added, + changed: changed, + removed: removed + }; + } + }, + + preset_remove: { + args: {}, + call: function(request) { + if (access(PRESET_CONF) == null) + return { code: 5, error: 'No online preset imported yet' }; + + if (!unlink(PRESET_CONF)) + return { code: 4, error: 'Failed to remove ' + PRESET_CONF }; + + return { code: 0 }; + } + }, + + preset_list: { + args: { url: '', prefix: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let url = (a.url != null) ? trim(a.url) : ''; + let api = github_tree_to_api((url == null) ? '' : url); + + // not a directory URL: caller falls back to single-file fetch + if (api == null) + return { code: 10, is_dir: false, files: [] }; + + let final_url = apply_prefix(api, a.prefix); + let data = http_get(final_url); + + if (data == null) + return { code: 2, error: 'Directory listing failed: ' + final_url, url: final_url }; + + let doc = json(data); + + if (doc == null || type(doc) != 'array') + return { code: 3, error: 'Unexpected directory listing from ' + final_url + ' (GitHub rate limit?)', url: final_url }; + + let files = []; + + for (let i = 0; i < length(doc); i++) { + let item = doc[i]; + + if (item == null || item.type != 'file' || item.name == null) + continue; + + if (match(item.name, /\.conf$/) == null) + continue; + + push(files, { + name: item.name, + size: (item.size != null) ? item.size : 0, + url: (item.download_url != null) ? item.download_url : '' + }); + } + + if (length(files) == 0) + return { code: 3, error: 'Directory contains no .conf files: ' + final_url, url: final_url }; + + return { code: 0, is_dir: true, files: files, url: final_url }; + } + }, + + file_view: { + args: { path: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let p = trim(a.path); + + if (p == null || !editable_path(p)) + return { code: 1, error: 'Path not allowed: ' + ((p != null) ? p : ''), editable: false }; + + return { + code: 0, + path: p, + editable: true, + entries: decorate(parse_conf(p)) + }; + } + }, + + file_set: { + args: { path: '', key: '', value: '', disabled: false }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let p = trim(a.path); + let key = trim(a.key); + let value = (a.value == null) ? '' : trim(a.value); + let disabled = (a.disabled == true); + + if (p == null || !editable_path(p)) + return { code: 1, error: 'Path not allowed' }; + + if (key == null || match(key, KEY_RE) == null) + return { code: 1, error: 'Invalid key' }; + + if (match(value, /[\r\n]/) != null) + return { code: 1, error: 'Invalid value' }; + + return file_rewrite(p, key, value, disabled); + } + }, + + file_delete: { + args: { path: '', key: '' }, + call: function(request) { + let a = (request.args != null) ? request.args : {}; + let p = trim(a.path); + let key = trim(a.key); + + if (p == null || !editable_path(p)) + return { code: 1, error: 'Path not allowed' }; + + if (key == null || length(key) == 0) + return { code: 1, error: 'Invalid key' }; + + return file_delete_key(p, key); + } + } +}; + +return { 'luci.sysctl': methods };