From c2374cbb987ecf4af8de698edf580b32c3c3fdd5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 10 Apr 2026 15:34:26 +0800 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=A8=20Sync=202026-04-10=2015:34:26?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hickory-dns/Makefile | 10 +- luci-app-momo/Makefile | 4 +- .../luci-static/resources/view/momo/app.js | 2 +- .../luci-static/resources/view/momo/log.js | 36 +- .../luci-static/resources/view/momo/mixin.js | 154 ++ luci-app-momo/po/templates/momo.pot | 260 ++- luci-app-momo/po/zh_Hans/momo.po | 274 ++- .../usr/share/luci/menu.d/luci-app-momo.json | 14 +- .../root/usr/share/rpcd/ucode/luci.momo | 10 +- luci-app-nikki/Makefile | 4 +- .../luci-static/resources/view/nikki/mixin.js | 1 - luci-app-openclaw/CHANGELOG.md | 118 + luci-app-openclaw/Makefile | 2 +- luci-app-openclaw/README.md | 29 +- luci-app-openclaw/VERSION | 2 +- .../luasrc/model/cbi/openclaw/basic.lua | 91 +- luci-app-openclaw/root/etc/init.d/openclaw | 203 +- .../root/etc/uci-defaults/99-openclaw | 12 + luci-app-openclaw/root/usr/bin/openclaw-env | 19 +- .../share/openclaw/oc-config-interactive.js | 2031 +++++++++++++++++ .../root/usr/share/openclaw/oc-config.sh | 338 +-- .../root/usr/share/openclaw/oc-menu-engine.js | 727 ++++++ .../cbi/passwall/client/node_subscribe.lua | 8 - .../passwall/client/node_subscribe_config.lua | 38 +- .../model/cbi/passwall/client/type/ray.lua | 56 +- .../cbi/passwall/client/type/sing-box.lua | 56 +- .../luasrc/passwall/util_sing-box.lua | 61 + .../luasrc/passwall/util_xray.lua | 109 +- luci-app-passwall/po/zh-cn/passwall.po | 17 +- .../root/usr/share/passwall/app.sh | 26 +- .../root/usr/share/passwall/subscribe.lua | 33 +- luci-app-passwall2/Makefile | 2 +- .../model/cbi/passwall2/client/global.lua | 13 + .../luasrc/passwall2/util_sing-box.lua | 190 +- .../luasrc/passwall2/util_xray.lua | 445 ++-- luci-app-passwall2/po/fa/passwall2.po | 6 + luci-app-passwall2/po/ru/passwall2.po | 6 + luci-app-passwall2/po/zh-cn/passwall2.po | 6 + luci-app-passwall2/po/zh-tw/passwall2.po | 6 + .../root/usr/share/passwall2/app.sh | 134 +- luci-app-ssr-plus/Makefile | 2 +- .../model/cbi/shadowsocksr/client-config.lua | 131 +- .../luasrc/model/cbi/shadowsocksr/client.lua | 1 - .../luasrc/model/cbi/shadowsocksr/servers.lua | 102 +- .../luasrc/view/shadowsocksr/ssrurl.htm | 249 +- luci-app-ssr-plus/po/templates/ssr-plus.pot | 779 ++++--- luci-app-ssr-plus/po/zh_Hans/ssr-plus.po | 781 ++++--- .../root/etc/init.d/shadowsocksr | 61 +- luci-app-ssr-plus/root/usr/bin/ssr-rules | 26 +- .../usr/share/shadowsocksr/gen_config.lua | 44 +- .../share/shadowsocksr/shadowsocksr.config | 2 - .../root/usr/share/shadowsocksr/subscribe.lua | 762 ++++--- momo/Makefile | 6 +- momo/files/momo.conf | 27 +- momo/files/momo.init | 109 +- momo/files/scripts/include.sh | 28 - momo/files/uci-defaults/init.sh | 21 + momo/files/uci-defaults/migrate.sh | 51 +- momo/files/ucode/include.uc | 12 +- momo/files/ucode/mixin.uc | 48 + mptcp/Makefile | 2 +- mptcp/files/etc/init.d/mptcp | 198 +- mptcp/files/usr/bin/multipath | 42 +- natflow/Makefile | 4 +- netdata/Makefile | 4 +- openlist2/Makefile | 6 +- 66 files changed, 6841 insertions(+), 2210 deletions(-) create mode 100644 luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js create mode 100644 luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js create mode 100644 luci-app-openclaw/root/usr/share/openclaw/oc-menu-engine.js create mode 100644 momo/files/uci-defaults/init.sh create mode 100644 momo/files/ucode/mixin.uc diff --git a/hickory-dns/Makefile b/hickory-dns/Makefile index f9a5f8af..f179dda9 100644 --- a/hickory-dns/Makefile +++ b/hickory-dns/Makefile @@ -1,16 +1,16 @@ include $(TOPDIR)/rules.mk PKG_NAME:=hickory-dns -PKG_VERSION:=2026.04.02~54a81de -PKG_RELEASE:=10 +PKG_VERSION:=2026.04.09~34a4cca +PKG_RELEASE:=11 -HICKORY_COMMIT:=54a81deefb24bb97df466b9032d5928345fffdde +HICKORY_COMMIT:=34a4cca1a707b642052b427938b7d1e8aaa71e3a PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz -PKG_SOURCE_URL:=https://codeload.github.com/hickory-dns/hickory-dns/tar.gz/54a81deefb24bb97df466b9032d5928345fffdde? +PKG_SOURCE_URL:=https://codeload.github.com/hickory-dns/hickory-dns/tar.gz/34a4cca1a707b642052b427938b7d1e8aaa71e3a? PKG_HASH:=skip -PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-54a81deefb24bb97df466b9032d5928345fffdde +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-34a4cca1a707b642052b427938b7d1e8aaa71e3a PKG_BUILD_DEPENDS:=rust/host PKG_BUILD_PARALLEL:=1 diff --git a/luci-app-momo/Makefile b/luci-app-momo/Makefile index c063ebef..c612361f 100644 --- a/luci-app-momo/Makefile +++ b/luci-app-momo/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk -PKG_VERSION:=1.1.1 -PKG_RELEASE:=3 +PKG_VERSION:=1.2.0 +PKG_RELEASE:=4 LUCI_TITLE:=LuCI Support for momo LUCI_DEPENDS:=+luci-base +momo diff --git a/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js b/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js index 7d95b660..27840a3c 100644 --- a/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js +++ b/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js @@ -114,7 +114,7 @@ return view.extend({ o = s.option(form.Flag, 'scheduled_restart', _('Scheduled Restart')); o.rmempty = false; - o = s.option(form.Value, 'cron_expression', _('Cron Expression')); + o = s.option(form.Value, 'scheduled_restart_cron', _('Scheduled Restart Cron')); o.retain = true; o.rmempty = false; o.depends('scheduled_restart', '1'); diff --git a/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js b/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js index 22641512..7fdc9c5c 100644 --- a/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js +++ b/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js @@ -24,35 +24,31 @@ return view.extend({ m = new form.Map('momo'); - s = m.section(form.NamedSection, 'log', 'log', _('Log Cleanup')); + s = m.section(form.NamedSection, 'log', 'log', _('Log')); - o = s.option(form.Flag, 'log_cleanup_enabled', _('Scheduled Log Cleanup')); + s.tab('log_config', _('Log Config')); + + o = s.taboption('log_config', form.Flag, 'scheduled_clear', _('Scheduled Clear')); o.rmempty = false; - - o = s.option(form.Value, 'log_cleanup_cron_expression', _('Log Cleanup Cron Expression')); + + o = s.taboption('log_config', form.Value, 'scheduled_clear_cron', _('Scheduled Clear Cron')); o.retain = true; o.rmempty = false; - o.placeholder = '0 4 * * *'; - o.depends('log_cleanup_enabled', '1'); - o.description = _('Run unconditional log cleanup at the configured cron schedule.'); + o.depends('scheduled_clear', '1'); - o = s.option(form.Flag, 'log_cleanup_size_enabled', _('Size-based Log Cleanup')); - o.rmempty = false; - - o = s.option(form.Value, 'log_cleanup_size_check_cron_expression', _('Log Size Check Cron Expression')); + o = s.taboption('log_config', form.Value, 'scheduled_clear_size_limit', _('Scheduled Clear Size Limit')); o.retain = true; o.rmempty = false; - o.placeholder = '*/30 * * * *'; - o.depends('log_cleanup_size_enabled', '1'); - o.description = _('Check log size at the configured cron schedule before cleaning up.'); - - o = s.option(form.Value, 'log_cleanup_size_mb', _('Log Cleanup Size Threshold (MB)')); o.datatype = 'uinteger'; - o.placeholder = '50'; - o.depends('log_cleanup_size_enabled', '1'); - o.description = _('Clear app, core and debug logs when their total size reaches this threshold.'); + o.depends('scheduled_clear', '1'); - s = m.section(form.NamedSection, 'placeholder', 'placeholder', _('Log')); + o = s.taboption('log_config', form.ListValue, 'scheduled_clear_size_limit_unit', _('Scheduled Clear Size Limit Unit')); + o.retain = true; + o.rmempty = false; + o.depends('scheduled_clear', '1'); + o.value('KB', 'KB'); + o.value('MB', 'MB'); + o.value('GB', 'GB'); s.tab('app_log', _('App Log')); diff --git a/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js b/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js new file mode 100644 index 00000000..3bdc5e28 --- /dev/null +++ b/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js @@ -0,0 +1,154 @@ +'use strict'; +'require form'; +'require view'; +'require uci'; +'require fs'; +'require network'; +'require poll'; +'require tools.widgets as widgets'; +'require tools.momo as momo'; + +return view.extend({ + load: function () { + return Promise.all([ + uci.load('momo'), + ]); + }, + render: function (data) { + let m, s, o; + + m = new form.Map('momo'); + + s = m.section(form.NamedSection, 'mixin', 'mixin', _('Mixin Option')); + + s.tab('log', _('Log Config')); + + o = s.taboption('log', form.ListValue, 'log_disabled', _('Log Disabled')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('log', form.ListValue, 'log_level', _('Log Level')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('panic'); + o.value('fatal'); + o.value('error'); + o.value('warn'); + o.value('info'); + o.value('debug'); + o.value('trace'); + + o = s.taboption('log', form.ListValue, 'log_timestamp', _('Log Timestamp')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('log', form.Value, 'log_output', _('Log Output')); + o.placeholder = _('Unmodified'); + + s.tab('dns', _('DNS Config')); + + o = s.taboption('dns', form.ListValue, 'dns_strategy', _('DNS Strategy')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('prefer_ipv4', _('Prefer IPv4')); + o.value('prefer_ipv6', _('Prefer IPv6')); + o.value('ipv4_only', _('IPv4 Only')); + o.value('ipv6_only', _('IPv6 Only')); + + o = s.taboption('dns', form.ListValue, 'dns_disable_cache', _('DNS Disable Cache')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('dns', form.ListValue, 'dns_disable_expire', _('DNS Disable Expire')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('dns', form.ListValue, 'dns_independent_cache', _('DNS Independent Cache')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('dns', form.Value, 'dns_cache_capacity', _('DNS Cache Capacity')); + o.datatype = 'uinteger'; + o.placeholder = _('Unmodified'); + + o = s.taboption('dns', form.ListValue, 'dns_reverse_mapping', _('DNS Reverse Mapping')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + s.tab('ntp', _('NTP Config')); + + o = s.taboption('ntp', form.ListValue, 'ntp_enabled', _('NTP Enabled')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('ntp', form.Value, 'ntp_server', _('NTP Server')); + o.placeholder = _('Unmodified'); + + o = s.taboption('ntp', form.Value, 'ntp_server_port', _('NTP Server Port')); + o.datatype = 'port'; + o.placeholder = _('Unmodified'); + + o = s.taboption('ntp', form.Value, 'ntp_interval', _('NTP Interval')); + o.placeholder = _('Unmodified'); + + s.tab('cache', _('Cache Config')); + + o = s.taboption('cache', form.ListValue, 'cache_enabled', _('Cache Enabled')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('cache', form.Value, 'cache_path', _('Cache Path')); + o.placeholder = _('Unmodified'); + + o = s.taboption('cache', form.ListValue, 'cache_store_fakeip', _('Cache Store FakeIP')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + o = s.taboption('cache', form.ListValue, 'cache_store_rdrc', _('Cache Store RDRC')); + o.optional = true; + o.placeholder = _('Unmodified'); + o.value('0', _('Disable')); + o.value('1', _('Enable')); + + s.tab('external_control', _('External Control Config')); + + o = s.taboption('external_control', form.Value, 'external_control_ui_path', _('UI Path')); + o.placeholder = _('Unmodified'); + + o = s.taboption('external_control', form.Value, 'external_control_ui_download_url', _('UI Download Url')); + o.placeholder = _('Unmodified'); + o.value('https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip', 'Zashboard (CDN Fonts)'); + o.value('https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip', 'Zashboard'); + o.value('https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip', 'MetaCubeXD'); + o.value('https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip', 'YACD'); + o.value('https://github.com/MetaCubeX/Razord-meta/archive/refs/heads/gh-pages.zip', 'Razord'); + + o = s.taboption('external_control', form.Value, 'external_control_api_listen', _('API Listen')); + o.datatype = 'ipaddrport(1)'; + o.placeholder = _('Unmodified'); + + o = s.taboption('external_control', form.Value, 'external_control_api_secret', _('API Secret')); + o.password = true; + o.placeholder = _('Unmodified'); + + return m.render(); + } +}); \ No newline at end of file diff --git a/luci-app-momo/po/templates/momo.pot b/luci-app-momo/po/templates/momo.pot index 3b4e7bde..e84f1adc 100644 --- a/luci-app-momo/po/templates/momo.pot +++ b/luci-app-momo/po/templates/momo.pot @@ -1,6 +1,14 @@ msgid "" msgstr "Content-Type: text/plain; charset=UTF-8" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:144 +msgid "API Listen" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:148 +msgid "API Secret" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:69 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:122 msgid "Access Control" @@ -24,7 +32,7 @@ msgstr "" msgid "App Config" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:53 msgid "App Log" msgstr "" @@ -56,6 +64,26 @@ msgstr "" msgid "CGroup" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:108 +msgid "Cache Config" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:110 +msgid "Cache Enabled" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:116 +msgid "Cache Path" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:119 +msgid "Cache Store FakeIP" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:125 +msgid "Cache Store RDRC" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/editor.js:27 msgid "Choose File" msgstr "" @@ -64,8 +92,8 @@ msgstr "" msgid "Choose Profile" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:33 -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:66 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:57 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:90 msgid "Clear Log" msgstr "" @@ -74,7 +102,7 @@ msgstr "" msgid "Commonly Used Port" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:62 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:86 msgid "Core Log" msgstr "" @@ -90,16 +118,40 @@ msgstr "" msgid "Core Version" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:117 -msgid "Cron Expression" -msgstr "" - #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:99 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:165 msgid "DNS" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:95 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:80 +msgid "DNS Cache Capacity" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:52 +msgid "DNS Config" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:62 +msgid "DNS Disable Cache" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:68 +msgid "DNS Disable Expire" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:74 +msgid "DNS Independent Cache" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:84 +msgid "DNS Reverse Mapping" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:54 +msgid "DNS Strategy" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:119 msgid "Debug Log" msgstr "" @@ -111,6 +163,16 @@ msgstr "" msgid "Destination UDP Port to Proxy" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:46 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:65 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:71 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:77 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:87 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:95 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:113 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:122 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:128 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:53 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:60 msgid "Disable" @@ -121,11 +183,21 @@ msgid "Edit Subscription" msgstr "" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/editor.js:25 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:37 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:45 msgid "Editor" msgstr "" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:96 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:30 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:47 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:66 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:72 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:78 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:88 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:96 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:114 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:123 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:129 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:33 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:66 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:77 @@ -138,6 +210,10 @@ msgstr "" msgid "Expire At" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:131 +msgid "External Control Config" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:48 msgid "Fake-IP Ping Hijack" msgstr "" @@ -155,7 +231,7 @@ msgstr "" msgid "General Config" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:99 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:123 msgid "Generate & Download" msgstr "" @@ -183,6 +259,10 @@ msgstr "" msgid "IPv4 DNS Hijack" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:59 +msgid "IPv4 Only" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:42 msgid "IPv4 Proxy" msgstr "" @@ -191,6 +271,10 @@ msgstr "" msgid "IPv6 DNS Hijack" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:60 +msgid "IPv6 Only" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:45 msgid "IPv6 Proxy" msgstr "" @@ -208,19 +292,68 @@ msgid "Local" msgstr "" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:27 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:45 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:53 msgid "Log" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:24 +msgid "Log Config" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:26 +msgid "Log Disabled" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:32 +msgid "Log Level" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:49 +msgid "Log Output" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:43 +msgid "Log Timestamp" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:194 msgid "Misc" msgstr "" +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:29 +msgid "Mixin Config" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:22 +msgid "Mixin Option" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:38 #: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:3 msgid "Momo" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:90 +msgid "NTP Config" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:92 +msgid "NTP Enabled" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:105 +msgid "NTP Interval" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:98 +msgid "NTP Server" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:101 +msgid "NTP Server Port" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:15 msgid "Not Running" msgstr "" @@ -241,6 +374,14 @@ msgstr "" msgid "Prefer" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:57 +msgid "Prefer IPv4" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:58 +msgid "Prefer IPv6" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/profile.js:21 #: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:21 msgid "Profile" @@ -257,7 +398,7 @@ msgstr "" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:29 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:31 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:29 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:37 msgid "Proxy Config" msgstr "" @@ -297,12 +438,32 @@ msgstr "" msgid "Running" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:31 +msgid "Scheduled Clear" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:34 +msgid "Scheduled Clear Cron" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:39 +msgid "Scheduled Clear Size Limit" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:45 +msgid "Scheduled Clear Size Limit Unit" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:114 msgid "Scheduled Restart" msgstr "" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:56 -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:89 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:117 +msgid "Scheduled Restart Cron" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:80 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:113 msgid "Scroll To Bottom" msgstr "" @@ -385,6 +546,14 @@ msgstr "" msgid "UDP Mode" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:136 +msgid "UI Download Url" +msgstr "" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:133 +msgid "UI Path" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:139 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:143 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:147 @@ -394,6 +563,31 @@ msgstr "" msgid "Unlimited" msgstr "" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:28 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:34 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:45 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:50 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:56 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:64 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:70 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:76 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:82 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:86 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:94 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:99 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:103 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:106 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:112 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:117 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:121 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:127 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:134 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:137 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:146 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:150 +msgid "Unmodified" +msgstr "" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/profile.js:63 msgid "Update" msgstr "" @@ -425,39 +619,3 @@ msgstr "" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:128 msgid "procd Config" msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:47 -msgid "Check log size at the configured cron schedule before cleaning up." -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:53 -msgid "Clear app, core and debug logs when their total size reaches this threshold." -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:27 -msgid "Log Cleanup" -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:32 -msgid "Log Cleanup Cron Expression" -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:49 -msgid "Log Cleanup Size Threshold (MB)" -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:42 -msgid "Log Size Check Cron Expression" -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:37 -msgid "Run unconditional log cleanup at the configured cron schedule." -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 -msgid "Scheduled Log Cleanup" -msgstr "" - -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:39 -msgid "Size-based Log Cleanup" -msgstr "" diff --git a/luci-app-momo/po/zh_Hans/momo.po b/luci-app-momo/po/zh_Hans/momo.po index 6ee9ae13..8e834d95 100644 --- a/luci-app-momo/po/zh_Hans/momo.po +++ b/luci-app-momo/po/zh_Hans/momo.po @@ -8,6 +8,14 @@ msgstr "" "MIME-Version: 1.0\n" "Content-Transfer-Encoding: 8bit\n" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:144 +msgid "API Listen" +msgstr "API 监听" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:148 +msgid "API Secret" +msgstr "API 密码" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:69 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:122 msgid "Access Control" @@ -31,7 +39,7 @@ msgstr "全部端口" msgid "App Config" msgstr "插件配置" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:53 msgid "App Log" msgstr "插件日志" @@ -63,6 +71,26 @@ msgstr "绕过 FWMark" msgid "CGroup" msgstr "控制组" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:108 +msgid "Cache Config" +msgstr "缓存配置" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:110 +msgid "Cache Enabled" +msgstr "启用缓存" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:116 +msgid "Cache Path" +msgstr "缓存文件路径" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:119 +msgid "Cache Store FakeIP" +msgstr "缓存 FakeIP" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:125 +msgid "Cache Store RDRC" +msgstr "缓存 RDRC" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/editor.js:27 msgid "Choose File" msgstr "选择文件" @@ -71,8 +99,8 @@ msgstr "选择文件" msgid "Choose Profile" msgstr "选择配置文件" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:33 -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:66 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:57 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:90 msgid "Clear Log" msgstr "清空日志" @@ -81,7 +109,7 @@ msgstr "清空日志" msgid "Commonly Used Port" msgstr "常用端口" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:62 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:86 msgid "Core Log" msgstr "核心日志" @@ -97,16 +125,40 @@ msgstr "核心状态" msgid "Core Version" msgstr "核心版本" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:117 -msgid "Cron Expression" -msgstr "Cron 表达式" - #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:99 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:165 msgid "DNS" msgstr "DNS" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:95 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:80 +msgid "DNS Cache Capacity" +msgstr "DNS 缓存容量" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:52 +msgid "DNS Config" +msgstr "DNS 配置" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:62 +msgid "DNS Disable Cache" +msgstr "禁用 DNS 缓存" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:68 +msgid "DNS Disable Expire" +msgstr "禁用 DNS 过期" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:74 +msgid "DNS Independent Cache" +msgstr "DNS 独立缓存" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:84 +msgid "DNS Reverse Mapping" +msgstr "DNS 反向映射" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:54 +msgid "DNS Strategy" +msgstr "DNS 策略" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:119 msgid "Debug Log" msgstr "调试日志" @@ -118,6 +170,16 @@ msgstr "要代理的 TCP 目标端口" msgid "Destination UDP Port to Proxy" msgstr "要代理的 UDP 目标端口" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:46 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:65 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:71 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:77 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:87 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:95 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:113 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:122 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:128 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:53 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:60 msgid "Disable" @@ -128,11 +190,21 @@ msgid "Edit Subscription" msgstr "编辑订阅" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/editor.js:25 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:37 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:45 msgid "Editor" msgstr "编辑器" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:96 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:30 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:47 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:66 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:72 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:78 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:88 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:96 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:114 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:123 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:129 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:33 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:66 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:77 @@ -145,6 +217,10 @@ msgstr "启用" msgid "Expire At" msgstr "到期时间" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:131 +msgid "External Control Config" +msgstr "外部控制配置" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:48 msgid "Fake-IP Ping Hijack" msgstr "Fake-IP Ping 劫持" @@ -162,7 +238,7 @@ msgstr "文件:" msgid "General Config" msgstr "全局配置" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:99 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:123 msgid "Generate & Download" msgstr "生成并下载" @@ -190,6 +266,10 @@ msgstr "使用说明" msgid "IPv4 DNS Hijack" msgstr "IPv4 DNS 劫持" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:59 +msgid "IPv4 Only" +msgstr "仅 IPv4" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:42 msgid "IPv4 Proxy" msgstr "IPv4 代理" @@ -198,6 +278,10 @@ msgstr "IPv4 代理" msgid "IPv6 DNS Hijack" msgstr "IPv6 DNS 劫持" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:60 +msgid "IPv6 Only" +msgstr "仅 IPv6" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:45 msgid "IPv6 Proxy" msgstr "IPv6 代理" @@ -215,19 +299,68 @@ msgid "Local" msgstr "本地" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:27 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:45 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:53 msgid "Log" msgstr "日志" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:24 +msgid "Log Config" +msgstr "日志配置" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:26 +msgid "Log Disabled" +msgstr "禁用日志" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:32 +msgid "Log Level" +msgstr "日志级别" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:49 +msgid "Log Output" +msgstr "日志输出文件路径" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:43 +msgid "Log Timestamp" +msgstr "打印时间戳" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:194 msgid "Misc" msgstr "杂项" +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:29 +msgid "Mixin Config" +msgstr "混入配置" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:22 +msgid "Mixin Option" +msgstr "混入选项" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:38 #: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:3 msgid "Momo" msgstr "Momo" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:90 +msgid "NTP Config" +msgstr "NTP 配置" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:92 +msgid "NTP Enabled" +msgstr "启用 NTP" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:105 +msgid "NTP Interval" +msgstr "NTP 时间同步间隔" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:98 +msgid "NTP Server" +msgstr "NTP 服务地址" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:101 +msgid "NTP Server Port" +msgstr "NTP 服务端口" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:15 msgid "Not Running" msgstr "未在运行" @@ -248,6 +381,14 @@ msgstr "打开面板" msgid "Prefer" msgstr "优先" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:57 +msgid "Prefer IPv4" +msgstr "优先 IPv4" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:58 +msgid "Prefer IPv6" +msgstr "优先 IPv6" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/profile.js:21 #: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:21 msgid "Profile" @@ -264,7 +405,7 @@ msgstr "代理" #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:29 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/proxy.js:31 -#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:29 +#: applications/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json:37 msgid "Proxy Config" msgstr "代理配置" @@ -304,12 +445,32 @@ msgstr "路由器代理" msgid "Running" msgstr "运行中" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:31 +msgid "Scheduled Clear" +msgstr "定时清除"" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:34 +msgid "Scheduled Clear Cron" +msgstr "Cron 表达式" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:39 +msgid "Scheduled Clear Size Limit" +msgstr "日志大小上限" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:45 +msgid "Scheduled Clear Size Limit Unit" +msgstr "日志大小上限单位" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:114 msgid "Scheduled Restart" msgstr "定时重启" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:56 -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:89 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:117 +msgid "Scheduled Restart Cron" +msgstr "Cron 表达式" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:80 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:113 msgid "Scroll To Bottom" msgstr "滚动到底部" @@ -392,6 +553,14 @@ msgstr "在 OpenWrt 上使用 sing-box 进行透明代理" msgid "UDP Mode" msgstr "UDP 模式" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:136 +msgid "UI Download Url" +msgstr "UI 下载地址" + +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:133 +msgid "UI Path" +msgstr "UI 路径" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:139 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:143 #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/app.js:147 @@ -401,6 +570,31 @@ msgstr "UDP 模式" msgid "Unlimited" msgstr "无限制" +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:28 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:34 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:45 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:50 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:56 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:64 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:70 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:76 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:82 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:86 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:94 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:99 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:103 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:106 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:112 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:117 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:121 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:127 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:134 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:137 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:146 +#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/mixin.js:150 +msgid "Unmodified" +msgstr "不修改" + #: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/profile.js:63 msgid "Update" msgstr "更新" @@ -433,38 +627,34 @@ msgstr "用户代理(UA)" msgid "procd Config" msgstr "procd 配置" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:47 -msgid "Check log size at the configured cron schedule before cleaning up." -msgstr "按设定的 Cron 周期检查日志大小,满足条件后再清理。" +#~ msgid "Cron Expression" +#~ msgstr "Cron 表达式" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:53 -msgid "Clear app, core and debug logs when their total size reaches this threshold." -msgstr "当插件日志、核心日志和调试日志总大小达到该阈值时自动清理。" +#~ msgid "Check log size at the configured cron schedule before cleaning up." +#~ msgstr "按设定的 Cron 周期检查日志大小,满足条件后再清理。" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:27 -msgid "Log Cleanup" -msgstr "日志清理" +#~ msgid "" +#~ "Clear app, core and debug logs when their total size reaches this " +#~ "threshold." +#~ msgstr "当插件日志、核心日志和调试日志总大小达到该阈值时自动清理。" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:32 -msgid "Log Cleanup Cron Expression" -msgstr "日志清理 Cron 表达式" +#~ msgid "Log Cleanup" +#~ msgstr "日志清理" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:49 -msgid "Log Cleanup Size Threshold (MB)" -msgstr "日志清理大小阈值(MB)" +#~ msgid "Log Cleanup Cron Expression" +#~ msgstr "日志清理 Cron 表达式" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:42 -msgid "Log Size Check Cron Expression" -msgstr "日志大小检查 Cron 表达式" +#~ msgid "Log Cleanup Size Threshold (MB)" +#~ msgstr "日志清理大小阈值(MB)" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:37 -msgid "Run unconditional log cleanup at the configured cron schedule." -msgstr "按设定的 Cron 时间无条件执行日志清理。" +#~ msgid "Log Size Check Cron Expression" +#~ msgstr "日志大小检查 Cron 表达式" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:29 -msgid "Scheduled Log Cleanup" -msgstr "定时日志清理" +#~ msgid "Run unconditional log cleanup at the configured cron schedule." +#~ msgstr "按设定的 Cron 时间无条件执行日志清理。" -#: applications/luci-app-momo/htdocs/luci-static/resources/view/momo/log.js:39 -msgid "Size-based Log Cleanup" -msgstr "按大小触发日志清理" +#~ msgid "Scheduled Log Cleanup" +#~ msgstr "定时日志清理" + +#~ msgid "Size-based Log Cleanup" +#~ msgstr "按大小触发日志清理" diff --git a/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json b/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json index af050de1..b5feb632 100644 --- a/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json +++ b/luci-app-momo/root/usr/share/luci/menu.d/luci-app-momo.json @@ -25,9 +25,17 @@ "path": "momo/profile" } }, + "admin/services/momo/mixin": { + "title": "Mixin Config", + "order": 30, + "action": { + "type": "view", + "path": "momo/mixin" + } + }, "admin/services/momo/proxy": { "title": "Proxy Config", - "order": 30, + "order": 40, "action": { "type": "view", "path": "momo/proxy" @@ -35,7 +43,7 @@ }, "admin/services/momo/editor": { "title": "Editor", - "order": 40, + "order": 50, "action": { "type": "view", "path": "momo/editor" @@ -43,7 +51,7 @@ }, "admin/services/momo/log": { "title": "Log", - "order": 50, + "order": 60, "action": { "type": "view", "path": "momo/log" diff --git a/luci-app-momo/root/usr/share/rpcd/ucode/luci.momo b/luci-app-momo/root/usr/share/rpcd/ucode/luci.momo index 9dce3295..667c616b 100644 --- a/luci-app-momo/root/usr/share/rpcd/ucode/luci.momo +++ b/luci-app-momo/root/usr/share/rpcd/ucode/luci.momo @@ -2,8 +2,8 @@ 'use strict'; -import { access, popen, readfile, writefile } from 'fs'; -import { get_paths, merge_exists, get_users, get_groups, get_cgroups } from '/etc/momo/ucode/include.uc'; +import { access, popen, readfile } from 'fs'; +import { get_paths, merge_exists, get_users, get_groups, get_cgroups, load_profile, save_profile } from '/etc/momo/ucode/include.uc'; const methods = { get_paths: { @@ -40,9 +40,8 @@ const methods = { profile: { args: { defaults: {} }, call: function(req) { - const paths = get_paths(); const defaults = req.args?.defaults ?? {}; - const profile = json(readfile(paths.run_profile_path)); + const profile = load_profile(); return merge_exists(defaults, profile); } }, @@ -67,8 +66,7 @@ const methods = { const query = req.args?.query; const body = req.args?.body; - const paths = get_paths(); - const profile = json(readfile(paths.run_profile_path)); + const profile = load_profile(); const api_listen = profile['experimental']['clash_api']['external_controller']; const api_secret = profile['experimental']['clash_api']['secret']; diff --git a/luci-app-nikki/Makefile b/luci-app-nikki/Makefile index c4faa169..86db9331 100644 --- a/luci-app-nikki/Makefile +++ b/luci-app-nikki/Makefile @@ -1,7 +1,7 @@ include $(TOPDIR)/rules.mk -PKG_VERSION:=1.25.3 -PKG_RELEASE:=3 +PKG_VERSION:=1.26.0 +PKG_RELEASE:=4 LUCI_TITLE:=LuCI Support for nikki LUCI_DEPENDS:=+luci-base +nikki diff --git a/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js b/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js index c1e51d79..943b7c01 100644 --- a/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js +++ b/luci-app-nikki/htdocs/luci-static/resources/view/nikki/mixin.js @@ -13,7 +13,6 @@ return view.extend({ return Promise.all([ uci.load('nikki'), network.getNetworks(), - ]); }, render: function (data) { diff --git a/luci-app-openclaw/CHANGELOG.md b/luci-app-openclaw/CHANGELOG.md index 4a11911e..f87e69d9 100644 --- a/luci-app-openclaw/CHANGELOG.md +++ b/luci-app-openclaw/CHANGELOG.md @@ -4,6 +4,124 @@ 格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)。 +## [2.0.4] - 2026-04-10 + +### 适配 OpenClaw v2026.4.9 + +本次更新适配 OpenClaw 最新稳定版本 (2026.4.9),包含多项破坏性配置变更处理和安全策略适配。 + +#### 版本变更 +- **OC_TESTED_VERSION**: 从 2026.3.28 更新到 2026.4.9 (跨 12 个版本) +- **Node.js 版本**: 保持 22.16.0 (v2026.4.x 最低要求 >= 22.14.0) + +#### 更新日志 UI 深度优化 + +参照 GitHub Releases 和 App Store 更新页面的设计规范,重构「检测升级」按钮触发的更新日志显示区域: + +- **Markdown 渲染引擎重构** (`ocMarkdownToHtml` 函数): + - 中英文混排字体栈优化:`PingFang SC` / `Microsoft YaHei` / `Noto Sans SC` + 系统字体回退 + - 字体渲染增强:`text-rendering: optimizeLegibility` + 抗锯齿平滑 + - 标题层级比例重构:一级标题 (20px) → 二级 (17px) → 三级 (15px) → 四级 (14px) + - 代码块样式:圆角边框 + 等宽字体栈 (`SF Mono`, Consolas, Menlo) + - 链接悬停效果:底部边框渐显动画 + - 列表标记美化:统一的 `list-style-position: inside` + 行高优化 (1.75) + +- **版本状态徽章设计**: + - 「有新版本」状态:渐变金色背景 + 橙色边框 + - 「已是最新」状态:渐变绿色背景 + 绿色边框 + - 「无法检查」状态:灰色背景 + 中性边框 + - 版本号使用等宽字体徽章样式,增强可读性 + +- **更新日志容器设计**: + - 卡片式布局:圆角 + 微阴影 + 白色背景 + - 版本标题栏:渐变背景 + 蓝色版本徽章 + GitHub 跳转链接 + - 内容区滚动条美化:`scrollbar-width: thin` + 自定义颜色 + - 操作按钮区:分组设计 + 阴影增强 + +- **视觉细节优化**: + - 段落间距:12px 统一间距 + - 行高优化:中文场景 1.75-1.8 + - 字间距微调:标题 `-0.02em`,正文 `0.01em` + - 颜色层级:主标题 `#1f2328` → 正文 `#32383f` → 次要文本 `#656d76` + +#### 破坏性变更处理 (v2026.4.5) + +OpenClaw v2026.4.5 移除了多项旧版配置别名,本版本新增自动清理逻辑: + +- **已废弃配置清理**: `sync_uci_to_json()` 新增清理以下废弃字段 + - `talk.voiceId` / `talk.apiKey` — 语音功能配置迁移 + - `browser.ssrfPolicy.allowPrivateNetwork` — SSRF 策略重构 + - `hooks.internal.handlers` — 内部钩子迁移 + - `channel.*.allow` / `group.*.allow` / `room.*.allow` — 迁移到 `enabled` 字段 + - `agents.defaults.cliBackends` — CLI 后端配置废弃 + +- **配置迁移工具集成**: 服务启动时自动调用 `openclaw doctor --fix` 迁移遗留配置 + +#### 权限模型适配 (v2026.4.7/4.9) + +OpenClaw 新增了严格的环境变量安全检查,阻止危险环境变量覆盖: + +- **环境变量安全审计**: `start_service()` 注入环境变量前进行路径安全验证 + - 阻止包含特殊字符 (`$`, `` ` ``, `|`, `&`) 的路径 + - 阻止 `/proc/*`, `/sys/*`, `/dev/*` 等危险目录 + - 确保 `JAVA_HOME`, `RUST_*`, `GIT_*`, `KUBECONFIG`, `AWS_*` 等危险变量不被意外注入 + +- **命令授权策略更新**: 适配 v2026.4.7 的权限收紧 + - `/allowlist add/remove` 现需要所有者授权 (LuCI 会话验证已满足) + - `gateway config.apply` 阻止修改 exec 审批路径 + +#### 安全更新 + +- **依赖安全**: `basic-ftp` 强制升级到 5.2.1 (修复 CRLF 命令注入漏洞 CVE) +- **浏览器 SSRF**: 新增安全策略配置选项 (默认不启用内网访问白名单) + +#### 改进 + +- **配置验证增强**: 服务启动时先执行配置 schema 验证,失败时自动尝试修复 +- **iframe 安全头修补**: 适配 v2026.4.x 新的安全头位置 + - v2026.4.x 将 `X-Frame-Options` 和 `frame-ancestors` 设置从 `gateway-cli-*.js` 迁移到 `server.impl-*.js` + - `patch_iframe_headers()` 函数现已支持两个位置的安全头修补 + - 解决 Web 控制台 iframe 嵌入被阻止的问题 +- **错误提示优化**: 配置迁移失败时输出详细的诊断信息 +- **移除 Gemini CLI 安装**: OpenClaw v2026.4.x 已废弃 `google-gemini-cli-auth` 插件 + - 节省约 155MB 磁盘空间 + - 减少安装时间约 30~60 秒 + - 用户配置 Google Gemini 请使用 API Key 方式 (推荐) +- **修复微信插件加载失败**: 清理 jiti 缓存目录解决权限冲突 + - jiti 编译 TypeScript 时在 `/tmp/jiti` 创建缓存 + - 服务启动前自动清理,避免 openclaw 用户无法写入 + +#### 修复 (2026-04-10) + +- **权限修复 EACCES 错误**: `doctor --fix` 以 root 运行后创建的文件导致 Gateway 无法写入 + - 根因: `doctor --fix` 在服务启动时以 root 身份执行,可能创建 root 所有的配置文件 + - 症状: `EACCES: permission denied, open '.../agents/main/agent/models.json.xxx.tmp'` + - 修复: `init.d/openclaw` 在 `doctor --fix` 后自动修复非 extensions 目录权限 + - 同时修复 `openclaw-env factory-reset` 后的权限问题 + +- **OpenWrt 兼容性**: 无 `ss` 命令时 Gateway 重启检测失败 + - 根因: `oc-config-interactive.js` 直接调用 `ss` 命令,部分精简固件未安装 + - 修复: 优先使用 `ss`,不存在时回退到 `netstat` + +- **新增交互式菜单引擎**: `oc-menu-engine.js` + - 方向键导航、搜索过滤、粘贴支持 (Bracketed Paste Mode) + - 纯 Node.js 实现,零外部依赖 + - 用于 SSH 命令行配置体验优化 + +#### 技术细节 + +**新增函数**: +- `_validate_path()`: 环境变量路径安全验证 +- `_run_config_migration()`: 执行 OpenClaw 官方配置迁移工具 + +**修改文件**: +- `root/etc/init.d/openclaw`: 配置同步逻辑、环境变量注入、权限修复 +- `root/usr/bin/openclaw-env`: OC_TESTED_VERSION 版本号、factory-reset 权限修复 +- `root/usr/share/openclaw/oc-config-interactive.js`: ss/netstat 兼容性 +- `root/usr/share/openclaw/oc-menu-engine.js`: 新增交互式菜单引擎 + +--- + ## [2.0.3] - 2026-04-03 ### 修复 diff --git a/luci-app-openclaw/Makefile b/luci-app-openclaw/Makefile index 00de4423..e22cfd25 100644 --- a/luci-app-openclaw/Makefile +++ b/luci-app-openclaw/Makefile @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-openclaw PKG_VERSION:=$(strip $(shell cat $(CURDIR)/VERSION 2>/dev/null || echo "1.0.0")) -PKG_RELEASE:=9 +PKG_RELEASE:=10 PKG_MAINTAINER:=10000ge10000 <10000ge10000@users.noreply.github.com> PKG_LICENSE:=GPL-3.0 diff --git a/luci-app-openclaw/README.md b/luci-app-openclaw/README.md index 7752d065..df1a9802 100644 --- a/luci-app-openclaw/README.md +++ b/luci-app-openclaw/README.md @@ -30,15 +30,19 @@ 无需 SDK,适用于已安装好的系统。 ```bash -wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.run -sh luci-app-openclaw.run +# 下载最新版本(自动获取版本号) +VER=$(curl -sI "https://github.com/10000ge10000/luci-app-openclaw/releases/latest" 2>/dev/null | grep -i "location:" | sed 's/.*tag\/v\{0,1\}//' | tr -d '\r\n') +wget "https://github.com/10000ge10000/luci-app-openclaw/releases/download/v${VER}/luci-app-openclaw_${VER}.run" +sh "luci-app-openclaw_${VER}.run" ``` ### 方式二:.ipk 安装 ```bash -wget https://github.com/10000ge10000/luci-app-openclaw/releases/latest/download/luci-app-openclaw.ipk -opkg install luci-app-openclaw.ipk +# 下载最新版本(自动获取版本号) +VER=$(curl -sI "https://github.com/10000ge10000/luci-app-openclaw/releases/latest" 2>/dev/null | grep -i "location:" | sed 's/.*tag\/v\{0,1\}//' | tr -d '\r\n') +wget "https://github.com/10000ge10000/luci-app-openclaw/releases/download/v${VER}/luci-app-openclaw_${VER}-1_all.ipk" +opkg install "luci-app-openclaw_${VER}-1_all.ipk" ``` ### 方式三:集成到固件编译 @@ -72,23 +76,6 @@ make package/luci-app-openclaw/compile V=s find bin/ -name "luci-app-openclaw*.ipk" ``` -### 方式四:手动安装 - -```bash -git clone https://github.com/10000ge10000/luci-app-openclaw.git -cd luci-app-openclaw - -cp -r root/* / -mkdir -p /usr/lib/lua/luci/controller /usr/lib/lua/luci/model/cbi/openclaw /usr/lib/lua/luci/view/openclaw -cp luasrc/controller/openclaw.lua /usr/lib/lua/luci/controller/ -cp luasrc/model/cbi/openclaw/*.lua /usr/lib/lua/luci/model/cbi/openclaw/ -cp luasrc/view/openclaw/*.htm /usr/lib/lua/luci/view/openclaw/ - -chmod +x /etc/init.d/openclaw /usr/bin/openclaw-env /usr/share/openclaw/oc-config.sh -sh /etc/uci-defaults/99-openclaw -rm -f /tmp/luci-indexcache /tmp/luci-modulecache/* -``` - ## 🔰 首次使用 diff --git a/luci-app-openclaw/VERSION b/luci-app-openclaw/VERSION index 50ffc5aa..2165f8f9 100644 --- a/luci-app-openclaw/VERSION +++ b/luci-app-openclaw/VERSION @@ -1 +1 @@ -2.0.3 +2.0.4 diff --git a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua index 4b903bbb..c630b915 100644 --- a/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua +++ b/luci-app-openclaw/luasrc/model/cbi/openclaw/basic.lua @@ -188,11 +188,27 @@ act.cfgvalue = function(self, section) html[#html+1] = '});' html[#html+1] = '}' - -- 轮询安装日志 + -- 轮询安装日志 (智能滚动: 用户向上滚动时暂停自动滚动,滚动到底部时恢复) html[#html+1] = 'var _lastLogLen=0;' + html[#html+1] = 'var _autoScrollEnabled=true;' -- 智能滚动状态标志 html[#html+1] = 'function ocPollSetupLog(){' html[#html+1] = 'if(_setupTimer)clearInterval(_setupTimer);' html[#html+1] = '_lastLogLen=0;' + html[#html+1] = '_autoScrollEnabled=true;' -- 初始状态: 启用自动滚动 + html[#html+1] = 'var logEl=document.getElementById("setup-log-content");' + -- 绑定滚动事件监听器 (只绑定一次) + html[#html+1] = 'if(!logEl._scrollListenerAttached){' + html[#html+1] = 'logEl.addEventListener("scroll",function(){' + html[#html+1] = 'var el=this;' + html[#html+1] = 'var atBottom=el.scrollHeight-el.scrollTop-el.clientHeight<5;' + html[#html+1] = 'if(atBottom){' + html[#html+1] = '_autoScrollEnabled=true;' -- 滚动到底部: 恢复自动滚动 + html[#html+1] = '}else{' + html[#html+1] = '_autoScrollEnabled=false;' -- 用户向上滚动: 暂停自动滚动 + html[#html+1] = '}' + html[#html+1] = '});' + html[#html+1] = 'logEl._scrollListenerAttached=true;' + html[#html+1] = '}' html[#html+1] = '_setupTimer=setInterval(function(){' html[#html+1] = '(new XHR()).get("' .. log_url .. '",null,function(x){' html[#html+1] = 'try{' @@ -204,7 +220,10 @@ act.cfgvalue = function(self, section) html[#html+1] = 'logEl.textContent+=newLog;' html[#html+1] = '_lastLogLen=r.log.length;' html[#html+1] = '}' + -- 智能滚动: 仅在自动滚动启用时滚动到底部 + html[#html+1] = 'if(_autoScrollEnabled){' html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;' + html[#html+1] = '}' html[#html+1] = 'if(r.state==="running"){' html[#html+1] = 'statusEl.innerHTML="⏳ 安装进行中...";' html[#html+1] = '}else if(r.state==="success"){' @@ -295,35 +314,38 @@ act.cfgvalue = function(self, section) html[#html+1] = '}catch(e){el.innerHTML="❌ 错误";}' html[#html+1] = '});}' - -- 简单的 Markdown 转 HTML 函数 (用于渲染 GitHub Release Notes) + -- Markdown 转 HTML 函数 (优化版 - 用于渲染 GitHub Release Notes) + -- 特性:中英文字体栈、代码块样式、链接悬停效果、列表美化 html[#html+1] = 'function ocMarkdownToHtml(md){' html[#html+1] = 'if(!md)return "";' -- 转义 HTML 特殊字符 html[#html+1] = 'var html=md.replace(/&/g,"&").replace(//g,">");' - -- 代码块 (```code```) - html[#html+1] = 'html=html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g,function(m,lang,code){return"
"+code.trim()+"
";});' + -- 代码块 (```code```) - 圆角边框 + 等宽字体栈 + html[#html+1] = 'html=html.replace(/```(\\w*)\\n([\\s\\S]*?)```/g,function(m,lang,code){return"
"+code.trim()+"
";});' -- 行内代码 (`code`) - html[#html+1] = 'html=html.replace(/`([^`]+)`/g,"$1");' - -- 标题 (### ## #) - html[#html+1] = 'html=html.replace(/^### (.+)$/gm,"

$1

");' - html[#html+1] = 'html=html.replace(/^## (.+)$/gm,"

$1

");' - html[#html+1] = 'html=html.replace(/^# (.+)$/gm,"

$1

");' + html[#html+1] = 'html=html.replace(/`([^`]+)`/g,"$1");' + -- 标题层级 (优化比例: h1=20px, h2=17px, h3=15px, h4=14px) + html[#html+1] = 'html=html.replace(/^#### (.+)$/gm,"
$1
");' + html[#html+1] = 'html=html.replace(/^### (.+)$/gm,"

$1

");' + html[#html+1] = 'html=html.replace(/^## (.+)$/gm,"

$1

");' + html[#html+1] = 'html=html.replace(/^# (.+)$/gm,"

$1

");' -- 粗体和斜体 - html[#html+1] = 'html=html.replace(/\\*\\*([^*]+)\\*\\*/g,"$1");' + html[#html+1] = 'html=html.replace(/\\*\\*([^*]+)\\*\\*/g,"$1");' html[#html+1] = 'html=html.replace(/\\*([^*]+)\\*/g,"$1");' - -- 链接 [text](url) - html[#html+1] = 'html=html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g,"$1");' + -- 链接 [text](url) - 悬停效果 + html[#html+1] = 'html=html.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g,"$1");' -- 无序列表 (- 或 *) - html[#html+1] = 'html=html.replace(/^[*-] (.+)$/gm,"
  • $1
  • ");' + html[#html+1] = 'html=html.replace(/^[*-] (.+)$/gm,"
  • $1
  • ");' -- 有序列表 (1. 2. 等) - html[#html+1] = 'html=html.replace(/^\\d+\\. (.+)$/gm,"
  • $1
  • ");' + html[#html+1] = 'html=html.replace(/^(\\d+)\\. (.+)$/gm,"
  • $1.$2
  • ");' -- 水平线 (--- 或 ***) - html[#html+1] = 'html=html.replace(/^(---|\\*\\*\\*)$/gm,"
    ");' - -- 段落 (连续的非空行合并) - html[#html+1] = 'html=html.replace(/\\n\\n/g,"

    ");' + html[#html+1] = 'html=html.replace(/^(---|\\*\\*\\*)$/gm,"


    ");' + -- 段落: 连续空行合并为段落分隔 (简化处理,避免生成未闭合标签) + html[#html+1] = 'html=html.replace(/\\n\\n+/g,"

    ");' -- 换行 html[#html+1] = 'html=html.replace(/\\n/g,"
    ");' - html[#html+1] = 'return"
    "+html+"
    ";' + -- 外层容器 - 中英文字体栈 + html[#html+1] = 'return"
    "+html+"
    ";' html[#html+1] = '}' -- 检测升级 (只检查插件版本,有新版本时显示更新内容) @@ -337,29 +359,31 @@ act.cfgvalue = function(self, section) html[#html+1] = 'var dot=document.getElementById("update-dot");if(dot)dot.style.display="none";' html[#html+1] = 'try{var r=JSON.parse(x.responseText);' html[#html+1] = 'var msgs=[];' - -- 插件版本检查 + -- 版本状态徽章样式 + html[#html+1] = 'var badgeNew="display:inline-flex;align-items:center;padding:3px 10px;border-radius:16px;font-size:12px;font-weight:600;background:linear-gradient(135deg,#fff7e6 0%,#ffe7ba 100%);color:#9a6700;border:1px solid #f5a623;";' + html[#html+1] = 'var badgeLatest="display:inline-flex;align-items:center;padding:3px 10px;border-radius:16px;font-size:12px;font-weight:600;background:linear-gradient(135deg,#e6f7e6 0%,#c8f7c8 100%);color:#1a7f1a;border:1px solid #28a745;";' + html[#html+1] = 'var badgeUnknown="display:inline-flex;align-items:center;padding:3px 10px;border-radius:16px;font-size:12px;font-weight:600;background:#f6f8fa;color:#656d76;border:1px solid #d0d7de;";' + html[#html+1] = 'var verBadge="display:inline-block;padding:2px 8px;border-radius:4px;font-size:12px;font-family:SF Mono,Consolas,Menlo,monospace;background:#e1e4e8;color:#24292f;margin-left:4px;";' + -- 插件版本检查 (带渐变徽章) html[#html+1] = 'if(r.plugin_current){' - html[#html+1] = 'if(r.plugin_has_update){msgs.push("🔌 插件: v"+r.plugin_current+" → v"+r.plugin_latest+" (有新版本)");}' - html[#html+1] = 'else if(r.plugin_latest){msgs.push("✅ 插件: v"+r.plugin_current+" (已是最新)");}' - html[#html+1] = 'else{msgs.push("🔌 插件: v"+r.plugin_current+" (无法检查最新版本)");}' + html[#html+1] = 'if(r.plugin_has_update){msgs.push("🔌 有新版本 v"+r.plugin_current+" → v"+r.plugin_latest+"");}' + html[#html+1] = 'else if(r.plugin_latest){msgs.push("✅ 已是最新 v"+r.plugin_current);}' + html[#html+1] = 'else{msgs.push("🔌 无法检查 v"+r.plugin_current);}' html[#html+1] = '}' - html[#html+1] = 'if(msgs.length===0)msgs.push("无法获取版本信息");' + html[#html+1] = 'if(msgs.length===0)msgs.push("无法获取版本信息");' html[#html+1] = 'el.innerHTML=msgs.join("
    ");' - -- 插件有更新时: release notes + 一键升级按钮 + GitHub 下载链接 + -- 插件有更新时: 卡片式更新日志 + 操作按钮 html[#html+1] = 'if(r.plugin_has_update){' html[#html+1] = 'act.style.display="block";' html[#html+1] = 'window._pluginLatestVer=r.plugin_latest;' html[#html+1] = 'var notesHtml="";' html[#html+1] = 'if(r.release_notes){' html[#html+1] = 'var rendered=ocMarkdownToHtml(r.release_notes);' - html[#html+1] = 'notesHtml=\'
    \'' - html[#html+1] = '+\'
    📋 v\'+r.plugin_latest+\' 更新内容
    \'' - html[#html+1] = '+rendered' - html[#html+1] = '+\'
    \';' + -- 卡片式容器: 圆角边框 + 微阴影 + 版本标题栏 + html[#html+1] = 'notesHtml="
    📋 更新日志v"+r.plugin_latest+"
    "+rendered+"
    ";' html[#html+1] = '}' - html[#html+1] = 'act.innerHTML=notesHtml' - html[#html+1] = '+\'\'' - html[#html+1] = '+\' 📥 手动下载\';' + -- 操作按钮区: 分组设计 + html[#html+1] = 'act.innerHTML=notesHtml+"
    📥 GitHub 下载
    ";' html[#html+1] = '}' html[#html+1] = '}catch(e){el.innerHTML="❌ 检测失败";}' html[#html+1] = '});}' @@ -390,10 +414,12 @@ act.cfgvalue = function(self, section) html[#html+1] = '}' -- 轮询插件升级日志 (带容错: 安装时文件被替换可能导致API暂时不可用) + -- 复用安装日志的智能滚动机制 html[#html+1] = 'var _pluginPollErrors=0;' html[#html+1] = 'function ocPollPluginUpgradeLog(){' html[#html+1] = 'if(_pluginUpgradeTimer)clearInterval(_pluginUpgradeTimer);' html[#html+1] = '_pluginPollErrors=0;' + html[#html+1] = '_autoScrollEnabled=true;' -- 重置: 启用自动滚动 html[#html+1] = '_pluginUpgradeTimer=setInterval(function(){' html[#html+1] = '(new XHR()).get("' .. plugin_upgrade_log_url .. '",null,function(x){' html[#html+1] = 'try{' @@ -402,7 +428,10 @@ act.cfgvalue = function(self, section) html[#html+1] = 'var logEl=document.getElementById("setup-log-content");' html[#html+1] = 'var statusEl=document.getElementById("setup-log-status");' html[#html+1] = 'if(r.log)logEl.textContent=r.log;' + -- 智能滚动: 仅在自动滚动启用时滚动到底部 + html[#html+1] = 'if(_autoScrollEnabled){' html[#html+1] = 'logEl.scrollTop=logEl.scrollHeight;' + html[#html+1] = '}' html[#html+1] = 'if(r.state==="running"){' html[#html+1] = 'statusEl.innerHTML="⏳ 插件升级中...";' html[#html+1] = '}else if(r.state==="success"){' diff --git a/luci-app-openclaw/root/etc/init.d/openclaw b/luci-app-openclaw/root/etc/init.d/openclaw index 1cd8fabb..b3f6c6ca 100755 --- a/luci-app-openclaw/root/etc/init.d/openclaw +++ b/luci-app-openclaw/root/etc/init.d/openclaw @@ -77,8 +77,20 @@ rm -f "$_tmpf" patch_iframe_headers() { # 移除 OpenClaw 网关的 X-Frame-Options 和 frame-ancestors 限制,允许 LuCI iframe 嵌入 -# v2026.3.8: 资产路径可能通过符号链接解析,需同时搜索 OC_GLOBAL 和 NODE_BASE -local gw_js +# v2026.4.x: 安全头设置迁移到 server.impl-*.js 文件 +local patched=0 + +# 1. 搜索 server.impl-*.js (v2026.4.x 新位置) +for f in $(find "${OC_GLOBAL}/lib/node_modules/openclaw/dist" -name "server.impl-*.js" -type f 2>/dev/null); do + if grep -q "X-Frame-Options.*DENY\|frame-ancestors.*none" "$f" 2>/dev/null; then + sed -i 's|res\.setHeader("X-Frame-Options", "DENY")|res.setHeader("X-Frame-Options", "ALLOW-FROM *") // patched by luci-app-openclaw|g' "$f" + sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f" + logger -t openclaw "Patched iframe headers in $f" + patched=1 + fi +done + +# 2. 兼容旧版本: 搜索 gateway-cli-*.js (v2026.3.x) for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do [ -d "$search_root" ] || continue for f in $(find "$search_root" -name "gateway-cli-*.js" -type f 2>/dev/null); do @@ -86,14 +98,17 @@ for search_root in "${OC_GLOBAL}" "${NODE_BASE}/lib"; do sed -i "s|res.setHeader(\"X-Frame-Options\", \"DENY\");|// res.setHeader(\"X-Frame-Options\", \"DENY\"); // patched by luci-app-openclaw|g" "$f" sed -i "s|\"frame-ancestors 'none'\"|\"frame-ancestors *\"|g" "$f" logger -t openclaw "Patched iframe headers in $f" + patched=1 fi done done + +[ $patched -eq 1 ] && logger -t openclaw "iframe headers patched successfully" } sync_uci_to_json() { # 将 UCI 配置同步到 openclaw.json,同时确保 token 双向同步 -local port bind token +local port bind token json_token port=$(uci -q get openclaw.main.port || echo "18789") bind=$(uci -q get openclaw.main.bind || echo "lan") token=$(uci -q get openclaw.main.token || echo "") @@ -104,14 +119,28 @@ if [ ! -f "$CONFIG_FILE" ]; then echo '{}' > "$CONFIG_FILE" fi -# UCI 没有 token 时,尝试从已有的 JSON 读取 -if [ -z "$token" ] && [ -x "$NODE_BIN" ]; then - token=$("$NODE_BIN" -e " +# 尝试从 JSON 读取 token (用于双向同步检测) +if [ -x "$NODE_BIN" ]; then + json_token=$("$NODE_BIN" -e " try{const d=JSON.parse(require('fs').readFileSync('${CONFIG_FILE}','utf8')); if(d.gateway&&d.gateway.auth&&d.gateway.auth.token)process.stdout.write(d.gateway.auth.token);}catch(e){} " 2>/dev/null) fi +# v2026.4.10: 双向同步策略 - JSON 中的 token 优先 +# 原因: doctor --fix 或其他操作可能修改 JSON 中的 token +# 如果 JSON 有 token 且与 UCI 不同,以 JSON 为准 +if [ -n "$json_token" ] && [ "$json_token" != "$token" ]; then + token="$json_token" + logger -t openclaw "Token 同步: JSON -> UCI" +fi + +# UCI 和 JSON 都没有 token 时,生成一个新的 +if [ -z "$token" ]; then + token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48) + logger -t openclaw "Token 生成: 新 token" +fi + # 如果仍然没有 token,生成一个新的 if [ -z "$token" ]; then token=$(head -c 24 /dev/urandom | hexdump -e '24/1 "%02x"' 2>/dev/null || openssl rand -hex 24 2>/dev/null || dd if=/dev/urandom bs=24 count=1 2>/dev/null | od -An -tx1 | tr -d ' \n' | head -c 48) @@ -162,6 +191,37 @@ d.update.checkOnStart=false; if(d.models&&d.models.providers&&d.models.providers.ollama){const ol=d.models.providers.ollama;if(ol.api==='openai-chat-completions'||ol.api==='openai-completions')ol.api='ollama';if(ol.baseUrl&&ol.baseUrl.endsWith('/v1'))ol.baseUrl=ol.baseUrl.replace(/\/v1$/,'');if(ol.apiKey==='ollama')ol.apiKey='ollama-local';} // v2026.3.7: 清理已废弃的顶层配置键 (loadConfig() 现在会严格校验) ['cli','commands.native','commands.nativeSkills','commands.ownerDisplay'].forEach(k=>{const ks=k.split('.');let o=d;for(let i=0;i{if(d[k]){Object.keys(d[k]).forEach(id=>{if(d[k][id]&&typeof d[k][id].allow!=='undefined'){d[k][id].enabled=d[k][id].allow;delete d[k][id].allow;}});}}); +// 5. agents.defaults.cliBackends 废弃 +if(d.agents&&d.agents.defaults){delete d.agents.defaults.cliBackends;} +// v2026.4.9: 自动同步 plugins.allow (解决 "plugins.allow is empty" 警告) +// 扫描已安装的插件并添加到 allow 列表 +if(!d.plugins)d.plugins={}; +if(!Array.isArray(d.plugins.allow))d.plugins.allow=[]; +// 从 plugins.installs 中提取已安装插件 +// 注意: 使用 installPath 中的目录名作为插件 ID (与 openclaw.plugin.json 中的 id 一致) +if(d.plugins.installs){ + Object.keys(d.plugins.installs).forEach(key=>{ + const inst=d.plugins.installs[key]; + if(inst&&inst.installPath){ + // 从 installPath 提取插件目录名 (如 openclaw-weixin) + const match=inst.installPath.match(/\/([^\/]+)\/?$/); + if(match&&match[1]&&!d.plugins.allow.includes(match[1])){ + d.plugins.allow.push(match[1]); + } + } + }); +} +// 始终信任 copilot-proxy (内置插件) +if(!d.plugins.allow.includes('copilot-proxy'))d.plugins.allow.push('copilot-proxy'); fs.writeFileSync(f,JSON.stringify(d,null,2)); " 2>/dev/null fi @@ -252,6 +312,98 @@ fi # 修复数据目录权限 (防止 root 用户操作后留下无法读取的文件) chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true + # v2026.4.9: 修复插件目录权限 (OpenClaw 要求插件目录属主为 root) + # 详见: https://github.com/nicepkg/openclaw/releases/tag/v2026.4.9 + local ext_dir="${OC_DATA}/.openclaw/extensions" + if [ -d "$ext_dir" ]; then + chown -R root:root "$ext_dir" 2>/dev/null || true + fi + + # v2026.4.x: 清理 jiti 缓存目录 (修复微信插件加载权限问题) + # jiti 编译 TypeScript 时会在 /tmp/jiti 创建缓存,如果由 root 创建则 openclaw 用户无法写入 + rm -rf /tmp/jiti 2>/dev/null || true + + # v2026.4.5: 执行 OpenClaw 官方配置迁移工具 + # 自动迁移遗留配置到新版本格式 + _run_config_migration() { + local oc_entry="$1" + [ -z "$oc_entry" ] && return + logger -t openclaw "执行配置迁移 (doctor --fix)..." + OPENCLAW_HOME="$OC_DATA" OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \ + "$NODE_BIN" "$oc_entry" doctor --fix 2>/dev/null && \ + logger -t openclaw "配置迁移完成" || \ + logger -t openclaw "配置迁移失败,请手动检查" + } + _run_config_migration "$oc_entry" + + # doctor --fix 后再次修复权限 (防止 root 创建的文件导致 EACCES) + # 注意: extensions 目录保持 root 权限 (OpenClaw 安全要求) + find "$OC_DATA" -user root ! -path "${OC_DATA}/.openclaw/extensions*" -exec chown openclaw:openclaw {} \; 2>/dev/null || true + + # v2026.4.10: doctor --fix 后确保关键配置字段存在 + # doctor --fix 可能会删除或遗漏某些必要字段,需要补充 + _ensure_critical_config() { + [ ! -f "$CONFIG_FILE" ] && return + [ ! -x "$NODE_BIN" ] && return + "$NODE_BIN" -e " +const fs = require('fs'); +const f = '${CONFIG_FILE}'; +let d = {}; +try { d = JSON.parse(fs.readFileSync(f, 'utf8')); } catch(e) {} + +// 确保 gateway.mode 存在 +if (!d.gateway) d.gateway = {}; +if (!d.gateway.mode) d.gateway.mode = 'local'; + +// 确保端口和绑定配置 +if (!d.gateway.port) d.gateway.port = 18789; +if (!d.gateway.bind) d.gateway.bind = 'lan'; + +// 确保 controlUi 安全配置存在 (允许非 loopback 绑定和 LuCI iframe 嵌入) +if (!d.gateway.controlUi) d.gateway.controlUi = {}; +if (d.gateway.controlUi.allowInsecureAuth !== true) d.gateway.controlUi.allowInsecureAuth = true; +if (d.gateway.controlUi.dangerouslyDisableDeviceAuth !== true) d.gateway.controlUi.dangerouslyDisableDeviceAuth = true; +if (d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback !== true) d.gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback = true; + +// 确保认证配置 +if (!d.gateway.auth) d.gateway.auth = {}; +if (!d.gateway.auth.mode) d.gateway.auth.mode = 'token'; + +// 禁用 ACP dispatch (路由器内存有限) +if (!d.acp) d.acp = {}; +if (!d.acp.dispatch) d.acp.dispatch = {}; +d.acp.dispatch.enabled = false; + +// 工具配置 +if (!d.tools) d.tools = {}; +d.tools.profile = 'coding'; + +// 禁用更新检查 +if (!d.update) d.update = {}; +d.update.checkOnStart = false; + +fs.writeFileSync(f, JSON.stringify(d, null, 2)); +" 2>/dev/null + chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true + } + _ensure_critical_config + + # v2026.4.10: 最终权限修复 - 确保 Gateway 启动前所有数据目录权限正确 + # doctor --fix 或 _ensure_critical_config 可能创建新的目录/文件 + # 关键: agents 目录下的 agent 子目录必须可被 openclaw 用户写入 + find "${OC_STATE_DIR}" -user root -type d -exec chown openclaw:openclaw {} \; 2>/dev/null || true + find "${OC_STATE_DIR}" -user root -type f -exec chown openclaw:openclaw {} \; 2>/dev/null || true + # 特别确保 agents/main/agent 目录权限 (模型配置写入目录) + local agent_dir="${OC_STATE_DIR}/agents/main/agent" + if [ -d "$agent_dir" ]; then + chown openclaw:openclaw "$agent_dir" 2>/dev/null || true + chmod 755 "$agent_dir" 2>/dev/null || true + fi + # extensions 目录除外 - 必须保持 root 权限 (OpenClaw 安全要求) + if [ -d "${OC_STATE_DIR}/extensions" ]; then + chown -R root:root "${OC_STATE_DIR}/extensions" 2>/dev/null || true + fi + # Patch iframe 安全头,允许 LuCI 嵌入 patch_iframe_headers @@ -305,19 +457,32 @@ _ensure_port_free() { _ensure_port_free "$port" # 启动 OpenClaw Gateway (主服务, 前台运行) -procd_open_instance "gateway" -procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \ ---port "$port" --bind "$gw_bind" -procd_set_param env \ -HOME="$OC_DATA" \ -OPENCLAW_HOME="$OC_DATA" \ -OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \ -OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \ -NODE_ICU_DATA="${NODE_BASE}/share/icu" \ -NODE_BASE="$NODE_BASE" \ -OC_GLOBAL="$OC_GLOBAL" \ -OC_DATA="$OC_DATA" \ -PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + # v2026.4.7/4.9: 环境变量安全验证 (阻止危险变量注入) + _validate_path() { + local p="$1" + case "$p" in + *'$'*|*'`'*|*'|'*|*'&'*) return 1 ;; + /proc/*|/sys/*|/dev/*) return 1 ;; + *) return 0 ;; + esac + } + for _dir in "$NODE_BASE" "$OC_GLOBAL" "$OC_DATA"; do + _validate_path "$_dir" || { logger -t openclaw "安全警告: 路径验证失败: $_dir"; return 1; } + done + + procd_open_instance "gateway" + procd_set_param command "$NODE_BIN" "$oc_entry" gateway run \ + --port "$port" --bind "$gw_bind" + procd_set_param env \ + HOME="$OC_DATA" \ + OPENCLAW_HOME="$OC_DATA" \ + OPENCLAW_STATE_DIR="${OC_DATA}/.openclaw" \ + OPENCLAW_CONFIG_PATH="$CONFIG_FILE" \ + NODE_ICU_DATA="${NODE_BASE}/share/icu" \ + NODE_BASE="$NODE_BASE" \ + OC_GLOBAL="$OC_GLOBAL" \ + OC_DATA="$OC_DATA" \ + PATH="${NODE_BASE}/bin:${OC_GLOBAL}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" procd_set_param user openclaw procd_set_param respawn 3600 10 5 procd_set_param stdout 1 diff --git a/luci-app-openclaw/root/etc/uci-defaults/99-openclaw b/luci-app-openclaw/root/etc/uci-defaults/99-openclaw index 1aef4a68..a953d077 100755 --- a/luci-app-openclaw/root/etc/uci-defaults/99-openclaw +++ b/luci-app-openclaw/root/etc/uci-defaults/99-openclaw @@ -90,4 +90,16 @@ if [ -z "$CURRENT_PTY_TOKEN" ]; then uci commit openclaw fi +# 启用服务自启动 (创建 /etc/rc.d/S99openclaw 符号链接) +# 这一步确保安装/升级后服务能随系统启动 +# 注意: 仅当 UCI 中 enabled='1' 或尚未设置 enabled 时才启用 +# 如果用户明确设置 enabled='0' (禁用),则尊重用户选择 +if [ -x /etc/init.d/openclaw ]; then + OC_ENABLED=$(uci -q get openclaw.main.enabled) + # enabled='1' 或未设置时启用自启动;enabled='0' 时不操作 + if [ "$OC_ENABLED" != "0" ]; then + /etc/init.d/openclaw enable 2>/dev/null + fi +fi + exit 0 diff --git a/luci-app-openclaw/root/usr/bin/openclaw-env b/luci-app-openclaw/root/usr/bin/openclaw-env index 3fbaca73..bcabeec0 100755 --- a/luci-app-openclaw/root/usr/bin/openclaw-env +++ b/luci-app-openclaw/root/usr/bin/openclaw-env @@ -19,7 +19,7 @@ NODE_VERSION_V1="22.15.1" # 默认使用 V2 版本 (可通过 NODE_VERSION 环境变量覆盖) NODE_VERSION="${NODE_VERSION:-${NODE_VERSION_V2}}" # 经过验证的 OpenClaw 稳定版本 (更新此值需同步测试) -OC_TESTED_VERSION="2026.3.28" +OC_TESTED_VERSION="2026.4.9" # 用户可通过 OC_VERSION 环境变量覆盖安装版本 OC_VERSION="${OC_VERSION:-}" @@ -375,18 +375,6 @@ install_openclaw() { log_error "OpenClaw 安装验证失败" exit 1 fi - - # 安装 Gemini CLI (官方模型配置向导的 Google Gemini OAuth 依赖) - if [ -x "$NPM_BIN" ]; then - echo "" - echo "=== 安装 Gemini CLI (Google OAuth 依赖) ===" - "$NPM_BIN" install -g @google/gemini-cli --prefix="$OC_GLOBAL" $install_flags 2>&1 | tail -3 - if command -v gemini >/dev/null 2>&1 || [ -x "$OC_GLOBAL/bin/gemini" ]; then - log_info "Gemini CLI 安装成功" - else - log_warn "Gemini CLI 安装失败 (不影响核心功能,仅影响 Google Gemini OAuth 登录)" - fi - fi } init_openclaw() { @@ -635,7 +623,10 @@ do_factory_reset() { log_info "新认证令牌: $new_token" fi - # 7. 重启服务 + # 7. 确保数据目录权限正确 (防止 root 操作留下的文件) + chown -R openclaw:openclaw "$OC_DATA" 2>/dev/null || true + + # 8. 重启服务 /etc/init.d/openclaw start >/dev/null 2>&1 & log_info "出厂设置已恢复,Gateway 重启中..." } diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js b/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js new file mode 100644 index 00000000..0143e735 --- /dev/null +++ b/luci-app-openclaw/root/usr/share/openclaw/oc-config-interactive.js @@ -0,0 +1,2031 @@ +#!/usr/bin/env node +/** + * ============================================================================ + * OpenClaw 配置工具 — 交互式菜单前端 + * ============================================================================ + * + * 功能特性: + * - 方向键 (↑↓) 导航菜单选项 (使用 oc-menu-engine 引擎) + * - 回车/空格 确认选择 + * - 实时搜索过滤 + * - 与 oc-config.sh 业务逻辑完全一致 + * - 纯 Node.js 实现,零外部依赖 + * - 完全兼容 xterm.js / Web PTY 环境 + */ + +const path = require('path'); +const fs = require('fs'); +const { spawn, execSync } = require('child_process'); +const menu = require('./oc-menu-engine'); +const { C, select, input, confirm, spinner, resetRenderCount } = menu; + +// ═══════════════════════════════════════════════════════════════════════════ +// 配置路径 (与 oc-config.sh 保持一致) +// ═══════════════════════════════════════════════════════════════════════════ + +const OC_BASE_PATH = process.env.OC_BASE_PATH || '/opt'; +const OC_INSTALL_PATH = process.env.OC_INSTALL_PATH || `${OC_BASE_PATH}/openclaw`; +const NODE_BASE = process.env.NODE_BASE || `${OC_INSTALL_PATH}/node`; +const OC_GLOBAL = process.env.OC_GLOBAL || `${OC_INSTALL_PATH}/global`; +const OC_DATA = process.env.OC_DATA || `${OC_INSTALL_PATH}/data`; +const OC_STATE_DIR = process.env.OPENCLAW_STATE_DIR || `${OC_DATA}/.openclaw`; +const CONFIG_FILE = process.env.OPENCLAW_CONFIG_PATH || `${OC_STATE_DIR}/openclaw.json`; +const NODE_BIN = `${NODE_BASE}/bin/node`; + +// ═══════════════════════════════════════════════════════════════════════════ +// 辅助函数 (与 oc-config.sh 逻辑对应) +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * 执行命令并返回结果 + */ +function runCommand(cmd, args = [], options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(cmd, args, { + stdio: ['inherit', 'pipe', 'pipe'], + shell: true, + env: { ...process.env, ...options.env }, + }); + + let stdout = ''; + let stderr = ''; + + child.stdout.on('data', (data) => { + stdout += data; + process.stdout.write(data); + }); + + child.stderr.on('data', (data) => { + stderr += data; + process.stderr.write(data); + }); + + child.on('close', (code) => { + if (code === 0) { + resolve({ stdout, stderr, code }); + } else { + reject(new Error(`Command failed with code ${code}: ${stderr}`)); + } + }); + }); +} + +/** + * 读取 JSON 配置文件 + */ +function readConfig() { + try { + if (fs.existsSync(CONFIG_FILE)) { + return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); + } + } catch (e) { + console.error(`${C.red}读取配置失败:${C.reset} ${e.message}`); + } + return {}; +} + +/** + * 写入 JSON 配置文件 + */ +function writeConfig(config) { + const dir = path.dirname(CONFIG_FILE); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); + try { + execSync(`chown openclaw:openclaw "${CONFIG_FILE}"`, { stdio: 'ignore' }); + } catch {} +} + +/** + * 获取 JSON 配置值 (对应 json_get) + */ +function jsonGet(keyPath) { + const config = readConfig(); + const keys = keyPath.split('.'); + let value = config; + for (const key of keys) { + if (value && typeof value === 'object' && key in value) { + value = value[key]; + } else { + return null; + } + } + return value; +} + +/** + * 设置 JSON 配置值 (对应 json_set) + */ +function jsonSet(keyPath, value) { + const config = readConfig(); + const keys = keyPath.split('.'); + let obj = config; + for (let i = 0; i < keys.length - 1; i++) { + if (!obj[keys[i]]) obj[keys[i]] = {}; + obj = obj[keys[i]]; + } + obj[keys[keys.length - 1]] = value; + writeConfig(config); +} + +/** + * 获取当前活跃模型 + */ +function getCurrentModel() { + const primary = jsonGet('agents.defaults.model.primary'); + if (primary) return primary; + const config = readConfig(); + if (config.models?.defaultModel) return config.models.defaultModel; + return null; +} + +/** + * 查找 OpenClaw 入口文件 + */ +function findOpenClawEntry() { + const searchPaths = [ + `${OC_GLOBAL}/lib/node_modules/openclaw/openclaw.mjs`, + `${OC_GLOBAL}/lib/node_modules/openclaw/dist/cli.js`, + `${OC_GLOBAL}/node_modules/openclaw/openclaw.mjs`, + `${OC_GLOBAL}/node_modules/openclaw/dist/cli.js`, + ]; + for (const p of searchPaths) { + if (fs.existsSync(p)) return p; + } + try { + const dirs = fs.readdirSync(OC_GLOBAL); + for (const dir of dirs) { + const p = `${OC_GLOBAL}/${dir}/node_modules/openclaw/openclaw.mjs`; + if (fs.existsSync(p)) return p; + } + } catch {} + return null; +} + +/** + * 执行 OpenClaw CLI 命令 (对应 oc_cmd) + */ +async function ocCmd(...args) { + const ocEntry = findOpenClawEntry(); + if (!ocEntry) throw new Error('OpenClaw 未安装'); + return runCommand(NODE_BIN, [ocEntry, ...args], { + env: { + OPENCLAW_HOME: OC_DATA, + OPENCLAW_CONFIG_PATH: CONFIG_FILE, + OPENCLAW_STATE_DIR: OC_STATE_DIR, + HOME: OC_DATA, + } + }); +} + +/** + * 注册模型并设为默认 (对应 register_and_set_model) + */ +function registerAndSetModel(modelId) { + const config = readConfig(); + if (!config.agents) config.agents = {}; + if (!config.agents.defaults) config.agents.defaults = {}; + if (!config.agents.defaults.models) config.agents.defaults.models = {}; + if (!config.agents.defaults.model) config.agents.defaults.model = {}; + config.agents.defaults.models[modelId] = {}; + config.agents.defaults.model.primary = modelId; + writeConfig(config); +} + +/** + * 写入 API Key 到 auth-profiles.json (对应 auth_set_apikey) + */ +function authSetApikey(provider, apiKey, profileId) { + const authDir = `${OC_STATE_DIR}/agents/main/agent`; + const authFile = `${authDir}/auth-profiles.json`; + try { + fs.mkdirSync(authDir, { recursive: true }); + } catch {} + + let authData = { version: 1, profiles: {}, usageStats: {} }; + try { + if (fs.existsSync(authFile)) { + authData = JSON.parse(fs.readFileSync(authFile, 'utf8')); + } + } catch {} + + if (!authData.profiles) authData.profiles = {}; + authData.profiles[profileId || `${provider}:manual`] = { + type: 'api_key', + provider: provider, + key: apiKey, + }; + + fs.writeFileSync(authFile, JSON.stringify(authData, null, 2)); + try { + execSync(`chown openclaw:openclaw "${authFile}"`, { stdio: 'ignore' }); + } catch {} +} + +/** + * 注册自定义提供商 (对应 register_custom_provider) + */ +function registerCustomProvider(providerName, baseUrl, apiKey, modelId, modelDisplay, ctxWindow, maxTok) { + const config = readConfig(); + if (!config.models) config.models = {}; + if (!config.models.providers) config.models.providers = {}; + config.models.mode = 'merge'; + config.models.providers[providerName] = { + baseUrl: baseUrl, + apiKey: apiKey, + api: 'openai-completions', + models: [{ + id: modelId, + name: modelDisplay || modelId, + reasoning: false, + input: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: parseInt(ctxWindow) || 128000, + maxTokens: parseInt(maxTok) || 32000, + }] + }; + writeConfig(config); +} + +/** + * 注册 Coding Plan 提供商 (对应 register_codingplan_provider) + */ +function registerCodingPlanProvider(apiKey) { + const config = readConfig(); + if (!config.models) config.models = {}; + if (!config.models.providers) config.models.providers = {}; + config.models.mode = 'merge'; + config.models.providers['bailian'] = { + baseUrl: 'https://coding.dashscope.aliyuncs.com/v1', + apiKey: apiKey, + api: 'openai-completions', + models: [ + { id: 'qwen3.5-plus', name: 'qwen3.5-plus', reasoning: false, input: ['text', 'image'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 65536 }, + { id: 'qwen3-coder-plus', name: 'qwen3-coder-plus', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 1000000, maxTokens: 65536 }, + { id: 'qwen3-coder-next', name: 'qwen3-coder-next', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 65536 }, + { id: 'qwen3-max-2026-01-23', name: 'qwen3-max-2026-01-23', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 65536 }, + { id: 'MiniMax-M2.5', name: 'MiniMax-M2.5', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204800, maxTokens: 131072 }, + { id: 'glm-5', name: 'glm-5', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202752, maxTokens: 16384 }, + { id: 'glm-4.7', name: 'glm-4.7', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202752, maxTokens: 16384 }, + { id: 'kimi-k2.5', name: 'kimi-k2.5', reasoning: false, input: ['text', 'image'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 32768 }, + ] + }; + + if (!config.agents) config.agents = {}; + if (!config.agents.defaults) config.agents.defaults = {}; + if (!config.agents.defaults.models) config.agents.defaults.models = {}; + ['qwen3.5-plus', 'qwen3-coder-plus', 'qwen3-coder-next', 'qwen3-max-2026-01-23', 'MiniMax-M2.5', 'glm-5', 'glm-4.7', 'kimi-k2.5'].forEach(m => { + config.agents.defaults.models[`bailian/${m}`] = {}; + }); + + writeConfig(config); +} + +/** + * 注册腾讯云 Coding Plan 提供商 (对应 register_lkeap_codingplan_provider) + */ +function registerLkeapCodingPlanProvider(apiKey) { + const config = readConfig(); + if (!config.models) config.models = {}; + if (!config.models.providers) config.models.providers = {}; + config.models.mode = 'merge'; + config.models.providers['lkeap'] = { + baseUrl: 'https://api.lkeap.cloud.tencent.com/coding/v3', + apiKey: apiKey, + api: 'openai-completions', + models: [ + { id: 'tc-code-latest', name: 'Auto (智能匹配最优模型)', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 8192 }, + { id: 'hunyuan-2.0-instruct', name: 'Tencent HY 2.0 Instruct', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 16000 }, + { id: 'hunyuan-2.0-thinking', name: 'Tencent HY 2.0 Think', reasoning: true, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 128000, maxTokens: 64000 }, + { id: 'hunyuan-t1', name: 'Hunyuan-T1', reasoning: true, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, maxTokens: 64000 }, + { id: 'hunyuan-turbos', name: 'Hunyuan-TurboS', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 32000, maxTokens: 16000 }, + { id: 'minimax-m2.5', name: 'MiniMax-M2.5', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 204800, maxTokens: 131072 }, + { id: 'kimi-k2.5', name: 'Kimi-K2.5', reasoning: false, input: ['text', 'image'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 262144, maxTokens: 32768 }, + { id: 'glm-5', name: 'GLM-5', reasoning: false, input: ['text'], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 202752, maxTokens: 8192 }, + ] + }; + + if (!config.agents) config.agents = {}; + if (!config.agents.defaults) config.agents.defaults = {}; + if (!config.agents.defaults.models) config.agents.defaults.models = {}; + ['tc-code-latest', 'hunyuan-2.0-instruct', 'hunyuan-2.0-thinking', 'hunyuan-t1', 'hunyuan-turbos', 'minimax-m2.5', 'kimi-k2.5', 'glm-5'].forEach(m => { + config.agents.defaults.models[`lkeap/${m}`] = {}; + }); + + writeConfig(config); +} + +/** + * 重启 Gateway + */ +async function restartGateway() { + resetRenderCount(); + console.log(`\n${C.yellow}正在重启 Gateway...${C.reset}`); + + try { + await runCommand('/etc/init.d/openclaw', ['restart_gateway']); + } catch {} + + const spin = spinner({ text: 'Gateway 启动中,请稍候 (约 15-30 秒)...' }); + spin.start(); + + const gwPort = jsonGet('gateway.port') || '18789'; + let waited = 0; + const maxWait = 30; + + while (waited < maxWait) { + await new Promise(r => setTimeout(r, 3000)); + waited += 3; + try { + // 兼容 OpenWrt: 优先 ss,回退 netstat + let stdout = ''; + try { + const result = await runCommand('ss', ['-tulnp']); + stdout = result.stdout; + } catch { + // ss 不存在,使用 netstat + const result = await runCommand('netstat', ['-tulnp']); + stdout = result.stdout; + } + if (stdout.includes(`:${gwPort} `)) { + spin.succeed(`Gateway 已重启成功 (${waited}秒)`); + return; + } + } catch {} + } + + spin.stop(`${C.yellow}Gateway 仍在启动中,请稍后确认${C.reset}`); + console.log(`${C.cyan} 查看日志: logread -e openclaw${C.reset}\n`); +} + +/** + * 询问是否重启 + */ +async function askRestart() { + const ok = await confirm({ prompt: '是否立即重启服务以应用配置?', defaultYes: true }); + if (ok) { + await restartGateway(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主菜单 (对应 main_menu) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showMainMenu() { + const currentModel = getCurrentModel(); + const statusLine = currentModel + ? `${C.green}当前模型: ${currentModel}${C.reset}` + : `${C.yellow}未配置模型${C.reset}`; + + const result = await select({ + title: 'OpenClaw AI Gateway — OpenWrt 配置管理', + header: statusLine, + showSearch: false, + items: [ + { label: `${C.dim}━━━ AI 模型配置 ━━━${C.reset}`, disabled: true }, + { key: '1', label: '配置 AI 模型和提供商', desc: '', value: 'model' }, + { key: '2', label: '设置活动模型', desc: '', value: 'set-active-model' }, + + { label: `${C.dim}━━━ 消息渠道 ━━━${C.reset}`, disabled: true }, + { key: '3', label: '配置消息渠道', desc: '电报/QQ/飞书', value: 'channels' }, + + { label: `${C.dim}━━━ 系统管理 ━━━${C.reset}`, disabled: true }, + { key: '4', label: '健康检查与状态', desc: '', value: 'health' }, + { key: '5', label: '查看日志', desc: '', value: 'logs' }, + { key: '6', label: '重启 Gateway', desc: '', value: 'restart' }, + + { label: `${C.dim}━━━ 高级选项 ━━━${C.reset}`, disabled: true }, + { key: '7', label: '高级配置', desc: '', value: 'advanced' }, + { key: '8', label: '重置配置', desc: '', value: 'reset' }, + { key: '9', label: '显示当前配置概览', desc: '', value: 'show-config' }, + { key: '10', label: '备份/还原配置', desc: '', value: 'backup' }, + + { label: '', disabled: true }, + { key: '0', label: '退出', desc: '', value: 'quit' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 模型配置菜单 (对应 configure_model) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showModelMenu() { + const result = await select({ + title: '配置 AI 模型和提供商', + showSearch: true, + items: [ + { label: `${C.green}${C.bold}── 推荐 ──${C.reset}`, disabled: true }, + { key: 'w', label: '官方完整模型配置向导', desc: `${C.green}(推荐,支持所有提供商)${C.reset}`, value: 'wizard' }, + + { label: `${C.bold}── 国外模型提供商 ──${C.reset}`, disabled: true }, + { key: 'a', label: 'OpenAI', desc: 'GPT-5.2, GPT-5 mini, GPT-4.1', value: 'openai' }, + { key: 'b', label: 'Anthropic', desc: 'Claude Sonnet 4, Opus 4, Haiku', value: 'anthropic' }, + { key: 'c', label: 'Google Gemini', desc: 'Gemini 2.5 Pro/Flash, Gemini 3', value: 'google' }, + { key: 'd', label: 'OpenRouter', desc: '聚合多家模型', value: 'openrouter' }, + { key: 'e', label: 'GitHub Copilot', desc: '需要 Copilot 订阅', value: 'copilot' }, + { key: 'f', label: 'xAI Grok', desc: 'Grok-4/3', value: 'xai' }, + + { label: `${C.bold}── 国内模型提供商 ──${C.reset}`, disabled: true }, + { key: 'g', label: '阿里云通义千问 Qwen', desc: 'Portal/API/Coding Plan', value: 'qwen' }, + { key: 'h', label: '硅基流动 SiliconFlow', desc: '', value: 'siliconflow' }, + { key: 'i', label: '腾讯云 Coding Plan', desc: 'HY T1/TurboS/GLM-5/Kimi', value: 'tencent' }, + { key: 'j', label: '百度千帆', desc: 'ERNIE-4.0, ERNIE-3.5', value: 'baidu' }, + { key: 'k', label: '智谱 GLM / Z.AI', desc: 'GLM-5.1, GLM-5, GLM-4.7', value: 'zhipu' }, + + { label: `${C.bold}── 本地模型 / 自定义 API ──${C.reset}`, disabled: true }, + { key: 'l', label: 'Ollama', desc: '本地模型,无需 API Key', value: 'ollama' }, + { key: 'm', label: '自定义 OpenAI 兼容 API', desc: '', value: 'custom' }, + { key: 'n', label: '自定义 Anthropic 兼容 API', desc: '', value: 'custom-anthropic' }, + + { label: '', disabled: true }, + { key: '0', label: '返回', desc: '', value: 'back' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 消息渠道菜单 (对应 configure_channels) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showChannelsMenu() { + const result = await select({ + title: '配置消息渠道', + showSearch: false, + items: [ + { label: `${C.dim}提示: 微信配置请使用 LuCI 界面「微信配置」菜单${C.reset}`, disabled: true }, + { label: '', disabled: true }, + { key: '1', label: 'QQ 机器人', desc: '腾讯QQ', value: 'qq' }, + { key: '2', label: 'Telegram', desc: `${C.green}最常用${C.reset}`, value: 'telegram' }, + { key: '3', label: 'Discord', desc: '', value: 'discord' }, + { key: '4', label: '飞书 (Feishu)', desc: '', value: 'feishu' }, + { key: '5', label: 'Slack', desc: '', value: 'slack' }, + { key: '6', label: 'WhatsApp', desc: '需通过 Web 控制台扫码', value: 'whatsapp' }, + { key: '7', label: 'Telegram 配对助手', desc: '', value: 'telegram-pairing' }, + { key: '8', label: '官方完整渠道配置向导', desc: '', value: 'wizard' }, + { label: '', disabled: true }, + { key: '0', label: '返回', desc: '', value: 'back' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 高级配置菜单 (对应 advanced_menu) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showAdvancedMenu() { + const gwPort = jsonGet('gateway.port') || '18789'; + const gwBind = jsonGet('gateway.bind') || 'lan'; + const gwMode = jsonGet('gateway.mode') || 'local'; + const logLevel = jsonGet('gateway.logLevel') || '未设置'; + const acpDispatch = jsonGet('acp.dispatch.enabled') || 'false'; + + const result = await select({ + title: '高级配置', + showSearch: false, + items: [ + { key: '1', label: 'Gateway 端口', desc: `当前: ${gwPort}`, value: 'port' }, + { key: '2', label: 'Gateway 绑定地址', desc: `当前: ${gwBind}`, value: 'bind' }, + { key: '3', label: 'Gateway 运行模式', desc: `当前: ${gwMode}`, value: 'mode' }, + { key: '4', label: '日志级别', desc: `当前: ${logLevel}`, value: 'loglevel' }, + { key: '5', label: 'ACP Dispatch 设置', desc: `当前: ${acpDispatch}`, value: 'acp' }, + { key: '6', label: '官方完整配置向导', desc: 'oc configure', value: 'wizard' }, + { key: '7', label: '查看原始配置 JSON', desc: '', value: 'view-json' }, + { key: '8', label: '编辑配置文件', desc: 'vi / nano', value: 'edit' }, + { key: '9', label: '导出配置备份', desc: '', value: 'backup' }, + { key: '10', label: '导入配置', desc: '', value: 'import' }, + { label: '', disabled: true }, + { key: '0', label: '返回', desc: '', value: 'back' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 重置配置菜单 (对应 reset_to_defaults) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showResetMenu() { + const result = await select({ + title: '恢复默认配置', + showSearch: false, + items: [ + { label: `${C.yellow}请选择恢复级别:${C.reset}`, disabled: true }, + { label: '', disabled: true }, + { key: '1', label: '仅重置网关设置', desc: '端口/绑定/模式恢复默认,保留模型和渠道', value: 'gateway' }, + { key: '2', label: '清除模型配置', desc: '移除所有 AI 模型和 API Key', value: 'models' }, + { key: '3', label: '清除渠道配置', desc: '移除所有消息渠道配置', value: 'channels' }, + { key: '4', label: '完全恢复出厂', desc: '删除所有配置,重新初始化', value: 'full' }, + { label: '', disabled: true }, + { key: '0', label: '返回', desc: '', value: 'back' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 备份/还原菜单 (对应 backup_restore_menu) +// ═══════════════════════════════════════════════════════════════════════════ + +async function showBackupMenu() { + const result = await select({ + title: '备份/还原配置', + showSearch: false, + items: [ + { key: '1', label: '创建配置备份', desc: '仅配置文件', value: 'create-config' }, + { key: '2', label: '创建完整备份', desc: '配置 + 状态数据', value: 'create-full' }, + { key: '3', label: '验证最新备份', desc: '', value: 'verify' }, + { key: '4', label: '查看备份列表', desc: '', value: 'list' }, + { key: '5', label: '从最新备份恢复配置', desc: '', value: 'restore' }, + { label: '', disabled: true }, + { key: '0', label: '返回', desc: '', value: 'back' }, + ], + }); + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 模型配置处理函数 (与 oc-config.sh 完全对应) +// ═══════════════════════════════════════════════════════════════════════════ + +async function configureOpenAI() { + resetRenderCount(); + console.log(`\n${C.bold}OpenAI 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://platform.openai.com/api-keys${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 OpenAI API Key (sk-...)', placeholder: 'sk-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'OpenAI 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'gpt-5.2', desc: '最强编程与代理旗舰 (推荐)', value: 'gpt-5.2' }, + { key: 'b', label: 'gpt-5-mini', desc: '高性价比推理', value: 'gpt-5-mini' }, + { key: 'c', label: 'gpt-5-nano', desc: '极速低成本', value: 'gpt-5-nano' }, + { key: 'd', label: 'gpt-4.1', desc: '最强非推理模型', value: 'gpt-4.1' }, + { key: 'e', label: 'o3', desc: '推理模型', value: 'o3' }, + { key: 'f', label: 'o4-mini', desc: '推理轻量', value: 'o4-mini' }, + { key: 'g', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-5.2' }); + if (!modelName) return false; + } + + authSetApikey('openai', apiKey); + registerAndSetModel(`openai/${modelName}`); + console.log(`\n${C.green}✅ OpenAI 已配置,活跃模型: openai/${modelName}${C.reset}\n`); + return true; +} + +async function configureAnthropic() { + resetRenderCount(); + console.log(`\n${C.bold}Anthropic 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://console.anthropic.com/settings/keys${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 Anthropic API Key (sk-ant-...)', placeholder: 'sk-ant-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'Anthropic 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' }, + { key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4 顶级推理', value: 'claude-opus-4-20250514' }, + { key: 'c', label: 'claude-haiku-4-5', desc: 'Claude Haiku 4.5 轻量快速', value: 'claude-haiku-4-5' }, + { key: 'd', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' }, + { key: 'e', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' }, + { key: 'f', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' }); + if (!modelName) return false; + } + + authSetApikey('anthropic', apiKey); + registerAndSetModel(`anthropic/${modelName}`); + console.log(`\n${C.green}✅ Anthropic 已配置,活跃模型: anthropic/${modelName}${C.reset}\n`); + return true; +} + +async function configureGoogle() { + resetRenderCount(); + console.log(`\n${C.bold}Google Gemini 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://aistudio.google.com/apikey${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 Google AI API Key', placeholder: 'AIza...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'Google Gemini 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'gemini-2.5-pro', desc: '旗舰推理 (推荐)', value: 'gemini-2.5-pro' }, + { key: 'b', label: 'gemini-2.5-flash', desc: '快速均衡', value: 'gemini-2.5-flash' }, + { key: 'c', label: 'gemini-2.5-flash-lite', desc: '极速低成本', value: 'gemini-2.5-flash-lite' }, + { key: 'd', label: 'gemini-3-flash-preview', desc: 'Gemini 3 Flash 预览', value: 'gemini-3-flash-preview' }, + { key: 'e', label: 'gemini-3-pro-preview', desc: 'Gemini 3 Pro 预览', value: 'gemini-3-pro-preview' }, + { key: 'f', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gemini-2.5-pro' }); + if (!modelName) return false; + } + + authSetApikey('google', apiKey); + registerAndSetModel(`google/${modelName}`); + console.log(`\n${C.green}✅ Google Gemini 已配置,活跃模型: google/${modelName}${C.reset}\n`); + return true; +} + +async function configureOpenRouter() { + resetRenderCount(); + console.log(`\n${C.bold}OpenRouter 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://openrouter.ai/keys${C.reset}`); + console.log(`${C.dim}聚合多家模型,一个 Key 可调用所有主流模型${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 OpenRouter API Key', placeholder: 'sk-or-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'OpenRouter 常用模型', + showSearch: false, + items: [ + { key: 'a', label: 'anthropic/claude-sonnet-4', desc: 'Claude Sonnet 4 (推荐)', value: 'anthropic/claude-sonnet-4' }, + { key: 'b', label: 'anthropic/claude-opus-4', desc: 'Claude Opus 4', value: 'anthropic/claude-opus-4' }, + { key: 'c', label: 'openai/gpt-5.2', desc: 'GPT-5.2', value: 'openai/gpt-5.2' }, + { key: 'd', label: 'google/gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'google/gemini-2.5-pro' }, + { key: 'e', label: 'deepseek/deepseek-r1', desc: 'DeepSeek R1', value: 'deepseek/deepseek-r1' }, + { key: 'f', label: 'meta-llama/llama-4-maverick', desc: 'Meta Llama 4', value: 'meta-llama/llama-4-maverick' }, + { key: 'g', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称 (格式: provider/model)', defaultValue: 'anthropic/claude-sonnet-4' }); + if (!modelName) return false; + } + + authSetApikey('openrouter', apiKey); + registerCustomProvider('openrouter', 'https://openrouter.ai/api/v1', apiKey, modelName, modelName); + registerAndSetModel(`openrouter/${modelName}`); + console.log(`\n${C.green}✅ OpenRouter 已配置,活跃模型: openrouter/${modelName}${C.reset}\n`); + return true; +} + +async function configureCopilot() { + resetRenderCount(); + console.log(`\n${C.bold}GitHub Copilot 配置${C.reset}`); + console.log(`${C.yellow}需要有效的 GitHub Copilot 订阅 (Free/Pro/Business 均可)${C.reset}\n`); + + console.log(`${C.cyan}启动 GitHub Copilot OAuth 登录...${C.reset}`); + console.log(`${C.dim}请在浏览器中打开显示的 URL,输入授权码完成登录${C.reset}\n`); + + try { + await ocCmd('models', 'auth', 'login-github-copilot', '--yes'); + console.log(`\n${C.green}✅ GitHub Copilot OAuth 认证成功${C.reset}\n`); + + const modelChoice = await select({ + title: 'GitHub Copilot 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'gpt-4.1', desc: 'GPT-4.1 (推荐)', value: 'gpt-4.1' }, + { key: 'b', label: 'gpt-4o', desc: 'GPT-4o', value: 'gpt-4o' }, + { key: 'c', label: 'gpt-5', desc: 'GPT-5', value: 'gpt-5' }, + { key: 'd', label: 'gpt-5-mini', desc: 'GPT-5 mini', value: 'gpt-5-mini' }, + { key: 'e', label: 'gpt-5.1', desc: 'GPT-5.1', value: 'gpt-5.1' }, + { key: 'f', label: 'gpt-5.2', desc: 'GPT-5.2', value: 'gpt-5.2' }, + { key: 'g', label: 'gpt-5.2-codex', desc: 'GPT-5.2 Codex', value: 'gpt-5.2-codex' }, + { key: 'h', label: 'claude-sonnet-4', desc: 'Claude Sonnet 4', value: 'claude-sonnet-4' }, + { key: 'i', label: 'claude-sonnet-4.5', desc: 'Claude Sonnet 4.5', value: 'claude-sonnet-4.5' }, + { key: 'j', label: 'claude-sonnet-4.6', desc: 'Claude Sonnet 4.6', value: 'claude-sonnet-4.6' }, + { key: 'k', label: 'gemini-2.5-pro', desc: 'Gemini 2.5 Pro', value: 'gemini-2.5-pro' }, + { key: 'm', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + + if (modelChoice) { + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'gpt-4.1' }); + if (!modelName) return false; + } + registerAndSetModel(`github-copilot/${modelName}`); + console.log(`\n${C.green}✅ 活跃模型已设置: github-copilot/${modelName}${C.reset}\n`); + } + return true; + } catch (e) { + console.log(`\n${C.yellow}OAuth 授权已退出或失败${C.reset}\n`); + return false; + } +} + +async function configureXAI() { + resetRenderCount(); + console.log(`\n${C.bold}xAI Grok 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://console.x.ai${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 xAI API Key', placeholder: '' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'xAI Grok 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'grok-4', desc: 'Grok 4 旗舰 (推荐)', value: 'grok-4' }, + { key: 'b', label: 'grok-4-fast', desc: 'Grok 4 Fast', value: 'grok-4-fast' }, + { key: 'c', label: 'grok-3', desc: 'Grok 3', value: 'grok-3' }, + { key: 'd', label: 'grok-3-fast', desc: 'Grok 3 Fast', value: 'grok-3-fast' }, + { key: 'e', label: 'grok-3-mini', desc: 'Grok 3 Mini', value: 'grok-3-mini' }, + { key: 'f', label: 'grok-3-mini-fast', desc: 'Grok 3 Mini Fast', value: 'grok-3-mini-fast' }, + { key: 'g', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'grok-4' }); + if (!modelName) return false; + } + + authSetApikey('xai', apiKey); + registerAndSetModel(`xai/${modelName}`); + console.log(`\n${C.green}✅ xAI Grok 已配置,活跃模型: xai/${modelName}${C.reset}\n`); + return true; +} + +async function configureQwen() { + resetRenderCount(); + console.log(`\n${C.bold}阿里云通义千问 Qwen 配置${C.reset}\n`); + + const modeChoice = await select({ + title: '配置方式', + showSearch: false, + items: [ + { key: 'a', label: '通过官方向导配置', desc: 'Qwen Portal OAuth', value: 'portal' }, + { key: 'b', label: '百炼按量付费 API Key', desc: 'sk-xxx, 按 token 计费', value: 'bailian' }, + { key: 'c', label: 'Coding Plan 套餐', desc: `${C.green}★ 推荐${C.reset}`, value: 'codingplan' }, + ], + }); + if (!modeChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + if (modeChoice.value === 'portal') { + console.log(`\n${C.cyan}启动 Qwen OAuth 授权...${C.reset}\n`); + try { + await ocCmd('models', 'auth', 'login', '--provider', 'qwen-portal', '--set-default'); + } catch {} + console.log(`\n${C.yellow}OAuth 授权已退出${C.reset}\n`); + return true; + } + + if (modeChoice.value === 'bailian') { + console.log(`\n${C.bold}百炼按量付费配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://dashscope.console.aliyun.com/apiKey${C.reset}`); + console.log(`${C.dim}Base URL: https://dashscope.aliyuncs.com/compatible-mode/v1${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入百炼 API Key (sk-...)', placeholder: 'sk-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: '百炼模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'qwen-max', desc: '千问Max 旗舰模型 (推荐)', value: 'qwen-max' }, + { key: 'b', label: 'qwen-plus', desc: '千问Plus 均衡之选', value: 'qwen-plus' }, + { key: 'c', label: 'qwen-flash', desc: '千问Flash 速度最快', value: 'qwen-flash' }, + { key: 'd', label: 'qwen-turbo', desc: '千问Turbo 经济实惠', value: 'qwen-turbo' }, + { key: 'e', label: 'qwen-long', desc: '千问Long 超长上下文', value: 'qwen-long' }, + { key: 'f', label: 'qwen3-coder-plus', desc: '代码专用旗舰', value: 'qwen3-coder-plus' }, + { key: 'g', label: 'qwq-plus', desc: 'QwQ推理模型', value: 'qwq-plus' }, + { key: 'h', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'qwen-max' }); + if (!modelName) return false; + } + + authSetApikey('dashscope', apiKey); + registerCustomProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/v1', apiKey, modelName, modelName); + registerAndSetModel(`dashscope/${modelName}`); + console.log(`\n${C.green}✅ 通义千问已配置 (按量付费),活跃模型: dashscope/${modelName}${C.reset}\n`); + return true; + } + + if (modeChoice.value === 'codingplan') { + console.log(`\n${C.bold}Coding Plan 套餐配置${C.reset}`); + console.log(`${C.yellow}订阅套餐: https://bailian.console.aliyun.com/cn-beijing/?tab=model#/efm/coding_plan${C.reset}`); + console.log(`${C.dim}Base URL: https://coding.dashscope.aliyuncs.com/v1${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 Coding Plan 专属 API Key (sk-sp-...)', placeholder: 'sk-sp-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'Coding Plan 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'qwen3.5-plus', desc: 'Qwen3.5 Plus (推荐, 100万上下文)', value: 'qwen3.5-plus' }, + { key: 'b', label: 'qwen3-coder-plus', desc: 'Qwen3 Coder Plus', value: 'qwen3-coder-plus' }, + { key: 'c', label: 'qwen3-coder-next', desc: 'Qwen3 Coder Next', value: 'qwen3-coder-next' }, + { key: 'd', label: 'qwen3-max-2026-01-23', desc: 'Qwen3 Max', value: 'qwen3-max-2026-01-23' }, + { key: 'e', label: 'MiniMax-M2.5', desc: 'MiniMax M2.5', value: 'MiniMax-M2.5' }, + { key: 'f', label: 'glm-5', desc: '智谱 GLM-5', value: 'glm-5' }, + { key: 'g', label: 'kimi-k2.5', desc: 'Kimi K2.5', value: 'kimi-k2.5' }, + { key: 'h', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'qwen3.5-plus' }); + if (!modelName) return false; + } + + console.log(`\n${C.cyan}正在注册 Coding Plan 提供商 (含全部可用模型)...${C.reset}`); + authSetApikey('bailian', apiKey); + registerCodingPlanProvider(apiKey); + registerAndSetModel(`bailian/${modelName}`); + console.log(`\n${C.green}✅ Coding Plan 已配置,活跃模型: bailian/${modelName}${C.reset}`); + console.log(`${C.dim}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${C.reset}\n`); + return true; + } + + return false; +} + +async function configureSiliconFlow() { + resetRenderCount(); + console.log(`\n${C.bold}硅基流动 SiliconFlow 配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://cloud.siliconflow.cn/account/ak${C.reset}`); + console.log(`${C.yellow}国内推理平台,支持多种开源模型${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 SiliconFlow API Key', placeholder: '' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + console.log(`\n${C.cyan}可用模型分类说明:${C.reset}`); + console.log(`${C.yellow}* Pro模型: 仅支持充值余额支付${C.reset}`); + console.log(`${C.yellow}* 非Pro模型: 支持代金券/免费额度${C.reset}\n`); + + const modelChoice = await select({ + title: 'SiliconFlow 模型选择', + showSearch: false, + items: [ + { label: `${C.cyan}── 非Pro模型 (支持代金券) ──${C.reset}`, disabled: true }, + { key: '1', label: 'deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (推荐)', value: 'deepseek-ai/DeepSeek-V3' }, + { key: '2', label: 'deepseek-ai/DeepSeek-R1', desc: 'DeepSeek-R1 推理模型', value: 'deepseek-ai/DeepSeek-R1' }, + { key: '3', label: 'Qwen/Qwen2.5-72B-Instruct', desc: '通义千问 2.5 72B', value: 'Qwen/Qwen2.5-72B-Instruct' }, + { key: '4', label: 'Qwen/Qwen2.5-7B-Instruct', desc: '通义千问 2.5 7B', value: 'Qwen/Qwen2.5-7B-Instruct' }, + { key: '5', label: 'THUDM/glm-4-9b-chat', desc: '智谱 GLM-4 9B', value: 'THUDM/glm-4-9b-chat' }, + { key: '6', label: '01-ai/Yi-1.5-34B-Chat-16K', desc: '零一万物 Yi-1.5', value: '01-ai/Yi-1.5-34B-Chat-16K' }, + { label: `${C.cyan}── Pro模型 (仅支持充值余额) ──${C.reset}`, disabled: true }, + { key: '7', label: 'Pro/deepseek-ai/DeepSeek-V3', desc: 'DeepSeek-V3 (Pro)', value: 'Pro/deepseek-ai/DeepSeek-V3' }, + { key: '8', label: 'Pro/zai-org/GLM-5', desc: '智谱 GLM-5', value: 'Pro/zai-org/GLM-5' }, + { key: '0', label: '手动输入其他模型名称', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型详细名称', defaultValue: 'deepseek-ai/DeepSeek-V3' }); + if (!modelName) return false; + } + + authSetApikey('siliconflow', apiKey); + registerCustomProvider('siliconflow', 'https://api.siliconflow.cn/v1', apiKey, modelName, modelName); + registerAndSetModel(`siliconflow/${modelName}`); + console.log(`\n${C.green}✅ SiliconFlow 已配置,活跃模型: siliconflow/${modelName}${C.reset}\n`); + return true; +} + +async function configureTencent() { + resetRenderCount(); + console.log(`\n${C.bold}腾讯云大模型 Coding Plan 套餐配置${C.reset}`); + console.log(`${C.yellow}订阅/管理套餐: https://hunyuan.cloud.tencent.com/#/app/subscription${C.reset}`); + console.log(`${C.dim}文档: https://cloud.tencent.com/document/product/1772/128947${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入 Coding Plan API Key (sk-sp-...)', placeholder: 'sk-sp-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: '腾讯云 Coding Plan 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'tc-code-latest', desc: '自动路由 (推荐)', value: 'tc-code-latest' }, + { key: 'b', label: 'hunyuan-t1', desc: '混元 T1 深度推理', value: 'hunyuan-t1' }, + { key: 'c', label: 'hunyuan-2.0-thinking', desc: '混元 2.0 Thinking', value: 'hunyuan-2.0-thinking' }, + { key: 'd', label: 'hunyuan-turbos', desc: '混元 TurboS 旗舰', value: 'hunyuan-turbos' }, + { key: 'e', label: 'hunyuan-2.0-instruct', desc: '混元 2.0 Instruct', value: 'hunyuan-2.0-instruct' }, + { key: 'f', label: 'glm-5', desc: '智谱 GLM-5', value: 'glm-5' }, + { key: 'g', label: 'kimi-k2.5', desc: 'Moonshot Kimi K2.5', value: 'kimi-k2.5' }, + { key: 'h', label: 'minimax-m2.5', desc: 'MiniMax M2.5', value: 'minimax-m2.5' }, + { key: 'z', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'tc-code-latest' }); + if (!modelName) return false; + } + + console.log(`\n${C.cyan}正在注册腾讯云 Coding Plan 提供商 (含全部套餐模型)...${C.reset}`); + authSetApikey('lkeap', apiKey); + registerLkeapCodingPlanProvider(apiKey); + registerAndSetModel(`lkeap/${modelName}`); + console.log(`\n${C.green}✅ 腾讯云 Coding Plan 已配置,活跃模型: lkeap/${modelName}${C.reset}`); + console.log(`${C.dim}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${C.reset}\n`); + return true; +} + +async function configureBaidu() { + resetRenderCount(); + console.log(`\n${C.bold}百度千帆大模型配置${C.reset}`); + console.log(`${C.yellow}获取 API Key: https://console.bce.baidu.com/qianfan/ais/console/onlineService${C.reset}\n`); + + const apiKey = await input({ prompt: '请输入百度千帆 API Key (Access Token)', placeholder: '' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: '百度千帆模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'ernie-4.0-8k', desc: '文心一言 4.0 (推荐)', value: 'ernie-4.0-8k' }, + { key: 'b', label: 'ernie-3.5-8k', desc: '文心一言 3.5', value: 'ernie-3.5-8k' }, + { key: 'c', label: 'ernie-4.0-turbo-8k', desc: '文心一言 4.0 Turbo', value: 'ernie-4.0-turbo-8k' }, + { key: 'd', label: 'ernie-speed-8k', desc: '文心一言 Speed 极速', value: 'ernie-speed-8k' }, + { key: 'e', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'ernie-4.0-8k' }); + if (!modelName) return false; + } + + authSetApikey('qianfan', apiKey); + registerCustomProvider('qianfan', 'https://aip.baidubce.com/rpc/2.0/ai_custom/v1/wenxinworkshop', apiKey, modelName, modelName); + registerAndSetModel(`qianfan/${modelName}`); + console.log(`\n${C.green}✅ 百度千帆已配置,活跃模型: qianfan/${modelName}${C.reset}\n`); + return true; +} + +async function configureZhipu() { + resetRenderCount(); + console.log(`\n${C.bold}智谱 GLM / Z.AI 配置${C.reset}\n`); + + const methodChoice = await select({ + title: '认证方式', + showSearch: false, + items: [ + { key: 'a', label: 'CN (open.bigmodel.cn)', desc: `${C.green}★ 国内用户推荐${C.reset}`, value: 'cn' }, + { key: 'b', label: 'Coding-Plan-CN', desc: '智谱 Coding Plan 套餐', value: 'coding-plan-cn' }, + { key: 'c', label: 'Global (api.z.ai)', desc: '全球版', value: 'global' }, + { key: 'd', label: '手动输入', desc: '', value: 'custom' }, + ], + }); + if (!methodChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let baseUrl = 'https://open.bigmodel.cn/api/paas/v4'; + switch (methodChoice.value) { + case 'cn': + console.log(`${C.yellow}获取 API Key: https://open.bigmodel.cn/api-key${C.reset}`); + break; + case 'coding-plan-cn': + baseUrl = 'https://open.bigmodel.cn/api/coding/paas/v4'; + console.log(`${C.yellow}Coding Plan 套餐 API Key${C.reset}`); + break; + case 'global': + baseUrl = 'https://api.z.ai/api/paas/v4'; + console.log(`${C.yellow}全球版 API Key${C.reset}`); + break; + case 'custom': + const customUrl = await input({ prompt: '请输入 Base URL', defaultValue: 'https://open.bigmodel.cn/api/paas/v4' }); + if (customUrl) baseUrl = customUrl; + break; + } + + const apiKey = await input({ prompt: '请输入智谱 API Key', placeholder: '' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: '智谱 GLM 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'glm-5.1', desc: 'GLM-5.1 (推荐)', value: 'glm-5.1' }, + { key: 'b', label: 'glm-5', desc: 'GLM-5', value: 'glm-5' }, + { key: 'c', label: 'glm-4.7', desc: 'GLM-4.7', value: 'glm-4.7' }, + { key: 'd', label: 'glm-4.7-flash', desc: 'GLM-4.7 Flash', value: 'glm-4.7-flash' }, + { key: 'e', label: 'glm-4.5', desc: 'GLM-4.5', value: 'glm-4.5' }, + { key: 'f', label: 'glm-4.5-flash', desc: 'GLM-4.5 Flash (免费)', value: 'glm-4.5-flash' }, + { key: 'g', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'glm-5.1' }); + if (!modelName) return false; + } + + // 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持) + registerCustomProvider('zai', baseUrl, apiKey, modelName, modelName, 128000, 4096); + authSetApikey('zai', apiKey); + registerAndSetModel(`zai/${modelName}`); + console.log(`\n${C.green}✅ 智谱 GLM 已配置,活跃模型: zai/${modelName}${C.reset}`); + console.log(`${C.dim} Base URL: ${baseUrl}${C.reset}\n`); + return true; +} + +async function configureOllama() { + resetRenderCount(); + console.log(`\n${C.bold}Ollama 本地模型配置${C.reset}`); + console.log(`${C.yellow}Ollama 在本地或局域网运行大模型,无需 API Key${C.reset}`); + console.log(`${C.yellow}安装 Ollama: https://ollama.com${C.reset}\n`); + + const modeChoice = await select({ + title: '连接方式', + showSearch: false, + items: [ + { key: 'a', label: '本机运行', desc: 'localhost:11434', value: 'local' }, + { key: 'b', label: '局域网其他设备', desc: '', value: 'remote' }, + ], + }); + if (!modeChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let ollamaUrl = 'http://127.0.0.1:11434'; + if (modeChoice.value === 'remote') { + const host = await input({ prompt: 'Ollama 地址 (如 192.168.1.100:11434)', placeholder: '' }); + if (!host) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + ollamaUrl = host.startsWith('http') ? host : `http://${host}`; + ollamaUrl = ollamaUrl.replace(/\/v1$/, '').replace(/\/$/, ''); + } + + // 尝试检测 Ollama 连通性 + console.log(`\n${C.cyan}检测 Ollama 连通性...${C.reset}`); + let modelList = []; + try { + const result = await runCommand('curl', ['-sf', '--connect-timeout', '3', '--max-time', '5', `${ollamaUrl}/api/tags`]); + const data = JSON.parse(result.stdout); + modelList = data.models || []; + console.log(`${C.green}✅ Ollama 已连接${C.reset}`); + } catch { + console.log(`${C.yellow}⚠️ 无法连接 Ollama (${ollamaUrl})${C.reset}`); + const continueAnyway = await confirm({ prompt: '仍要继续配置?', defaultYes: false }); + if (!continueAnyway) return false; + } + + let modelName = ''; + if (modelList.length > 0) { + const items = modelList.map((m, i) => ({ + key: String(i + 1), + label: m.name, + desc: '', + value: m.name, + })); + items.push({ key: 'm', label: '手动输入模型名', desc: '', value: 'custom' }); + + const modelChoice = await select({ + title: '已安装的模型', + showSearch: false, + items, + }); + + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + modelName = modelChoice.value === 'custom' + ? await input({ prompt: '请输入模型名称', defaultValue: 'llama3.3' }) + : modelChoice.value; + } else { + modelName = await input({ prompt: '请输入模型名称', placeholder: 'llama3.3, qwen2.5...' }); + } + + if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + // 注册 Ollama 提供商 + const config = readConfig(); + if (!config.models) config.models = {}; + if (!config.models.providers) config.models.providers = {}; + config.models.mode = 'merge'; + config.models.providers['ollama'] = { + baseUrl: ollamaUrl, + apiKey: 'ollama-local', + api: 'ollama', + models: [{ + id: modelName, + name: modelName, + reasoning: false, + input: ['text'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 32000, + }] + }; + writeConfig(config); + + authSetApikey('ollama', 'ollama-local', 'ollama:local'); + registerAndSetModel(`ollama/${modelName}`); + console.log(`\n${C.green}✅ Ollama 已配置,活跃模型: ollama/${modelName}${C.reset}`); + console.log(`${C.cyan} Ollama 地址: ${ollamaUrl}${C.reset}\n`); + return true; +} + +async function configureCustomAPI() { + resetRenderCount(); + console.log(`\n${C.bold}自定义 OpenAI 兼容 API 配置${C.reset}`); + console.log(`${C.yellow}支持任何兼容 OpenAI API 格式的服务商${C.reset}\n`); + + const baseUrl = await input({ prompt: 'API Base URL (如 https://api.example.com/v1)', placeholder: '' }); + if (!baseUrl) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const apiKey = await input({ prompt: 'API Key', placeholder: 'sk-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelName = await input({ prompt: '模型名称', placeholder: '' }); + if (!modelName) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + authSetApikey('openai-compatible', apiKey, 'openai-compatible:manual'); + registerCustomProvider('openai-compatible', baseUrl.replace(/\/$/, ''), apiKey, modelName, modelName); + registerAndSetModel(`openai-compatible/${modelName}`); + console.log(`\n${C.green}✅ 自定义 API 已配置,活跃模型: openai-compatible/${modelName}${C.reset}\n`); + return true; +} + +async function configureCustomAnthropic() { + resetRenderCount(); + console.log(`\n${C.bold}自定义 Anthropic 兼容 API 配置${C.reset}`); + console.log(`${C.yellow}支持任何兼容 Anthropic Messages API 格式的服务商${C.reset}\n`); + + const baseUrl = await input({ prompt: 'API Base URL', placeholder: 'https://api.anthropic.com' }); + if (!baseUrl) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const apiKey = await input({ prompt: 'API Key', placeholder: 'sk-ant-...' }); + if (!apiKey) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + const modelChoice = await select({ + title: 'Anthropic 模型选择', + showSearch: false, + items: [ + { key: 'a', label: 'claude-sonnet-4-20250514', desc: 'Claude Sonnet 4 (推荐)', value: 'claude-sonnet-4-20250514' }, + { key: 'b', label: 'claude-opus-4-20250514', desc: 'Claude Opus 4', value: 'claude-opus-4-20250514' }, + { key: 'c', label: '手动输入模型名', desc: '', value: 'custom' }, + ], + }); + if (!modelChoice) { console.log(`${C.yellow}已取消${C.reset}`); return false; } + + let modelName = modelChoice.value; + if (modelChoice.value === 'custom') { + modelName = await input({ prompt: '请输入模型名称', defaultValue: 'claude-sonnet-4-20250514' }); + if (!modelName) return false; + } + + const config = readConfig(); + if (!config.models) config.models = {}; + if (!config.models.providers) config.models.providers = {}; + config.models.mode = 'merge'; + config.models.providers['anthropic-compatible'] = { + baseUrl: baseUrl.replace(/\/$/, ''), + apiKey: apiKey, + api: 'anthropic-messages', + models: [{ + id: modelName, + name: modelName, + reasoning: false, + input: ['text', 'image'], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 200000, + maxTokens: 16000, + }] + }; + writeConfig(config); + + authSetApikey('anthropic-compatible', apiKey, 'anthropic-compatible:manual'); + registerAndSetModel(`anthropic-compatible/${modelName}`); + console.log(`\n${C.green}✅ 自定义 Anthropic API 已配置,活跃模型: anthropic-compatible/${modelName}${C.reset}\n`); + return true; +} + +async function launchWizard() { + resetRenderCount(); + console.log(`\n${C.cyan}启动官方完整模型配置向导...${C.reset}\n`); + console.log(`${C.yellow}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${C.reset}\n`); + try { + await ocCmd('configure', '--section', 'model'); + } catch (e) { + console.log(`${C.yellow}配置向导已退出${C.reset}\n`); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 模型配置处理入口 +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleModelConfig() { + while (true) { + const choice = await showModelMenu(); + if (!choice || choice.value === 'back') break; + + resetRenderCount(); + let configured = false; + + switch (choice.value) { + case 'wizard': await launchWizard(); break; + case 'openai': configured = await configureOpenAI(); break; + case 'anthropic': configured = await configureAnthropic(); break; + case 'google': configured = await configureGoogle(); break; + case 'openrouter': configured = await configureOpenRouter(); break; + case 'copilot': configured = await configureCopilot(); break; + case 'xai': configured = await configureXAI(); break; + case 'qwen': configured = await configureQwen(); break; + case 'siliconflow': configured = await configureSiliconFlow(); break; + case 'tencent': configured = await configureTencent(); break; + case 'baidu': configured = await configureBaidu(); break; + case 'zhipu': configured = await configureZhipu(); break; + case 'ollama': configured = await configureOllama(); break; + case 'custom': configured = await configureCustomAPI(); break; + case 'custom-anthropic': configured = await configureCustomAnthropic(); break; + } + + if (configured) await askRestart(); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 设置活动模型 (对应 set_active_model) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleSetActiveModel() { + resetRenderCount(); + console.log(`\n${C.bold}设定当前活跃模型${C.reset}\n`); + + const currentModel = getCurrentModel(); + console.log(` 当前活跃模型: ${C.green}${C.bold}${currentModel || '未设置'}${C.reset}\n`); + + try { + const result = await ocCmd('models', 'list', '--json'); + const modelsData = JSON.parse(result.stdout); + const models = modelsData.models || []; + + if (models.length > 0) { + const items = models.map((m, i) => ({ + key: String(i + 1), + label: m.key, + desc: m.name && m.name !== m.key ? `(${m.name})` : '', + value: m.key, + selected: m.key === currentModel, + })); + + items.push({ label: '', disabled: true }); + items.push({ key: 'm', label: '手动输入模型 ID', desc: '', value: 'manual' }); + items.push({ key: '0', label: '返回', desc: '', value: 'back' }); + + const choice = await select({ + title: '已配置的模型', + showSearch: true, + items, + }); + + if (!choice || choice.value === 'back') return; + + if (choice.value === 'manual') { + const manualModel = await input({ + prompt: '请输入模型 ID', + placeholder: 'openai/gpt-4o', + defaultValue: currentModel || '', + }); + if (manualModel) { + registerAndSetModel(manualModel); + console.log(`\n${C.green}✅ 活跃模型已设为: ${manualModel}${C.reset}\n`); + await askRestart(); + } + } else { + registerAndSetModel(choice.value); + console.log(`\n${C.green}✅ 活跃模型已切换为: ${choice.value}${C.reset}\n`); + await askRestart(); + } + } else { + console.log(`${C.yellow}尚未配置任何模型。${C.reset}`); + console.log(`${C.yellow}请先通过「配置 AI 模型提供商」添加模型。${C.reset}\n`); + + const manualModel = await input({ + prompt: '直接输入模型 ID 设置 (留空返回)', + defaultValue: '', + }); + if (manualModel) { + registerAndSetModel(manualModel); + console.log(`\n${C.green}✅ 活跃模型已设为: ${manualModel}${C.reset}\n`); + await askRestart(); + } + } + } catch (e) { + console.log(`${C.red}获取模型列表失败: ${e.message}${C.reset}\n`); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 消息渠道处理 (对应 configure_channels 等函数) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleChannels() { + while (true) { + const choice = await showChannelsMenu(); + if (!choice || choice.value === 'back') break; + + resetRenderCount(); + + switch (choice.value) { + case 'qq': { + console.log(`\n${C.bold}QQ 机器人配置${C.reset}\n`); + console.log(`${C.yellow}获取 App ID 和 App Secret:${C.reset}`); + console.log(` 1. 前往 ${C.cyan}https://q.qq.com/qqbot/openclaw/login.html${C.reset}`); + console.log(` 2. 用手机 QQ 扫码注册/登录`); + console.log(` 3. 创建机器人后复制 App ID 和 App Secret\n`); + + const appId = await input({ prompt: '请输入 QQ 机器人 App ID', placeholder: '' }); + if (!appId) { console.log(`${C.yellow}已取消${C.reset}`); break; } + + const appSecret = await input({ prompt: '请输入 QQ 机器人 App Secret', placeholder: '' }); + if (!appSecret) { console.log(`${C.yellow}已取消${C.reset}`); break; } + + // 写入配置 + const cfg = readConfig(); + if (!cfg.channels) cfg.channels = {}; + if (!cfg.channels.qqbot) cfg.channels.qqbot = {}; + cfg.channels.qqbot.appId = appId; + cfg.channels.qqbot.clientSecret = appSecret; + cfg.channels.qqbot.enabled = true; + writeConfig(cfg); + + console.log(`\n${C.green}✅ QQ 机器人配置已保存${C.reset}\n`); + await askRestart(); + break; + } + case 'telegram': { + console.log(`\n${C.bold}Telegram Bot 配置${C.reset}\n`); + console.log(`${C.yellow}获取 Bot Token:${C.reset}`); + console.log(` 1. 打开 Telegram → 搜索 ${C.cyan}@BotFather${C.reset}`); + console.log(` 2. 发送 ${C.cyan}/newbot${C.reset} → 按提示创建`); + console.log(` 3. 复制生成的 Token\n`); + + const token = await input({ prompt: '请输入 Telegram Bot Token', placeholder: '123456:ABC...' }); + if (!token) { console.log(`${C.yellow}已取消${C.reset}`); break; } + + // 验证格式 + if (!/^[0-9]+:[A-Za-z0-9_-]+$/.test(token)) { + console.log(`${C.red}✗ Token 格式错误${C.reset}`); + console.log(`${C.yellow}正确格式: 123456789:ABCdefGHIjklMNOpqr${C.reset}\n`); + break; + } + + const cfg = readConfig(); + if (!cfg.channels) cfg.channels = {}; + if (!cfg.channels.telegram) cfg.channels.telegram = {}; + cfg.channels.telegram.botToken = token; + writeConfig(cfg); + + console.log(`\n${C.green}✅ Telegram Bot Token 已保存${C.reset}\n`); + await askRestart(); + break; + } + case 'discord': { + console.log(`\n${C.bold}Discord Bot 配置${C.reset}\n`); + console.log(`${C.yellow}获取 Bot Token:${C.cyan} https://discord.com/developers/applications${C.reset}\n`); + + const token = await input({ prompt: '请输入 Discord Bot Token', placeholder: '' }); + if (!token) { console.log(`${C.yellow}已取消${C.reset}`); break; } + + const cfg = readConfig(); + if (!cfg.channels) cfg.channels = {}; + if (!cfg.channels.discord) cfg.channels.discord = {}; + cfg.channels.discord.botToken = token; + writeConfig(cfg); + + console.log(`\n${C.green}✅ Discord Bot Token 已保存${C.reset}\n`); + await askRestart(); + break; + } + case 'feishu': { + console.log(`\n${C.bold}飞书 Bot 配置${C.reset}\n`); + console.log(`${C.cyan}即将执行飞书官方安装向导...${C.reset}\n`); + + const doInstall = await confirm({ prompt: '是否开始安装?', defaultYes: true }); + if (!doInstall) break; + + console.log(`\n${C.cyan}正在启动飞书安装向导...${C.reset}\n`); + try { + await runCommand('npx', ['-y', '@larksuite/openclaw-lark-tools', 'install'], { env: { HOME: OC_DATA } }); + } catch (e) { + console.log(`${C.yellow}安装向导已退出${C.reset}\n`); + } + break; + } + case 'slack': { + console.log(`\n${C.bold}Slack Bot 配置${C.reset}\n`); + console.log(`${C.yellow}获取 Bot Token:${C.cyan} https://api.slack.com/apps${C.reset}\n`); + + const token = await input({ prompt: '请输入 Slack Bot Token (xoxb-...)', placeholder: '' }); + if (!token) { console.log(`${C.yellow}已取消${C.reset}`); break; } + + const cfg = readConfig(); + if (!cfg.channels) cfg.channels = {}; + if (!cfg.channels.slack) cfg.channels.slack = {}; + cfg.channels.slack.botToken = token; + writeConfig(cfg); + + console.log(`\n${C.green}✅ Slack Bot Token 已保存${C.reset}\n`); + await askRestart(); + break; + } + case 'whatsapp': { + const gwToken = jsonGet('gateway.auth.token') || ''; + const gwPort = jsonGet('gateway.port') || '18789'; + console.log(`\n${C.yellow}WhatsApp 需要通过 Web 控制台扫码配对:${C.reset}`); + console.log(`${C.cyan}http://<你的路由器IP>:${gwPort}/?token=${gwToken}${C.reset}`); + console.log(`打开后进入 Channels → WhatsApp 扫码即可。\n`); + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + case 'telegram-pairing': { + console.log(`\n${C.cyan}启动 Telegram 配对助手...${C.reset}\n`); + try { + await ocCmd('models', 'auth', 'login-telegram-bot'); + } catch (e) {} + break; + } + case 'wizard': { + console.log(`\n${C.cyan}启动官方渠道配置向导...${C.reset}\n`); + try { + await ocCmd('configure', '--section', 'channels'); + } catch (e) {} + break; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 健康检查 (对应 health_check) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleHealthCheck() { + resetRenderCount(); + console.log(`\n${C.bold}健康检查${C.reset}\n`); + + // 验证配置文件 + console.log(`${C.cyan}验证配置文件格式...${C.reset}`); + try { + const result = await ocCmd('config', 'validate', '--json'); + const validateData = JSON.parse(result.stdout); + if (validateData.valid) { + console.log(`${C.green}✅ 配置文件格式有效${C.reset}\n`); + } else if (validateData.errors && validateData.errors.length > 0) { + console.log(`${C.red}❌ 配置文件存在错误:${C.reset}`); + validateData.errors.forEach(e => console.log(` ${C.yellow}• ${e.message}${C.reset}`)); + console.log(''); + } + } catch (e) { + console.log(`${C.yellow}⚠️ 无法验证配置文件${C.reset}\n`); + } + + // 检查端口 + console.log(`${C.cyan}检查服务状态...${C.reset}`); + try { + await runCommand('/etc/init.d/openclaw', ['status_service']); + } catch (e) {} + + console.log(`\n${C.cyan}提示: 查看详细日志请运行 logread -e openclaw${C.reset}`); + await input({ prompt: '按回车继续', defaultValue: '' }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 显示当前配置 (对应 show_current_config) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleShowConfig() { + resetRenderCount(); + + const gwPort = jsonGet('gateway.port') || '18789'; + const gwBind = jsonGet('gateway.bind') || 'lan'; + const gwMode = jsonGet('gateway.mode') || 'local'; + const currentModel = getCurrentModel(); + + console.log(`\n${C.green}┌──────────────────────────────────────────────────────────┐${C.reset}`); + console.log(`${C.green}│${C.reset} 📋 ${C.bold}当前配置概览${C.reset}`); + console.log(`${C.green}├──────────────────────────────────────────────────────────┤${C.reset}`); + console.log(`${C.green}│${C.reset} 网关端口 ............ ${C.cyan}${gwPort}${C.reset}`); + console.log(`${C.green}│${C.reset} 绑定模式 ............ ${C.cyan}${gwBind}${C.reset}`); + console.log(`${C.green}│${C.reset} 运行模式 ............ ${C.cyan}${gwMode}${C.reset}`); + console.log(`${C.green}│${C.reset} 活跃模型 ............ ${currentModel ? C.cyan + currentModel : C.yellow + '未配置'}${C.reset}`); + + // 渠道状态 + console.log(`${C.green}├──────────────────────────────────────────────────────────┤${C.reset}`); + console.log(`${C.green}│${C.reset} ${C.bold}渠道配置状态${C.reset}`); + + const tgToken = jsonGet('channels.telegram.botToken'); + const dcToken = jsonGet('channels.discord.botToken'); + const fsAppId = jsonGet('channels.feishu.appId'); + const skToken = jsonGet('channels.slack.botToken'); + const qqAppId = jsonGet('channels.qqbot.appId'); + + if (qqAppId) { + console.log(`${C.green}│${C.reset} QQ (qqbot) ......... ${C.green}✅ 已配置${C.reset} (AppID: ${qqAppId.slice(0, 8)}...)`); + } else { + console.log(`${C.green}│${C.reset} QQ (qqbot) ......... ${C.yellow}❌ 未配置${C.reset}`); + } + if (tgToken) { + console.log(`${C.green}│${C.reset} Telegram ........... ${C.green}✅ 已配置${C.reset} (${tgToken.slice(0, 12)}...)`); + } else { + console.log(`${C.green}│${C.reset} Telegram ........... ${C.yellow}❌ 未配置${C.reset}`); + } + if (dcToken) { + console.log(`${C.green}│${C.reset} Discord ............ ${C.green}✅ 已配置${C.reset}`); + } else { + console.log(`${C.green}│${C.reset} Discord ............ ${C.yellow}❌ 未配置${C.reset}`); + } + if (fsAppId) { + console.log(`${C.green}│${C.reset} 飞书 ............... ${C.green}✅ 已配置${C.reset} (AppID: ${fsAppId.slice(0, 6)}...)`); + } else { + console.log(`${C.green}│${C.reset} 飞书 ............... ${C.yellow}❌ 未配置${C.reset}`); + } + if (skToken) { + console.log(`${C.green}│${C.reset} Slack .............. ${C.green}✅ 已配置${C.reset}`); + } else { + console.log(`${C.green}│${C.reset} Slack .............. ${C.yellow}❌ 未配置${C.reset}`); + } + + console.log(`${C.green}└──────────────────────────────────────────────────────────┘${C.reset}\n`); + + await input({ prompt: '按回车继续', defaultValue: '' }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 高级配置处理 (对应 advanced_menu) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleAdvancedConfig() { + while (true) { + const choice = await showAdvancedMenu(); + if (!choice || choice.value === 'back') break; + + resetRenderCount(); + + switch (choice.value) { + case 'port': { + const newPort = await input({ + prompt: '请输入 Gateway 端口', + defaultValue: String(jsonGet('gateway.port') || '18789'), + }); + if (newPort) { + jsonSet('gateway.port', parseInt(newPort)); + try { execSync(`uci set openclaw.main.port="${newPort}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {} + console.log(`\n${C.green}✅ 端口已设置为 ${newPort}${C.reset}\n`); + await askRestart(); + } + break; + } + case 'bind': { + const bindChoice = await select({ + title: '绑定地址选项', + showSearch: false, + items: [ + { key: '1', label: 'lan', desc: '仅 LAN 接口 (推荐)', value: 'lan' }, + { key: '2', label: 'loopback', desc: '仅本机访问', value: 'loopback' }, + { key: '3', label: 'all', desc: '所有接口 (0.0.0.0)', value: 'all' }, + ], + }); + if (bindChoice) { + jsonSet('gateway.bind', bindChoice.value); + try { execSync(`uci set openclaw.main.bind="${bindChoice.value}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {} + console.log(`\n${C.green}✅ 绑定地址已设置为 ${bindChoice.value}${C.reset}\n`); + await askRestart(); + } + break; + } + case 'mode': { + const modeChoice = await select({ + title: '运行模式选项', + showSearch: false, + items: [ + { key: '1', label: 'local', desc: '本地模式 (推荐)', value: 'local' }, + { key: '2', label: 'remote', desc: '远程模式', value: 'remote' }, + ], + }); + if (modeChoice) { + jsonSet('gateway.mode', modeChoice.value); + console.log(`\n${C.green}✅ 运行模式已设置为 ${modeChoice.value}${C.reset}\n`); + await askRestart(); + } + break; + } + case 'loglevel': { + const levelChoice = await select({ + title: '日志级别选项', + showSearch: false, + items: [ + { key: '1', label: 'debug', desc: '详细调试', value: 'debug' }, + { key: '2', label: 'info', desc: '常规信息', value: 'info' }, + { key: '3', label: 'warn', desc: '警告及以上', value: 'warn' }, + { key: '4', label: 'error', desc: '仅错误', value: 'error' }, + ], + }); + if (levelChoice) { + jsonSet('gateway.logLevel', levelChoice.value); + console.log(`\n${C.green}✅ 日志级别已设置为 ${levelChoice.value}${C.reset}\n`); + await askRestart(); + } + break; + } + case 'acp': { + const acpChoice = await select({ + title: 'ACP Dispatch 选项', + showSearch: false, + items: [ + { key: '1', label: 'false', desc: '禁用 (推荐路由器使用)', value: 'false' }, + { key: '2', label: 'true', desc: '启用 (可能占用大量内存)', value: 'true' }, + ], + }); + if (acpChoice) { + jsonSet('acp.dispatch.enabled', acpChoice.value === 'true'); + console.log(`\n${C.green}✅ ACP Dispatch 已设置为 ${acpChoice.value}${C.reset}\n`); + await askRestart(); + } + break; + } + case 'wizard': + console.log(`\n${C.cyan}启动官方配置向导...${C.reset}\n`); + try { + await ocCmd('configure'); + } catch (e) {} + break; + case 'view-json': + console.log(`\n${C.cyan}配置文件路径: ${CONFIG_FILE}${C.reset}\n`); + try { + const content = fs.readFileSync(CONFIG_FILE, 'utf8'); + console.log(content); + } catch (e) { + console.log(`${C.red}无法读取配置文件${C.reset}`); + } + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + case 'edit': + console.log(`\n${C.yellow}请在 SSH 终端中手动编辑配置文件:${C.reset}`); + console.log(` vi ${CONFIG_FILE}\n`); + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + case 'backup': + await handleBackup(); + break; + case 'import': { + const importPath = await input({ + prompt: '请输入备份文件路径', + defaultValue: '', + }); + if (importPath && fs.existsSync(importPath)) { + try { + fs.copyFileSync(importPath, CONFIG_FILE); + console.log(`\n${C.green}✅ 配置已导入${C.reset}\n`); + await askRestart(); + } catch (e) { + console.log(`${C.red}导入失败: ${e.message}${C.reset}\n`); + } + } else { + console.log(`${C.yellow}文件不存在${C.reset}\n`); + } + break; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 重置配置处理 (对应 reset_to_defaults) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleReset() { + while (true) { + const choice = await showResetMenu(); + if (!choice || choice.value === 'back') break; + + resetRenderCount(); + + switch (choice.value) { + case 'gateway': { + console.log(`\n${C.yellow}将重置: 网关端口→18789, 绑定→lan, 模式→local${C.reset}`); + console.log(`${C.yellow}保留: 认证令牌、模型配置、消息渠道${C.reset}\n`); + const ok = await confirm({ prompt: '确认恢复网关默认设置?', defaultYes: false }); + if (ok) { + jsonSet('gateway.port', 18789); + jsonSet('gateway.bind', 'lan'); + jsonSet('gateway.mode', 'local'); + jsonSet('gateway.controlUi.allowInsecureAuth', true); + jsonSet('gateway.controlUi.dangerouslyDisableDeviceAuth', true); + jsonSet('gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback', true); + jsonSet('gateway.tailscale.mode', 'off'); + console.log(`\n${C.green}✅ 网关设置已恢复默认${C.reset}\n`); + await askRestart(); + } + break; + } + case 'models': { + console.log(`\n${C.red}⚠️ 将清除: 所有模型配置、API Key、活跃模型设置${C.reset}\n`); + const ok = await confirm({ prompt: '确认清除所有模型配置?', defaultYes: false }); + if (ok) { + const cfg = readConfig(); + delete cfg.models; + if (cfg.agents?.defaults?.model) delete cfg.agents.defaults.model; + if (cfg.agents?.defaults?.models) delete cfg.agents.defaults.models; + writeConfig(cfg); + // 清除 auth-profiles.json + const authFile = `${OC_STATE_DIR}/agents/main/agent/auth-profiles.json`; + try { + fs.writeFileSync(authFile, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }, null, 2)); + } catch {} + console.log(`\n${C.green}✅ 模型配置已清除${C.reset}`); + console.log(`${C.yellow}请通过菜单 [1] 重新配置 AI 模型${C.reset}\n`); + await askRestart(); + } + break; + } + case 'channels': { + console.log(`\n${C.red}⚠️ 将清除: 所有消息渠道配置 (Telegram/Discord/飞书等)${C.reset}\n`); + const ok = await confirm({ prompt: '确认清除所有渠道配置?', defaultYes: false }); + if (ok) { + const cfg = readConfig(); + delete cfg.channels; + writeConfig(cfg); + console.log(`\n${C.green}✅ 渠道配置已清除${C.reset}\n`); + await askRestart(); + } + break; + } + case 'full': { + console.log(`\n${C.red}╔══════════════════════════════════════════════════════╗${C.reset}`); + console.log(`${C.red}║ ⚠️ 完全恢复出厂设置 ║${C.reset}`); + console.log(`${C.red}║ 此操作将删除所有配置并重新初始化 ║${C.reset}`); + console.log(`${C.red}╚══════════════════════════════════════════════════════╝${C.reset}\n`); + + const confirmStr = await input({ prompt: '输入 RESET 确认恢复出厂设置', defaultValue: '' }); + if (confirmStr !== 'RESET') { + console.log(`${C.cyan}已取消${C.reset}\n`); + break; + } + + // 执行恢复 + console.log(`\n${C.cyan}[1/5] 停止 Gateway...${C.reset}`); + try { await runCommand('/etc/init.d/openclaw', ['stop']); } catch {} + + console.log(`${C.cyan}[2/5] 备份当前配置...${C.reset}`); + const backupDir = `${OC_STATE_DIR}/backups`; + const backupTs = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); + try { + if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); + fs.copyFileSync(CONFIG_FILE, `${backupDir}/openclaw_${backupTs}.json`); + console.log(`${C.green} 备份已保存: backups/openclaw_${backupTs}.json${C.reset}`); + } catch {} + + console.log(`${C.cyan}[3/5] 重置配置...${C.reset}`); + writeConfig({}); + + console.log(`${C.cyan}[4/5] 重新初始化...${C.reset}`); + // 生成新 token + const crypto = require('crypto'); + const newToken = crypto.randomBytes(24).toString('hex'); + + console.log(`${C.cyan}[5/5] 应用 OpenWrt 适配配置...${C.reset}`); + jsonSet('gateway.port', 18789); + jsonSet('gateway.bind', 'lan'); + jsonSet('gateway.mode', 'local'); + jsonSet('gateway.auth.mode', 'token'); + jsonSet('gateway.auth.token', newToken); + jsonSet('gateway.controlUi.allowInsecureAuth', true); + jsonSet('gateway.controlUi.dangerouslyDisableDeviceAuth', true); + jsonSet('gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback', true); + jsonSet('gateway.tailscale.mode', 'off'); + jsonSet('acp.dispatch.enabled', false); + jsonSet('tools.profile', 'coding'); + + try { execSync(`uci set openclaw.main.token="${newToken}" && uci commit openclaw`, { stdio: 'ignore' }); } catch {} + + console.log(`\n${C.green}✅ 出厂设置已恢复!${C.reset}\n`); + console.log(`${C.cyan}新认证令牌: ${newToken}${C.reset}\n`); + + await restartGateway(); + break; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 备份/还原处理 (对应 backup_restore_menu) +// ═══════════════════════════════════════════════════════════════════════════ + +async function handleBackup() { + while (true) { + const choice = await showBackupMenu(); + if (!choice || choice.value === 'back') break; + + resetRenderCount(); + const backupDir = `${OC_STATE_DIR}/backups`; + try { if (!fs.existsSync(backupDir)) fs.mkdirSync(backupDir, { recursive: true }); } catch {} + + switch (choice.value) { + case 'create-config': { + console.log(`\n${C.cyan}正在创建配置备份...${C.reset}`); + try { + await ocCmd('backup', 'create', '--only-config', '--no-include-workspace'); + console.log(`${C.green}✅ 配置备份已创建${C.reset}\n`); + } catch (e) { + console.log(`${C.yellow}⚠️ 备份功能需要 OpenClaw v2026.3.8+${C.reset}\n`); + } + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + case 'create-full': { + console.log(`\n${C.cyan}正在创建完整备份...${C.reset}`); + try { + await ocCmd('backup', 'create', '--no-include-workspace'); + console.log(`${C.green}✅ 完整备份已创建${C.reset}\n`); + } catch (e) { + console.log(`${C.yellow}⚠️ 备份失败${C.reset}\n`); + } + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + case 'verify': { + const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.tar.gz')).sort().reverse(); + if (files.length === 0) { + console.log(`${C.yellow}未找到备份文件${C.reset}\n`); + } else { + const latest = `${backupDir}/${files[0]}`; + console.log(`${C.cyan}验证备份: ${latest}${C.reset}`); + try { + await ocCmd('backup', 'verify', latest); + } catch {} + } + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + case 'list': { + const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.tar.gz') || f.endsWith('.json')).sort().reverse(); + if (files.length === 0) { + console.log(`${C.yellow}暂无备份文件${C.reset}\n`); + } else { + console.log(`\n${C.bold}备份文件列表:${C.reset}`); + files.slice(0, 10).forEach(f => { + const stat = fs.statSync(`${backupDir}/${f}`); + console.log(` ${C.dim}${f} (${(stat.size / 1024).toFixed(1)} KB)${C.reset}`); + }); + console.log(`${C.dim}\n备份目录: ${backupDir}${C.reset}\n`); + } + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + case 'restore': { + const files = fs.readdirSync(backupDir).filter(f => f.endsWith('.json')).sort().reverse(); + if (files.length === 0) { + console.log(`${C.yellow}未找到配置备份文件${C.reset}\n`); + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + } + + const fileChoice = await select({ + title: '选择备份文件', + showSearch: false, + items: files.slice(0, 10).map((f, i) => ({ + key: String(i + 1), + label: f, + desc: '', + value: f, + })), + }); + + if (!fileChoice) break; + + const ok = await confirm({ prompt: '确认恢复此备份?', defaultYes: false }); + if (ok) { + try { + fs.copyFileSync(`${backupDir}/${fileChoice.value}`, CONFIG_FILE); + console.log(`\n${C.green}✅ 配置已恢复${C.reset}\n`); + await askRestart(); + } catch (e) { + console.log(`${C.red}恢复失败: ${e.message}${C.reset}\n`); + } + } + break; + } + } + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 主函数 +// ═══════════════════════════════════════════════════════════════════════════ + +async function main() { + const command = process.argv[2]; + + if (command === 'model') { + await handleModelConfig(); + return; + } + + if (command === 'status') { + await runCommand('/etc/init.d/openclaw', ['status_service']); + return; + } + + if (command === 'restart') { + const ok = await confirm({ prompt: '确认重启 OpenClaw 服务?' }); + if (ok) await runCommand('/etc/init.d/openclaw', ['restart']); + return; + } + + // 交互式主菜单 + while (true) { + const choice = await showMainMenu(); + + if (!choice || choice.value === 'quit') { + console.log(`\n${C.green}再见!${C.reset}\n`); + break; + } + + switch (choice.value) { + case 'model': + await handleModelConfig(); + break; + case 'set-active-model': + await handleSetActiveModel(); + break; + case 'channels': + await handleChannels(); + break; + case 'health': + await handleHealthCheck(); + break; + case 'logs': + resetRenderCount(); + console.log(`\n${C.cyan}=== OpenClaw 日志 ===${C.reset}\n`); + await runCommand('logread', ['-e', 'openclaw']); + console.log(''); + await input({ prompt: '按回车继续', defaultValue: '' }); + break; + case 'restart': + resetRenderCount(); + await restartGateway(); + break; + case 'advanced': + await handleAdvancedConfig(); + break; + case 'reset': + await handleReset(); + break; + case 'show-config': + await handleShowConfig(); + break; + case 'backup': + await handleBackup(); + break; + } + } +} + +main().catch(e => { + console.error(`${C.red}错误:${C.reset}`, e.message); + process.exit(1); +}); diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh index edbcf925..3c06136b 100755 --- a/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh +++ b/luci-app-openclaw/root/usr/share/openclaw/oc-config.sh @@ -8,6 +8,10 @@ RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m' CYAN='\033[0;36m'; BOLD='\033[1m'; DIM='\033[2m'; NC='\033[0m' +# ── 交互式菜单引擎路径 ── +OC_MENU_ENGINE="/usr/share/openclaw/oc-menu-engine.js" +OC_INTERACTIVE="/usr/share/openclaw/oc-config-interactive.js" + # ── 端口检查兼容函数 (ss 或 netstat) ── # check_port_listening — 检查端口是否在监听,返回 0/1 check_port_listening() { @@ -200,10 +204,21 @@ enable_auth_plugins() { const d=JSON.parse(fs.readFileSync('${CONFIG_FILE}','utf8')); if(!d.plugins)d.plugins={};if(!d.plugins.entries)d.plugins.entries={}; const e=d.plugins.entries; - ['qwen-portal-auth','copilot-proxy','google-gemini-cli-auth','minimax-portal-auth'].forEach(p=>{ + // 启用有效的认证插件 + ['copilot-proxy'].forEach(p=>{ if(!e[p])e[p]={};e[p].enabled=true; }); + // 清理已废弃或未安装的插件配置 (v2026.4.9: 这些插件已移除或更名) + delete e['qwen-portal-auth']; + delete e['google-gemini-cli-auth']; + delete e['minimax-portal-auth']; delete e['google-antigravity-auth']; + delete e['openclaw-weixin']; // 微信插件有独立安装流程,不应在 entries 中 + // 清理过时的 installs 配置 + if(d.plugins && d.plugins.installs){ + delete d.plugins.installs['google-gemini-cli-auth']; + delete d.plugins.installs['minimax-portal-auth']; + } fs.writeFileSync('${CONFIG_FILE}',JSON.stringify(d,null,2)); }catch(e){} " 2>/dev/null @@ -641,45 +656,48 @@ show_current_config() { # ══════════════════════════════════════════════════════════════ configure_model() { echo "" - echo -e " ${BOLD}🤖 配置 AI 模型提供商${NC}" + echo -e " ${BOLD}🤖 配置 AI 模型和提供商${NC}" echo "" - echo -e " ${GREEN}${BOLD}--- 推荐 ---${NC}" - echo -e " ${CYAN}1)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}" + echo -e " ${GREEN}${BOLD}🌟 ── 推荐 ──${NC}" + echo -e " ${CYAN}w)${NC} 🌟 官方完整模型配置向导 ${GREEN}(推荐,支持所有提供商)${NC}" echo "" - echo -e " ${BOLD}--- 快速配置 ---${NC}" - echo -e " ${CYAN}2)${NC} OpenAI (GPT-5.2, GPT-5 mini, GPT-4.1)" - echo -e " ${CYAN}3)${NC} Anthropic (Claude Sonnet 4, Opus 4, Haiku)" - echo -e " ${CYAN}4)${NC} Google Gemini (Gemini 2.5 Pro/Flash, Gemini 3)" - echo -e " ${CYAN}5)${NC} OpenRouter (聚合多家模型)" - echo -e " ${CYAN}6)${NC} DeepSeek (DeepSeek-V3/R1)" - echo -e " ${CYAN}7)${NC} GitHub Copilot (需要 Copilot 订阅)" - echo -e " ${CYAN}8)${NC} 阿里云通义千问 Qwen (Portal/API/Coding Plan)" - echo -e " ${CYAN}9)${NC} xAI Grok (Grok-3/3-mini)" - echo -e " ${CYAN}10)${NC} Groq (Llama 4, Llama 3.3)" - echo -e " ${CYAN}11)${NC} 硅基流动 SiliconFlow" - echo -e " ${CYAN}12)${NC} Ollama (本地模型,无需 API Key)" - echo -e " ${CYAN}13)${NC} 腾讯云 Coding Plan (HY T1/TurboS/GLM-5/Kimi)" - echo -e " ${CYAN}14)${NC} Mistral AI (Mistral Large, Codestral)" - echo -e " ${CYAN}15)${NC} 百度千帆 (ERNIE-4.0, ERNIE-3.5)" - echo -e " ${CYAN}16)${NC} 自定义 OpenAI 兼容 API" - echo -e " ${CYAN}0)${NC} 返回" + echo -e " ${BOLD}🌍 ── 国外模型提供商 ──${NC}" + echo -e " ${CYAN}a)${NC} OpenAI (GPT-5.2, GPT-5 mini, GPT-4.1)" + echo -e " ${CYAN}b)${NC} Anthropic (Claude Sonnet 4, Opus 4, Haiku)" + echo -e " ${CYAN}c)${NC} Google Gemini (Gemini 2.5 Pro/Flash, Gemini 3)" + echo -e " ${CYAN}d)${NC} OpenRouter (聚合多家模型)" + echo -e " ${CYAN}e)${NC} GitHub Copilot (需要 Copilot 订阅)" + echo -e " ${CYAN}f)${NC} xAI Grok (Grok-4/3)" echo "" - prompt_with_default "请选择" "1" choice + echo -e " ${BOLD}🇨🇳 ── 国内模型提供商 ──${NC}" + echo -e " ${CYAN}g)${NC} 阿里云通义千问 Qwen (Portal/API/Coding Plan)" + echo -e " ${CYAN}h)${NC} 硅基流动 SiliconFlow" + echo -e " ${CYAN}i)${NC} 腾讯云 Coding Plan (HY T1/TurboS/GLM-5/Kimi)" + echo -e " ${CYAN}j)${NC} 百度千帆 (ERNIE-4.0, ERNIE-3.5)" + echo -e " ${CYAN}k)${NC} 智谱 GLM / Z.AI (GLM-5.1, GLM-5)" + echo "" + echo -e " ${BOLD}🏠 ── 本地模型 / 自定义 API ──${NC}" + echo -e " ${CYAN}l)${NC} Ollama (本地模型,无需 API Key)" + echo -e " ${CYAN}m)${NC} 自定义 OpenAI 兼容 API" + echo "" + echo -e " ${CYAN}q)${NC} 返回" + echo "" + prompt_with_default "请选择" "w" choice case "$choice" in - 1) + w) echo "" echo -e " ${CYAN}启动官方完整模型配置向导...${NC}" echo -e " ${YELLOW}提示: ↑↓ 移动, Tab/空格 选中, 回车 确认${NC}" echo "" - echo -e " ${CYAN}预启用模型认证插件...${NC}" + echo -e " ${CYAN}清理过时插件配置...${NC}" enable_auth_plugins echo "" (oc_cmd configure --section model) || echo -e " ${YELLOW}配置向导已退出${NC}" echo "" ask_restart ;; - 2) + a) echo "" echo -e " ${BOLD}OpenAI 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://platform.openai.com/api-keys${NC}" @@ -712,7 +730,7 @@ configure_model() { echo -e " ${GREEN}✅ OpenAI 已配置,活跃模型: openai/${model_name}${NC}" fi ;; - 3) + b) echo "" echo -e " ${BOLD}Anthropic 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://console.anthropic.com/settings/keys${NC}" @@ -743,7 +761,7 @@ configure_model() { echo -e " ${GREEN}✅ Anthropic 已配置,活跃模型: anthropic/${model_name}${NC}" fi ;; - 4) + c) echo "" echo -e " ${BOLD}Google Gemini 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://aistudio.google.com/apikey${NC}" @@ -774,7 +792,7 @@ configure_model() { echo -e " ${GREEN}✅ Google Gemini 已配置,活跃模型: google/${model_name}${NC}" fi ;; - 5) + d) echo "" echo -e " ${BOLD}OpenRouter 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://openrouter.ai/keys${NC}" @@ -808,33 +826,7 @@ configure_model() { echo -e " ${GREEN}✅ OpenRouter 已配置,活跃模型: openrouter/${model_name}${NC}" fi ;; - 6) - echo "" - echo -e " ${BOLD}DeepSeek 配置${NC}" - echo -e " ${YELLOW}获取 API Key: https://platform.deepseek.com/api_keys${NC}" - echo "" - prompt_with_default "请输入 DeepSeek API Key" "" api_key - if [ -n "$api_key" ]; then - echo "" - echo -e " ${CYAN}可用模型:${NC}" - echo -e " ${CYAN}a)${NC} deepseek-chat — DeepSeek-V3 (通用对话)" - echo -e " ${CYAN}b)${NC} deepseek-reasoner — DeepSeek-R1 (深度推理)" - echo -e " ${CYAN}c)${NC} 手动输入模型名" - echo "" - prompt_with_default "请选择模型" "a" model_choice - case "$model_choice" in - a) model_name="deepseek-chat" ;; - b) model_name="deepseek-reasoner" ;; - c) prompt_with_default "请输入模型名称" "deepseek-chat" model_name ;; - *) model_name="deepseek-chat" ;; - esac - auth_set_apikey deepseek "$api_key" - register_custom_provider deepseek "https://api.deepseek.com/v1" "$api_key" "$model_name" "$model_name" - register_and_set_model "deepseek/${model_name}" - echo -e " ${GREEN}✅ DeepSeek 已配置,活跃模型: deepseek/${model_name}${NC}" - fi - ;; - 7) + e) echo "" echo -e " ${BOLD}GitHub Copilot 配置${NC}" echo -e " ${YELLOW}需要有效的 GitHub Copilot 订阅 (Free/Pro/Business 均可)${NC}" @@ -889,7 +881,7 @@ configure_model() { echo -e " ${YELLOW}OAuth 授权已退出或失败${NC}" fi ;; - 8) + g) echo "" echo -e " ${BOLD}阿里云通义千问 Qwen 配置${NC}" echo "" @@ -1016,7 +1008,7 @@ configure_model() { ;; esac ;; - 9) + f) echo "" echo -e " ${BOLD}xAI Grok 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://console.x.ai${NC}" @@ -1049,41 +1041,7 @@ configure_model() { echo -e " ${GREEN}✅ xAI Grok 已配置,活跃模型: xai/${model_name}${NC}" fi ;; - 10) - echo "" - echo -e " ${BOLD}Groq 配置${NC}" - echo -e " ${YELLOW}获取 API Key: https://console.groq.com/keys${NC}" - echo -e " ${YELLOW}Groq 提供超快推理速度${NC}" - echo "" - prompt_with_default "请输入 Groq API Key" "" api_key - if [ -n "$api_key" ]; then - echo "" - echo -e " ${CYAN}可用模型:${NC}" - echo -e " ${CYAN}a)${NC} meta-llama/llama-4-maverick-17b-128e-instruct — Llama 4 Maverick (推荐)" - echo -e " ${CYAN}b)${NC} meta-llama/llama-4-scout-17b-16e-instruct — Llama 4 Scout" - echo -e " ${CYAN}c)${NC} moonshotai/kimi-k2-instruct — Kimi K2" - echo -e " ${CYAN}d)${NC} qwen/qwen3-32b — 通义千问 Qwen3 32B" - echo -e " ${CYAN}e)${NC} llama-3.3-70b-versatile — Llama 3.3 70B" - echo -e " ${CYAN}f)${NC} llama-3.1-8b-instant — Llama 3.1 8B (极速)" - echo -e " ${CYAN}g)${NC} 手动输入模型名" - echo "" - prompt_with_default "请选择模型" "a" model_choice - case "$model_choice" in - a) model_name="meta-llama/llama-4-maverick-17b-128e-instruct" ;; - b) model_name="meta-llama/llama-4-scout-17b-16e-instruct" ;; - c) model_name="moonshotai/kimi-k2-instruct" ;; - d) model_name="qwen/qwen3-32b" ;; - e) model_name="llama-3.3-70b-versatile" ;; - f) model_name="llama-3.1-8b-instant" ;; - g) prompt_with_default "请输入模型名称" "meta-llama/llama-4-maverick-17b-128e-instruct" model_name ;; - *) model_name="meta-llama/llama-4-maverick-17b-128e-instruct" ;; - esac - auth_set_apikey groq "$api_key" - register_and_set_model "groq/${model_name}" - echo -e " ${GREEN}✅ Groq 已配置,活跃模型: groq/${model_name}${NC}" - fi - ;; - 11) + h) echo "" echo -e " ${BOLD}硅基流动 SiliconFlow 配置${NC}" echo -e " ${YELLOW}获取 API Key: https://cloud.siliconflow.cn/account/ak${NC}" @@ -1130,7 +1088,7 @@ configure_model() { echo -e " ${GREEN}✅ SiliconFlow 已配置,活跃模型: siliconflow/${model_name}${NC}" fi ;; - 12) + l) echo "" echo -e " ${BOLD}🦙 Ollama 本地模型配置${NC}" echo -e " ${YELLOW}Ollama 在本地或局域网运行大模型,无需 API Key${NC}" @@ -1246,7 +1204,7 @@ configure_model() { fi fi ;; - 13) + i) echo "" echo -e " ${BOLD}腾讯云大模型 Coding Plan 套餐配置${NC}" echo "" @@ -1295,37 +1253,7 @@ configure_model() { echo -e " ${DIM}提示: 套餐内全部模型已注册,可随时在 WebChat 中通过 /model 切换${NC}" fi ;; - 14) - echo "" - echo -e " ${BOLD}Mistral AI 配置${NC}" - echo -e " ${YELLOW}获取 API Key: https://console.mistral.ai/api-keys/${NC}" - echo "" - prompt_with_default "请输入 Mistral API Key" "" api_key - if [ -n "$api_key" ]; then - auth_set_apikey mistral "$api_key" - echo "" - echo -e " ${CYAN}可用模型:${NC}" - echo -e " ${CYAN}a)${NC} mistral-large-latest — 旗舰模型,最强性能 (推荐)" - echo -e " ${CYAN}b)${NC} mistral-medium-latest — 均衡模型" - echo -e " ${CYAN}c)${NC} codestral-latest — 代码专用,极速补全" - echo -e " ${CYAN}d)${NC} mistral-small-latest — 轻量快速" - echo -e " ${CYAN}e)${NC} 手动输入模型名" - echo "" - prompt_with_default "请选择模型" "a" model_choice - case "$model_choice" in - a) model_name="mistral-large-latest" ;; - b) model_name="mistral-medium-latest" ;; - c) model_name="codestral-latest" ;; - d) model_name="mistral-small-latest" ;; - e) prompt_with_default "请输入模型名称" "mistral-large-latest" model_name ;; - *) model_name="mistral-large-latest" ;; - esac - register_custom_provider mistral "https://api.mistral.ai/v1" "$api_key" "$model_name" "$model_name" - register_and_set_model "mistral/${model_name}" - echo -e " ${GREEN}✅ Mistral AI 已配置,活跃模型: mistral/${model_name}${NC}" - fi - ;; - 15) + j) echo "" echo -e " ${BOLD}百度千帆大模型配置${NC}" echo -e " ${YELLOW}获取 API Key: https://console.bce.baidu.com/qianfan/ais/console/onlineService${NC}" @@ -1359,7 +1287,70 @@ configure_model() { echo -e " ${GREEN}✅ 百度千帆已配置,活跃模型: qianfan/${model_name}${NC}" fi ;; - 16) + k) + echo "" + echo -e " ${BOLD}智谱 GLM / Z.AI 配置${NC}" + echo "" + echo -e " ${CYAN}认证方式:${NC}" + echo -e " ${CYAN}a)${NC} CN (open.bigmodel.cn) ${GREEN}★ 国内用户推荐${NC}" + echo -e " ${CYAN}b)${NC} Coding-Plan-CN (智谱 Coding Plan 套餐)" + echo -e " ${CYAN}c)${NC} Global (api.z.ai)" + echo -e " ${CYAN}d)${NC} 手动输入 API Key" + echo "" + prompt_with_default "请选择认证方式" "a" zai_method + case "$zai_method" in + a) + zai_base_url="https://open.bigmodel.cn/api/paas/v4" + echo -e " ${YELLOW}获取 API Key: https://open.bigmodel.cn/api-key${NC}" + ;; + b) + zai_base_url="https://open.bigmodel.cn/api/coding/paas/v4" + echo -e " ${YELLOW}Coding Plan 套餐 API Key (sk-...)${NC}" + ;; + c) + zai_base_url="https://api.z.ai/api/paas/v4" + echo -e " ${YELLOW}全球版 API Key${NC}" + ;; + d) + prompt_with_default "请输入 Base URL" "https://open.bigmodel.cn/api/paas/v4" zai_base_url + ;; + *) + zai_base_url="https://open.bigmodel.cn/api/paas/v4" + ;; + esac + echo "" + prompt_with_default "请输入智谱 API Key" "" api_key + if [ -n "$api_key" ]; then + echo "" + echo -e " ${CYAN}可用模型:${NC}" + echo -e " ${CYAN}a)${NC} glm-5.1 — GLM-5.1 (推荐)" + echo -e " ${CYAN}b)${NC} glm-5 — GLM-5" + echo -e " ${CYAN}c)${NC} glm-4.7 — GLM-4.7" + echo -e " ${CYAN}d)${NC} glm-4.7-flash — GLM-4.7 Flash" + echo -e " ${CYAN}e)${NC} glm-4.5 — GLM-4.5" + echo -e " ${CYAN}f)${NC} glm-4.5-flash — GLM-4.5 Flash (免费额度)" + echo -e " ${CYAN}g)${NC} 手动输入模型名" + echo "" + prompt_with_default "请选择模型" "a" model_choice + case "$model_choice" in + a) model_name="glm-5.1" ;; + b) model_name="glm-5" ;; + c) model_name="glm-4.7" ;; + d) model_name="glm-4.7-flash" ;; + e) model_name="glm-4.5" ;; + f) model_name="glm-4.5-flash" ;; + g) prompt_with_default "请输入模型名称" "glm-5.1" model_name ;; + *) model_name="glm-5.1" ;; + esac + # 智谱 GLM 使用原生 zai provider (OpenClaw 内置支持) + auth_set_apikey zai "$api_key" + register_custom_provider zai "$zai_base_url" "$api_key" "$model_name" "$model_name" "128000" "4096" + register_and_set_model "zai/${model_name}" + echo -e " ${GREEN}✅ 智谱 GLM 已配置,活跃模型: zai/${model_name}${NC}" + echo -e " ${DIM} Base URL: ${zai_base_url}${NC}" + fi + ;; + m) echo "" echo -e " ${BOLD}自定义 OpenAI 兼容 API${NC}" echo -e " ${YELLOW}支持任何兼容 OpenAI API 格式的服务商${NC}" @@ -1374,7 +1365,7 @@ configure_model() { echo -e " ${GREEN}✅ 自定义模型已配置,活跃模型: openai-compatible/${model_name}${NC}" fi ;; - 0) return ;; + q) return ;; esac if [ "$choice" != "0" ] && [ "$choice" != "1" ]; then @@ -2227,7 +2218,7 @@ reset_to_defaults() { echo -e " ${CYAN}已取消${NC}" fi ;; - 4) + c) echo "" echo -e " ${RED}╔══════════════════════════════════════════════════════╗${NC}" echo -e " ${RED}║ ⚠️ 完全恢复出厂设置 ║${NC}" @@ -2409,7 +2400,7 @@ backup_restore_menu() { oc_cmd backup verify "$latest" 2>&1 fi ;; - 4) + c) echo "" if [ -d "$backup_dir" ]; then local count=$(ls "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | wc -l) @@ -2427,7 +2418,7 @@ backup_restore_menu() { echo "" echo -e " ${DIM}备份目录: ${backup_dir}${NC}" ;; - 5) + d) local latest=$(ls -t "${backup_dir}"/*-openclaw-backup.tar.gz 2>/dev/null | head -1) if [ -z "$latest" ]; then echo -e " ${YELLOW}未找到备份文件,请先创建备份${NC}" @@ -2479,7 +2470,60 @@ backup_restore_menu() { esac } +# ══════════════════════════════════════════════════════════════════════════ +# 交互式菜单 (方向键导航) +# ══════════════════════════════════════════════════════════════════════════ + +# 检测是否支持交互模式 (需要 Node.js + TTY 终端) +can_use_interactive() { + [ -x "$NODE_BIN" ] || return 1 + [ -f "$OC_INTERACTIVE" ] || return 1 + [ -f "$OC_MENU_ENGINE" ] || return 1 + # 检测是否为真实 TTY (排除某些非交互环境) + [ -t 0 ] && [ -t 1 ] || return 1 + return 0 +} + +# 启动交互式菜单 (方向键导航版本) +launch_interactive_menu() { + if ! can_use_interactive; then + echo -e " ${YELLOW}⚠️ 当前环境不支持交互模式,使用传统菜单${NC}" + return 1 + fi + + # 调用 Node.js 交互式前端 + "$NODE_BIN" "$OC_INTERACTIVE" 2>&1 + local rc=$? + + # 返回后刷新配置权限 + chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true + return $rc +} + +# 启动交互式模型配置 +launch_interactive_model_config() { + if ! can_use_interactive; then + echo -e " ${YELLOW}⚠️ 当前环境不支持交互模式,使用传统菜单${NC}" + configure_model # 回退到传统菜单 + return $? + fi + + # 调用 Node.js 交互式前端的模型配置模块 + "$NODE_BIN" "$OC_INTERACTIVE" model 2>&1 + local rc=$? + + chown openclaw:openclaw "$CONFIG_FILE" 2>/dev/null || true + return $rc +} + main_menu() { + # 直接启动交互模式 (如果支持) + if can_use_interactive && [ "${OC_FORCE_TRADITIONAL:-0}" != "1" ]; then + launch_interactive_menu + return $? + fi + + # 传统菜单 (回退方案) while true; do echo "" echo -e "${GREEN}╔══════════════════════════════════════════════════════════════╗${NC}" @@ -2693,6 +2737,29 @@ advanced_menu() { # ── 支持命令行参数 ── case "${1:-}" in + --tui|--interactive) + # 强制启动交互式菜单 (方向键导航) + if can_use_interactive; then + launch_interactive_menu + else + echo -e "${YELLOW}⚠️ 当前环境不支持交互模式${NC}" + echo "需要: Node.js + TTY 终端" + exit 1 + fi + ;; + --traditional) + # 强制使用传统菜单 + export OC_FORCE_TRADITIONAL=1 + main_menu + ;; + --model) + # 直接进入模型配置 + if can_use_interactive; then + launch_interactive_model_config + else + configure_model + fi + ;; --set) if [ -n "${2:-}" ] && [ -n "${3:-}" ]; then json_set "$2" "$3" @@ -2727,12 +2794,21 @@ case "${1:-}" in echo "OpenClaw AI Gateway — OpenWrt 配置管理工具" echo "" echo "用法:" - echo " oc-config.sh # 进入交互式菜单" + echo " oc-config.sh # 进入交互式菜单 (自动检测)" + echo " oc-config.sh --tui # 强制使用方向键交互模式" + echo " oc-config.sh --traditional # 强制使用传统数字菜单" + echo " oc-config.sh --model # 直接进入模型配置" echo " oc-config.sh --set K V # 设置配置项" echo " oc-config.sh --get K # 读取配置项" echo " oc-config.sh --restart # 重启 Gateway" echo " oc-config.sh --status # 查看状态" echo "" + echo "交互模式快捷键:" + echo " ↑↓ 导航选项" + echo " 回车 确认选择" + echo " ESC/q 返回上级" + echo " 字符 搜索过滤" + echo "" ;; "") main_menu diff --git a/luci-app-openclaw/root/usr/share/openclaw/oc-menu-engine.js b/luci-app-openclaw/root/usr/share/openclaw/oc-menu-engine.js new file mode 100644 index 00000000..c2449383 --- /dev/null +++ b/luci-app-openclaw/root/usr/share/openclaw/oc-menu-engine.js @@ -0,0 +1,727 @@ +/** + * ============================================================================ + * OpenClaw 配置工具 — 交互式菜单引擎 + * ============================================================================ + * + * 功能特性: + * - 方向键 (↑↓) 导航菜单选项 + * - 回车/空格 确认选择 + * - Tab 在多列菜单中切换 + * - ESC/q 返回上级菜单 + * - 搜索过滤 (输入字符实时过滤) + * - 纯 Node.js 实现,零外部依赖 + * - 完全兼容 xterm.js / Web PTY 环境 + * + * 使用方式: + * const menu = require('./oc-menu-engine'); + * const choice = await menu.select({ + * title: '选择模型提供商', + * items: [ + * { key: 'a', label: 'OpenAI', desc: 'GPT-5, GPT-4.1' }, + * { key: 'b', label: 'Anthropic', desc: 'Claude' }, + * ] + * }); + */ + +// ANSI 颜色代码 +const C = { + reset: '\x1b[0m', + bold: '\x1b[1m', + dim: '\x1b[2m', + italic: '\x1b[3m', + underline: '\x1b[4m', + // 前景色 + black: '\x1b[30m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m', + // 亮色 + brightRed: '\x1b[91m', + brightGreen: '\x1b[92m', + brightYellow: '\x1b[93m', + brightBlue: '\x1b[94m', + brightMagenta: '\x1b[95m', + brightCyan: '\x1b[96m', + // 背景色 + bgBlack: '\x1b[40m', + bgRed: '\x1b[41m', + bgGreen: '\x1b[42m', + bgYellow: '\x1b[43m', + bgBlue: '\x1b[44m', + bgMagenta: '\x1b[45m', + bgCyan: '\x1b[46m', + bgWhite: '\x1b[47m', + // 光标控制 + hide: '\x1b[?25l', + show: '\x1b[?25h', + save: '\x1b[s', + restore: '\x1b[u', + clearLine: '\x1b[2K', + clearDown: '\x1b[J', + // 移动 + up: (n = 1) => `\x1b[${n}A`, + down: (n = 1) => `\x1b[${n}B`, + right: (n = 1) => `\x1b[${n}C`, + left: (n = 1) => `\x1b[${n}D`, + toCol: (n) => `\x1b[${n}G`, + toRow: (n) => `\x1b[${n}H`, + to: (row, col) => `\x1b[${row};${col}H`, +}; + +// ═══════════════════════════════════════════════════════════════════════════ +// 终端输入处理 +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * 设置终端为原始模式 (逐字节读取) + */ +function setRawMode(enable) { + if (process.stdin.isTTY) { + process.stdin.setRawMode(enable); + } +} + +/** + * 从 stdin 读取单个按键 + * 支持 Bracketed Paste Mode (粘贴检测) + * @returns {Promise<{name: string, sequence: string, char: string, paste?: string}>} + */ +function readKey() { + return new Promise((resolve) => { + const onData = (buffer) => { + process.stdin.off('data', onData); + + const seq = buffer.toString('utf8'); + + // 解析按键 + let key = { sequence: seq, char: '', name: '' }; + + // 检测 Bracketed Paste (粘贴操作) + // 终端在粘贴时发送: ESC[200~ <粘贴内容> ESC[201~ + if (seq.startsWith('\x1b[200~') && seq.endsWith('\x1b[201~')) { + // 提取粘贴的内容 (去掉首尾的转义序列) + const pasteContent = seq.slice(6, -6); + key.name = 'paste'; + key.paste = pasteContent; + key.char = ''; + resolve(key); + return; + } + + if (seq === '\r' || seq === '\n') { + key.name = 'return'; + } else if (seq === '\t') { + key.name = 'tab'; + } else if (seq === '\x1b') { + key.name = 'escape'; + } else if (seq === '\x7f' || seq === '\x08') { + key.name = 'backspace'; + } else if (seq === '\x1b[A') { + key.name = 'up'; + } else if (seq === '\x1b[B') { + key.name = 'down'; + } else if (seq === '\x1b[C') { + key.name = 'right'; + } else if (seq === '\x1b[D') { + key.name = 'left'; + } else if (seq === '\x1b[1;2A' || seq === '\x1b[1;2B') { + // Shift+Up/Down - 可用于快速滚动 + key.name = seq.endsWith('A') ? 'shift-up' : 'shift-down'; + } else if (seq === '\x1b[5~') { + key.name = 'pageup'; + } else if (seq === '\x1b[6~') { + key.name = 'pagedown'; + } else if (seq === '\x1b[H') { + key.name = 'home'; + } else if (seq === '\x1b[F') { + key.name = 'end'; + } else if (seq.length === 1 && seq >= ' ' && seq <= '~') { + key.char = seq; + key.name = seq.toLowerCase(); + } else if (seq.startsWith('\x1b[') && seq.endsWith('~')) { + // 功能键 F1-F12 + const code = seq.slice(2, -1); + if (code >= '11' && code <= '24') { + key.name = `f${parseInt(code) - 10}`; + } + } + + resolve(key); + }; + + process.stdin.once('data', onData); + }); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 交互式选择菜单 +// ═══════════════════════════════════════════════════════════════════════════ + +// 记录上一次渲染的菜单行数,用于正确清除 +let lastRenderLines = 0; + +/** + * 重置渲染计数器 (在打印非菜单内容前调用) + */ +function resetRenderCount() { + lastRenderLines = 0; +} + +/** + * 渲染菜单选项 + * @param {Array} items 菜单项数组 + * @param {number} selected 当前选中索引 + * @param {string} searchFilter 搜索过滤字符串 + * @param {object} options 配置选项 + * @returns {Array} 过滤后的菜单项 + */ +function renderMenu(items, selected, searchFilter = '', options = {}) { + const { + title = '请选择', + header = '', + footer = '', + showSearch = true, + showHelp = true, + maxWidth = 80, + } = options; + + // 过滤项目 + let filteredItems = items; + if (searchFilter) { + const filter = searchFilter.toLowerCase(); + filteredItems = items.filter(item => + (item.label || '').toLowerCase().includes(filter) || + (item.desc || '').toLowerCase().includes(filter) || + (item.key || '').toLowerCase().includes(filter) + ); + } + + // 输出缓冲 + const lines = []; + + // 标题 + if (title) { + lines.push(`${C.bold}${C.cyan} ${title}${C.reset}`); + lines.push(''); + } + + // 头部信息 + if (header) { + lines.push(` ${header}`); + lines.push(''); + } + + // 搜索框 + if (showSearch && searchFilter !== undefined) { + const searchPrompt = searchFilter + ? `${C.dim}搜索: ${C.reset}${C.yellow}${searchFilter}${C.reset}${C.dim}█${C.reset}` + : `${C.dim}搜索: ${C.reset}${C.dim}(输入字符过滤,ESC 清空)${C.reset}`; + lines.push(` ${searchPrompt}`); + lines.push(''); + } + + // 菜单项 + filteredItems.forEach((item, idx) => { + const isSelected = idx === selected; + const isDisabled = item.disabled; + + // 选中态样式 - 使用背景色高亮整行 + const cursor = isSelected ? `${C.cyan}▸${C.reset} ` : ' '; + const labelStyle = isSelected ? `${C.bold}${C.cyan}` : (isDisabled ? C.dim : ''); + const descStyle = isSelected ? C.cyan : C.dim; + + // 构建行 + const keyLabel = item.key ? `${C.yellow}${item.key}${C.reset}` : ''; + const mainLabel = `${labelStyle}${item.label || ''}${C.reset}`; + const descLabel = item.desc ? `${descStyle}— ${item.desc}${C.reset}` : ''; + + // 拼接 + let line = cursor; + if (keyLabel) line += `${C.dim}[${C.reset}${keyLabel}${C.dim}]${C.reset} `; + line += mainLabel; + if (descLabel && !isDisabled) line += ` ${descLabel}`; + + lines.push(line); + }); + + // 空状态 + if (filteredItems.length === 0) { + lines.push(` ${C.dim}(没有匹配的项目)${C.reset}`); + } + + // 底部帮助 + if (showHelp) { + lines.push(''); + lines.push(` ${C.dim}↑↓ 导航 回车 确认 ESC/q 返回${showSearch ? ' 输入 搜索' : ''}${C.reset}`); + } + + // 自定义底部 + if (footer) { + lines.push(''); + lines.push(` ${footer}`); + } + + // 计算需要的清除行数 + const totalLines = lines.length + 1; + + // 清除之前的内容: 移动到开头,清除到屏幕底部 + let clearSeq = '\r'; + if (lastRenderLines > 0) { + // 移动到上一次渲染的第一行 + clearSeq += `\x1b[${lastRenderLines}A`; + // 清除从此行到屏幕底部 + clearSeq += C.clearDown; + } + lastRenderLines = totalLines; + + // 输出: 清除旧内容 + 新内容 + process.stdout.write(clearSeq + lines.join('\n') + '\n'); + + return filteredItems; +} + +/** + * 交互式单选菜单 + * @param {object} options 配置选项 + * @returns {Promise} 选中的项目或 null (取消) + */ +async function select(options = {}) { + const { + items = [], + title = '请选择', + header = '', + footer = '', + defaultIndex = 0, + showSearch = true, + allowCancel = true, + exitKeys = ['escape', 'q'], + onSelect = null, // 选择回调 (可用于预览) + } = options; + + if (items.length === 0) { + return null; + } + + // 重置渲染行计数 + lastRenderLines = 0; + + // 初始化状态 + let selected = Math.min(defaultIndex, items.length - 1); + let searchFilter = ''; + let filteredItems = items; + let done = false; + let result = null; + + // 保存终端状态 + setRawMode(true); + process.stdout.write(C.hide); + process.stdout.write(C.save); + + try { + // 初始渲染 + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + + while (!done) { + const key = await readKey(); + + // 处理按键 + if (key.name === 'return') { + // 确认选择 + if (filteredItems.length > 0 && !filteredItems[selected].disabled) { + result = filteredItems[selected]; + done = true; + } + } else if (key.name === 'up') { + // 上移 + if (selected > 0) { + selected--; + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } + } else if (key.name === 'down') { + // 下移 + if (selected < filteredItems.length - 1) { + selected++; + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } + } else if (key.name === 'pageup') { + // 上翻页 + selected = Math.max(0, selected - 10); + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } else if (key.name === 'pagedown') { + // 下翻页 + selected = Math.min(filteredItems.length - 1, selected + 10); + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } else if (key.name === 'home') { + selected = 0; + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } else if (key.name === 'end') { + selected = filteredItems.length - 1; + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } else if (exitKeys.includes(key.name) && allowCancel) { + // 取消/退出 + done = true; + } else if (key.name === 'backspace') { + // 删除搜索字符 + if (searchFilter.length > 0) { + searchFilter = searchFilter.slice(0, -1); + selected = 0; // 重置选中 + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } + } else if (key.char && key.char.length === 1) { + // 输入字符 -> 搜索 + if (showSearch) { + searchFilter += key.char; + selected = 0; + filteredItems = renderMenu(items, selected, searchFilter, { title, header, footer, showSearch }); + } + } + + // 直接按键选择 (如果菜单项有 key 属性) + if (key.char && !showSearch) { + const matchByChar = items.find(item => (item.key || '').toLowerCase() === key.name); + if (matchByChar) { + result = matchByChar; + done = true; + } + } + } + } finally { + // 恢复终端状态 + process.stdout.write(C.restore); + process.stdout.write(C.clearDown); + process.stdout.write(C.show); + setRawMode(false); + } + + return result; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 其他交互组件 +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * 文本输入框 + * 支持粘贴操作 (Bracketed Paste Mode) + * @param {object} options 配置选项 + * @returns {Promise} 输入的文本或 null (取消) + */ +async function input(options = {}) { + const { + prompt = '请输入', + defaultValue = '', + placeholder = '', + validate = null, + allowCancel = true, + hidden = false, // 密码输入模式 + } = options; + + let value = ''; + let done = false; + let cancelled = false; + + // 启用 Bracketed Paste Mode + process.stdout.write('\x1b[?2004h'); + setRawMode(true); + process.stdout.write(C.hide); + + try { + // 初始渲染 + const renderInput = () => { + const displayValue = hidden ? '*'.repeat(value.length) : value; + const promptText = `${C.bold}${C.cyan}${prompt}:${C.reset} `; + const valueText = displayValue || `${C.dim}${placeholder}${C.reset}`; + + process.stdout.write(`\r${C.clearLine}${promptText}${valueText}`); + + // 光标定位 + if (displayValue) { + process.stdout.write(C.left(displayValue.length - value.length)); + } + }; + + renderInput(); + + while (!done) { + const key = await readKey(); + + if (key.name === 'return') { + // 确认 + const finalValue = value || defaultValue; + if (validate) { + const error = validate(finalValue); + if (error) { + process.stdout.write(`\n${C.red} ✗ ${error}${C.reset}\n`); + renderInput(); + continue; + } + } + done = true; + } else if ((key.name === 'escape' || key.name === 'q') && allowCancel) { + done = true; + cancelled = true; + } else if (key.name === 'backspace') { + if (value.length > 0) { + value = value.slice(0, -1); + renderInput(); + } + } else if (key.name === 'paste' && key.paste) { + // 处理粘贴操作 (Bracketed Paste Mode) + // 清理粘贴内容中的控制字符和换行符 + const cleanPaste = key.paste + .replace(/[\r\n]+/g, ' ') // 换行符替换为空格 + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') // 移除 ANSI 转义序列 + .replace(/[\x00-\x1f]/g, ''); // 移除控制字符 + value += cleanPaste; + renderInput(); + } else if (key.char && key.char.length === 1) { + value += key.char; + renderInput(); + } else if (key.sequence && key.sequence.length > 1 && !key.name.startsWith('page') && + !['up', 'down', 'left', 'right', 'home', 'end'].includes(key.name)) { + // 回退处理: 如果收到未知的多字符序列,可能是粘贴内容 + // 尝试提取可打印字符 + const extracted = key.sequence + .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '') + .replace(/[\r\n]+/g, ' ') + .replace(/[\x00-\x1f]/g, '') + .replace(/[^\x20-\x7e]/g, ''); + if (extracted.length > 0) { + value += extracted; + renderInput(); + } + } + } + } finally { + // 禁用 Bracketed Paste Mode + process.stdout.write('\x1b[?2004l'); + process.stdout.write(C.show); + setRawMode(false); + } + + if (cancelled) return null; + + // 显示最终值 (非隐藏模式) + if (!hidden) { + process.stdout.write(`\n`); + } + + return value || defaultValue; +} + +/** + * 确认对话框 + * @param {object} options 配置选项 + * @returns {Promise} 用户选择 + */ +async function confirm(options = {}) { + const { + prompt = '确认吗?', + defaultYes = true, + } = options; + + let selected = defaultYes; // true = Yes, false = No + + setRawMode(true); + process.stdout.write(C.hide); + + const renderConfirm = () => { + const yesStyle = selected ? `${C.bold}${C.green}` : C.dim; + const noStyle = selected ? C.dim : `${C.bold}${C.red}`; + + process.stdout.write( + `\r${C.clearLine}${C.bold}${C.cyan}${prompt}${C.reset} ` + + `${yesStyle}[Y] 是${C.reset} / ` + + `${noStyle}[N] 否${C.reset} ` + + `${C.dim}(←→ 切换, 回车 确认)${C.reset}` + ); + }; + + renderConfirm(); + + try { + while (true) { + const key = await readKey(); + + if (key.name === 'return') { + process.stdout.write('\n'); + return selected; + } else if (key.name === 'left' || key.name === 'y') { + selected = true; + renderConfirm(); + } else if (key.name === 'right' || key.name === 'n') { + selected = false; + renderConfirm(); + } else if (key.name === 'escape') { + process.stdout.write('\n'); + return false; + } + } + } finally { + process.stdout.write(C.show); + setRawMode(false); + } +} + +/** + * 多选菜单 (复选框) + * @param {object} options 配置选项 + * @returns {Promise} 选中的项目数组 + */ +async function multiselect(options = {}) { + const { + items = [], + title = '请选择 (空格 切换选中)', + defaultSelected = [], + } = options; + + if (items.length === 0) return []; + + const selectedSet = new Set(defaultSelected); + let cursor = 0; + let done = false; + let cancelled = false; + + setRawMode(true); + process.stdout.write(C.hide); + + const render = () => { + const lines = [`\r${C.bold}${C.cyan}${title}${C.reset}\n`]; + + items.forEach((item, idx) => { + const isCursor = idx === cursor; + const isChecked = selectedSet.has(idx); + + const cursorStyle = isCursor ? `${C.bold}${C.cyan}▸` : ' '; + const checkChar = isChecked ? `${C.green}✓${C.reset}` : `${C.dim}○${C.reset}`; + const labelStyle = isCursor ? C.bold : ''; + + lines.push(`\r${cursorStyle} ${checkChar} ${labelStyle}${item.label}${C.reset}`); + }); + + lines.push(`\r\n${C.dim}↑↓ 移动 空格 选中 回车 确认 ESC 取消${C.reset}`); + + process.stdout.write(C.clearDown + lines.join('\n') + '\n'); + }; + + render(); + + try { + while (!done) { + const key = await readKey(); + + if (key.name === 'return') { + done = true; + } else if (key.name === 'escape' || key.name === 'q') { + done = true; + cancelled = true; + } else if (key.name === 'up' && cursor > 0) { + cursor--; + render(); + } else if (key.name === 'down' && cursor < items.length - 1) { + cursor++; + render(); + } else if (key.name === ' ' || key.name === 'tab') { + if (selectedSet.has(cursor)) { + selectedSet.delete(cursor); + } else { + selectedSet.add(cursor); + } + render(); + } + } + } finally { + process.stdout.write(C.show); + setRawMode(false); + } + + if (cancelled) return []; + + return Array.from(selectedSet).map(idx => items[idx]); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 进度指示器 +// ═══════════════════════════════════════════════════════════════════════════ + +/** + * 加载动画 (Spinner) + */ +const spinners = { + dots: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + line: ['-', '\\', '|', '/'], + circle: ['◜', '◠', '◝', '◞', '◡', '◟'], +}; + +function spinner(options = {}) { + const { + text = '加载中...', + style = 'dots', + } = options; + + const frames = spinners[style] || spinners.dots; + let frame = 0; + let interval = null; + let stopped = false; + + return { + start() { + process.stdout.write(C.hide); + interval = setInterval(() => { + if (stopped) return; + const symbol = `${C.cyan}${frames[frame]}${C.reset}`; + process.stdout.write(`\r${C.clearLine}${symbol} ${text}`); + frame = (frame + 1) % frames.length; + }, 80); + }, + update(newText) { + if (interval && !stopped) { + const symbol = `${C.cyan}${frames[frame]}${C.reset}`; + process.stdout.write(`\r${C.clearLine}${symbol} ${newText}`); + } + }, + stop(finalText = '') { + stopped = true; + if (interval) { + clearInterval(interval); + interval = null; + } + process.stdout.write(`\r${C.clearLine}${finalText}\n`); + process.stdout.write(C.show); + }, + succeed(text) { + this.stop(`${C.green}✓${C.reset} ${text}`); + }, + fail(text) { + this.stop(`${C.red}✗${C.reset} ${text}`); + }, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// 导出 +// ═══════════════════════════════════════════════════════════════════════════ + +module.exports = { + // 颜色常量 + C, + + // 核心交互 + select, + input, + confirm, + multiselect, + + // 工具 + spinner, + readKey, + setRawMode, + resetRenderCount, + + // 底层渲染 + renderMenu, +}; diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua index f045bc87..cb31a737 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe.lua @@ -120,14 +120,6 @@ if #ss_type > 0 or #trojan_type > 0 or #vmess_type > 0 or #vless_type > 0 or #hy translate("The configured type also applies to the core specified when manually importing nodes.")) end -o = s:option(ListValue, "domain_strategy", "Sing-box " .. translate("Domain Strategy"), translate("Set the default domain resolution strategy for the sing-box node.")) -o.default = "" -o:value("", translate("Auto")) -o:value("prefer_ipv4", translate("Prefer IPv4")) -o:value("prefer_ipv6", translate("Prefer IPv6")) -o:value("ipv4_only", translate("IPv4 Only")) -o:value("ipv6_only", translate("IPv6 Only")) - ---- Subscribe Delete All o = s:option(DummyValue, "_stop", translate("Delete All Subscribe Node")) o.rawhtml = true diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua index 16446623..4cb4fb5a 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/node_subscribe_config.lua @@ -133,6 +133,35 @@ o.validate = function(self, value) return value:gsub("%s+", ""):gsub("%z", "") end +o = s:option(ListValue, "domain_resolver", translate("Domain DNS Resolve")) +o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "
    " .. + translate("Supports only Xray or Sing-box node types.") .. "
    " .. string.format('%s', + translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead.")) +o:value("", translate("Auto")) +o:value("tcp", "TCP") +o:value("udp", "UDP") +o:value("https", "DoH") + +o = s:option(Value, "domain_resolver_dns", "DNS") +o.datatype = "or(ipaddr,ipaddrport)" +o:value("114.114.114.114") +o:value("223.5.5.5:53") +o.default = o.keylist[1] +o:depends({ domain_resolver = "tcp" }) +o:depends({ domain_resolver = "udp" }) + +o = s:option(Value, "domain_resolver_dns_https", "DNS") +o:value("https://120.53.53.53/dns-query", "DNSPod") +o:value("https://223.5.5.5/dns-query", "AliDNS") +o.default = o.keylist[1] +o:depends({ domain_resolver = "https" }) + +o = s:option(ListValue, "domain_strategy", translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect.")) +o.default = "" +o:value("", translate("Auto")) +o:value("UseIPv4", translate("IPv4 Only")) +o:value("UseIPv6", translate("IPv6 Only")) + o = s:option(Flag, "allowInsecure", translate("allowInsecure")) o.default = "1" o.rmempty = false @@ -203,15 +232,6 @@ if #hysteria2_type > 0 then end end -o = s:option(ListValue, "domain_strategy", "Sing-box " .. translate("Domain Strategy"), translate("Set the default domain resolution strategy for the sing-box node.")) -o.default = "global" -o:value("global", translate("Use global config")) -o:value("", translate("Auto")) -o:value("prefer_ipv4", translate("Prefer IPv4")) -o:value("prefer_ipv6", translate("Prefer IPv6")) -o:value("ipv4_only", translate("IPv4 Only")) -o:value("ipv6_only", translate("IPv6 Only")) - o = s:option(Flag, "boot_update", translate("Update Once on Boot"), translate("Updates the subscription the first time PassWall runs automatically after each system boot.")) o.default = 0 diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua index 891ed102..512c387e 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/ray.lua @@ -240,16 +240,6 @@ o = s:option(Value, _n("address"), translate("Address (Support Domain Name)")) o = s:option(Value, _n("port"), translate("Port")) o.datatype = "port" -local protocols = s.fields[_n("protocol")].keylist -if #protocols > 0 then - for index, value in ipairs(protocols) do - if not value:find("^_") then - s.fields[_n("address")]:depends({ [_n("protocol")] = value }) - s.fields[_n("port")]:depends({ [_n("protocol")] = value }) - end - end -end - o = s:option(Value, _n("uuid"), translate("ID")) o.password = true o:depends({ [_n("protocol")] = "vmess" }) @@ -735,10 +725,48 @@ o.datatype = "uinteger" o.placeholder = 0 o:depends({ [_n("protocol")] = "vless" }) -for i, v in ipairs(s.fields[_n("protocol")].keylist) do - if not v:find("^_") and v ~= "hysteria2" then - s.fields[_n("tcp_fast_open")]:depends({ [_n("protocol")] = v }) - s.fields[_n("tcpMptcp")]:depends({ [_n("protocol")] = v }) +o = s:option(ListValue, _n("domain_resolver"), translate("Domain DNS Resolve")) +o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "
    " .. string.format('%s', + translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead.")) +o:value("", translate("Auto")) +o:value("tcp", "TCP") +o:value("udp", "UDP") +o:value("https", "DoH") + +o = s:option(Value, _n("domain_resolver_dns"), "DNS") +o.datatype = "or(ipaddr,ipaddrport)" +o:value("114.114.114.114") +o:value("223.5.5.5:53") +o.default = o.keylist[1] +o:depends({ [_n("domain_resolver")] = "tcp" }) +o:depends({ [_n("domain_resolver")] = "udp" }) + +o = s:option(Value, _n("domain_resolver_dns_https"), "DNS") +o:value("https://120.53.53.53/dns-query", "DNSPod") +o:value("https://223.5.5.5/dns-query", "AliDNS") +o.default = o.keylist[1] +o:depends({ [_n("domain_resolver")] = "https" }) + +o = s:option(ListValue, _n("domain_strategy"), translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect.")) +o.default = "" +o:value("", translate("Auto")) +o:value("UseIPv4", translate("IPv4 Only")) +o:value("UseIPv6", translate("IPv6 Only")) + +local protocols = s.fields[_n("protocol")].keylist +if #protocols > 0 then + for i, v in ipairs(protocols) do + if not v:find("^_") then + s.fields[_n("address")]:depends({ [_n("protocol")] = v }) + s.fields[_n("port")]:depends({ [_n("protocol")] = v }) + s.fields[_n("domain_resolver")]:depends({ [_n("protocol")] = v }) + s.fields[_n("domain_strategy")]:depends({ [_n("protocol")] = v }) + + if v ~= "hysteria2" then + s.fields[_n("tcp_fast_open")]:depends({ [_n("protocol")] = v }) + s.fields[_n("tcpMptcp")]:depends({ [_n("protocol")] = v }) + end + end end end end diff --git a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua index 95c0a2a0..9d83f5ea 100644 --- a/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua +++ b/luci-app-passwall/luasrc/model/cbi/passwall/client/type/sing-box.lua @@ -206,16 +206,6 @@ o = s:option(Value, _n("address"), translate("Address (Support Domain Name)")) o = s:option(Value, _n("port"), translate("Port")) o.datatype = "port" -local protocols = s.fields[_n("protocol")].keylist -if #protocols > 0 then - for index, value in ipairs(protocols) do - if not value:find("^_") then - s.fields[_n("address")]:depends({ [_n("protocol")] = value }) - s.fields[_n("port")]:depends({ [_n("protocol")] = value }) - end - end -end - o = s:option(Value, _n("uuid"), translate("ID")) o.password = true o:depends({ [_n("protocol")] = "vmess" }) @@ -742,6 +732,28 @@ o:value("v2ray-plugin") o = s:option(Value, _n("plugin_opts"), translate("opts")) o:depends({ [_n("plugin_enabled")] = true }) +o = s:option(ListValue, _n("domain_resolver"), translate("Domain DNS Resolve")) +o.description = translate("If the node address is a domain name, this DNS will be used for resolution.") .. "
    " .. string.format('%s', + translate("Note: For node-specific DNS only. Keep Auto to avoid extra overhead.")) +o:value("", translate("Auto")) +o:value("tcp", "TCP") +o:value("udp", "UDP") +o:value("https", "DoH") + +o = s:option(Value, _n("domain_resolver_dns"), "DNS") +o.datatype = "or(ipaddr,ipaddrport)" +o:value("114.114.114.114") +o:value("223.5.5.5:53") +o.default = o.keylist[1] +o:depends({ [_n("domain_resolver")] = "tcp" }) +o:depends({ [_n("domain_resolver")] = "udp" }) + +o = s:option(Value, _n("domain_resolver_dns_https"), "DNS") +o:value("https://120.53.53.53/dns-query", "DNSPod") +o:value("https://223.5.5.5/dns-query", "AliDNS") +o.default = o.keylist[1] +o:depends({ [_n("domain_resolver")] = "https" }) + o = s:option(ListValue, _n("domain_strategy"), translate("Domain Strategy"), translate("If is domain name, The requested domain name will be resolved to IP before connect.")) o.default = "" o:value("", translate("Auto")) @@ -749,17 +761,19 @@ o:value("prefer_ipv4", translate("Prefer IPv4")) o:value("prefer_ipv6", translate("Prefer IPv6")) o:value("ipv4_only", translate("IPv4 Only")) o:value("ipv6_only", translate("IPv6 Only")) -o:depends({ [_n("protocol")] = "socks" }) -o:depends({ [_n("protocol")] = "http" }) -o:depends({ [_n("protocol")] = "shadowsocks" }) -o:depends({ [_n("protocol")] = "vmess" }) -o:depends({ [_n("protocol")] = "trojan" }) -o:depends({ [_n("protocol")] = "wireguard" }) -o:depends({ [_n("protocol")] = "hysteria" }) -o:depends({ [_n("protocol")] = "vless" }) -o:depends({ [_n("protocol")] = "tuic" }) -o:depends({ [_n("protocol")] = "hysteria2" }) -o:depends({ [_n("protocol")] = "anytls" }) + + +local protocols = s.fields[_n("protocol")].keylist +if #protocols > 0 then + for i, v in ipairs(protocols) do + if not v:find("^_") then + s.fields[_n("address")]:depends({ [_n("protocol")] = v }) + s.fields[_n("port")]:depends({ [_n("protocol")] = v }) + s.fields[_n("domain_resolver")]:depends({ [_n("protocol")] = v }) + s.fields[_n("domain_strategy")]:depends({ [_n("protocol")] = v }) + end + end +end end -- [[ Normal single node End ]] diff --git a/luci-app-passwall/luasrc/passwall/util_sing-box.lua b/luci-app-passwall/luasrc/passwall/util_sing-box.lua index 35877c91..e7d7e2c6 100644 --- a/luci-app-passwall/luasrc/passwall/util_sing-box.lua +++ b/luci-app-passwall/luasrc/passwall/util_sing-box.lua @@ -11,6 +11,10 @@ local ech_domain = {} local local_version = api.get_app_version("sing-box"):match("[^v]+") local version_ge_1_13_0 = api.compare_versions(local_version, ">=", "1.13.0") +local GLOBAL = { + DNS_SERVER = {} +} + local GEO_VAR = { OK = nil, DIR = nil, @@ -154,6 +158,47 @@ function gen_outbound(flag, node, tag, proxy_table) detour = node.detour, } + if api.datatypes.hostname(node.address) and node.domain_resolver and (node.domain_resolver_dns or node.domain_resolver_dns_https) then + local dns_tag = node_id .. "_dns" + local dns_proto = node.domain_resolver + local server_address + local server_port + local server_path + if dns_proto == "https" then + local _a = api.parseURL(node.domain_resolver_dns_https) + if _a then + server_address = _a.hostname + if _a.port then + server_port = _a.port + else + server_port = 443 + end + server_path = _a.pathname + end + else + server_address = node.domain_resolver_dns + server_port = 53 + local split = api.split(server_address, ":") + if #split > 1 then + server_address = split[1] + server_port = tonumber(split[#split]) + end + end + GLOBAL.DNS_SERVER[node_id] = { + server = { + tag = dns_tag, + type = dns_proto, + server = server_address, + server_port = server_port, + path = server_path, + domain_resolver = "direct", + detour = "direct" + }, + domain = node.address + } + result.domain_resolver.server = dns_tag + end + local tls = nil if node.protocol == "hysteria" or node.protocol == "hysteria2" or node.protocol == "tuic" or node.protocol == "naive" then node.tls = "1" @@ -1922,6 +1967,16 @@ function gen_config(var) } end + for i, v in pairs(GLOBAL.DNS_SERVER) do + table.insert(dns.servers, v.server) + if not dns.rules then dns.rules = {} end + table.insert(dns.rules, { + action = "route", + server = v.server.tag, + domain = v.domain, + }) + end + if next(ech_domain) ~= nil then table.insert(dns.servers, { tag = "ech-dns", @@ -1983,6 +2038,12 @@ function gen_config(var) if not value["_flag_proxy_tag"] and not value.detour and value["_id"] and value.server and (value.server_port or value.server_ports) and not no_run then sys.call(string.format("echo '%s' >> %s", value["_id"], api.TMP_PATH .. "/direct_node_list")) end + if not value.detour and value.server then + value.detour = "direct" + end + if value.server and not api.datatypes.hostname(value.server) then + value.domain_resolver = nil + end for k, v in pairs(config.outbounds[index]) do if k:find("_") == 1 then config.outbounds[index][k] = nil diff --git a/luci-app-passwall/luasrc/passwall/util_xray.lua b/luci-app-passwall/luasrc/passwall/util_xray.lua index 61c20127..7e010993 100644 --- a/luci-app-passwall/luasrc/passwall/util_xray.lua +++ b/luci-app-passwall/luasrc/passwall/util_xray.lua @@ -6,6 +6,11 @@ local jsonc = api.jsonc local appname = "passwall" local fs = api.fs +local GLOBAL = { + DNS_SERVER = {}, + DNS_HOSTNAME = {} +} + local xray_version = api.get_app_version("xray") local function get_domain_excluded() @@ -134,6 +139,7 @@ function gen_outbound(flag, node, tag, proxy_table) streamSettings = (node.streamSettings or node.protocol == "vmess" or node.protocol == "vless" or node.protocol == "socks" or node.protocol == "shadowsocks" or node.protocol == "trojan" or node.protocol == "hysteria") and { sockopt = { mark = 255, + domainStrategy = node.domain_strategy or "UseIP", tcpFastOpen = (node.tcp_fast_open == "1") and true or nil, tcpMptcp = (node.tcpMptcp == "1") and true or nil }, @@ -397,6 +403,48 @@ function gen_outbound(flag, node, tag, proxy_table) result.streamSettings.tlsSettings.alpn = alpn end end + + if api.datatypes.hostname(node.address) and node.domain_resolver and (node.domain_resolver_dns or node.domain_resolver_dns_https) then + local dns_tag = node_id .. "_dns" + local dns_proto = node.domain_resolver + local config_address + local config_port + if dns_proto == "https" then + local _a = api.parseURL(node.domain_resolver_dns_https) + if _a then + config_address = node.domain_resolver_dns_https + if _a.port then + config_port = _a.port + else + config_port = 443 + end + if _a.hostname then + if api.datatypes.hostname(_a.hostname) then + GLOBAL.DNS_HOSTNAME[_a.hostname] = true + end + end + end + else + local server_address = node.domain_resolver_dns + local config_port = 53 + local split = api.split(server_address, ":") + if #split > 1 then + server_address = split[1] + config_port = tonumber(split[#split]) + end + config_address = server_address + if dns_proto == "tcp" then + config_address = dns_proto .. "://" .. server_address .. ":" .. config_port + end + end + GLOBAL.DNS_SERVER[node_id] = { + tag = dns_tag, + queryStrategy = node.domain_strategy or "UseIP", + address = config_address, + port = config_port, + domains = {"full:" .. node.address} + } + end end return result end @@ -1437,7 +1485,15 @@ function gen_config(var) end end - if (remote_dns_udp_server and remote_dns_udp_port) or (remote_dns_tcp_server and remote_dns_tcp_port) then + local node_dns = {} + for i, v in pairs(GLOBAL.DNS_SERVER) do + table.insert(node_dns, { + server = v, + outboundTag = "direct" + }) + end + + if (remote_dns_udp_server and remote_dns_udp_port) or (remote_dns_tcp_server and remote_dns_tcp_port) or #node_dns > 0 then if not routing then routing = { domainStrategy = "IPOnDemand", @@ -1461,6 +1517,8 @@ function gen_config(var) queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP" } + direct_dns_udp_server = (direct_dns_udp_server and direct_dns_udp_server ~= "") and direct_dns_udp_server or nil + if direct_dns_udp_server or direct_dns_tcp_server then local domain = {} local nodes_domain_text = sys.exec('uci show passwall | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') @@ -1489,14 +1547,12 @@ function gen_config(var) end end - local _remote_dns = { - --tag = "dns-global-remote", - queryStrategy = (remote_dns_query_strategy and remote_dns_query_strategy ~= "") and remote_dns_query_strategy or "UseIPv4", - } + local _remote_dns = {} + if remote_dns_udp_server then _remote_dns.address = remote_dns_udp_server _remote_dns.port = tonumber(remote_dns_udp_port) or 53 - else + elseif remote_dns_tcp_server then _remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53 end @@ -1510,7 +1566,11 @@ function gen_config(var) _remote_dns.port = tonumber(remote_dns_doh_port) end - table.insert(dns.servers, _remote_dns) + if next(_remote_dns) then + -- _remote_dns.tag = "dns-global-remote" + _remote_dns.queryStrategy = (remote_dns_query_strategy and remote_dns_query_strategy ~= "") and remote_dns_query_strategy or "UseIPv4" + table.insert(dns.servers, _remote_dns) + end local _remote_fakedns = { --tag = "dns-global-remote-fakedns", @@ -1649,7 +1709,7 @@ function gen_config(var) dns_server = nil end if dns_server then - dns_server.finalQuery = true + --dns_server.finalQuery = true dns_server.domains = value.domain if value.shunt_rule_name then dns_server.tag = "dns-in-" .. value.shunt_rule_name @@ -1666,7 +1726,7 @@ function gen_config(var) end local _outboundTag - if not api.is_local_ip(_remote_dns.address) or dns_outbound_tag == "blackhole" then --dns为本地ip,不走代理 + if _remote_dns.address and not api.is_local_ip(_remote_dns.address) or dns_outbound_tag == "blackhole" then --dns为本地ip,不走代理 _outboundTag = dns_outbound_tag else _outboundTag = "direct" @@ -1698,6 +1758,37 @@ function gen_config(var) if dns_hosts_len == 0 then dns.hosts = nil end + + -- 自定义节点 DNS + if #node_dns > 0 and #dns.servers < 1 then + dns.servers = { "localhost" } + end + for i = #node_dns, 1, -1 do + local value = node_dns[i] + table.insert(routing.rules, 1, { + inboundTag = { + value.server.tag + }, + outboundTag = value.outboundTag, + }) + table.insert(dns.servers, 2, value.server) + end + if next(GLOBAL.DNS_HOSTNAME) then + local hostname = {} + for line, _ in pairs(GLOBAL.DNS_HOSTNAME) do + table.insert(hostname, line) + end + table.insert(dns.servers, 2, { + tag = "bootstrap", + address = "223.5.5.5", + queryStrategy = "UseIPv4", + domains = hostname + }) + table.insert(routing.rules, 1, { + inboundTag = { "bootstrap" }, + outboundTag = "direct" + }) + end end if inbounds or outbounds then diff --git a/luci-app-passwall/po/zh-cn/passwall.po b/luci-app-passwall/po/zh-cn/passwall.po index fe9163ed..7746d8e2 100644 --- a/luci-app-passwall/po/zh-cn/passwall.po +++ b/luci-app-passwall/po/zh-cn/passwall.po @@ -1972,6 +1972,18 @@ msgstr "延迟(ms)" msgid "If is domain name, The requested domain name will be resolved to IP before connect." msgstr "如果是域名,域名将在请求发出之前解析为 IP。" +msgid "Domain DNS Resolve" +msgstr "域名 DNS 解析" + +msgid "If the node address is a domain name, this DNS will be used for resolution." +msgstr "如果节点地址是域名,则将使用此 DNS 进行解析。" + +msgid "Note: For node-specific DNS only. Keep Auto to avoid extra overhead." +msgstr "注意:仅用于节点专用 DNS,通常请保持自动,以免增加开销。" + +msgid "Supports only Xray or Sing-box node types." +msgstr "仅支持 Xray 或 Sing-box 类型节点。" + msgid "Chain Proxy" msgstr "链式代理" @@ -1985,15 +1997,12 @@ msgid "" "Chained proxy works only with Xray or Sing-box nodes.
    " "You can only use manual or imported nodes as chained nodes." msgstr "" -"链式代理仅支持 Xray 与 Sing-box 节点。
    " +"链式代理仅支持 Xray 或 Sing-box 节点。
    " "仅支持手动添加或导入的节点用作链式节点。" msgid "Only work with using the %s node." msgstr "与使用 %s 节点时生效。" -msgid "Set the default domain resolution strategy for the sing-box node." -msgstr "为 sing-box 节点设置默认的域名解析策略。" - msgid "Total Lines" msgstr "总行数:" diff --git a/luci-app-passwall/root/usr/share/passwall/app.sh b/luci-app-passwall/root/usr/share/passwall/app.sh index c2749cfe..1520bef3 100755 --- a/luci-app-passwall/root/usr/share/passwall/app.sh +++ b/luci-app-passwall/root/usr/share/passwall/app.sh @@ -186,7 +186,18 @@ run_singbox() { [ -n "$no_run" ] && json_add_string "no_run" "1" local _json_arg="$(json_dump)" lua $UTIL_SINGBOX gen_config "${_json_arg}" > $config_file - [ -n "$no_run" ] || ln_run "$SINGBOX_BIN" "sing-box" $log_file run -c "$config_file" + [ -n "$no_run" ] && return + + local test_log_file=$log_file + [ "$test_log_file" = "/dev/null" ] && test_log_file="${TMP_PATH}/${config_file##*/}_test.log" + $SINGBOX_BIN check -c "$config_file" > $test_log_file 2>&1; local status=$? + if [ "${status}" = 0 ]; then + ln_run "$SINGBOX_BIN" "sing-box" "${log_file}" run -c "$config_file" + else + echolog "Sing-box 配置文件 $config_file 校验有误,进程启动失败,错误信息:" + cat ${test_log_file} >> ${LOG_FILE} + fi + [ "$test_log_file" != "$log_file" ] && rm -f "${test_log_file}" } run_xray() { @@ -275,7 +286,18 @@ run_xray() { [ -n "$no_run" ] && json_add_string "no_run" "1" local _json_arg="$(json_dump)" lua $UTIL_XRAY gen_config "${_json_arg}" > $config_file - [ -n "$no_run" ] || ln_run "$XRAY_BIN" "xray" $log_file run -c "$config_file" + [ -n "$no_run" ] && return + + local test_log_file=$log_file + [ "$test_log_file" = "/dev/null" ] && test_log_file="${TMP_PATH}/${config_file##*/}_test.log" + $XRAY_BIN run -test -c "$config_file" > $test_log_file; local status=$? + if [ "${status}" = 0 ]; then + ln_run "$XRAY_BIN" "xray" "${log_file}" run -c "$config_file" + else + echolog "Xray 配置文件 $config_file 校验有误,进程启动失败,错误信息:" + cat ${test_log_file} >> ${LOG_FILE} + fi + [ "$test_log_file" != "$log_file" ] && rm -f "${test_log_file}" } run_dns2socks() { diff --git a/luci-app-passwall/root/usr/share/passwall/subscribe.lua b/luci-app-passwall/root/usr/share/passwall/subscribe.lua index 4d12d6e3..56c6b682 100755 --- a/luci-app-passwall/root/usr/share/passwall/subscribe.lua +++ b/luci-app-passwall/root/usr/share/passwall/subscribe.lua @@ -42,8 +42,7 @@ local core_has = { ["trojan-plus"] = has_trojan_plus, ["hysteria2"] = has_hysteria2 } ---- -local domain_strategy_default = uci:get(appname, "@global_subscribe[0]", "domain_strategy") or "" -local domain_strategy_node = "" +local domain_resolver, domain_resolver_dns, domain_resolver_dns_https, domain_strategy local preproxy_node_group, to_node_group, chain_node_type = "", "", "" -- 判断是否过滤节点关键字 local filter_keyword_mode_default = uci:get(appname, "@global_subscribe[0]", "filter_keyword_mode") or "0" @@ -1855,9 +1854,22 @@ local function update_node(manual) if kkk ~= "group" or vvv ~= "default" then uci:set(appname, cfgid, kkk, vvv) end - -- sing-box 域名解析策略 - if kkk == "type" and vvv == "sing-box" then - uci:set(appname, cfgid, "domain_strategy", domain_strategy_node) + -- sing-box/xray 节点域名解析 + if kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then + if domain_resolver then + uci:set(appname, cfgid, "domain_resolver", domain_resolver) + if domain_resolver_dns then + uci:set(appname, cfgid, "domain_resolver_dns", domain_resolver_dns) + elseif domain_resolver_dns_https then + uci:set(appname, cfgid, "domain_resolver_dns_https", domain_resolver_dns_https) + end + end + if domain_strategy then + if vvv == "sing-box" then + domain_strategy = (domain_strategy == "UseIPv4" and "ipv4_only") or (domain_strategy == "UseIPv6" and "ipv6_only") or domain_strategy + end + uci:set(appname, cfgid, "domain_strategy", domain_strategy) + end end -- 订阅组链式代理 if chain_node_type ~= "" and kkk == "type" and (vvv == "Xray" or vvv == "sing-box") then @@ -2069,12 +2081,11 @@ local execute = function() if hysteria2_type ~= "global" and core_has[hysteria2_type] then hysteria2_type_default = hysteria2_type end - local domain_strategy = value.domain_strategy or "global" - if domain_strategy ~= "global" then - domain_strategy_node = domain_strategy - else - domain_strategy_node = domain_strategy_default - end + + domain_resolver = value.domain_resolver + domain_resolver_dns = value.domain_resolver_dns + domain_resolver_dns_https = value.domain_resolver_dns_https + domain_strategy = (value.domain_strategy == "UseIPv4" or value.domain_strategy == "UseIPv6") and value.domain_strategy or nil -- 订阅组链式代理 local function valid_chain_node(node) diff --git a/luci-app-passwall2/Makefile b/luci-app-passwall2/Makefile index 85ef1abf..2ef979d1 100644 --- a/luci-app-passwall2/Makefile +++ b/luci-app-passwall2/Makefile @@ -6,7 +6,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-passwall2 -PKG_VERSION:=26.4.5 +PKG_VERSION:=26.4.10 PKG_RELEASE:=1 PKG_PO_VERSION:=$(PKG_VERSION) diff --git a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/global.lua b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/global.lua index 32a585fc..84199dfc 100644 --- a/luci-app-passwall2/luasrc/model/cbi/passwall2/client/global.lua +++ b/luci-app-passwall2/luasrc/model/cbi/passwall2/client/global.lua @@ -168,6 +168,19 @@ node_socks_bind_local:depends({ node = "", ["!reverse"] = true }) s:tab("DNS", translate("DNS")) +o = s:taboption("DNS", TextValue, "direct_dns_shunt", translate("Direct domain DNS routing")) +o.description = "
      " +.. "
    • " .. translate("Subdomain (recommended): Begining with 'domain:' and the rest is a domain. When the targeting domain is exactly the value, or is a subdomain of the value, this rule takes effect. Example: rule 'domain:v2ray.com' matches 'www.v2ray.com', 'v2ray.com', but not 'xv2ray.com'.") .. "
    • " +.. "
    • " .. translate("Full domain: Begining with 'full:' and the rest is a domain. When the targeting domain is exactly the value, the rule takes effect. Example: rule 'domain:v2ray.com' matches 'v2ray.com', but not 'www.v2ray.com'.") .. "
    • " +.. "
    • " .. translate("Such as:") .. "
    • " +.. "
    • " .. "domain:my-nodes.com tcp://223.5.5.5" .. "
    • " +.. "
    • " .. "domain:vpn.com udp://119.29.29.29:53" .. "
    • " +.. "
    • " .. "full:www.dnspod.com https://120.53.53.53/dns-query" .. "
    • " +.. "
    • " .. '' .. translate("Please note that the program will not start if the format is incorrect!") .. '' .. "
    • " +.. "
    " +o.rows = 3 +o.wrap = "off" + o = s:taboption("DNS", ListValue, "direct_dns_query_strategy", translate("Direct Query Strategy")) o.default = "UseIP" o:value("UseIP") diff --git a/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua b/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua index 8740556f..b454278d 100644 --- a/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua +++ b/luci-app-passwall2/luasrc/passwall2/util_sing-box.lua @@ -91,6 +91,34 @@ local function convert_geofile() convert(GEO_VAR.IP_PATH, "geoip", GEO_VAR.IP_TAGS) end +function parseDNS(str) + local result_dns_server + -- [proto]://[ip] + -- [proto]://[ip]:[port] + -- https://[ip]/[path] + -- https://[ip]:[port]/[path] + local _a = api.parseURL(str) + if _a then + if _a.protocol == "tcp" or _a.protocol == "udp" or _a.protocol == "https" then + result_dns_server = { + type = _a.protocol, + server = _a.hostname + } + if _a.port then + result_dns_server.server_port = _a.port + else + if _a.protocol == "https" then + result_dns_server.server_port = 443 + else + result_dns_server.server_port = 53 + end + end + result_dns_server.path = _a.pathname + end + end + return result_dns_server +end + function gen_outbound(flag, node, tag, proxy_table) local result = nil if node then @@ -1754,6 +1782,13 @@ function gen_config(var) tag = "local" }) + local direct_strategy = "prefer_ipv6" + if direct_dns_query_strategy == "UseIPv4" then + direct_strategy = "ipv4_only" + elseif direct_dns_query_strategy == "UseIPv6" then + direct_strategy = "ipv6_only" + end + if dns_listen_port then local dns_host = "" if flag == "global" then @@ -1891,13 +1926,6 @@ function gen_config(var) }) end - direct_strategy = "prefer_ipv6" - if direct_dns_query_strategy == "UseIPv4" then - direct_strategy = "ipv4_only" - elseif direct_dns_query_strategy == "UseIPv6" then - direct_strategy = "ipv6_only" - end - local server_port = tonumber(direct_dns_udp_port) or 53 table.insert(dns.servers, { @@ -2229,7 +2257,155 @@ function gen_proto_config(var) return jsonc.stringify(config, 1) end +function gen_front_dns_config(var) + local dns_listen_port = var["dns_listen_port"] + local direct_dns_udp_server = var["direct_dns_udp_server"] + local direct_dns_udp_port = var["direct_dns_udp_port"] + local direct_dns_query_strategy = var["direct_dns_query_strategy"] + local default_dns_udp_server = var["default_dns_udp_server"] + local default_dns_udp_port = var["default_dns_udp_port"] + + local dns = { + servers = {}, + rules = {} + } + local inbounds = {} + local outbounds = {} + local route = {} + + local direct_strategy = "prefer_ipv6" + if direct_dns_query_strategy == "UseIPv4" then + direct_strategy = "ipv4_only" + elseif direct_dns_query_strategy == "UseIPv6" then + direct_strategy = "ipv6_only" + end + + table.insert(outbounds, { + type = "direct", + tag = "direct", + routing_mark = 255, + domain_resolver = { + server = "direct", + strategy = direct_strategy + } + }) + + local direct_dns_shunt = uci:get(appname, "@global[0]", "direct_dns_shunt") or "" + if #direct_dns_shunt > 0 then + local dns_server = {} + string.gsub(direct_dns_shunt, '[^' .. "\r\n" .. ']+', function(w) + if w:find("#") == 1 then return end + local domain = sys.exec(string.format("echo -n $(echo %s | awk -F ' ' '{print $1}')", w)) + local dns = sys.exec(string.format("echo -n $(echo %s | awk -F ' ' '{print $2}')", w)) + if domain ~= "" and dns ~= "" then + local new_dns_server = parseDNS(dns) + if new_dns_server then + if not dns_server[dns] then + dns_server[dns] = {} + end + if not dns_server[dns].server then + dns_server[dns].server = new_dns_server + dns_server[dns].server.tag = dns + dns_server[dns].server.detour = "direct" + end + if not dns_server[dns].rule then + dns_server[dns].rule = { + action = "route", + server = dns, + domain = {}, + domain_suffix = {}, + domain_keyword = {} + } + end + if domain:find("full:") == 1 then + table.insert(dns_server[dns].rule.domain, domain:sub(1 + #"full:")) + elseif domain:find("domain:") == 1 then + table.insert(dns_server[dns].rule.domain_suffix, domain:sub(1 + #"domain:")) + else + table.insert(dns_server[dns].rule.domain_keyword, domain) + end + end + end + end) + for k, v in pairs(dns_server) do + table.insert(dns.servers, v.server) + if #v.rule.domain == 0 then v.rule.domain = nil end + if #v.rule.domain_suffix == 0 then v.rule.domain_suffix = nil end + if #v.rule.domain_keyword == 0 then v.rule.domain_keyword = nil end + table.insert(dns.rules, v.rule) + end + end + + if direct_dns_udp_server then + table.insert(dns.servers, { + tag = "direct", + type = "udp", + server = direct_dns_udp_server, + server_port = tonumber(direct_dns_udp_port) or 53, + detour = "direct" + }) + local node_domain = {} + local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') + string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w) + table.insert(node_domain, w) + end) + if #node_domain > 0 then + table.insert(dns.rules, { + action = "route", + server = "direct", + domain = node_domain + }) + end + end + + if default_dns_udp_server then + table.insert(dns.servers, { + tag = "default", + type = "udp", + server = default_dns_udp_server, + server_port = tonumber(default_dns_udp_port) or 53, + detour = "direct" + }) + dns.final = "default" + else + dns.final = "direct" + end + + local dns_in_inbound = { + type = "direct", + tag = "dns-in", + listen = "127.0.0.1", + listen_port = tonumber(dns_listen_port), + } + table.insert(inbounds, dns_in_inbound) + + route.rules = {} + table.insert(route.rules, { + action = "hijack-dns", + inbound = dns_in_inbound.tag + }) + table.insert(route.rules, { + action = "sniff", + inbound = dns_in_inbound.tag + }) + route.final = "direct" + + local config = { + log = { + disabled = true, + level = "debug", + timestamp = true, + }, + dns = dns, + inbounds = inbounds, + outbounds = outbounds, + route = route + } + return jsonc.stringify(config, 1) +end + _G.gen_config = gen_config +_G.gen_front_dns_config = gen_front_dns_config _G.gen_proto_config = gen_proto_config _G.geo_convert_srs = geo_convert_srs diff --git a/luci-app-passwall2/luasrc/passwall2/util_xray.lua b/luci-app-passwall2/luasrc/passwall2/util_xray.lua index 039ee0d6..b76714e8 100644 --- a/luci-app-passwall2/luasrc/passwall2/util_xray.lua +++ b/luci-app-passwall2/luasrc/passwall2/util_xray.lua @@ -28,6 +28,35 @@ local function get_domain_excluded() return hosts end +function parseDNS(str) + local result_dns_server + -- [proto]://[ip] + -- [proto]://[ip]:[port] + -- https://[ip]/[path] + -- https://[ip]:[port]/[path] + local _a = api.parseURL(str) + if _a then + if _a.protocol == "tcp" or _a.protocol == "udp" or _a.protocol == "https" then + result_dns_server = { + address = str + } + if _a.protocol == "udp" then + result_dns_server.address = _a.hostname + end + if _a.port then + result_dns_server.port = _a.port + else + if _a.protocol == "https" then + result_dns_server.port = 443 + else + result_dns_server.port = 53 + end + end + end + end + return result_dns_server +end + function gen_outbound(flag, node, tag, proxy_table) local result = nil if node then @@ -1451,12 +1480,22 @@ function gen_config(var) queryStrategy = "UseIP" } - table.insert(dns_servers, { - server = { - tag = "local", - address = "localhost" + local _direct_dns = nil + if direct_dns_udp_server then + _direct_dns = { + tag = direct_dns_tag, + address = direct_dns_udp_server, + port = tonumber(direct_dns_udp_port) or 53, + queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP" } - }) + + if _direct_dns.address then + table.insert(dns_servers, { + outboundTag = "direct", + server = _direct_dns + }) + end + end if dns_listen_port then local _remote_dns_proto = "tcp" @@ -1556,8 +1595,7 @@ function gen_config(var) server = _remote_fakedns }) end - - local _direct_dns = nil + if direct_dns_udp_server then local domain = {} local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') @@ -1565,24 +1603,10 @@ function gen_config(var) table.insert(domain, "full:" .. w) end) if #domain > 0 then - table.insert(dns_domain_rules, 1, { - shunt_rule_name = "logic-vpslist", - outboundTag = "direct", - domain = domain - }) - end - - _direct_dns = { - tag = direct_dns_tag, - address = direct_dns_udp_server, - port = tonumber(direct_dns_udp_port) or 53, - queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP", - } - - if _direct_dns.address then - table.insert(dns_servers, { - outboundTag = "direct", - server = _direct_dns + table.insert(dns.servers, 1, { + tag = "local", + address = "localhost", + domains = domain }) end end @@ -1773,6 +1797,21 @@ function gen_config(var) end end + local has_default_dns_server = "0" + for index, value in ipairs(dns.servers) do + if value.domains == nil then + has_default_dns_server = "1" + break + end + end + + if #dns.servers == 0 or not has_default_server == "0" then + table.insert(dns.servers, 1, { + tag = "local", + address = "localhost" + }) + end + if redir_port then local inbound = { port = tonumber(redir_port), @@ -1994,250 +2033,154 @@ function gen_proto_config(var) return jsonc.stringify(config, 1) end -function gen_dns_config(var) +function gen_front_dns_config(var) local dns_listen_port = var["dns_listen_port"] - local dns_out_tag = var["dns_out_tag"] local direct_dns_udp_server = var["direct_dns_udp_server"] local direct_dns_udp_port = var["direct_dns_udp_port"] - local direct_dns_tcp_server = var["direct_dns_tcp_server"] - local direct_dns_tcp_port = var["direct_dns_tcp_port"] - local direct_dns_doh_url = var["direct_dns_doh_url"] - local direct_dns_doh_host = var["direct_dns_doh_host"] - local direct_dns_doh_ip = var["direct_dns_doh_ip"] - local direct_dns_doh_port = var["direct_dns_doh_port"] - local direct_dns_query_strategy = var["direct_dns_query_strategy"] - local remote_dns_udp_server = var["remote_dns_udp_server"] - local remote_dns_udp_port = var["remote_dns_udp_port"] - local remote_dns_tcp_server = var["remote_dns_tcp_server"] - local remote_dns_tcp_port = var["remote_dns_tcp_port"] - local remote_dns_doh_url = var["remote_dns_doh_url"] - local remote_dns_doh_host = var["remote_dns_doh_host"] - local remote_dns_doh_ip = var["remote_dns_doh_ip"] - local remote_dns_doh_port = var["remote_dns_doh_port"] - local remote_dns_query_strategy = var["remote_dns_query_strategy"] - local remote_dns_detour = var["remote_dns_detour"] - local remote_dns_client_ip = var["remote_dns_client_ip"] - local remote_dns_outbound_socks_address = var["remote_dns_outbound_socks_address"] - local remote_dns_outbound_socks_port = var["remote_dns_outbound_socks_port"] - local dns_cache = var["dns_cache"] - local loglevel = var["loglevel"] or "warning" - + local default_dns_udp_server = var["default_dns_udp_server"] + local default_dns_udp_port = var["default_dns_udp_port"] + + local queryStrategy = "UseIP" + local dns = { + tag = "dns-global-direct", + disableCache = false, + disableFallback = true, + disableFallbackIfMatch = true, + queryStrategy = queryStrategy, + servers = {} + } local inbounds = {} local outbounds = {} - local dns = nil - local routing = nil + local routing = { + rules = {} + } - if dns_listen_port then - routing = { - domainStrategy = "IPOnDemand", - rules = {} - } - - dns = { - tag = "dns-global", - hosts = {}, - disableCache = (dns_cache == "1") and false or true, - disableFallback = true, - disableFallbackIfMatch = true, - servers = {}, - clientIp = (remote_dns_client_ip and remote_dns_client_ip ~= "") and remote_dns_client_ip or nil, - } - - local other_type_dns_proto, other_type_dns_server, other_type_dns_port - - if dns_out_tag == "remote" then - dns.queryStrategy = (remote_dns_query_strategy and remote_dns_query_strategy ~= "") and remote_dns_query_strategy or "UseIPv4" - if remote_dns_detour == "direct" then - dns_out_tag = "direct" - table.insert(outbounds, 1, { - tag = dns_out_tag, - protocol = "freedom", - settings = { - domainStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP" - }, - streamSettings = { - sockopt = { - mark = 255 - } - } - }) - else - if remote_dns_outbound_socks_address and remote_dns_outbound_socks_port then - table.insert(outbounds, 1, { - tag = dns_out_tag, - protocol = "socks", - streamSettings = { - network = "tcp", - security = "none" - }, - settings = { - servers = { - { - address = remote_dns_outbound_socks_address, - port = tonumber(remote_dns_outbound_socks_port) - } - } - } - }) - end - end - - local _remote_dns = { - tag = "dns-in-remote" + table.insert(outbounds, { + tag = "direct", + protocol = "freedom", + settings = { + domainStrategy = queryStrategy + }, + streamSettings = { + sockopt = { + mark = 255 } - - if remote_dns_udp_server then - _remote_dns.address = remote_dns_udp_server - _remote_dns.port = tonumber(remote_dns_udp_port) or 53 - - other_type_dns_proto = "udp" - other_type_dns_server = remote_dns_udp_server - other_type_dns_port = _remote_dns.port - end - - if remote_dns_tcp_server then - _remote_dns.address = "tcp://" .. remote_dns_tcp_server .. ":" .. tonumber(remote_dns_tcp_port) or 53 - _remote_dns.port = tonumber(remote_dns_tcp_port) or 53 - - other_type_dns_proto = "tcp" - other_type_dns_server = remote_dns_tcp_server - other_type_dns_port = _remote_dns.port - end - - if remote_dns_doh_url and remote_dns_doh_host then - if remote_dns_doh_ip and remote_dns_doh_host ~= remote_dns_doh_ip and not api.is_ip(remote_dns_doh_host) then - dns.hosts[remote_dns_doh_host] = remote_dns_doh_ip + } + }) + + if default_dns_udp_server then + table.insert(dns.servers, { + tag = "default", + address = default_dns_udp_server, + port = tonumber(default_dns_udp_port) or 53, + queryStrategy = queryStrategy, + }) + end + + local direct_dns_shunt = uci:get(appname, "@global[0]", "direct_dns_shunt") or "" + if #direct_dns_shunt > 0 then + local dns_server = {} + string.gsub(direct_dns_shunt, '[^' .. "\r\n" .. ']+', function(w) + if w:find("#") == 1 then return end + local domain = sys.exec(string.format("echo -n $(echo %s | awk -F ' ' '{print $1}')", w)) + local dns = sys.exec(string.format("echo -n $(echo %s | awk -F ' ' '{print $2}')", w)) + if domain ~= "" and dns ~= "" then + local new_dns_server = parseDNS(dns) + if new_dns_server then + if not dns_server[dns] then + dns_server[dns] = {} + end + dns_server[dns].tag = dns + dns_server[dns].queryStrategy = queryStrategy + dns_server[dns].address = new_dns_server.address + dns_server[dns].port = new_dns_server.port + dns_server[dns].finalQuery = true + if not dns_server[dns].domains then + dns_server[dns].domains = {} + end + table.insert(dns_server[dns].domains, domain) end - _remote_dns.address = remote_dns_doh_url - _remote_dns.port = tonumber(remote_dns_doh_port) or 443 end - - table.insert(dns.servers, _remote_dns) - elseif dns_out_tag == "direct" then - dns.queryStrategy = (direct_dns_query_strategy and direct_dns_query_strategy ~= "") and direct_dns_query_strategy or "UseIP" - table.insert(outbounds, 1, { - tag = dns_out_tag, - protocol = "freedom", - settings = { - domainStrategy = dns.queryStrategy + end) + for k, v in pairs(dns_server) do + table.insert(dns.servers, v) + table.insert(routing.rules, { + inboundTag = { + v.tag }, - streamSettings = { - sockopt = { - mark = 255 - } - } + outboundTag = "direct" }) - - local _direct_dns = { - tag = "dns-in-direct" - } - - if direct_dns_udp_server then - _direct_dns.address = direct_dns_udp_server - _direct_dns.port = tonumber(direct_dns_udp_port) or 53 - table.insert(routing.rules, 1, { - ip = { - direct_dns_udp_server - }, - port = tonumber(direct_dns_udp_port) or 53, - network = "udp", - outboundTag = "direct" - }) - - other_type_dns_proto = "udp" - other_type_dns_server = direct_dns_udp_server - other_type_dns_port = _direct_dns.port - end - - if direct_dns_tcp_server then - _direct_dns.address = "tcp+local://" .. direct_dns_tcp_server - _direct_dns.port = tonumber(direct_dns_tcp_port) or 53 - - other_type_dns_proto = "tcp" - other_type_dns_server = direct_dns_tcp_server - other_type_dns_port = _direct_dns.port - end - - if direct_dns_doh_url and direct_dns_doh_host then - if direct_dns_doh_ip and direct_dns_doh_host ~= direct_dns_doh_ip and not api.is_ip(direct_dns_doh_host) then - dns.hosts[direct_dns_doh_host] = direct_dns_doh_ip - end - _direct_dns.address = direct_dns_doh_url:gsub("https://", "https+local://") - _direct_dns.port = tonumber(direct_dns_doh_port) or 443 - end - - table.insert(dns.servers, _direct_dns) end - - local dns_hosts_len = 0 - for key, value in pairs(dns.hosts) do - dns_hosts_len = dns_hosts_len + 1 - end - - if dns_hosts_len == 0 then - dns.hosts = nil - end - - table.insert(inbounds, { - listen = "127.0.0.1", - port = tonumber(dns_listen_port), - protocol = "dokodemo-door", - tag = "dns-in", - settings = { - address = other_type_dns_server or "1.1.1.1", - port = other_type_dns_port or 53, - network = "tcp,udp" - } - }) - - table.insert(outbounds, { - tag = "dns-out", - protocol = "dns", - proxySettings = { - tag = dns_out_tag - }, - settings = { - address = other_type_dns_server or "1.1.1.1", - port = other_type_dns_port or 53, - network = other_type_dns_proto or "tcp", - nonIPQuery = "reject" - } - }) - - table.insert(routing.rules, 1, { - inboundTag = { - "dns-in" - }, - outboundTag = "dns-out" - }) - - table.insert(routing.rules, { - inboundTag = { - "dns-global" - }, - outboundTag = dns_out_tag - }) end - - if inbounds or outbounds then - local config = { - log = { - --dnsLog = true, - loglevel = loglevel - }, - dns = dns, - inbounds = inbounds, - outbounds = outbounds, - routing = routing + + if direct_dns_udp_server then + local node_domain = {} + local nodes_domain_text = sys.exec('uci show passwall2 | grep ".address=" | cut -d "\'" -f 2 | grep "[a-zA-Z]$" | sort -u') + string.gsub(nodes_domain_text, '[^' .. "\r\n" .. ']+', function(w) + table.insert(node_domain, "full:" .. w) + end) + if #node_domain > 0 then + table.insert(dns.servers, { + tag = "direct", + address = direct_dns_udp_server, + port = tonumber(direct_dns_udp_port) or 53, + queryStrategy = queryStrategy, + domains = node_domain + }) + end + end + + table.insert(inbounds, { + tag = "dns-in", + listen = "127.0.0.1", + port = tonumber(dns_listen_port), + protocol = "dokodemo-door", + settings = { + address = "0.0.0.0", + network = "tcp,udp" } - return jsonc.stringify(config, 1) - end - + }) + + table.insert(outbounds, { + tag = "dns-out", + protocol = "dns", + proxySettings = { + tag = "direct" + }, + settings = { + address = direct_dns_udp_server, + port = tonumber(direct_dns_udp_port) or 53, + network = "udp", + nonIPQuery = "skip", + blockTypes = { + 65 + } + } + }) + + table.insert(routing.rules, { + inboundTag = { + "dns-in" + }, + outboundTag = "dns-out" + }) + + local config = { + log = { + dnsLog = true, + loglevel = "debug" + }, + dns = dns, + inbounds = inbounds, + outbounds = outbounds, + routing = routing + } + return jsonc.stringify(config, 1) end _G.gen_config = gen_config +_G.gen_front_dns_config = gen_front_dns_config _G.gen_proto_config = gen_proto_config -_G.gen_dns_config = gen_dns_config if arg[1] then local func =_G[arg[1]] diff --git a/luci-app-passwall2/po/fa/passwall2.po b/luci-app-passwall2/po/fa/passwall2.po index 46e41b8d..16d161f1 100644 --- a/luci-app-passwall2/po/fa/passwall2.po +++ b/luci-app-passwall2/po/fa/passwall2.po @@ -121,6 +121,12 @@ msgstr "0 به معنای عدم استفاده است" msgid "Current node: %s" msgstr "گره فعلی: %s" +msgid "Direct domain DNS routing" +msgstr "مسیریابی مستقیم DNS دامنه" + +msgid "Please note that the program will not start if the format is incorrect!" +msgstr "لطفا توجه داشته باشید که اگر قالب نادرست باشد، برنامه شروع نخواهد شد!" + msgid "IP:Port mode acceptable, multi value split with english comma." msgstr "حالت IP:Port قابل قبول است، مقادیر متعدد را با کاما انگلیسی جدا کنید." diff --git a/luci-app-passwall2/po/ru/passwall2.po b/luci-app-passwall2/po/ru/passwall2.po index 5013a339..d72e63e4 100644 --- a/luci-app-passwall2/po/ru/passwall2.po +++ b/luci-app-passwall2/po/ru/passwall2.po @@ -118,6 +118,12 @@ msgstr "0 — отключить функцию" msgid "Current node: %s" msgstr "Текущий узел: %s" +msgid "Direct domain DNS routing" +msgstr "Прямая маршрутизация DNS домена" + +msgid "Please note that the program will not start if the format is incorrect!" +msgstr "Обратите внимание, что программа не запустится, если формат указан неверно!" + msgid "IP:Port mode acceptable, multi value split with english comma." msgstr "Допустим формат IP:Port. Несколько значений указывайте через запятую." diff --git a/luci-app-passwall2/po/zh-cn/passwall2.po b/luci-app-passwall2/po/zh-cn/passwall2.po index 6e06ca35..c00f9380 100644 --- a/luci-app-passwall2/po/zh-cn/passwall2.po +++ b/luci-app-passwall2/po/zh-cn/passwall2.po @@ -118,6 +118,12 @@ msgstr "0为不使用" msgid "Current node: %s" msgstr "当前节点:%s" +msgid "Direct domain DNS routing" +msgstr "直连域名 DNS 分流" + +msgid "Please note that the program will not start if the format is incorrect!" +msgstr "请注意,格式不正确将无法启动!" + msgid "IP:Port mode acceptable, multi value split with english comma." msgstr "接受 IP:Port 形式的输入,多个以英文逗号分隔。" diff --git a/luci-app-passwall2/po/zh-tw/passwall2.po b/luci-app-passwall2/po/zh-tw/passwall2.po index 938d43c0..d3ca033e 100644 --- a/luci-app-passwall2/po/zh-tw/passwall2.po +++ b/luci-app-passwall2/po/zh-tw/passwall2.po @@ -118,6 +118,12 @@ msgstr "0為不使用" msgid "Current node: %s" msgstr "當前節點:%s" +msgid "Direct domain DNS routing" +msgstr "直連網域 DNS 分流" + +msgid "Please note that the program will not start if the format is incorrect!" +msgstr "請注意,格式不正確將無法啟動!" + msgid "IP:Port mode acceptable, multi value split with english comma." msgstr "接受 IP:Port 形式的輸入,多個以英文逗号分隔。" diff --git a/luci-app-passwall2/root/usr/share/passwall2/app.sh b/luci-app-passwall2/root/usr/share/passwall2/app.sh index 1f5b73eb..c2bfcb04 100755 --- a/luci-app-passwall2/root/usr/share/passwall2/app.sh +++ b/luci-app-passwall2/root/usr/share/passwall2/app.sh @@ -106,14 +106,6 @@ run_xray() { [ -n "$dns_listen_port" ] && { json_add_string "dns_listen_port" "${dns_listen_port}" [ -n "$dns_cache" ] && json_add_string "dns_cache" "${dns_cache}" - - local _dns=$(get_first_dns AUTO_DNS 53 | sed 's/#/:/g') - local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') - local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}') - - DIRECT_DNS_UDP_SERVER=${_dns_address} - DIRECT_DNS_UDP_PORT=${_dns_port} - [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${write_ipset_direct}" = "1" ] && { direct_dnsmasq_listen_port=$(get_new_port $(expr $dns_listen_port + 1) udp) @@ -188,38 +180,17 @@ run_xray() { [ -n "$remote_dns_client_ip" ] && json_add_string "remote_dns_client_ip" "${remote_dns_client_ip}" local _json2_arg="$(json_dump)" - local independent_dns - if [ -z "${independent_dns}" ]; then - local _json2_keys key - json_load "${_json2_arg}" - json_get_keys _json2_keys - for key in ${_json2_keys}; do - json_get_var "_json2_$key" "$key" - done - json_load "${_json1_arg}" - for key in ${_json2_keys}; do - eval "local _v=\$_json2_$key" - json_add_string "$key" "$_v" - done - else - dns_remote_listen_port=$(get_new_port $(expr ${direct_dnsmasq_listen_port:-${dns_listen_port}} + 1) udp) - V2RAY_DNS_REMOTE_CONFIG="${TMP_PATH}/${flag}_dns_remote.json" - V2RAY_DNS_REMOTE_LOG="${TMP_PATH}/${flag}_dns_remote.log" - V2RAY_DNS_REMOTE_LOG="/dev/null" - json_load "${_json2_arg}" - json_add_string "dns_out_tag" "remote" - json_add_string "dns_listen_port" "${dns_remote_listen_port}" - json_add_string "remote_dns_outbound_socks_address" "127.0.0.1" - json_add_string "remote_dns_outbound_socks_port" "${socks_port}" - _json2_arg="$(json_dump)" - lua $UTIL_XRAY gen_dns_config "${_json2_arg}" > $V2RAY_DNS_REMOTE_CONFIG - ln_run "$XRAY_BIN" "xray" $V2RAY_DNS_REMOTE_LOG run -c "$V2RAY_DNS_REMOTE_CONFIG" - - json_load "${_json1_arg}" - json_add_string "remote_dns_udp_port" "${dns_remote_listen_port}" - json_add_string "remote_dns_udp_server" "127.0.0.1" - json_add_string "remote_dns_query_strategy" "${remote_dns_query_strategy}" - fi + local _json2_keys key + json_load "${_json2_arg}" + json_get_keys _json2_keys + for key in ${_json2_keys}; do + json_get_var "_json2_$key" "$key" + done + json_load "${_json1_arg}" + for key in ${_json2_keys}; do + eval "local _v=\$_json2_$key" + json_add_string "$key" "$_v" + done } [ -n "${redir_port}" ] && { @@ -290,13 +261,6 @@ run_singbox() { } } [ -n "$dns_listen_port" ] && { - local _dns=$(get_first_dns AUTO_DNS 53 | sed 's/#/:/g') - local _dns_address=$(echo ${_dns} | awk -F ':' '{print $1}') - local _dns_port=$(echo ${_dns} | awk -F ':' '{print $2}') - - DIRECT_DNS_UDP_SERVER=${_dns_address} - DIRECT_DNS_UDP_PORT=${_dns_port} - [ "${node_protocol}" = "_shunt" ] && local write_ipset_direct=$(config_n_get $node write_ipset_direct 0) [ "${write_ipset_direct}" = "1" ] && { direct_dnsmasq_listen_port=$(get_new_port $(expr $dns_listen_port + 1) udp) @@ -633,8 +597,6 @@ run_global() { TYPE=$(echo $(config_n_get $NODE type) | tr 'A-Z' 'a-z') [ -z "$TYPE" ] && return 1 - mkdir -p ${GLOBAL_ACL_PATH} - if [ $PROXY_IPV6 == "1" ]; then log_i18n 0 "To enable experimental IPv6 transparent proxy (TProxy), please ensure your node and type support IPv6!" fi @@ -712,8 +674,62 @@ run_global() { return 1 fi + set_cache_var "ACL_GLOBAL_node" "$NODE" + set_cache_var "ACL_GLOBAL_redir_port" "$REDIR_PORT" +} + +run_front_dns() { + local switch=0 + direct_dns_shunt=$(config_t_get global direct_dns_shunt) + direct_dns_shunt=$(echo "${direct_dns_shunt}" | grep -v "^#") + [ -n "${direct_dns_shunt}" ] && switch=1 + [ "${switch}" == "1" ] && { + local config_file="${TMP_PATH}/direct_dns.json" + local log_file="${TMP_PATH}/direct_dns.log" + log_file="/dev/null" + local listen_port=$(get_new_port 10553) + json_init + json_add_string "dns_listen_port" "${listen_port}" + json_add_string "direct_dns_udp_server" "${DIRECT_DNS_UDP_SERVER}" + json_add_string "direct_dns_udp_port" "${DIRECT_DNS_UDP_PORT}" + json_add_string "direct_dns_query_strategy" "${DIRECT_DNS_QUERY_STRATEGY}" + [ -n "${ACL_GLOBAL_node}" ] && [ -n "${TUN_DNS_PORT}" ] && { + json_add_string "default_dns_udp_server" "127.0.0.1" + json_add_string "default_dns_udp_port" "${TUN_DNS_PORT}" + } + local _json_arg="$(json_dump)" + + local preferred_core="" + [ -n "${XRAY_BIN}" ] && preferred_core="xray" + [ -n "${SINGBOX_BIN}" ] && preferred_core="sing-box" + + if [ "${preferred_core}" = "xray" ]; then + lua $UTIL_XRAY gen_front_dns_config "${_json_arg}" > $config_file + ln_run "$XRAY_BIN" "xray" "${log_file}" run -c "$config_file" + elif [ "${preferred_core}" = "sing-box" ]; then + lua $UTIL_SINGBOX gen_front_dns_config "${_json_arg}" > $config_file + ln_run "$SINGBOX_BIN" "sing-box" "${log_file}" run -c "$config_file" + else + return 1 + fi + + FRONT_DNS_SERVER="127.0.0.1" + FRONT_DNS_PORT="${listen_port}" + } +} + +run_global_dnsmasq() { + [ -z "${ACL_GLOBAL_node}" ] && [ -z "${FRONT_DNS_PORT}" ] && return local RUN_NEW_DNSMASQ=1 RUN_NEW_DNSMASQ=${DNS_REDIRECT} + DNSMASQ_DEFAULT_DNS="${AUTO_DNS}" + DNSMASQ_LOCAL_DNS="${LOCAL_DNS:-${AUTO_DNS}}" + DNSMASQ_TUN_DNS="${TUN_DNS}" + [ -n "${FRONT_DNS_PORT}" ] && { + DNSMASQ_DEFAULT_DNS="${FRONT_DNS_SERVER}#${FRONT_DNS_PORT}" + DNSMASQ_LOCAL_DNS="${DNSMASQ_DEFAULT_DNS}" + DNSMASQ_TUN_DNS="${DNSMASQ_DEFAULT_DNS}" + } if [ "${RUN_NEW_DNSMASQ}" == "0" ]; then #The old logic will be removed in the future. #Run a copy dnsmasq instance, DNS hijack that don't need a proxy devices. @@ -738,9 +754,9 @@ run_global() { json_add_string "FLAG" "default" json_add_string "TMP_DNSMASQ_PATH" "${GLOBAL_DNSMASQ_CONF_PATH}" json_add_string "DNSMASQ_CONF_FILE" "${GLOBAL_DNSMASQ_CONF}" - json_add_string "DEFAULT_DNS" "${AUTO_DNS}" - json_add_string "LOCAL_DNS" "${LOCAL_DNS:-${AUTO_DNS}}" - json_add_string "TUN_DNS" "${TUN_DNS}" + json_add_string "DEFAULT_DNS" "${DNSMASQ_DEFAULT_DNS}" + json_add_string "LOCAL_DNS" "${DNSMASQ_LOCAL_DNS}" + json_add_string "TUN_DNS" "${DNSMASQ_TUN_DNS}" json_add_string "NFTFLAG" "${nftflag:-0}" json_add_string "NO_LOGIC_LOG" "${NO_LOGIC_LOG:-0}" lua $APP_PATH/helper_dnsmasq.lua add_rule "$(json_dump)" @@ -753,14 +769,11 @@ run_global() { else #Run a copy dnsmasq instance, DNS hijack for that need proxy devices. GLOBAL_DNSMASQ_PORT=$(get_new_port 11400) - run_copy_dnsmasq flag="default" listen_port=$GLOBAL_DNSMASQ_PORT tun_dns="${TUN_DNS}" + run_copy_dnsmasq flag="default" listen_port=$GLOBAL_DNSMASQ_PORT tun_dns="${DNSMASQ_TUN_DNS}" DNS_REDIRECT_PORT=${GLOBAL_DNSMASQ_PORT} #dhcp.leases to hosts $APP_PATH/lease2hosts.sh > /dev/null 2>&1 & fi - - set_cache_var "ACL_GLOBAL_node" "$NODE" - set_cache_var "ACL_GLOBAL_redir_port" "$REDIR_PORT" } start_socks() { @@ -1188,7 +1201,10 @@ start() { lua $APP_PATH/helper_dnsmasq.lua restart "$(json_dump)" } fi + mkdir -p ${GLOBAL_ACL_PATH} [ "$ENABLED_DEFAULT_ACL" == 1 ] && run_global + run_front_dns + run_global_dnsmasq [ -n "$USE_TABLES" ] && source $APP_PATH/${USE_TABLES}.sh start set_cache_var "USE_TABLES" "$USE_TABLES" if [ "$ENABLED_DEFAULT_ACL" == 1 ] || [ "$ENABLED_ACLS" == 1 ]; then @@ -1312,6 +1328,12 @@ get_config() { [ -z "${DEFAULT_DNS}" ] && DEFAULT_DNS=$(echo -n $ISP_DNS | tr ' ' '\n' | head -2 | tr '\n' ',' | sed 's/,$//') AUTO_DNS=${DEFAULT_DNS:-119.29.29.29} + local AUTO_DNS2=$(get_first_dns AUTO_DNS 53 | sed 's/#/:/g') + local AUTO_DNS_ADDRESS=$(echo ${AUTO_DNS2} | awk -F ':' '{print $1}') + local AUTO_DNS_PORT=$(echo ${AUTO_DNS2} | awk -F ':' '{print $2}') + DIRECT_DNS_UDP_SERVER=${AUTO_DNS_ADDRESS} + DIRECT_DNS_UDP_PORT=${AUTO_DNS_PORT} + DNSMASQ_CONF_DIR=/tmp/dnsmasq.d DEFAULT_DNSMASQ_CFGID="$(uci -q show "dhcp.@dnsmasq[0]" | awk 'NR==1 {split($0, conf, /[.=]/); print conf[2]}')" if [ -f "/tmp/etc/dnsmasq.conf.$DEFAULT_DNSMASQ_CFGID" ]; then diff --git a/luci-app-ssr-plus/Makefile b/luci-app-ssr-plus/Makefile index d5e9ad10..afa3f10b 100644 --- a/luci-app-ssr-plus/Makefile +++ b/luci-app-ssr-plus/Makefile @@ -4,7 +4,7 @@ LUCI_TITLE:=luci-app-ssr-plus LUCI_PKGARCH:=all PKG_NAME:=luci-app-ssr-plus PKG_VERSION:=190 -PKG_RELEASE:=8 +PKG_RELEASE:=9 PKG_CONFIG_DEPENDS:= \ CONFIG_PACKAGE_$(PKG_NAME)_Iptables_Transparent_Proxy \ diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua index 2a885cc4..807b81dc 100644 --- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua +++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client-config.lua @@ -5,6 +5,7 @@ require "nixio.fs" require "luci.sys" require "luci.http" require "luci.jsonc" +local nixio = require "nixio" require "luci.model.uci" local uci = require "luci.model.uci".cursor() @@ -146,11 +147,11 @@ local function set_apply_on_parse(map) end end -local has_xray = is_finded("xray") -local has_hysteria2 = is_finded("hysteria") - local has_ss_rust = is_finded("sslocal") or is_finded("ssserver") local has_ss_libev = is_finded("ss-redir") or is_finded("ss-local") +local has_trojan = is_finded("trojan") +local has_xray = is_finded("xray") +local has_hysteria2 = is_finded("hysteria") local server_table = {} local encrypt_methods = { @@ -277,25 +278,63 @@ o.value = sid -- 新增一个选择框,用于选择 Xray 或 Hysteria2 核心 o = s:option(ListValue, "_xray_hy2_type", string.format("%s", translatef("%s Node Use Type", "Hysteria2"))) o.description = translate("The configured type also applies to the core specified when manually importing nodes.") --- 设置默认 Xray 或 Hysteria2 核心 --- 动态添加选项 -if has_xray then - o:value("v2ray", translate("Xray (Hysteria2)")) -end +-- 注意:Auto 选项使用特殊字符串 "__auto__" 而不是空字符串 +o:value("__auto__", translate("Auto")) if has_hysteria2 then - o:value("hysteria2", translate("Hysteria2")) + o:value("hysteria2", translate("Hysteria2")) +end +if has_xray then + o:value("v2ray", translate("Xray (Hysteria2)")) end -- 读取全局 xray_hy2_type o.cfgvalue = function(self, section) - return self.map.uci:get("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type") or "hysteria2" + local val = uci:get("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type") + if val == nil or val == "" then + return "__auto__" -- 对应 Auto 选项 + end + return val end o.rmempty = true +-- 保存时更新全局配置 o.write = function(self, section, value) - -- 更新 server_subscribe 的 xray_hy2_type - local old_value = self.map.uci:get("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type") - if old_value ~= value then - self.map.uci:set("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type", value) - end + if value == "__auto__" then + -- 删除全局配置 + uci:delete("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type") + else + -- 设置具体值 + uci:set("shadowsocksr", "@server_subscribe[0]", "xray_hy2_type", value) + end +end + +-- 新增一个选择框,用于选择 Xray 或 Trojan 核心 +o = s:option(ListValue, "_xray_tj_type", string.format("%s", translatef("%s Node Use Type", "Trojan"))) +o.description = translate("The configured type also applies to the core specified when manually importing nodes.") +-- 注意:Auto 选项使用特殊字符串 "__auto__" 而不是空字符串 +o:value("__auto__", translate("Auto")) +if has_hysteria2 then + o:value("trojan", translate("Trojan")) +end +if has_xray then + o:value("v2ray", translate("Xray (Trojan)")) +end +-- 读取全局 xray_tj_type +o.cfgvalue = function(self, section) + local val = uci:get("shadowsocksr", "@server_subscribe[0]", "xray_tj_type") + if val == nil or val == "" then + return "__auto__" -- 对应 Auto 选项 + end + return val +end +o.rmempty = true +-- 保存时更新全局配置 +o.write = function(self, section, value) + if value == "__auto__" then + -- 删除全局配置 + uci:delete("shadowsocksr", "@server_subscribe[0]", "xray_tj_type") + else + -- 设置具体值 + uci:set("shadowsocksr", "@server_subscribe[0]", "xray_tj_type", value) + end end o = s:option(ListValue, "type", translate("Server Node Type")) @@ -329,6 +368,29 @@ end if is_finded("redsocks2") then o:value("tun", translate("Network Tunnel")) end +local old_cfgvalue = o.cfgvalue +o.cfgvalue = function(self, section) + local val = self.map.uci:get("shadowsocksr", section, "type") + if val == "ss-rust" or val == "ss-libev" then + return "ss" + end + if old_cfgvalue then + return old_cfgvalue(self, section) + end + return val +end +-- 重写 write,当用户选择 "ss" 时不写入(由 _ss_core 负责写入具体核心) +local old_write = o.write +o.write = function(self, section, value) + if value == "ss" then + return -- 不做任何写入,等待 _ss_core 写入 + end + if old_write then + old_write(self, section, value) + else + self.map.uci:set("shadowsocksr", section, "type", value) + end +end o.description = translate("Using incorrect encryption mothod may causes service fail to start") @@ -343,29 +405,37 @@ end o:depends("type", "tun") o.description = translate("Redirect traffic to this network interface") --- 新增一个选择框,用于选择 Shadowsocks 版本 -o = s:option(ListValue, "_has_ss_type", string.format("%s", translatef("%s Node Use Version", "ShadowSocks"))) +-- 新增一个选择框,用于选择 Shadowsocks 具体版本(仅当节点类型为 ss 或其具体子类型时显示) +o = s:option(ListValue, "_ss_core", string.format("%s", translatef("%s Node Use Version", "ShadowSocks"))) o.description = translate("Selection ShadowSocks Node Use Version.") --- 设置默认 Shadowsocks 版本 --- 动态添加选项 if has_ss_rust then - o:value("ss-rust", translate("ShadowSocks-rust Version")) + o:value("ss-rust", translate("ShadowSocks-rust Version")) end if has_ss_libev then - o:value("ss-libev", translate("ShadowSocks-libev Version")) + o:value("ss-libev", translate("ShadowSocks-libev Version")) end --- 读取全局 ss_type o.cfgvalue = function(self, section) - return self.map.uci:get("shadowsocksr", "@server_subscribe[0]", "ss_type") or "ss-rust" + -- 读取当前节点的 type 值,如果已经是具体核心则显示对应的选项 + local node_type = self.map.uci:get("shadowsocksr", section, "type") + if node_type == "ss-rust" or node_type == "ss-libev" then + return node_type + end + -- 如果全局 ss_type 有值且为具体核心则返回该值 + local ss_type = self.map.uci:get("shadowsocksr", "@server_subscribe[0]", "ss_type") + if ss_type == "ss-rust" or ss_type == "ss-libev" then + return ss_type + end + -- 如果节点 type 是旧的 "ss",则返回空,手动选择 + return nil end +-- 显示条件:当节点类型为 "ss" 或其具体核心时显示 o:depends("type", "ss") o.rmempty = true +-- 保存时,将选择的值直接写入当前节点的 type 字段 o.write = function(self, section, value) - -- 更新 server_subscribe 的 ss_type - local old_value = self.map.uci:get("shadowsocksr", "@server_subscribe[0]", "ss_type") - if old_value ~= value then - self.map.uci:set("shadowsocksr", "@server_subscribe[0]", "ss_type", value) - end + if value and value ~= "" then + self.map.uci:set("shadowsocksr", section, "type", value) + end end o = s:option(ListValue, "v2ray_protocol", translate("V2Ray/XRay protocol")) @@ -539,7 +609,7 @@ o = s:option(Value, "port_range", translate("Port hopping range")) o.description = translate("Format as 10000:20000 or 10000-20000 Multiple groups are separated by commas (,).") o:depends({type = "hysteria2", flag_port_hopping = true}) o:depends({type = "v2ray", v2ray_protocol = "hysteria2", flag_port_hopping = true}) ---o.datatype = "portrange" +o.datatype = "or(uinteger,portrange)" o.rmempty = true o = s:option(Flag, "flag_transport", translate("Enable Transport Protocol Settings")) @@ -554,9 +624,10 @@ o.default = "udp" o.rmempty = true o = s:option(Value, "hopinterval", translate("Port Hopping Interval(Unit:Second)")) +o.description = translate("Supports a fixed value or a random range (e.g., 30, 5-30), minimum 5.") o:depends({type = "hysteria2", flag_transport = true, flag_port_hopping = true}) o:depends({type = "v2ray", v2ray_protocol = "hysteria2", flag_port_hopping = true}) -o.datatype = "uinteger" +o.datatype = "or(uinteger,portrange)" o.rmempty = true o.default = "30" diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua index f24ab954..7978c57b 100644 --- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua +++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/client.lua @@ -295,4 +295,3 @@ end m:section(SimpleSection).template = "shadowsocksr/status_bottom" return m - diff --git a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua index 65d1a7f1..07a74257 100644 --- a/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua +++ b/luci-app-ssr-plus/luasrc/model/cbi/shadowsocksr/servers.lua @@ -70,9 +70,14 @@ local function set_apply_on_parse(map) end end +local has_ss_rust = is_finded("sslocal") or is_finded("ssserver") +local has_ss_libev = is_finded("ss-redir") or is_finded("ss-local") +local has_trojan = is_finded("trojan") local has_xray = is_finded("xray") local has_hysteria2 = is_finded("hysteria") +local ss_type_list = {} +local tj_type_list = {} local hy2_type_list = {} if has_hysteria2 then @@ -82,19 +87,12 @@ if has_xray then table.insert(hy2_type_list, { id = "v2ray", name = translate("Xray (Hysteria2)") }) end --- 如果用户没有手动设置,则自动选择 -if not xray_hy2_type or xray_hy2_type == "" then - if has_hysteria2 then - xray_hy2_type = "hysteria2" - elseif has_xray then - xray_hy2_type = "v2ray" - end +if has_trojan then + table.insert(tj_type_list, { id = "trojan", name = translate("Trojan") }) +end +if has_xray then + table.insert(tj_type_list, { id = "v2ray", name = translate("Xray (Trojan)") }) end - -local has_ss_rust = is_finded("sslocal") or is_finded("ssserver") -local has_ss_libev = is_finded("ss-redir") or is_finded("ss-local") - -local ss_type_list = {} if has_ss_rust then table.insert(ss_type_list, { id = "ss-rust", name = translate("ShadowSocks-rust Version") }) @@ -106,17 +104,6 @@ if has_xray then table.insert(ss_type_list, { id = "v2ray", name = translate("Xray (ShadowSocks)") }) end --- 如果用户没有手动设置,则自动选择 -if not ss_type or ss_type == "" then - if has_ss_rust then - ss_type = "ss-rust" - elseif has_ss_libev then - ss_type = "ss-libev" - elseif has_xray then - ss_type = "v2ray" - end -end - uci:foreach("shadowsocksr", "servers", function(s) server_count = server_count + 1 end) @@ -162,22 +149,87 @@ o:depends("auto_update", "1") -- 确保 hy2_type_list 不为空 if #hy2_type_list > 0 then + local sid = uci:get_first("shadowsocksr", "server_subscribe") + if not sid then + uci:foreach("shadowsocksr", "server_subscribe", function(section) + sid = section[".name"] + return false + end) + end + if sid then + local old_val = uci:get("shadowsocksr", sid, "xray_hy2_type") + if old_val and old_val ~= "" then + if (old_val == "hysteria2" and not has_hysteria2) or + (old_val == "v2ray" and not has_xray) then + -- 核心不可用,设置为空(删除配置) + uci:set("shadowsocksr", sid, "xray_hy2_type", "") + uci:commit("shadowsocksr") + end + end + end o = s:option(ListValue, "xray_hy2_type", string.format("%s", translatef("%s Node Use Type", "Hysteria2"))) o.description = translate("The configured type also applies to the core specified when manually importing nodes.") + o:value("", translate("Auto")) for _, v in ipairs(hy2_type_list) do o:value(v.id, v.name) -- 存储 "Xray" / "Hysteria2",但 UI 显示完整名称 end - o.default = xray_hy2_type -- 设置默认值 +end + +-- 确保 tj_type_list 不为空 +if #tj_type_list > 0 then + local sid = uci:get_first("shadowsocksr", "server_subscribe") + if not sid then + uci:foreach("shadowsocksr", "server_subscribe", function(section) + sid = section[".name"] + return false + end) + end + if sid then + local old_val = uci:get("shadowsocksr", sid, "xray_tj_type") + if old_val and old_val ~= "" then + if (old_val == "trojan" and not has_trojan) or + (old_val == "v2ray" and not has_xray) then + -- 核心不可用,设置为空(删除配置) + uci:set("shadowsocksr", sid, "xray_tj_type", "") + uci:commit("shadowsocksr") + end + end + end + o = s:option(ListValue, "xray_tj_type", string.format("%s", translatef("%s Node Use Type", "Trojan"))) + o.description = translate("The configured type also applies to the core specified when manually importing nodes.") + o:value("", translate("Auto")) + for _, v in ipairs(tj_type_list) do + o:value(v.id, v.name) -- 存储 "Xray" / "Trojan",但 UI 显示完整名称 + end end -- 确保 ss_type_list 不为空 if #ss_type_list > 0 then + local sid = uci:get_first("shadowsocksr", "server_subscribe") + if not sid then + uci:foreach("shadowsocksr", "server_subscribe", function(section) + sid = section[".name"] + return false + end) + end + if sid then + local old_val = uci:get("shadowsocksr", sid, "ss_type") + if old_val and old_val ~= "" then + if (old_val == "ss-rust" and not has_ss_rust) or + (old_val == "ss-libev" and not has_ss_libev) or + (old_val == "v2ray" and not has_xray) then + -- 核心不可用,设置为空(删除配置) + uci:set("shadowsocksr", sid, "ss_type", "") + uci:commit("shadowsocksr") + end + end + end o = s:option(ListValue, "ss_type", string.format("%s", translatef("%s Node Use Version", "ShadowSocks"))) o.description = translate("Selection ShadowSocks Node Use Version.") + o:value("", translate("Auto")) for _, v in ipairs(ss_type_list) do o:value(v.id, v.name) -- 存储 "ss-libev" / "ss-rust",但 UI 显示完整名称 end - o.default = ss_type -- 设置默认值 end o = s:option(DynamicList, "subscribe_url", translate("Subscribe URL")) diff --git a/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm b/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm index 0f3d6e36..b5610c2e 100644 --- a/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm +++ b/luci-app-ssr-plus/luasrc/view/shadowsocksr/ssrurl.htm @@ -3,11 +3,24 @@ local map = self.map local ss_type = map:get("@server_subscribe[0]", "ss_type") local xray_hy2_type = map:get("@server_subscribe[0]", "xray_hy2_type") +local xray_tj_type = map:get("@server_subscribe[0]", "xray_tj_type") + +local has_ss_rust = luci.sys.exec('type -t -p sslocal 2>/dev/null || type -t -p ssserver 2>/dev/null') ~= "" +local has_ss_libev = luci.sys.exec('type -t -p ss-redir 2>/dev/null || type -t -p ss-local 2>/dev/null') ~= "" +local has_hysteria = luci.sys.exec('type -t -p hysteria 2>/dev/null') ~= "" +local has_trojan = luci.sys.exec('type -t -p trojan 2>/dev/null') ~= "" +local has_xray = luci.sys.exec('type -t -p xray 2>/dev/null') ~= "" -%>