From a71748a807ecdc466ee26d63cd029fc18f42e571 Mon Sep 17 00:00:00 2001 From: action Date: Sun, 1 Nov 2026 17:09:57 +0800 Subject: [PATCH] update 2026-11-01 17:09:57 --- honk/Makefile | 127 +- honk/files/90-honk-boot | 5 + honk/files/config.d/api.dae | 0 honk/files/config.d/dns.dae | 24 - honk/files/config.d/node.dae | 35 - honk/files/config.d/route.dae | 27 - honk/files/config.dae | 55 +- honk/files/control.sh | 984 ++ honk/files/honk.config | 18 +- honk/files/honk.init | 179 +- honk/files/job.sh | 127 + honk/files/lifecycle.sh | 268 + honk/files/lock.sh | 32 + honk/files/log-level-filter.sh | 57 + honk/files/log-lines.sh | 9 + honk/files/maintenance.sh | 1207 ++ honk/files/redact-log.sh | 6 + honk/files/system.dae | 22 + honk/files/update.sh | 292 + luci-app-honk/Makefile | 113 +- .../resources/view/honk/configuration.js | 798 ++ .../resources/view/honk/converter.js | 501 + .../resources/view/honk/dashboard.js | 133 + .../luci-static/resources/view/honk/honk.css | 1049 ++ .../luci-static/resources/view/honk/logs.js | 192 + .../resources/view/honk/maintenance.js | 555 + .../resources/view/honk/overview.js | 424 + .../luci-static/resources/view/honk/rpc.js | 489 + .../resources/view/honk/settings.js | 142 + .../luci-static/resources/view/honk/sha256.js | 72 + .../view/honk/vendor/js-yaml.LICENSE | 21 + .../resources/view/honk/vendor/js-yaml.min.js | 33 + luci-app-honk/po/zh-cn/honk.po | 1915 ++++ luci-app-honk/po/zh_Hans/honk.po | 364 - .../etc/honk/doona/THIRD-PARTY-NOTICES.txt | 712 -- .../honk/doona/assets/AddCircle-V0Kluz-m.js | 1 - .../assets/AppearanceControls-Bpq6OQjk.js | 1 - .../honk/doona/assets/AreaChart-BxyW1lgi.js | 1 - .../honk/doona/assets/AreaCurve-DQSHOSpG.js | 1 - .../honk/doona/assets/CardView-CwlxcqMf.js | 1 - .../honk/doona/assets/Checkbox-xU-Ds7Hn.js | 1 - .../honk/doona/assets/CheckboxSet-CRYfymLL.js | 1 - .../honk/doona/assets/CodeEditor-DW62W8R0.js | 1 - .../etc/honk/doona/assets/Config-DdQPDBRG.js | 2 - .../honk/doona/assets/Connections-E-DQbY3S.js | 1 - .../etc/honk/doona/assets/Copy-DRn0jPgo.js | 1 - .../honk/doona/assets/Coverage-BRhvKmi_.js | 1 - .../etc/honk/doona/assets/DaeCode-DEjXqvav.js | 1 - .../etc/honk/doona/assets/Delete-DIaM96Pb.js | 1 - .../etc/honk/doona/assets/Dns-CsAXSf3H.css | 1 - .../etc/honk/doona/assets/Dns-Ct51pA2s.js | 1 - .../etc/honk/doona/assets/Donut-Cogv0HSG.js | 1 - .../etc/honk/doona/assets/Editors-CMGR9TtS.js | 1 - .../honk/doona/assets/Editors-rPgk_VZK.css | 1 - .../etc/honk/doona/assets/Events-BNRsSpN9.js | 1 - .../honk/doona/assets/FlagPicker-DZPvPF1E.js | 1 - .../etc/honk/doona/assets/Flows-BekA1vQh.css | 1 - .../etc/honk/doona/assets/Flows-DfgvM-Ta.js | 1 - .../honk/doona/assets/GridList-BuXKU7Uk.js | 1 - .../honk/doona/assets/ItemText-u3CUc_6I.js | 1 - .../etc/honk/doona/assets/LinkOut-B50sd8cz.js | 1 - .../etc/honk/doona/assets/Login-DaBUo9Xb.js | 1 - .../etc/honk/doona/assets/Logs-C9CHXk6c.js | 1 - .../doona/assets/ModeReadOnlyHint-DDRE5yga.js | 1 - .../honk/doona/assets/NodePath-DCKKYRsk.js | 1 - .../etc/honk/doona/assets/Nodes-BP4CnthX.js | 2 - .../etc/honk/doona/assets/Nodes-Dz09mbUJ.css | 1 - .../honk/doona/assets/NumberField-kCp6sey7.js | 1 - .../honk/doona/assets/Overview-CgY0TDEB.js | 1 - .../honk/doona/assets/Policies-SR8BE_dk.js | 1 - .../assets/ProbeOptionsDialog-BDgL7_BI.js | 1 - .../etc/honk/doona/assets/Rules-Bt5JZQKX.js | 2 - .../doona/assets/SearchDialog-CznCqNH0.js | 1 - .../honk/doona/assets/SearchList-C88Y6c53.js | 1 - .../doona/assets/SearchSelect-CsXd0DEh.js | 1 - .../etc/honk/doona/assets/Select-BFkb6iie.js | 1 - .../honk/doona/assets/Settings-BVpmQMTB.css | 1 - .../honk/doona/assets/Settings-IzcnYfjs.js | 2 - .../honk/doona/assets/Sparkline-DULE2gkm.js | 1 - .../assets/SubscriptionFields-CWqR97pB.js | 1 - .../etc/honk/doona/assets/Table-6xnX6eUx.js | 1 - .../etc/honk/doona/assets/Tabs-HxpCyCLR.js | 1 - .../etc/honk/doona/assets/Tag-BZqiYh1Z.js | 1 - .../honk/doona/assets/TimeCell-CRwMWdtu.js | 1 - .../etc/honk/doona/assets/Trace-CDFraj3q.js | 1 - .../assets/TwemojiCountryFlags-Bymva2JV.woff2 | Bin 78292 -> 0 bytes .../honk/doona/assets/Virtualizer-BH6NJSXn.js | 1 - .../assets/WallpaperSettings-Bkh54SrZ.js | 1 - .../doona/assets/XhttpOptions-BHPc7rZI.js | 1 - .../doona/assets/_commonjsHelpers-CXUWDbkB.js | 1 - .../honk/doona/assets/activity-CKk6X7wg.css | 1 - .../honk/doona/assets/activity-pUN0wcVd.js | 2 - .../etc/honk/doona/assets/array-C0VuvQyb.js | 1 - .../etc/honk/doona/assets/auth-Br_GGGEt.js | 2 - .../etc/honk/doona/assets/auth-rPpvn4ox.js | 1 - .../honk/doona/assets/daeTokens-BVtuNnWZ.js | 1 - .../doona/assets/duck-night-CbKHyiul.webp | Bin 38214 -> 0 bytes .../etc/honk/doona/assets/files-Dt8K7QfP.js | 1 - .../etc/honk/doona/assets/flows-Cd1KbGP2.js | 1 - .../etc/honk/doona/assets/fonts-C5_wsAqZ.css | 1 - .../honk/doona/assets/fonts-sc-Dyn9aW84.css | 1 - .../honk/doona/assets/fonts-tc-D9rvq3k7.css | 1 - .../etc/honk/doona/assets/geodata-CvcFemyc.js | 1 - .../doona/assets/geodataUpdate-BANPx3Of.js | 1 - .../etc/honk/doona/assets/glass-BaA-OKzR.css | 1 - .../etc/honk/doona/assets/glass-D0JDY1or.js | 1 - .../doona/assets/gorges-dark-FlgXSNn1.webp | Bin 17050 -> 0 bytes .../doona/assets/gorges-light-DCWkLX37.webp | Bin 16902 -> 0 bytes .../honk/doona/assets/halloween-Dfny7j3b.js | 1 - .../honk/doona/assets/halloween-e3DfiasY.css | 1 - .../etc/honk/doona/assets/impact-B8feJDtJ.css | 1 - .../etc/honk/doona/assets/index-BnzLYrTn.css | 1 - .../etc/honk/doona/assets/index-D2MLmo5c.js | 1 - .../etc/honk/doona/assets/index-DFPUSiWe.js | 2 - .../etc/honk/doona/assets/index-DUM-iwa2.js | 1 - .../honk/doona/assets/ipLookup-47nEpvjK.js | 1 - .../honk/doona/assets/ipLookup-BYorpVJi.css | 1 - .../etc/honk/doona/assets/labels-D8xxfRTQ.js | 1 - .../honk/doona/assets/lake-dark-B_VaUlAe.webp | Bin 18114 -> 0 bytes .../doona/assets/lake-light-oWll36-V.webp | Bin 17966 -> 0 bytes .../etc/honk/doona/assets/lens-BMneTJKP.js | 1 - .../honk/doona/assets/limitHint-drqGf1xh.js | 1 - .../assets/locale-en-activity-D3WqzCdU.js | 1 - .../doona/assets/locale-en-core-BYFqSMNd.js | 1 - .../doona/assets/locale-en-rest-CE5BtEfD.js | 1 - .../assets/locale-zh-CN-activity-B_7_QXPJ.js | 1 - .../assets/locale-zh-CN-core-BfCAILG5.js | 1 - .../assets/locale-zh-CN-rest-BcxkEWKL.js | 1 - .../assets/locale-zh-TW-activity-C7Vz2kIV.js | 1 - .../assets/locale-zh-TW-core-C7Ay33Lr.js | 1 - .../assets/locale-zh-TW-rest-UNIj0wey.js | 1 - .../etc/honk/doona/assets/logo-CKC46m8V.svg | 16 - .../etc/honk/doona/assets/logo-obi05X1B.svg | 11 - .../etc/honk/doona/assets/logs-lI5kXcWp.js | 1 - .../doona/assets/match.worker-B2aIccpg.js | 1 - .../etc/honk/doona/assets/nav-BLmatpph.js | 1 - .../etc/honk/doona/assets/nav-BO8viH9G.js | 1 - .../etc/honk/doona/assets/nav-Bw7F3lhF.js | 1 - .../etc/honk/doona/assets/nav-C6jMmQ4d.js | 1 - .../etc/honk/doona/assets/nav-CISoRdvs.js | 1 - .../etc/honk/doona/assets/nav-CfbmlNWr.js | 1 - .../etc/honk/doona/assets/nav-lSzYOU50.js | 1 - .../etc/honk/doona/assets/nodes-HzIJdCpg.js | 1 - .../honk/doona/assets/outbounds-BMHzxQr8.js | 1 - .../doona/assets/paletteSheet-Vc0PGoNW.js | 1 - .../etc/honk/doona/assets/probe-Db_cbYvO.js | 1 - .../honk/doona/assets/recorder-BHyqDNp5.js | 1 - .../etc/honk/doona/assets/rule-D1hMywhM.js | 1 - .../doona/assets/square-dark-CG0rwsS1.webp | Bin 15298 -> 0 bytes .../doona/assets/square-light-OU6dsCf9.webp | Bin 14642 -> 0 bytes .../doona/assets/taishan-dark-BI3cd4fj.webp | Bin 25370 -> 0 bytes .../doona/assets/taishan-light-Dip0HmJZ.webp | Bin 25326 -> 0 bytes .../useGridSelectionCheckbox-wMhI-U-R.js | 1 - .../doona/assets/useQuickRule-BGNvNXBX.js | 1 - .../doona/assets/vendor-editor-50h2cCk6.js | 1 - .../doona/assets/vendor-react-64fcanlc.js | 10 - .../honk/doona/assets/wall-dark-DCO0xUUO.webp | Bin 23078 -> 0 bytes .../doona/assets/wall-light-BYfte2Bi.webp | Bin 23078 -> 0 bytes .../honk/doona/assets/wallpaper-CEC7gitM.js | 1 - .../etc/honk/doona/icons/apple-touch-icon.png | Bin 8563 -> 0 bytes .../root/etc/honk/doona/icons/icon-192.png | Bin 9623 -> 0 bytes .../root/etc/honk/doona/icons/icon-512.png | Bin 27580 -> 0 bytes .../etc/honk/doona/icons/maskable-512.png | Bin 16136 -> 0 bytes luci-app-honk/root/etc/honk/doona/index.html | 97 - luci-app-honk/root/etc/honk/doona/logo.svg | 11 - .../root/etc/honk/doona/manifest.webmanifest | 16 - luci-app-honk/root/etc/honk/doona/sw.js | 91 - luci-app-honk/root/etc/uci-defaults/luci-honk | 67 - luci-app-honk/root/usr/libexec/rpcd/honk | 1277 +++ .../root/usr/share/honk/download_dashboard.sh | 219 - .../root/usr/share/luci-app-honk/geo-cron.sh | 38 + .../root/usr/share/luci-app-honk/pkg-info.sh | 34 + .../usr/share/luci-app-honk/refresh-index.sh | 19 + .../usr/share/luci-app-honk/update-geo.sh | 96 + .../usr/share/luci-app-honk/update-pkg.sh | 97 + .../usr/share/luci/menu.d/luci-app-honk.json | 63 +- .../usr/share/rpcd/acl.d/luci-app-honk.json | 122 +- .../root/usr/share/rpcd/ucode/honk.uc | 619 -- .../honk/addon/edit/closebrackets.js | 143 - .../honk/addon/edit/matchbrackets.js | 160 - .../resources/honk/addon/fold/foldcode.js | 159 - .../resources/honk/addon/fold/foldgutter.css | 20 - .../resources/honk/addon/fold/foldgutter.js | 169 - .../resources/honk/addon/fold/indent-fold.js | 48 - .../www/luci-static/resources/honk/common.js | 852 -- .../resources/honk/dashprofiles.js | 111 - .../resources/honk/lib/codemirror.css | 344 - .../resources/honk/lib/codemirror.js | 9884 ----------------- .../resources/honk/mode/dae/dae.js | 130 - .../luci-static/resources/view/honk/api.js | 11 - .../resources/view/honk/dashboard.js | 599 - .../luci-static/resources/view/honk/dns.js | 10 - .../luci-static/resources/view/honk/global.js | 82 - .../luci-static/resources/view/honk/log.js | 98 - .../luci-static/resources/view/honk/node.js | 10 - .../luci-static/resources/view/honk/route.js | 10 - .../model/cbi/passwall/client/shunt_rules.lua | 14 +- luci-app-passwall/po/zh-cn/passwall.po | 12 +- .../root/usr/share/passwall/luci_update.sh | 8 +- .../root/usr/share/passwall/nftables.sh | 2 +- .../cbi/passwall2/client/shunt_rules.lua | 18 +- luci-app-passwall2/po/fa/passwall2.po | 16 +- luci-app-passwall2/po/ru/passwall2.po | 14 +- luci-app-passwall2/po/zh-cn/passwall2.po | 14 +- luci-app-passwall2/po/zh-tw/passwall2.po | 14 +- .../root/usr/share/passwall2/luci_update.sh | 8 +- .../root/usr/share/passwall2/nftables.sh | 2 +- .../.dev/src/media/components/_card.css | 16 +- .../.dev/tests/build-output.test.js | 7 +- .../htdocs/luci-static/shadcn/main.css | 2 +- 210 files changed, 11401 insertions(+), 15592 deletions(-) create mode 100644 honk/files/90-honk-boot delete mode 100644 honk/files/config.d/api.dae delete mode 100644 honk/files/config.d/dns.dae delete mode 100644 honk/files/config.d/node.dae delete mode 100644 honk/files/config.d/route.dae create mode 100755 honk/files/control.sh mode change 100644 => 100755 honk/files/honk.init create mode 100644 honk/files/job.sh create mode 100644 honk/files/lifecycle.sh create mode 100644 honk/files/lock.sh create mode 100644 honk/files/log-level-filter.sh create mode 100644 honk/files/log-lines.sh create mode 100755 honk/files/maintenance.sh create mode 100644 honk/files/redact-log.sh create mode 100644 honk/files/system.dae create mode 100644 honk/files/update.sh create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/configuration.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/converter.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/dashboard.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/honk.css create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/logs.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/maintenance.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/overview.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/rpc.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/settings.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/sha256.js create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/vendor/js-yaml.LICENSE create mode 100644 luci-app-honk/htdocs/luci-static/resources/view/honk/vendor/js-yaml.min.js create mode 100644 luci-app-honk/po/zh-cn/honk.po delete mode 100644 luci-app-honk/po/zh_Hans/honk.po delete mode 100644 luci-app-honk/root/etc/honk/doona/THIRD-PARTY-NOTICES.txt delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/AddCircle-V0Kluz-m.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/AppearanceControls-Bpq6OQjk.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/AreaChart-BxyW1lgi.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/AreaCurve-DQSHOSpG.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/CardView-CwlxcqMf.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Checkbox-xU-Ds7Hn.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/CheckboxSet-CRYfymLL.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/CodeEditor-DW62W8R0.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Config-DdQPDBRG.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Connections-E-DQbY3S.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Copy-DRn0jPgo.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Coverage-BRhvKmi_.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/DaeCode-DEjXqvav.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Delete-DIaM96Pb.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Dns-CsAXSf3H.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Dns-Ct51pA2s.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Donut-Cogv0HSG.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Editors-CMGR9TtS.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Editors-rPgk_VZK.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Events-BNRsSpN9.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/FlagPicker-DZPvPF1E.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Flows-BekA1vQh.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Flows-DfgvM-Ta.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/GridList-BuXKU7Uk.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/ItemText-u3CUc_6I.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/LinkOut-B50sd8cz.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Login-DaBUo9Xb.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Logs-C9CHXk6c.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/ModeReadOnlyHint-DDRE5yga.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/NodePath-DCKKYRsk.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Nodes-BP4CnthX.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Nodes-Dz09mbUJ.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/NumberField-kCp6sey7.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Overview-CgY0TDEB.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Policies-SR8BE_dk.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/ProbeOptionsDialog-BDgL7_BI.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Rules-Bt5JZQKX.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/SearchDialog-CznCqNH0.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/SearchList-C88Y6c53.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/SearchSelect-CsXd0DEh.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Select-BFkb6iie.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Settings-BVpmQMTB.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Settings-IzcnYfjs.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Sparkline-DULE2gkm.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/SubscriptionFields-CWqR97pB.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Table-6xnX6eUx.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Tabs-HxpCyCLR.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Tag-BZqiYh1Z.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/TimeCell-CRwMWdtu.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Trace-CDFraj3q.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/TwemojiCountryFlags-Bymva2JV.woff2 delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/Virtualizer-BH6NJSXn.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/WallpaperSettings-Bkh54SrZ.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/XhttpOptions-BHPc7rZI.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/_commonjsHelpers-CXUWDbkB.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/activity-CKk6X7wg.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/activity-pUN0wcVd.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/array-C0VuvQyb.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/auth-Br_GGGEt.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/auth-rPpvn4ox.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/daeTokens-BVtuNnWZ.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/duck-night-CbKHyiul.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/files-Dt8K7QfP.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/flows-Cd1KbGP2.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/fonts-C5_wsAqZ.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/fonts-sc-Dyn9aW84.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/fonts-tc-D9rvq3k7.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/geodata-CvcFemyc.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/geodataUpdate-BANPx3Of.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/glass-BaA-OKzR.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/glass-D0JDY1or.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/gorges-dark-FlgXSNn1.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/gorges-light-DCWkLX37.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/halloween-Dfny7j3b.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/halloween-e3DfiasY.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/impact-B8feJDtJ.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/index-BnzLYrTn.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/index-D2MLmo5c.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/index-DFPUSiWe.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/index-DUM-iwa2.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/ipLookup-47nEpvjK.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/ipLookup-BYorpVJi.css delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/labels-D8xxfRTQ.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/lake-dark-B_VaUlAe.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/lake-light-oWll36-V.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/lens-BMneTJKP.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/limitHint-drqGf1xh.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-en-activity-D3WqzCdU.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-en-core-BYFqSMNd.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-en-rest-CE5BtEfD.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-CN-activity-B_7_QXPJ.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-CN-core-BfCAILG5.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-CN-rest-BcxkEWKL.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-TW-activity-C7Vz2kIV.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-TW-core-C7Ay33Lr.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/locale-zh-TW-rest-UNIj0wey.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/logo-CKC46m8V.svg delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/logo-obi05X1B.svg delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/logs-lI5kXcWp.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/match.worker-B2aIccpg.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-BLmatpph.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-BO8viH9G.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-Bw7F3lhF.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-C6jMmQ4d.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-CISoRdvs.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-CfbmlNWr.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nav-lSzYOU50.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/nodes-HzIJdCpg.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/outbounds-BMHzxQr8.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/paletteSheet-Vc0PGoNW.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/probe-Db_cbYvO.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/recorder-BHyqDNp5.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/rule-D1hMywhM.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/square-dark-CG0rwsS1.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/square-light-OU6dsCf9.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/taishan-dark-BI3cd4fj.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/taishan-light-Dip0HmJZ.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/useGridSelectionCheckbox-wMhI-U-R.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/useQuickRule-BGNvNXBX.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/vendor-editor-50h2cCk6.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/vendor-react-64fcanlc.js delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/wall-dark-DCO0xUUO.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/wall-light-BYfte2Bi.webp delete mode 100644 luci-app-honk/root/etc/honk/doona/assets/wallpaper-CEC7gitM.js delete mode 100644 luci-app-honk/root/etc/honk/doona/icons/apple-touch-icon.png delete mode 100644 luci-app-honk/root/etc/honk/doona/icons/icon-192.png delete mode 100644 luci-app-honk/root/etc/honk/doona/icons/icon-512.png delete mode 100644 luci-app-honk/root/etc/honk/doona/icons/maskable-512.png delete mode 100644 luci-app-honk/root/etc/honk/doona/index.html delete mode 100644 luci-app-honk/root/etc/honk/doona/logo.svg delete mode 100644 luci-app-honk/root/etc/honk/doona/manifest.webmanifest delete mode 100644 luci-app-honk/root/etc/honk/doona/sw.js delete mode 100644 luci-app-honk/root/etc/uci-defaults/luci-honk create mode 100755 luci-app-honk/root/usr/libexec/rpcd/honk delete mode 100755 luci-app-honk/root/usr/share/honk/download_dashboard.sh create mode 100644 luci-app-honk/root/usr/share/luci-app-honk/geo-cron.sh create mode 100755 luci-app-honk/root/usr/share/luci-app-honk/pkg-info.sh create mode 100755 luci-app-honk/root/usr/share/luci-app-honk/refresh-index.sh create mode 100644 luci-app-honk/root/usr/share/luci-app-honk/update-geo.sh create mode 100755 luci-app-honk/root/usr/share/luci-app-honk/update-pkg.sh delete mode 100644 luci-app-honk/root/usr/share/rpcd/ucode/honk.uc delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/edit/closebrackets.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/edit/matchbrackets.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/fold/foldcode.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/fold/foldgutter.css delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/fold/foldgutter.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/addon/fold/indent-fold.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/common.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/dashprofiles.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/lib/codemirror.css delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/lib/codemirror.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/honk/mode/dae/dae.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/api.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/dashboard.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/dns.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/global.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/log.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/node.js delete mode 100644 luci-app-honk/root/www/luci-static/resources/view/honk/route.js diff --git a/honk/Makefile b/honk/Makefile index 7ab224a1..462f634b 100644 --- a/honk/Makefile +++ b/honk/Makefile @@ -1,79 +1,79 @@ -# SPDX-License-Identifier: GPL-2.0-only +# SPDX-License-Identifier: GPL-3.0-only +# +# Copyright (C) 2026 kenzok8 include $(TOPDIR)/rules.mk PKG_NAME:=honk -PKG_VERSION:=0.1.0_beta4 +PKG_VERSION:=2026.11.01 PKG_RELEASE:=1 -# Raw upstream release tag. It keeps the dots in qualifiers (e.g. v0.0.1.beta.49) -# because that is what the GitHub release asset name uses. -HONK_RELEASE_TAG:=v0.1.0-beta.4 -HONK_ASSET_TAG:=v0.1.0-beta.4 +# Pinned honk-core revision (upstream daeuniverse/honk main), cross-compiled by +# ci/build-core.sh. Every release run publishes its core archives under an +# immutable source- tag; the tag and SHA-256 values below are the exact +# assets behind CORE_COMMIT, so a plain feed build downloads and verifies them +# without any generated file. A staged run may still override them through +# honk/generated-stage.mk, which is generated and never committed. +CORE_COMMIT:=54b2e1cfbe1e7f8a93dba3a0f64fabf2a9f88e38 -# SHA256 of each release tarball, kept in sync by scripts/update_honk_version.sh. -HONK_HASH_X86_64:=48f694b13b9c5200e8e884ee19431035bd67b0ef01d04caef007047ad11ba02c -HONK_HASH_AARCH64:=6cc92f862a1684ba9c49e9f9c08fb108f53444f338f1e9be84013e2f7fb7423d +# Immutable source asset tag holding this pin's core archives. +HONK_SOURCE_TAG?=source-41281523460 +HONK_SOURCE_URL?=https://github.com/kenzok8/openwrt-honk/releases/download/$(HONK_SOURCE_TAG) -PKG_LICENSE:=AGPL-3.0-only -PKG_LICENSE_FILE:=LICENSE -PKG_MAINTAINER:=luci-app-honk maintainers +-include $(CURDIR)/generated-stage.mk -ifeq ($(ARCH),x86_64) - HONK_TARGET:=x86_64-unknown-linux-musl +HONK_ASSET_X86_64?=honk-core-$(CORE_COMMIT)-x86_64.tar.gz +HONK_ASSET_AARCH64?=honk-core-$(CORE_COMMIT)-aarch64.tar.gz +HONK_ASSET_ARMV7?=honk-core-$(CORE_COMMIT)-armv7.tar.gz +HONK_HASH_X86_64?=a5e46463505cc6ca15d704d77b57eba37b3ffe5d71498af928f052f612724deb +HONK_HASH_AARCH64?=98084529a1186ecc60d0b4882a0d419cc127b7d7c86ad5952ae0eda236709274 +HONK_HASH_ARMV7?=e1f2a1dff427220301e4a343202e2c2c2b5efd8e996423ca981c6e0145540d60 + +# ARCH_PACKAGES is the package arch (e.g. arm_cortex-a7_neon-vfpv4); +# ARCH is the CPU arch (x86_64/aarch64/arm) and coincides for 64-bit. +ifeq ($(ARCH_PACKAGES),x86_64) + HONK_ASSET:=$(HONK_ASSET_X86_64) HONK_HASH:=$(HONK_HASH_X86_64) -else ifeq ($(ARCH),aarch64) - HONK_TARGET:=aarch64-unknown-linux-musl +else ifeq ($(filter aarch64%,$(ARCH_PACKAGES)),$(ARCH_PACKAGES)) + HONK_ASSET:=$(HONK_ASSET_AARCH64) HONK_HASH:=$(HONK_HASH_AARCH64) +else ifeq ($(filter arm_%,$(ARCH_PACKAGES)),$(ARCH_PACKAGES)) + HONK_ASSET:=$(HONK_ASSET_ARMV7) + HONK_HASH:=$(HONK_HASH_ARMV7) endif -# mimalloc build: faster QUIC. honk.init sets MIMALLOC_ARENA_RESERVE=0 so the -# 1 GiB arena reservation stays out of VmSize. Use "-stock" to go back. -HONK_SUFFIX:= -# Upstream feature variant: -native-api (recommended for OpenWrt gateway), -full, or empty for legacy releases. -HONK_VARIANT:=-native-api -HONK_ASSET:=honk-core-$(HONK_ASSET_TAG)-$(HONK_TARGET)$(HONK_SUFFIX)$(HONK_VARIANT).tar.gz +PKG_LICENSE:=AGPL-3.0-only +PKG_LICENSE_FILES:=LICENSE +PKG_MAINTAINER:=kenzok8 PKG_SOURCE:=$(HONK_ASSET) -PKG_SOURCE_URL:=https://github.com/daeuniverse/honk/releases/download/$(HONK_RELEASE_TAG)/ +PKG_SOURCE_URL?=$(HONK_SOURCE_URL)/ PKG_HASH:=$(HONK_HASH) +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION) + include $(INCLUDE_DIR)/package.mk define Package/honk SECTION:=net CATEGORY:=Network SUBMENU:=Web Servers/Proxies - TITLE:=honk - eBPF transparent proxy engine (dae-compatible) + TITLE:=Honk transparent proxy core URL:=https://github.com/daeuniverse/honk - DEPENDS:=@(x86_64||aarch64) \ - +ca-bundle +ip-full \ - +kmod-sched-core +kmod-sched-bpf +kmod-veth +kmod-nft-queue \ - +v2ray-geoip +v2ray-geosite \ - +@KERNEL_XDP_SOCKETS \ - +HONK_USE_VMLINUX_BTF:vmlinux-btf + # 不直接依赖 libubox:包名带 ABI 日期戳,会锁死固件;脚本只用 jshn 提供的 jshn.sh。 + DEPENDS:=@(x86_64||aarch64||arm) +ca-bundle +ip-full +ubus +kmod-sched-core +kmod-sched-bpf +kmod-veth +kmod-nft-queue +jsonfilter +jshn +sqlite3-cli +usign +coreutils-stat +v2ray-geoip +v2ray-geosite +@KERNEL_DEBUG_INFO_BTF endef -define Package/honk/config - choice - prompt "BTF source for CO-RE" - default HONK_USE_KERNEL_BTF - depends on PACKAGE_honk - - config HONK_USE_KERNEL_BTF - bool "Use kernel BTF (integrated)" - depends on KERNEL_DEBUG_INFO_BTF - - config HONK_USE_VMLINUX_BTF - bool "Use vmlinux-btf package" - endchoice +define Package/honk/description + Honk is a transparent proxy core for OpenWrt (x86_64, aarch64, armv7). endef define Package/honk/conffiles -/etc/honk/config.dae -/etc/honk/config.d/ -/etc/honk/state/ /etc/config/honk +/etc/honk/config.dae +/etc/honk/system.dae +/etc/honk/manual-config.dae +/etc/honk/managed-config.dae endef define Build/Prepare @@ -82,25 +82,46 @@ define Build/Prepare endef define Build/Compile - true endef define Package/honk/install $(INSTALL_DIR) $(1)/usr/bin $(INSTALL_BIN) $(PKG_BUILD_DIR)/honk-core $(1)/usr/bin/honk-core - $(INSTALL_DIR) $(1)/etc/honk/config.d - $(INSTALL_CONF) $(CURDIR)/files/config.dae $(1)/etc/honk/config.dae - $(INSTALL_CONF) $(CURDIR)/files/config.d/node.dae $(1)/etc/honk/config.d/node.dae - $(INSTALL_CONF) $(CURDIR)/files/config.d/route.dae $(1)/etc/honk/config.d/route.dae - $(INSTALL_CONF) $(CURDIR)/files/config.d/dns.dae $(1)/etc/honk/config.d/dns.dae - $(INSTALL_CONF) $(CURDIR)/files/config.d/api.dae $(1)/etc/honk/config.d/api.dae + $(INSTALL_DIR) $(1)/usr/share/licenses/honk + $(INSTALL_DATA) $(PKG_BUILD_DIR)/LICENSE $(1)/usr/share/licenses/honk/LICENSE $(INSTALL_DIR) $(1)/etc/config $(INSTALL_CONF) $(CURDIR)/files/honk.config $(1)/etc/config/honk + $(INSTALL_DIR) $(1)/etc/honk + $(INSTALL_CONF) $(CURDIR)/files/config.dae $(1)/etc/honk/config.dae + $(INSTALL_CONF) $(CURDIR)/files/system.dae $(1)/etc/honk/system.dae + chmod 0600 $(1)/etc/honk/system.dae + $(INSTALL_DIR) $(1)/etc/honk/state + chmod 0700 $(1)/etc/honk/state + $(INSTALL_DIR) $(1)/etc/init.d $(INSTALL_BIN) $(CURDIR)/files/honk.init $(1)/etc/init.d/honk + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_BIN) $(CURDIR)/files/90-honk-boot $(1)/etc/uci-defaults/90-honk + + $(INSTALL_DIR) $(1)/usr/share/honk + $(INSTALL_DATA) $(CURDIR)/files/config.dae $(1)/usr/share/honk/default-config.dae + # Provenance is only generated by a source build; a plain feed build has + # no such file and must still install cleanly. + if [ -f $(CURDIR)/generated-provenance.json ]; then \ + $(INSTALL_DATA) $(CURDIR)/generated-provenance.json $(1)/usr/share/honk/provenance.json; \ + fi + $(INSTALL_BIN) $(CURDIR)/files/control.sh $(1)/usr/share/honk/control.sh + $(INSTALL_BIN) $(CURDIR)/files/lock.sh $(1)/usr/share/honk/lock.sh + $(INSTALL_BIN) $(CURDIR)/files/lifecycle.sh $(1)/usr/share/honk/lifecycle.sh + $(INSTALL_BIN) $(CURDIR)/files/maintenance.sh $(1)/usr/share/honk/maintenance.sh + $(INSTALL_BIN) $(CURDIR)/files/job.sh $(1)/usr/share/honk/job.sh + $(INSTALL_BIN) $(CURDIR)/files/update.sh $(1)/usr/share/honk/update.sh + $(INSTALL_BIN) $(CURDIR)/files/redact-log.sh $(1)/usr/share/honk/redact-log.sh + $(INSTALL_BIN) $(CURDIR)/files/log-level-filter.sh $(1)/usr/share/honk/log-level-filter.sh + $(INSTALL_DATA) $(CURDIR)/files/log-lines.sh $(1)/usr/share/honk/log-lines.sh endef $(eval $(call BuildPackage,honk)) diff --git a/honk/files/90-honk-boot b/honk/files/90-honk-boot new file mode 100644 index 00000000..2930b4e1 --- /dev/null +++ b/honk/files/90-honk-boot @@ -0,0 +1,5 @@ +#!/bin/sh +# 开机自启是内置行为:装上就 enable,是否真正启动由 honk.main.enabled 决定, +# 不再提供单独的 boot_enabled 开关。 +/etc/init.d/honk enable +exit 0 diff --git a/honk/files/config.d/api.dae b/honk/files/config.d/api.dae deleted file mode 100644 index e69de29b..00000000 diff --git a/honk/files/config.d/dns.dae b/honk/files/config.d/dns.dae deleted file mode 100644 index b4791ba4..00000000 --- a/honk/files/config.d/dns.dae +++ /dev/null @@ -1,24 +0,0 @@ -# dns.dae -dns { - #bind: 'tcp+udp://[::]:53530' - client_subnet: auto - max_cache_size: 50000 - #ipversion_prefer: 4 - upstream { - local: 'udp://127.0.0.1:53' -> direct - remote: 'tcp+udp://dns.google:53' - } - routing { - request { - qtype(https) -> reject - qname(geosite: gfw) -> remote - fallback: local - } - response { - upstream(remote) -> accept - qname(geosite: private) -> accept - ip(geoip: cn) -> accept - fallback: remote - } - } -} diff --git a/honk/files/config.d/node.dae b/honk/files/config.d/node.dae deleted file mode 100644 index 848dcb48..00000000 --- a/honk/files/config.d/node.dae +++ /dev/null @@ -1,35 +0,0 @@ -# node.dae -node { - node1: 'xxx' - node2: 'xxx' -} - -subscription { - # 短形式:默认 86400s 刷新一次 - sub: 'http://sub' - - # 短形式 + UA:仍然只能默认 86400s - sub_ua: 'http://sub'(honk/1.0 like) - - # 块形式:可以自定义定时刷新周期 - sub2: { - url: 'http://sub' - ua: 'honk/1.0' - interval: '10000s' # 每 10000 秒刷新一次 - } - - # 禁用定期刷新 - sub3: { - url: 'http://sub' - interval: 0 - } -} - -group { - proxy { - filter: subtag(sub) && !name(keyword: 'ExpireAt:') - filter: name(node1, node2) - policy: score - } -} - diff --git a/honk/files/config.d/route.dae b/honk/files/config.d/route.dae deleted file mode 100644 index 31106c7f..00000000 --- a/honk/files/config.d/route.dae +++ /dev/null @@ -1,27 +0,0 @@ -# route.dae -routing { - pname(dnsmasq, zerotier-one) -> direct(must) - dip(224.0.0.0/3, 'ff00::/8', geoip: private) -> direct - domain(geosite: category-bank-cn, geosite: boc@!cn, geosite: synology) -> direct - - # block quic, if u node has poor quic support, enable this line - # l4proto(udp) && dport(443) -> block - - domain(geosite: spotify) -> proxy - domain(geosite: category-doh) -> proxy - domain(geosite: tiktok) -> proxy - domain(geosite: category-ai-!cn, geosite: github) -> proxy - domain(geosite: gfw) -> proxy - - dip(geoip: telegram) -> proxy - - dip(geoip: cn) -> direct - # geoip:cn always using chiness asn, can use domain rule to override - # domain(geosite: cn) -> direct - - # using proxy for ssh, http, https, and common ports, avoid (bt, or other p2p) - # dport(22, 80, 443, 8080, 6443, 9090) -> proxy - - # fallback: direct - fallback: proxy -} diff --git a/honk/files/config.dae b/honk/files/config.dae index 159259b8..4a9eaaa9 100644 --- a/honk/files/config.dae +++ b/honk/files/config.dae @@ -1,31 +1,34 @@ -# config.dae -# Load all honk files placed in ./config.d/. include { - config.d/*.dae + system.dae } -global { - log_level: warn - check_interval: 600s - check_tolerance: 30ms - lan_interface: br-lan - wan_interface: auto - auto_config_kernel_parameter: true - disable_waiting_network: false - data_dir: '/etc/honk' - log_file: '/var/log/honk/honk.log' - store_subscribe: true - # if u not using dns bind, set dial_mode to domain++ prefer - dial_mode: domain - bootstrap_resolver: '223.5.5.5:53' - tcp_check_url: 'https://www.gstatic.com/generate_204' - tcp_check_http_method: HEAD - udp_check_dns: 'dns.google.com:53', '8.8.8.8', '2001:4860:4860::8888' -} - -assets { - geodata { - geosite: 'https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geosite.dat' - geoip: 'https://raw.githubusercontent.com/QiuSimons/geoip-moedove/refs/heads/main/geoip.dat' +group { + proxy { + policy: min_moving_avg } } + +dns { + upstream { + default: 'https://223.5.5.5/dns-query?tls_server_name=dns.alidns.com' -> direct + remote: 'https://8.8.8.8/dns-query?tls_server_name=dns.google' -> proxy + } + routing { + request { + qname(geosite: private) -> asis + qname(geosite: cn) -> default + fallback: remote + } + } +} + +routing { + dip(224.0.0.0/3, 'ff00::/8') -> direct + # 路由器自身地址直连,管理面板不被代理;服务启动时按本机地址重写下面区块 + # honk:local-direct-begin + # honk:local-direct-end + dip(geoip: private) && !dport(53) -> direct(must) + dip(geoip: cn) && !dport(53) -> direct(must) + domain(geosite: cn) -> direct + fallback: direct +} diff --git a/honk/files/control.sh b/honk/files/control.sh new file mode 100755 index 00000000..56c5a4bf --- /dev/null +++ b/honk/files/control.sh @@ -0,0 +1,984 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH + +HONK_CORE=/usr/bin/honk-core +HONK_MAINT_DIR=/etc/.honk-maintenance +HONK_MAINT_JOURNAL=$HONK_MAINT_DIR/journal +HONK_MAINT_CAP_FILE=$HONK_MAINT_DIR/capability + +. /lib/functions.sh +. /usr/share/honk/lock.sh +. /usr/share/honk/lifecycle.sh + +fail() { + echo "honk: $*" >&2 + return 1 +} + +load_settings() { + config_load honk + config_get_bool enabled main enabled 0 + config_get_bool boot_enabled main boot_enabled 0 + config_get_bool initialized main initialized 0 + config_get config_mode main config_mode managed + config_get config_file main config_file /etc/honk/config.dae + config_get listen_port main listen_port 9527 + config_get_bool clash_api_enabled main clash_api_enabled 0 + config_get clash_api_port main clash_api_port 9090 + config_get clash_api_secret main clash_api_secret '' + config_get lan_network main lan_network lan +} + +valid_clash_api() { + [ "$clash_api_enabled" = 1 ] || return 0 + case "$clash_api_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$clash_api_port" -ge 1024 ] && [ "$clash_api_port" -le 65535 ] || return 1 + [ "$clash_api_port" != "$listen_port" ] || return 1 + case "$clash_api_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + [ "${#clash_api_secret}" -ge 6 ] && [ "${#clash_api_secret}" -le 128 ] +} + +valid_network_name() { + case "$lan_network" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + *) return 0 ;; + esac +} + +module_loaded() { + [ -d "/sys/module/$1" ] || grep -q "^$1 " /proc/modules +} + +module_available() { + module_loaded "$1" && return 0 + module_path=$(printf '%s' "$1" | tr '_' '-') + module_dir=/lib/modules/$(uname -r) + [ -r "$module_dir/modules.builtin" ] && grep -Eq "/${1}\.ko$|/${module_path}\.ko$" "$module_dir/modules.builtin" && return 0 + find "$module_dir" -type f \( -name "$1.ko" -o -name "$1.ko.gz" -o -name "$1.ko.xz" -o -name "$1.ko.zst" \ + -o -name "$module_path.ko" -o -name "$module_path.ko.gz" -o -name "$module_path.ko.xz" -o -name "$module_path.ko.zst" \) -print 2>/dev/null | grep -q . +} + +system_config_readonly() { + permissions=$(ls -ldn /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$permissions" = '-rw-------' ] && return 0 + [ "$permissions" = '-r--r--r--' ] && ! grep -q 'clash_api {' /etc/honk/system.dae +} + +no_honk_resources() { + # honk-core removes dae0/dae0peer and the daens netns when it exits cleanly. A + # crash or SIGKILL leaves them behind, and preflight then refuses every later + # start, so the service can never come back without hand surgery. Reclaim them + # here, but only when no core is running to own them. + if ! pidof honk-core >/dev/null 2>&1; then + for stale_link in dae0 dae0peer; do + ip link show "$stale_link" >/dev/null 2>&1 && ip link del "$stale_link" >/dev/null 2>&1 + done + if ip netns list 2>/dev/null | awk '{print $1}' | grep -qx daens; then + ip netns del daens >/dev/null 2>&1 + fi + fi + for link in dae0 dae0peer; do + ip link show "$link" >/dev/null 2>&1 && { fail "$link 已存在(上次没有正常退出),请先停止服务或执行系统恢复"; return 1; } + done + ip netns list 2>/dev/null | awk '{print $1}' | grep -qx daens && { fail "daens 网络命名空间已存在,请先停止服务或执行系统恢复"; return 1; } + return 0 +} + +check_port() { + checked_port=${1:-$listen_port} + if command -v netstat >/dev/null 2>&1; then + netstat -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${checked_port}$" && { fail "监听端口已被占用,请换一个端口"; return 1; } + elif command -v ss >/dev/null 2>&1; then + ss -lnt 2>/dev/null | awk '{print $4}' | grep -Eq "(^|:)${checked_port}$" && { fail "监听端口已被占用,请换一个端口"; return 1; } + else + fail "缺少 netstat/ss,无法检查端口占用(装 ip-full 或 net-tools)" + return 1 + fi + return 0 +} + +check_network() { + valid_network_name || { fail "LAN 网络名包含非法字符"; return 1; } + command -v ubus >/dev/null 2>&1 || { fail "ubus 不可用,无法校验网络设置"; return 1; } + ubus call "network.interface.$lan_network" status >/dev/null 2>&1 || { fail "选择的 LAN 网络在 netifd 里不存在,请在服务设置里重新选择"; return 1; } +} + +# 本机 LAN/WAN 设备地址(跳过链路本地),与 honk 的 local_direct_cidrs 对齐 +local_direct_addresses() { + list='' + . /lib/functions/network.sh + for net in "$lan_network" wan; do + network_get_device dev "$net" || continue + case "$dev" in ''|*[!A-Za-z0-9_.-]*) continue ;; esac + for addr in $(ip -o addr show dev "$dev" 2>/dev/null | awk '/inet6? / { split($4, a, "/"); print a[1] }'); do + case "$addr" in fe80:*|169.254.*) continue ;; esac + case "$addr" in ''|*[!0-9a-fA-F:.]*) continue ;; esac + [ "${#addr}" -le 45 ] || continue + case " $list " in *" $addr "*) continue ;; esac + list="${list:+$list, }$addr" + done + done + printf '%s\n' "$list" +} + +# 把标记区块换成当前本机地址,honk 的 LAN self-protection 检查才认 +sync_local_direct() { + begin=' # honk:local-direct-begin' + end=' # honk:local-direct-end' + grep -qxF "$begin" /etc/honk/config.dae || return 0 + grep -qxF "$end" /etc/honk/config.dae || return 0 + ips=$(local_direct_addresses) + [ -n "$ips" ] && rule=" dip($ips) && !dport(53) -> direct(must)" || rule='' + # honk-core 只认 .dae 后缀,临时文件必须带它 + temp=/etc/honk/.config.$$.dae + ( umask 077; : > "$temp" ) || return 1 + awk -v begin="$begin" -v end="$end" -v rule="$rule" ' + $0 == begin { print; if (rule != "") print rule; skip = 1; next } + skip && $0 == end { print; skip = 0; next } + skip { next } + { print } + ' /etc/honk/config.dae > "$temp" || { rm -f "$temp"; return 1; } + cmp -s "$temp" /etc/honk/config.dae && { rm -f "$temp"; return 0; } + "$HONK_CORE" config validate --config "$temp" >/dev/null 2>&1 || { rm -f "$temp"; return 1; } + chmod 0600 "$temp" + mv -f "$temp" /etc/honk/config.dae || { rm -f "$temp"; return 1; } +} + +check_config() { + [ "$config_file" = /etc/honk/config.dae ] || { fail "配置路径必须为 /etc/honk/config.dae"; return 1; } + [ -f /etc/honk/config.dae ] && [ ! -L /etc/honk/config.dae ] || { fail "/etc/honk/config.dae 不是普通文件(可能被替换成软链)"; return 1; } + case "$config_mode" in + manual) + "$HONK_CORE" config validate --config /etc/honk/config.dae >/dev/null || { fail "手动模式的 config.dae 未通过静态校验,请先修正配置"; return 1; } + return 0 + ;; + managed) ;; + *) fail "配置模式取值非法(应为 managed 或 manual)"; return 1 ;; + esac + [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] || { fail "/etc/honk/system.dae 不是普通文件(可能被替换成软链)"; return 1; } + system_config_readonly || { fail "/etc/honk/system.dae 权限不对,应为 0600"; return 1; } + [ -r /usr/share/doona/index.html ] || { fail "缺少 Doona 静态文件(/usr/share/doona/index.html),请重新安装 luci-app-honk"; return 1; } + case "$listen_port" in + ''|*[!0-9]*) fail "监听端口必须是数字"; return 1 ;; + esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || { fail "监听端口必须在 1024-65535 之间"; return 1; } + valid_clash_api || { fail "Clash API 设置不合法(端口 1024-65535,密钥 6-128 位)"; return 1; } +} + +check_kernel() { + kernel=$(uname -r | cut -d- -f1) + major=$(printf '%s' "$kernel" | cut -d. -f1) + minor=$(printf '%s' "$kernel" | cut -d. -f2) + [ "$major" -gt 6 ] || { [ "$major" -eq 6 ] && [ "$minor" -ge 12 ]; } || { fail "内核版本过低,需要 Linux 6.12 或更新"; return 1; } + [ -r /sys/kernel/btf/vmlinux ] || { fail "内核缺少 BTF(/sys/kernel/btf/vmlinux),请换用带 BTF 的固件"; return 1; } + [ -d /sys/fs/bpf ] && grep -q ' /sys/fs/bpf bpf ' /proc/mounts || { fail "bpffs 未挂载到 /sys/fs/bpf,请检查内核或手工挂载"; return 1; } + command -v ip >/dev/null 2>&1 || { fail "缺少 ip 命令(装 ip-full)"; return 1; } + command -v modprobe >/dev/null 2>&1 || { fail "缺少 modprobe 命令(装 kmod)"; return 1; } + [ -r /sys/kernel/btf/vmlinux ] || { fail "内核缺少 BTF(/sys/kernel/btf/vmlinux),请换用带 BTF 的固件"; return 1; } + for module in sch_ingress cls_bpf veth nft_queue nfnetlink_queue; do + module_available "$module" || { fail "缺少内核模块 $module,请确认 kmod-sched-core / kmod-sched-bpf / kmod-veth / kmod-nft-queue 已安装"; return 1; } + done +} + +check_conflicts() { + for process in dae daed clashoo mihomo sing-box; do + pidof "$process" >/dev/null 2>&1 && { fail "检测到冲突进程 $process 正在运行,请先停掉它(多代理同时开会互相抢流量)"; return 1; } + done + for service in dae daed clashoo mihomo sing-box; do + if [ -x "/etc/init.d/$service" ] && "/etc/init.d/$service" running >/dev/null 2>&1; then + fail "检测到冲突服务 $service 正在运行,请先停用它(多代理同时开会互相抢流量)" + return 1 + fi + done +} + +maintenance_value() { + awk -F= -v key="$1" '$1 == key { value = substr($0, length($1) + 2); count++ } END { if (count == 1) print value; else exit 1 }' "$2" +} + +proc_starttime() { + [ -r "/proc/$1/stat" ] || return 1 + awk '{ sub(/^.*\) /, ""); split($0, fields, /[[:space:]]+/); if (fields[20] ~ /^[0-9]+$/) print fields[20]; else exit 1 }' "/proc/$1/stat" 2>/dev/null +} + +protected_root_file() { + [ -f "$1" ] && [ ! -L "$1" ] || return 1 + protected_permissions=$(ls -ln "$1" 2>/dev/null | awk 'NR == 1 { print $1 ":" $3 ":" $4 }') + [ "$protected_permissions" = '-rw-------:0:0' ] +} + +consume_maintenance_capability() { + [ -n "${HONK_MAINT_CAP:-}" ] || return 1 + [ -d "$HONK_MAINT_DIR" ] && [ ! -L "$HONK_MAINT_DIR" ] || return 1 + protected_root_file "$HONK_MAINT_JOURNAL" && protected_root_file "$HONK_MAINT_CAP_FILE" || return 1 + awk -F= 'BEGIN { split("format nonce txn_id phase owner_pid owner_starttime", keys, " ") } NR > 6 || index($0, "=") == 0 || $1 != keys[NR] { exit 1 } END { if (NR != 6) exit 1 }' "$HONK_MAINT_CAP_FILE" || return 1 + consumed_cap=$HONK_MAINT_DIR/.capability-consumed.$$ + [ ! -e "$consumed_cap" ] || return 1 + mv "$HONK_MAINT_CAP_FILE" "$consumed_cap" 2>/dev/null || return 1 + # Chain with any existing EXIT trap instead of overwriting it. + _prev_exit_trap=$(trap -p EXIT | sed "s/^trap -- '//;s/' EXIT$//") + trap 'rm -f "$consumed_cap"; [ -n "$_prev_exit_trap" ] && eval "$_prev_exit_trap"' 0 + cap_format=$(maintenance_value format "$consumed_cap") || return 1 + cap_nonce=$(maintenance_value nonce "$consumed_cap") || return 1 + cap_txn=$(maintenance_value txn_id "$consumed_cap") || return 1 + cap_phase=$(maintenance_value phase "$consumed_cap") || return 1 + cap_pid=$(maintenance_value owner_pid "$consumed_cap") || return 1 + cap_starttime=$(maintenance_value owner_starttime "$consumed_cap") || return 1 + journal_format=$(maintenance_value format "$HONK_MAINT_JOURNAL") || return 1 + journal_operation=$(maintenance_value operation "$HONK_MAINT_JOURNAL") || return 1 + journal_phase=$(maintenance_value phase "$HONK_MAINT_JOURNAL") || return 1 + journal_txn=$(maintenance_value txn_id "$HONK_MAINT_JOURNAL") || return 1 + case "$cap_nonce:$cap_txn:$cap_pid:$cap_starttime" in *[!0-9a-f:]*|::*|:|'' ) return 1 ;; esac + case "$cap_nonce" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#cap_nonce}" -eq 32 ] && [ "${#cap_txn}" -eq 32 ] || return 1 + [ "$cap_format" = 1 ] && [ "$journal_format" = 1 ] || return 1 + case "$cap_pid:$cap_starttime" in *[!0-9:]*) return 1 ;; esac + [ "$cap_pid" -gt 1 ] && [ "$cap_starttime" -gt 0 ] || return 1 + [ "$HONK_MAINT_CAP" = "$cap_nonce" ] && [ "$cap_txn" = "$journal_txn" ] || return 1 + case "$cap_phase:$journal_operation:$journal_phase" in + candidate_health_start:restore:new_installed|candidate_health_start:import_apply:new_installed|rollback_old_start:restore:rollback_old_start|rollback_old_start:reset:rollback_old_start|rollback_old_start:import_apply:rollback_old_start) ;; + *) return 1 ;; + esac + read -r lock_pid lock_starttime < "$HONK_LOCK_DIR/pid" || return 1 + [ "$lock_pid:$lock_starttime" = "$cap_pid:$cap_starttime" ] || return 1 + [ "$(proc_starttime "$cap_pid")" = "$cap_starttime" ] || return 1 + return 0 +} + +maintenance_start_guard() { + if [ -e "$HONK_MAINT_JOURNAL" ] || [ -L "$HONK_MAINT_JOURNAL" ]; then + consume_maintenance_capability || { fail "maintenance_recovery_required"; return 1; } + elif [ -n "${HONK_MAINT_CAP:-}" ]; then + fail "maintenance_capability_without_journal" + return 1 + fi + if lock_owner_invalid || honk_lock_is_stale; then + # The recorded owner is gone, so nothing is in flight. Reclaim the lock + # instead of refusing every later start: a caller that timed out cannot + # release it, and leaving it wedges the service until someone SSHes in. + # A live lock still blocks below through honk_lock_acquire. + logger -t honk "reclaiming stale operation lock" 2>/dev/null || : + rm -rf "$HONK_LOCK_DIR" + fi +} + +lock_owner_invalid() { + [ -d "$HONK_LOCK_DIR" ] || return 1 + lock_owner=$(cat "$HONK_LOCK_DIR/pid" 2>/dev/null) + # pid file now stores "pid starttime"; only the leading PID matters here. + lock_owner=${lock_owner%% *} + case "$lock_owner" in ''|*[!0-9]*) return 0 ;; esac + return 1 +} + +preflight() { + load_settings + [ "$enabled" = 1 ] && [ "$initialized" = 1 ] || { fail "请先在 LuCI 里启用并完成初始化"; return 1; } + if [ "$config_mode" = manual ]; then + maintenance_start_guard && check_config && check_kernel && no_honk_resources && check_conflicts + return $? + fi + maintenance_start_guard && check_config && check_network && check_kernel && no_honk_resources && check_conflicts && check_port && { + [ "$clash_api_enabled" = 0 ] || check_port "$clash_api_port" + } +} + +prepare_runtime() { + preflight || return 1 + for module in sch_ingress cls_bpf veth nft_queue nfnetlink_queue; do + module_loaded "$module" || modprobe "$module" || { fail "无法加载内核模块 $module,请确认对应 kmod 包已安装"; return 1; } + done + mkdir -p /etc/honk/state || { fail "无法创建状态目录(/etc/honk/state),请检查磁盘空间与权限"; return 1; } + chmod 0700 /etc/honk/state || return 1 +} + +valid_username() { + case "$1" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + esac + [ "$(printf '%s' "$1" | wc -c)" -le 64 ] +} + +valid_password() { + password_bytes=$(printf '%s' "$1" | wc -c) + [ "$password_bytes" -ge 8 ] && [ "$password_bytes" -le 512 ] +} + +read_credentials() { + json_file=$(mktemp /tmp/honk-credentials.XXXXXX) || return 1 + chmod 0600 "$json_file" || { rm -f "$json_file"; return 1; } + cat > "$json_file" || { rm -f "$json_file"; return 1; } + json_bytes=$(wc -c < "$json_file") + [ "$json_bytes" -le 2048 ] || { rm -f "$json_file"; return 1; } + grep -Fq '\u0000' "$json_file" && { rm -f "$json_file"; return 1; } + newline=' +' + username_raw=$(jsonfilter -i "$json_file" -e '@.username' 2>/dev/null; printf '__HNK_END__') + password_raw=$(jsonfilter -i "$json_file" -e '@.password' 2>/dev/null; printf '__HNK_END__') + username=${username_raw%__HNK_END__} + password=${password_raw%__HNK_END__} + username=${username%"$newline"} + password=${password%"$newline"} + rm -f "$json_file" + [ -n "$username" ] && [ -n "$password" ] || return 1 + valid_username "$username" && valid_password "$password" +} + +network_settings() { + . /lib/functions/network.sh + network_get_device lan_device "$lan_network" || return 1 + network_get_ipaddr lan_ip "$lan_network" || return 1 + case "$lan_device" in + ''|*[!A-Za-z0-9_.-]*) return 1 ;; + esac + [ "$(printf '%s' "$lan_device" | wc -c)" -le 15 ] || return 1 + printf '%s\n' "$lan_ip" | awk -F. 'NF == 4 { for (i=1;i<=4;i++) if ($i !~ /^[0-9]+$/ || $i > 255) exit 1; exit 0 } { exit 1 }' || return 1 +} + +ensure_loopback_system() { + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "初始化期间 system.dae 被其他进程改动,已中止"; return 1; } + current_listen=$(sed -n "s/^[[:space:]]*listen:[[:space:]]*'\([^']*\)'.*/\1/p" /etc/honk/system.dae | head -n 1) + [ "$current_listen" = "127.0.0.1:$listen_port" ] && return 0 + temp_system=$(mktemp /etc/honk/.system.dae.XXXXXX) || return 1 + sed "s/^[[:space:]]*listen:[[:space:]]*'[^']*'.*/ listen: '127.0.0.1:$listen_port'/" /etc/honk/system.dae > "$temp_system" || { rm -f "$temp_system"; return 1; } + chmod 0600 "$temp_system" || { rm -f "$temp_system"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_system"; fail "初始化期间 system.dae 被其他进程改动,已中止"; return 1; } + mv -f "$temp_system" /etc/honk/system.dae || { rm -f "$temp_system"; return 1; } + system_written_hash=$(sha256sum /etc/honk/system.dae | awk '{print $1}') +} + +restore_system() { + [ -n "$system_written_hash" ] || return 0 + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "回滚期间 system.dae 被其他进程改动,已中止"; return 1; } + [ "$current_hash" != "$original_system_hash" ] || { system_written_hash=""; return 0; } + if [ -z "$original_system_hash" ]; then + rm -f /etc/honk/system.dae || return 1 + system_written_hash="" + return 0 + fi + temp_restore=$(mktemp /etc/honk/.system.dae.restore.XXXXXX) || return 1 + cp -p "$old_system" "$temp_restore" || { rm -f "$temp_restore"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_restore"; fail "回滚期间 system.dae 被其他进程改动,已中止"; return 1; } + mv -f "$temp_restore" /etc/honk/system.dae || { rm -f "$temp_restore"; return 1; } + system_written_hash="" +} + +cleanup_initialize() { + if mock_stop >/dev/null 2>&1; then + if [ -n "$system_written_hash" ] && ! restore_system; then + logger -t honk "system config rollback refused or failed; inspect /etc/honk/system.dae" + fi + rm -rf "$temp_dir" + honk_lock_release >/dev/null 2>&1 + else + logger -t honk "mock process cleanup failed; keeping operation lock and temporary files" + fi +} + +write_runtime_system() { + lan_device=$1 + lan_ip=$2 + system_port=${3:-$listen_port} + system_listen=${4:-$lan_ip} + valid_clash_api || { fail "Clash API 设置不合法(端口 1024-65535,密钥 6-128 位)"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { fail "写入运行配置时 system.dae 被其他进程改动,已中止"; return 1; } + temp_system=$(mktemp /etc/honk/.system.dae.XXXXXX) || return 1 + { + printf "global {\n wan_interface: auto\n lan_interface: '%s'\n" "$lan_device" + printf " log_level: info\n dial_mode: domain\n allow_insecure: false\n" + printf " auto_config_kernel_parameter: true\n data_dir: '/etc/honk'\n" + printf " store_subscribe: true\n nfqueue_enable: true\n}\n\n" + printf "experimental {\n native_api {\n enabled: true\n listen: '%s:%s'\n" "$system_listen" "$system_port" + printf " password_auth: true\n allow_anonymous_loopback: false\n" + printf " config_write: true\n ui: '/usr/share/doona'\n }\n" + if [ "$clash_api_enabled" = 1 ]; then + printf " clash_api {\n external_controller: '%s:%s'\n external_ui: '/usr/share/honk/zashboard'\n secret: '%s'\n default_mode: Rule\n }\n" "$system_listen" "$clash_api_port" "$clash_api_secret" + fi + printf "}\n" + } > "$temp_system" || { rm -f "$temp_system"; return 1; } + chmod 0600 "$temp_system" || { rm -f "$temp_system"; return 1; } + current_hash=$(sha256sum /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$current_hash" = "$system_written_hash" ] || { rm -f "$temp_system"; fail "写入运行配置时 system.dae 被其他进程改动,已中止"; return 1; } + mv -f "$temp_system" /etc/honk/system.dae || { rm -f "$temp_system"; return 1; } + system_written_hash=$(sha256sum /etc/honk/system.dae | awk '{print $1}') +} + +managed_core_pid() { + honk_lifecycle_managed_pid +} + +configured_system_port() { + sed -n "s/^[[:space:]]*listen:[[:space:]]*'[^:]*:\([0-9][0-9]*\)'.*/\1/p" /etc/honk/system.dae 2>/dev/null | head -n 1 +} + +configured_clash_port() { + sed -n "s/^[[:space:]]*external_controller:[[:space:]]*'[^:]*:\([0-9][0-9]*\)'.*/\1/p" /etc/honk/system.dae 2>/dev/null | head -n 1 +} + +sync_boot_link() { + if [ "$1" = 1 ]; then + /etc/init.d/honk enable + else + /etc/init.d/honk disable + fi +} + +restore_old_runtime() { + [ "$old_running" = 1 ] || return 0 + runtime_enabled=$(uci -q get honk.main.enabled || echo 0) + if [ "$runtime_enabled" != 1 ]; then uci set honk.main.enabled=1 && uci commit honk || return 1; fi + honk_lifecycle_start 30 || { + [ "$runtime_enabled" = 1 ] || { uci set "honk.main.enabled=$runtime_enabled" && uci commit honk; } + return 1 + } + if [ "$runtime_enabled" != 1 ]; then uci set "honk.main.enabled=$runtime_enabled" && uci commit honk || return 1; fi +} + +settings_rollback() { + rollback_ok=1 + honk_lifecycle_stop 30 || return 1 + if [ -n "$system_written_hash" ] && ! restore_system; then rollback_ok=0; fi + if ! uci import honk < "$old_uci" || ! uci commit honk; then rollback_ok=0; fi + if ! sync_boot_link "$old_boot_enabled"; then rollback_ok=0; fi + restore_old_runtime || rollback_ok=0 + [ "$rollback_ok" = 1 ] +} + +settings() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + load_settings + [ "$config_mode" = manual ] && { echo '{"ok":false,"message":"settings_unavailable_in_manual_mode"}'; return 1; } + . /usr/share/libubox/jshn.sh || { echo '{"ok":false,"message":"jshn_unavailable"}'; return 1; } + # Bound stdin to 64KiB (DoS protection); read one extra byte to detect truncation. + settings_json=$(head -c 65537) + if [ "${#settings_json}" -gt 65536 ]; then + echo '{"ok":false,"message":"settings_too_large"}'; return 1 + fi + json_load "$settings_json" || { echo '{"ok":false,"message":"invalid_settings_json"}'; return 1; } + json_get_var new_network lan_network + json_get_type network_type lan_network + json_get_var new_port listen_port + json_get_type port_type listen_port + json_get_var new_boot_enabled boot_enabled + json_get_type boot_enabled_type boot_enabled + json_get_var new_clash_enabled clash_api_enabled + json_get_type clash_enabled_type clash_api_enabled + json_get_var new_clash_port clash_api_port + json_get_type clash_port_type clash_api_port + json_get_var new_clash_secret clash_api_secret + json_get_type clash_secret_type clash_api_secret + [ "$network_type" = string ] || { echo '{"ok":false,"message":"invalid_lan_network"}'; return 1; } + case "$port_type" in int|integer) ;; *) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + # boot_enabled 已内置(恒为 1),前端不再提交,缺省就按当前值处理 + case "$boot_enabled_type" in ''|null|boolean) ;; *) echo '{"ok":false,"message":"invalid_boolean_settings"}'; return 1 ;; esac + case "$clash_enabled_type" in ''|null|boolean) ;; *) echo '{"ok":false,"message":"invalid_clash_api_settings"}'; return 1 ;; esac + case "$clash_port_type" in ''|null|int|integer) ;; *) echo '{"ok":false,"message":"invalid_clash_api_settings"}'; return 1 ;; esac + case "$clash_secret_type" in ''|null|string) ;; *) echo '{"ok":false,"message":"invalid_clash_api_settings"}'; return 1 ;; esac + case "$new_network" in ''|*[!A-Za-z0-9_.-]*) echo '{"ok":false,"message":"invalid_lan_network"}'; return 1 ;; esac + case "$new_port" in ''|*[!0-9]*) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + [ "$new_port" -ge 1024 ] && [ "$new_port" -le 65535 ] || { echo '{"ok":false,"message":"invalid_listen_port"}'; return 1; } + load_settings + case "$boot_enabled_type" in ''|null) new_boot_enabled=1 ;; esac + new_boot_enabled=1 + case "$clash_enabled_type" in ''|null) new_clash_enabled=$clash_api_enabled ;; esac + case "$clash_port_type" in ''|null) new_clash_port=$clash_api_port ;; esac + [ -n "$new_clash_secret" ] || new_clash_secret=$clash_api_secret + new_enabled=$enabled + old_network=$lan_network + old_port=$listen_port + old_enabled=$enabled + old_boot_enabled=$boot_enabled + old_initialized=$initialized + old_clash_enabled=$clash_api_enabled + old_clash_port=$clash_api_port + old_clash_secret=$clash_api_secret + [ "$initialized" = 1 ] || { [ "$new_enabled" = 0 ] || { echo '{"ok":false,"message":"initialize_before_enabling_service"}'; return 1; }; } + lan_network=$new_network + listen_port=$new_port + clash_api_enabled=$new_clash_enabled + clash_api_port=$new_clash_port + clash_api_secret=$new_clash_secret + valid_clash_api || { echo '{"ok":false,"message":"invalid_clash_api_settings"}'; return 1; } + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + old_running=0 + if managed_core_pid >/dev/null 2>&1; then + old_running=1 + elif pidof honk-core >/dev/null 2>&1; then + echo '{"ok":false,"message":"unmanaged_core_running"}' + return 1 + fi + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$new_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$clash_api_enabled" = 1 ] && ! { [ "$old_running" = 1 ] && [ "$(configured_clash_port)" = "$new_clash_port" ]; }; then + check_port "$new_clash_port" || { echo '{"ok":false,"message":"clash_api_port_occupied"}'; return 1; } + fi + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + load_settings + [ "$lan_network" = "$old_network" ] && [ "$listen_port" = "$old_port" ] && [ "$enabled" = "$old_enabled" ] && [ "$boot_enabled" = "$old_boot_enabled" ] && [ "$initialized" = "$old_initialized" ] && [ "$clash_api_enabled" = "$old_clash_enabled" ] && [ "$clash_api_port" = "$old_clash_port" ] && [ "$clash_api_secret" = "$old_clash_secret" ] || { echo '{"ok":false,"message":"settings_changed_concurrently"}'; return 1; } + new_enabled=$enabled + managed_core_pid >/dev/null 2>&1 && recheck_running=1 || recheck_running=0 + [ "$recheck_running" = "$old_running" ] || { echo '{"ok":false,"message":"service_state_changed_concurrently"}'; return 1; } + old_running=$recheck_running + lan_network=$new_network + listen_port=$new_port + clash_api_enabled=$new_clash_enabled + clash_api_port=$new_clash_port + clash_api_secret=$new_clash_secret + check_network && network_settings || { echo '{"ok":false,"message":"lan_network_changed_during_settings"}'; return 1; } + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$new_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$clash_api_enabled" = 1 ] && ! { [ "$old_running" = 1 ] && [ "$(configured_clash_port)" = "$new_clash_port" ]; }; then + check_port "$new_clash_port" || { echo '{"ok":false,"message":"clash_api_port_occupied"}'; return 1; } + fi + if [ "$initialized" = 1 ] && ! check_config; then echo '{"ok":false,"message":"package_config_unavailable"}'; return 1; fi + umask 077 + settings_dir=$(mktemp -d /tmp/honk-settings.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + old_uci=$settings_dir/honk.uci + uci export honk > "$old_uci" || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"uci_snapshot_failed"}'; return 1; } + old_system=$settings_dir/system.dae + system_written_hash="" + if [ "$initialized" = 1 ]; then + [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_config_unavailable"}'; return 1; } + cp -p /etc/honk/system.dae "$old_system" || { rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_snapshot_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + system_written_hash=$original_system_hash + fi + if [ "$old_running" = 1 ]; then + honk_lifecycle_stop 30 || { + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"service_stop_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"service_stop_and_rollback_failed_snapshot_retained"}'; fi + return 1 + } + fi + if [ "$initialized" = 1 ] && ! write_runtime_system "$lan_device" "$lan_ip" "$new_port"; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"system_config_write_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"system_config_write_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + uci set "honk.main.lan_network=$new_network" && uci set "honk.main.listen_port=$new_port" && uci set "honk.main.enabled=$new_enabled" && uci set "honk.main.boot_enabled=$new_boot_enabled" && uci set "honk.main.clash_api_enabled=$new_clash_enabled" && uci set "honk.main.clash_api_port=$new_clash_port" && uci set "honk.main.clash_api_secret=$new_clash_secret" && uci commit honk && chmod 0600 /etc/config/honk || { + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"settings_commit_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"settings_commit_and_rollback_failed_snapshot_retained"}'; fi + return 1 + } + if ! sync_boot_link "$new_boot_enabled"; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"boot_link_update_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"boot_link_update_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + if [ "$initialized" = 1 ]; then + service_ok=0 + if [ "$new_enabled" = 1 ]; then + honk_lifecycle_start 30 || service_ok=1 + else + honk_lifecycle_stop 30 || service_ok=1 + fi + if [ "$service_ok" != 0 ]; then + settings_rollback; rollback_status=$? + if [ "$rollback_status" = 0 ]; then rm -rf "$settings_dir"; echo '{"ok":false,"message":"service_apply_failed_rolled_back"}'; else logger -t honk "settings rollback failed; snapshot retained at $settings_dir"; echo '{"ok":false,"message":"service_apply_and_rollback_failed_snapshot_retained"}'; fi + return 1 + fi + fi + system_written_hash="" + rm -rf "$settings_dir" + echo '{"ok":true,"message":"settings_saved"}' +} + +diagnose() { + load_settings + errors="" + managed_core_pid >/dev/null 2>&1 && running=1 || running=0 + check_config >/dev/null 2>&1 || errors="$errors invalid_package_config" + check_kernel >/dev/null 2>&1 || errors="$errors kernel_requirements_unmet" + if [ "$config_mode" = manual ]; then + if [ "$running" != 1 ]; then + no_honk_resources >/dev/null 2>&1 || errors="$errors honk_resources_occupied" + pidof honk-core >/dev/null 2>&1 && errors="$errors unmanaged_core_running" + fi + else + check_network >/dev/null 2>&1 || errors="$errors lan_network_unavailable" + network_settings >/dev/null 2>&1 || errors="$errors lan_ipv4_unavailable" + command -v curl >/dev/null 2>&1 || errors="$errors curl_missing" + if [ "$running" != 1 ]; then + no_honk_resources >/dev/null 2>&1 || errors="$errors honk_resources_occupied" + pidof honk-core >/dev/null 2>&1 && errors="$errors unmanaged_core_running" + check_port >/dev/null 2>&1 || errors="$errors listen_port_occupied" + if [ "$clash_api_enabled" = 1 ]; then + check_port "$clash_api_port" >/dev/null 2>&1 || errors="$errors clash_api_port_occupied" + fi + fi + fi + check_conflicts >/dev/null 2>&1 || errors="$errors proxy_conflict" + if [ -d "$HONK_LOCK_DIR" ]; then + if lock_owner_invalid || honk_lock_is_stale; then + errors="$errors stale_operation_lock maintenance_recovery_required" + else + errors="$errors operation_in_progress" + fi + fi + { [ -e "$HONK_MAINT_JOURNAL" ] || [ -L "$HONK_MAINT_JOURNAL" ]; } && errors="$errors maintenance_recovery_required" + if [ -n "$errors" ]; then + printf '%s\n' "$errors" | sed 's/^ *//' + return 1 + fi + return 0 +} + +main_has_system_include() { + awk ' + /^[[:space:]]*include[[:space:]]*\{/ { inside = 1 } + inside && /(^|[^[:alnum:]_.-])system\.dae([^[:alnum:]_.-]|$)/ { found = 1 } + inside && /\}/ { inside = 0 } + END { exit !found } + ' /etc/honk/config.dae +} + +clash_system_matches() { + if [ "$clash_api_enabled" = 0 ]; then + ! grep -q 'clash_api {' /etc/honk/system.dae + return + fi + permissions=$(ls -ldn /etc/honk/system.dae 2>/dev/null | awk '{print $1}') + [ "$permissions" = '-rw-------' ] || return 1 + grep -Fqx " external_controller: '$system_host:$clash_api_port'" /etc/honk/system.dae || return 1 + grep -Fqx " external_ui: '/usr/share/honk/zashboard'" /etc/honk/system.dae || return 1 + system_secret=$(sed -n "s/^ secret: '\([^']*\)'$/\1/p" /etc/honk/system.dae) + [ "$system_secret" = "$clash_api_secret" ] +} + +repair() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + load_settings + if [ "$config_mode" = manual ]; then + check_config && { echo '{"ok":true,"message":"manual_config_valid"}'; return 0; } + echo '{"ok":false,"message":"manual_config_invalid"}' + return 1 + fi + old_network=$lan_network + old_port=$listen_port + old_enabled=$enabled + old_boot_enabled=$boot_enabled + old_initialized=$initialized + old_clash_enabled=$clash_api_enabled + old_clash_port=$clash_api_port + old_clash_secret=$clash_api_secret + [ "$config_file" = /etc/honk/config.dae ] || { echo '{"ok":false,"message":"invalid_config_path"}'; return 1; } + [ -f /etc/honk/config.dae ] && [ ! -L /etc/honk/config.dae ] || { echo '{"ok":false,"message":"main_config_unavailable"}'; return 1; } + main_hash=$(sha256sum /etc/honk/config.dae | awk '{print $1}') + main_has_system_include || { echo '{"ok":false,"message":"required_system_include_missing_manual_repair"}'; return 1; } + valid_network_name || { echo '{"ok":false,"message":"invalid_lan_network"}'; return 1; } + case "$listen_port" in ''|*[!0-9]*) echo '{"ok":false,"message":"invalid_listen_port"}'; return 1 ;; esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || { echo '{"ok":false,"message":"invalid_listen_port"}'; return 1; } + valid_clash_api || { echo '{"ok":false,"message":"invalid_clash_api_settings"}'; return 1; } + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + old_running=0 + managed_core_pid >/dev/null 2>&1 && old_running=1 + if pidof honk-core >/dev/null 2>&1 && [ "$old_running" = 0 ]; then echo '{"ok":false,"message":"unmanaged_core_running"}'; return 1; fi + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$listen_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$clash_api_enabled" = 1 ] && ! { [ "$old_running" = 1 ] && [ "$(configured_clash_port)" = "$clash_api_port" ]; }; then + check_port "$clash_api_port" || { echo '{"ok":false,"message":"clash_api_port_occupied"}'; return 1; } + fi + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + load_settings + [ "$lan_network" = "$old_network" ] && [ "$listen_port" = "$old_port" ] && [ "$enabled" = "$old_enabled" ] && [ "$boot_enabled" = "$old_boot_enabled" ] && [ "$initialized" = "$old_initialized" ] && [ "$clash_api_enabled" = "$old_clash_enabled" ] && [ "$clash_api_port" = "$old_clash_port" ] && [ "$clash_api_secret" = "$old_clash_secret" ] || { echo '{"ok":false,"message":"settings_changed_concurrently"}'; return 1; } + managed_core_pid >/dev/null 2>&1 && recheck_running=1 || recheck_running=0 + [ "$recheck_running" = "$old_running" ] || { echo '{"ok":false,"message":"service_state_changed_concurrently"}'; return 1; } + [ "$(sha256sum /etc/honk/config.dae | awk '{print $1}')" = "$main_hash" ] || { echo '{"ok":false,"message":"main_config_changed_concurrently"}'; return 1; } + check_network && network_settings || { echo '{"ok":false,"message":"lan_network_changed_during_repair"}'; return 1; } + if ! { [ "$old_running" = 1 ] && [ "$(configured_system_port)" = "$listen_port" ]; }; then + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + fi + if [ "$clash_api_enabled" = 1 ] && ! { [ "$old_running" = 1 ] && [ "$(configured_clash_port)" = "$clash_api_port" ]; }; then + check_port "$clash_api_port" || { echo '{"ok":false,"message":"clash_api_port_occupied"}'; return 1; } + fi + if [ "$initialized" = 1 ]; then system_host=$lan_ip; else system_host=127.0.0.1; fi + if [ -r /usr/share/doona/index.html ] && [ -f /etc/honk/system.dae ] && [ ! -L /etc/honk/system.dae ] && system_config_readonly && \ + grep -Fqx ' wan_interface: auto' /etc/honk/system.dae && grep -Fqx " lan_interface: '$lan_device'" /etc/honk/system.dae && \ + grep -Fqx " data_dir: '/etc/honk'" /etc/honk/system.dae && grep -Fqx ' nfqueue_enable: true' /etc/honk/system.dae && \ + grep -Fqx " listen: '$system_host:$listen_port'" /etc/honk/system.dae && \ + grep -Fqx " ui: '/usr/share/doona'" /etc/honk/system.dae && grep -Fqx ' password_auth: true' /etc/honk/system.dae && \ + grep -Fqx ' allow_anonymous_loopback: false' /etc/honk/system.dae && grep -Fqx ' config_write: true' /etc/honk/system.dae && clash_system_matches; then + echo '{"ok":true,"message":"system_config_healthy"}' + return 0 + fi + if [ -e /etc/honk/system.dae ] && { [ ! -f /etc/honk/system.dae ] || [ -L /etc/honk/system.dae ]; }; then + echo '{"ok":false,"message":"system_config_not_regular_file"}' + return 1 + fi + [ -r /usr/share/doona/index.html ] || { echo '{"ok":false,"message":"ui_assets_missing"}'; return 1; } + umask 077 + repair_dir=$(mktemp -d /tmp/honk-repair.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + old_system=$repair_dir/system.dae + original_system_hash="" + if [ -f /etc/honk/system.dae ]; then + cp -p /etc/honk/system.dae "$old_system" || { rm -rf "$repair_dir"; echo '{"ok":false,"message":"system_snapshot_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + fi + system_written_hash=$original_system_hash + if [ "$old_running" = 1 ]; then + if ! honk_lifecycle_stop 30; then + logger -t honk "repair stop/drain unconfirmed; no restart attempted; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"service_stop_unconfirmed_snapshot_retained"}' + return 1 + fi + fi + if [ "$initialized" = 1 ]; then system_listen=$lan_ip; else system_listen=127.0.0.1; fi + if ! write_runtime_system "$lan_device" "$lan_ip" "$listen_port" "$system_listen"; then + repair_rollback_ok=1 + [ -n "$system_written_hash" ] && ! restore_system && repair_rollback_ok=0 + restore_old_runtime || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then + rm -rf "$repair_dir" + echo '{"ok":false,"message":"system_config_repair_failed_rolled_back"}' + else + logger -t honk "repair rollback failed; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"system_config_repair_rollback_failed_snapshot_retained"}' + fi + return 1 + fi + if [ "$initialized" = 1 ] && { [ "$old_running" = 1 ] || [ "$enabled" = 1 ]; }; then + if ! honk_lifecycle_start 30; then + repair_rollback_ok=1 + honk_lifecycle_stop 30 || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then restore_system || repair_rollback_ok=0; fi + restore_old_runtime || repair_rollback_ok=0 + if [ "$repair_rollback_ok" = 1 ]; then + rm -rf "$repair_dir" + echo '{"ok":false,"message":"repair_restart_failed_rolled_back"}' + else + logger -t honk "repair restart rollback failed; snapshot retained at $repair_dir" + echo '{"ok":false,"message":"repair_restart_and_rollback_failed_snapshot_retained"}' + fi + return 1 + fi + fi + system_written_hash="" + rm -rf "$repair_dir" + echo '{"ok":true,"message":"system_config_repaired"}' +} + +api_discovery() { + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 3 "$1/api" -o "$2" +} + +mock_start() { + mock_config=$1 + mock_log=$2 + DAE_LOCATION_ASSET=/usr/share/v2ray "$HONK_CORE" --config "$mock_config" --mock-ebpf > "$mock_log" 2>&1 & + mock_pid=$! + mock_identity="" + attempt=0 + while [ "$attempt" -lt 20 ]; do + if ! kill -0 "$mock_pid" 2>/dev/null; then + return 1 + fi + if api_discovery "$3" "$4" 2>/dev/null; then + mock_identity=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) + case "$mock_identity" in + *"$mock_config"*--mock-ebpf*) + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] && return 0 + ;; + esac + return 1 + fi + sleep 1 + attempt=$((attempt + 1)) + done + return 1 +} + +mock_stop() { + [ -n "$mock_pid" ] || return 0 + [ -r "/proc/$mock_pid/cmdline" ] || { mock_pid=""; return 0; } + current=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) + case "$current" in + *"$mock_config"*--mock-ebpf*) + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + kill -TERM "$mock_pid" 2>/dev/null || return 1 + ;; + *) return 1 ;; + esac + attempt=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$attempt" -lt 10 ]; do sleep 1; attempt=$((attempt + 1)); done + if kill -0 "$mock_pid" 2>/dev/null; then return 1; fi + wait "$mock_pid" 2>/dev/null || true + mock_pid="" +} + +post_credentials() { + endpoint=$1 + response_file=$2 + json_init + json_add_string username "$username" + json_add_string password "$password" + json_dump | curl -sS --noproxy '*' --connect-timeout 1 --max-time 5 -o "$response_file" -w '%{http_code}' \ + -H 'Content-Type: application/json' --data-binary @- "$endpoint" +} + +initialize_preflight() { + load_settings + [ "$enabled" = 0 ] && [ "$initialized" = 0 ] || { echo '{"ok":false,"message":"disable_service_before_initialization"}'; return 1; } + [ "$config_file" = /etc/honk/config.dae ] || { echo '{"ok":false,"message":"invalid_config_path"}'; return 1; } + [ -r /usr/share/doona/index.html ] || { echo '{"ok":false,"message":"ui_assets_missing"}'; return 1; } + if [ "$1" = read_credentials ]; then + read_credentials || { echo '{"ok":false,"message":"invalid_credentials"}'; return 1; } + fi + [ -x "$HONK_CORE" ] || { echo '{"ok":false,"message":"core_missing"}'; return 1; } + command -v curl >/dev/null 2>&1 || { echo '{"ok":false,"message":"curl_missing"}'; return 1; } + pidof honk-core >/dev/null 2>&1 && { echo '{"ok":false,"message":"core_already_running"}'; return 1; } + no_honk_resources || { echo '{"ok":false,"message":"honk_resources_already_exist"}'; return 1; } + check_port || { echo '{"ok":false,"message":"listen_port_occupied"}'; return 1; } + check_network || { echo '{"ok":false,"message":"lan_network_unavailable"}'; return 1; } + network_settings || { echo '{"ok":false,"message":"lan_ipv4_unavailable"}'; return 1; } + check_config || { echo '{"ok":false,"message":"invalid_package_config"}'; return 1; } + return 0 +} + +initialize() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then echo '{"ok":false,"message":"update_recovery_required"}'; return 1; fi + . /usr/share/libubox/jshn.sh || return 1 + initialize_preflight read_credentials || return 1 + honk_lock_acquire || { echo '{"ok":false,"message":"operation_in_progress"}'; return 1; } + trap 'honk_lock_release >/dev/null 2>&1' 0 + initialize_preflight || return 1 + + umask 077 + temp_dir=$(mktemp -d /tmp/honk-init.XXXXXX) || { echo '{"ok":false,"message":"temporary_storage_unavailable"}'; return 1; } + mock_pid="" + trap cleanup_initialize 0 + trap 'exit 1' HUP INT TERM + old_system=$temp_dir/system.dae + cp -p /etc/honk/system.dae "$old_system" || { echo '{"ok":false,"message":"system_config_backup_failed"}'; return 1; } + original_system_hash=$(sha256sum "$old_system" | awk '{print $1}') + system_written_hash=$original_system_hash + ensure_loopback_system || { echo '{"ok":false,"message":"loopback_listener_write_failed"}'; return 1; } + mock_config=$temp_dir/mock.dae + mock_log=$temp_dir/core.log + response_file=$temp_dir/response.json + cat > "$mock_config" </dev/null || true + loopback_url="http://127.0.0.1:$listen_port" + if ! mock_start "$mock_config" "$mock_log" "$loopback_url" "$response_file"; then + echo '{"ok":false,"message":"mock_core_start_failed"}' + return 1 + fi + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + case "$setup_required" in + true) + status=$(post_credentials "$loopback_url/api/v1/auth/setup" "$response_file") + [ "$status" = 201 ] || { echo '{"ok":false,"message":"administrator_setup_failed"}'; return 1; } + ;; + false) + status=$(post_credentials "$loopback_url/api/v1/auth/login" "$response_file") + [ "$status" = 200 ] || { echo '{"ok":false,"message":"administrator_exists_reuse_credentials_or_reset_state"}'; return 1; } + ;; + *) echo '{"ok":false,"message":"native_api_setup_state_unavailable"}'; return 1 ;; + esac + api_discovery "$loopback_url" "$response_file" || { echo '{"ok":false,"message":"setup_verification_failed"}'; return 1; } + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + [ "$setup_required" = false ] || { echo '{"ok":false,"message":"setup_verification_failed"}'; return 1; } + mock_stop || { echo '{"ok":false,"message":"mock_core_stop_failed"}'; return 1; } + if ! mock_start "$mock_config" "$mock_log" "$loopback_url" "$response_file"; then + echo '{"ok":false,"message":"mock_core_restart_failed"}' + return 1 + fi + api_discovery "$loopback_url" "$response_file" || { echo '{"ok":false,"message":"persistence_verification_failed"}'; return 1; } + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + [ "$setup_required" = false ] || { echo '{"ok":false,"message":"persistence_verification_failed"}'; return 1; } + status=$(post_credentials "$loopback_url/api/v1/auth/login" "$response_file") + [ "$status" = 200 ] || { echo '{"ok":false,"message":"persistence_login_failed"}'; return 1; } + mock_stop || { echo '{"ok":false,"message":"mock_core_stop_failed"}'; return 1; } + + write_runtime_system "$lan_device" "$lan_ip" || { restore_system; echo '{"ok":false,"message":"system_config_write_failed"}'; return 1; } + lan_url="http://$lan_ip:$listen_port" + if ! mock_start /etc/honk/config.dae "$mock_log" "$lan_url" "$response_file"; then + restore_system + echo '{"ok":false,"message":"generated_config_invalid_restored_loopback"}' + return 1 + fi + api_discovery "$lan_url" "$response_file" || { + mock_stop >/dev/null 2>&1 + restore_system + echo '{"ok":false,"message":"lan_api_verification_failed_restored_loopback"}' + return 1 + } + mock_stop || { + restore_system + echo '{"ok":false,"message":"mock_core_stop_failed_restored_loopback"}' + return 1 + } + uci set honk.main.initialized='1' && uci commit honk || { + restore_system + uci set honk.main.initialized='0' >/dev/null 2>&1 + uci commit honk >/dev/null 2>&1 + echo '{"ok":false,"message":"uci_commit_failed_restored_loopback"}' + return 1 + } + system_written_hash="" + password="" + username="" + echo '{"ok":true,"message":"administrator_initialized"}' +} + +case "$1" in + check) + preflight + ;; + diagnose) + diagnose + ;; + preflight) + prepare_runtime + ;; + local-direct) + load_settings + sync_local_direct + ;; + initialize) + initialize + ;; + settings) + settings + ;; + repair) + repair + ;; + *) + echo "usage: $0 {check|diagnose|preflight|local-direct|initialize|settings|repair}" >&2 + exit 2 + ;; +esac diff --git a/honk/files/honk.config b/honk/files/honk.config index f30d297c..2bc9c858 100644 --- a/honk/files/honk.config +++ b/honk/files/honk.config @@ -1,7 +1,15 @@ - -config honk 'config' +config honk 'main' option enabled '0' - option advanced '0' - option dashboard 'none' + option boot_enabled '0' + option initialized '0' + option config_mode 'managed' option config_file '/etc/honk/config.dae' - + option listen_port '9527' + option clash_api_enabled '0' + option clash_api_port '9090' + option clash_api_secret '' + option lan_network 'lan' + option geoip_url '' + option geosite_url '' + option geo_auto '0' + option geo_auto_freq 'daily' diff --git a/honk/files/honk.init b/honk/files/honk.init old mode 100644 new mode 100755 index 386a2a8b..073fedcc --- a/honk/files/honk.init +++ b/honk/files/honk.init @@ -1,124 +1,85 @@ #!/bin/sh /etc/rc.common USE_PROCD=1 -START=96 +START=95 STOP=10 -extra_command "hot_reload" "Hot-reload service" +. /lib/functions.sh -CONF="honk" -PROG="/usr/bin/honk-core" -# honk-core hardcodes dae-compatible datapath names (dae0/dae0peer + daens); -# these are the real interface/netns names, not leftover dae config. -HONK_HOST_IFACE="dae0" -HONK_PEER_IFACE="dae0peer" -HONK_NETNS_NAME="daens" -HONK_NETNS_PATH="/run/netns/$HONK_NETNS_NAME" -RESOLV_HONK="/tmp/resolv.conf.honk" +HONK_CONTROL=/usr/share/honk/control.sh +HONK_CONFIG=/etc/honk/config.dae -set_chmod_640() { - mkdir -p /etc/honk/config.d /etc/honk/state /var/log/honk - chmod -R u=rwX,go= /etc/honk 2>/dev/null || true - chmod 700 /etc/honk /etc/honk/config.d /etc/honk/state 2>/dev/null || true -} - -cleanup_netns() { - ip netns del "$HONK_NETNS_NAME" 2>/dev/null || true - umount "$HONK_NETNS_PATH" 2>/dev/null || true - rm -f "$HONK_NETNS_PATH" 2>/dev/null || true - ip link del "$HONK_HOST_IFACE" 2>/dev/null || true - ip link del "$HONK_PEER_IFACE" 2>/dev/null || true -} - -hijack_resolv_conf() { - grep -q ' /tmp/resolv\.conf ' /proc/mounts && return 0 - - local dns_list WANS - . /lib/functions/network.sh - unset -v __NETWORK_CACHE - __network_ifstatus WANS "" "[@.route[@.target='0.0.0.0' && !@.table]].interface" 2>/dev/null - set -- $WANS - - if [ $# -eq 1 ]; then - dns_list=$(ubus call "network.interface.$1" status 2>/dev/null | \ - jsonfilter -e '@["dns-server"][*]' 2>/dev/null) - if [ -z "$dns_list" ] && [ -f "/tmp/resolv.conf.d/resolv.conf.auto" ]; then - dns_list=$(awk '/^nameserver/{print $2}' "/tmp/resolv.conf.d/resolv.conf.auto") - fi - fi - - [ -z "$dns_list" ] && dns_list="119.29.29.29 223.5.5.5" - - [ -f /tmp/resolv.conf ] || touch /tmp/resolv.conf - grep -E '^(search|domain) ' /tmp/resolv.conf > "$RESOLV_HONK" 2>/dev/null - - for dns in $dns_list; do - echo "nameserver $dns" >> "$RESOLV_HONK" - done - - mount --bind "$RESOLV_HONK" /tmp/resolv.conf - mount -o remount,ro,bind /tmp/resolv.conf -} - -restore_resolv_conf() { - while grep -q ' /tmp/resolv\.conf ' /proc/mounts; do - mount -o remount,rw,bind /tmp/resolv.conf 2>/dev/null - umount /tmp/resolv.conf 2>/dev/null || break - done - rm -f "$RESOLV_HONK" +boot() { + # 开机自启内置:只要服务是"已启用 + 已初始化"就起来, + # 用户主动停掉(enabled=0)后重启也不会被强行拉起。 + start } start_service() { - local enabled config_file + local enabled initialized config_file config_mode ready_token ready_hash ready_runtime ready_api_enabled ready_api_listen ready_clash_api_listen ready_clash_api_secret_set old_ready_token + config_load honk + config_get_bool enabled main enabled 0 + config_get_bool initialized main initialized 0 + [ "$enabled" -eq 1 ] && [ "$initialized" -eq 1 ] || { + logger -t honk "refusing to start before explicit initialization and enablement" + return 1 + } + config_get config_file main config_file "$HONK_CONFIG" + config_get config_mode main config_mode managed + [ "$config_file" = "$HONK_CONFIG" ] || { + logger -t honk "refusing non-default config path" + return 1 + } + "$HONK_CONTROL" preflight || return 1 + "$HONK_CONTROL" local-direct || logger -t honk "local-direct sync skipped" + unset HONK_MAINT_CAP - config_load "$CONF" - config_get_bool enabled "config" "enabled" "0" - [ "$enabled" -eq "1" ] || return 1 - - config_get config_file "config" "config_file" "/etc/honk/config.dae" - - if [ ! -x "$PROG" ]; then - logger -t honk "not starting: $PROG is missing or not executable" - return 1 - fi - - set_chmod_640 - cleanup_netns - hijack_resolv_conf - - procd_open_instance "$CONF" - # mimalloc pre-reserves a 1 GiB arena; VSZ only, but it dwarfs the real - # footprint in ps. MIMALLOC_ARENA_RESERVE=0: grow on demand instead. - procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" MIMALLOC_ARENA_RESERVE="0" - procd_set_param command "$PROG" --config "$config_file" - procd_set_param netdev "$HONK_HOST_IFACE" "$HONK_PEER_IFACE" - procd_set_param limits core="unlimited" - procd_set_param limits nofile="1000000 1000000" - procd_set_param term_timeout 8 - procd_set_param respawn 3600 5 5 - procd_set_param stderr 1 - procd_close_instance -} - -service_stopped() { - restore_resolv_conf - cleanup_netns + if [ "$config_mode" = manual ]; then + ready_token=$(cat /proc/sys/kernel/random/uuid 2>/dev/null) || return 1 + case "$ready_token" in *[!0-9a-f-]*|'') return 1 ;; esac + ready_hash=$(sha256sum "$HONK_CONFIG" 2>/dev/null | awk '{print $1}') || return 1 + case "$ready_hash" in *[!0-9a-f]*|'') return 1 ;; esac + ready_runtime=$(/usr/bin/honk-core config validate --config "$HONK_CONFIG" 2>/dev/null) || return 1 + ready_api_enabled=$(printf '%s' "$ready_runtime" | jsonfilter -e '@.runtime.native_api_enabled' 2>/dev/null) + ready_api_listen=$(printf '%s' "$ready_runtime" | jsonfilter -e '@.runtime.native_api_listen' 2>/dev/null) + ready_clash_api_listen=$(printf '%s' "$ready_runtime" | jsonfilter -e '@.runtime.clash_api_listen' 2>/dev/null) + ready_clash_api_secret_set=$(printf '%s' "$ready_runtime" | jsonfilter -e '@.runtime.clash_api_secret_set' 2>/dev/null) + case "$ready_api_enabled" in true|false) ;; *) return 1 ;; esac + case "$ready_api_listen" in *[!A-Za-z0-9:._-]*) return 1 ;; esac + case "$ready_clash_api_listen" in *[!A-Za-z0-9:._-]*) return 1 ;; esac + case "$ready_clash_api_secret_set" in true|false) ;; *) return 1 ;; esac + mkdir -p /var/run/honk || return 1 + chmod 0700 /var/run/honk || return 1 + if [ -f /var/run/honk/readiness ] && [ ! -L /var/run/honk/readiness ]; then + read -r old_ready_token _ < /var/run/honk/readiness + case "$old_ready_token" in ''|*[!0-9a-f-]*) ;; *) rm -f "/var/run/honk/core-ready-$old_ready_token" ;; esac + fi + printf '%s %s\n' "$ready_token" "$ready_hash" > /var/run/honk/readiness || return 1 + chmod 0600 /var/run/honk/readiness || return 1 + fi + procd_open_instance main + procd_set_param command /usr/bin/honk-core --config "$HONK_CONFIG" --disable-timestamp + # One env call, no empty values: procd drops the whole list when a KEY has an + # empty value, which silently stripped the readiness hash the core requires. + ready_env="DAE_LOCATION_ASSET=/usr/share/v2ray" + if [ "$config_mode" = manual ]; then + ready_env="$ready_env HONK_READY_TOKEN=$ready_token HONK_READY_CONFIG_HASH=$ready_hash HONK_READY_API_ENABLED=$ready_api_enabled HONK_READY_CLASH_API_SECRET_SET=$ready_clash_api_secret_set" + [ -n "$ready_api_listen" ] && ready_env="$ready_env HONK_READY_API_LISTEN=$ready_api_listen" + [ -n "$ready_clash_api_listen" ] && ready_env="$ready_env HONK_READY_CLASH_API_LISTEN=$ready_clash_api_listen" + fi + # shellcheck disable=SC2086 + procd_set_param env $ready_env + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_set_param respawn 3600 5 5 + procd_set_param term_timeout 20 + procd_close_instance } reload_service() { - stop - start -} - -service_triggers() { - procd_add_reload_trigger "$CONF" -} - -hot_reload() { - set_chmod_640 - if /etc/init.d/honk running; then - "$PROG" reload - else - start - fi + # honk-core has no hot-reload API wired here; a reload is an explicit + # restart so existing connections are cycled. The UI only exposes + # restart, this exists so manual `/etc/init.d/honk reload` is defined. + logger -t honk "reload requested, restarting (no hot-reload)" + restart } diff --git a/honk/files/job.sh b/honk/files/job.sh new file mode 100644 index 00000000..96e0d084 --- /dev/null +++ b/honk/files/job.sh @@ -0,0 +1,127 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 +. /usr/share/libubox/jshn.sh + +JOBS=/tmp/honk-jobs +MAINT=/usr/share/honk/maintenance.sh + +write_status() { + status_state=$1 + status_phase=$2 + status_ok=$3 + status_message=$4 + # jshn 的布尔只认 1/0,传 "true" 会被写进状态文件成 false。 + case "$status_ok" in true|1) status_ok=1 ;; *) status_ok=0 ;; esac + status_tmp=$job_dir/status.new + { + json_init + json_add_string state "$status_state" + json_add_string phase "$status_phase" + json_add_boolean ok "$status_ok" + json_add_string message "$status_message" + json_dump + } > "$status_tmp" || return 1 + chmod 0600 "$status_tmp" || return 1 + mv -f "$status_tmp" "$job_dir/status" || return 1 +} + +job_id=$1 +operation=$2 +case "$job_id" in + *[!0-9a-f]*|'') exit 2 ;; +esac +[ "${#job_id}" -eq 32 ] || exit 2 +case "$operation" in backup|restore|reset|update_check|update_apply) ;; *) exit 2 ;; esac + +job_dir=$JOBS/$job_id +[ -d "$JOBS" ] && [ ! -L "$JOBS" ] && [ -d "$job_dir" ] && [ ! -L "$job_dir" ] || exit 2 +[ "$(stat -c '%u:%a' "$JOBS" 2>/dev/null)" = 0:700 ] && [ "$(stat -c '%u:%a' "$job_dir" 2>/dev/null)" = 0:700 ] || exit 2 +[ "$(id -u)" = 0 ] || exit 2 +write_status running preparing true preparing || exit 1 +: > "$job_dir/output" || exit 1 +chmod 0600 "$job_dir/output" || exit 1 +# stderr 单独存:混进 output 会让结果 JSON 解析失败,报成“操作失败”。 +: > "$job_dir/stderr" || exit 1 +chmod 0600 "$job_dir/stderr" || exit 1 +if [ "$operation" = update_apply ]; then + /usr/share/honk/update.sh apply > "$job_dir/output" 2> "$job_dir/stderr" & +elif [ "$operation" = update_check ]; then + /usr/share/honk/update.sh check > "$job_dir/output" 2> "$job_dir/stderr" & +else + "$MAINT" "$operation" > "$job_dir/output" 2> "$job_dir/stderr" & +fi +maint_pid=$! +logger -t honk-maintenance "$operation started" 2>/dev/null || : +# Total watchdog: never poll forever if maintenance.sh hangs. +_poll_deadline=$(($(date +%s) + 1800)) +while kill -0 "$maint_pid" 2>/dev/null; do + if [ "$(date +%s)" -ge "$_poll_deadline" ]; then + logger -t honk-maintenance "$operation timed out after 1800s, killing $maint_pid" 2>/dev/null || : + kill -9 "$maint_pid" 2>/dev/null || : + break + fi + journal_phase=$(sed -n 's/^phase=//p' /etc/.honk-maintenance/journal 2>/dev/null | sed -n '1p') + if [ "$operation" = update_apply ]; then + update_phase=$(cat /etc/.honk-update/phase 2>/dev/null) + case "$update_phase" in + downloading) status_phase=downloading ;; + verifying) status_phase=validating ;; + stopping) status_phase=stopping ;; + installing) status_phase=applying ;; + starting) status_phase=starting ;; + *) status_phase=preparing ;; + esac + elif [ "$operation" = update_check ]; then + status_phase=checking_feed + else case "$journal_phase" in + prepared) status_phase=preparing ;; + stopped) status_phase=stopping ;; + old_moved|new_installed) status_phase=applying ;; + uci_applied) status_phase=validating ;; + service_started) status_phase=starting ;; + rollback_old_start) status_phase=rolling_back ;; + committed) status_phase=committing ;; + *) status_phase=preparing ;; + esac; fi + write_status running "$status_phase" true "$status_phase" || : + sleep 1 +done +wait "$maint_pid" +operation_rc=$? +json_load "$(cat "$job_dir/output" 2>/dev/null)" 2>/dev/null || { + if [ -e /etc/.honk-maintenance/journal ] || [ -L /etc/.honk-maintenance/journal ] || [ -d /run/honk.lock ]; then + write_status rollback_required recovery false maintenance_recovery_required + else + write_status failed complete false operation_failed + fi + logger -t honk-maintenance "$operation failed (invalid result)" 2>/dev/null || : + exit 1 +} +json_get_var operation_ok ok +json_get_var operation_message message +if [ "$operation_rc" -eq 0 ] && [ "$operation_ok" = 1 ]; then + if [ "$operation" = update_check ]; then + json_add_string state succeeded + json_add_string phase complete + json_add_string job_id "$job_id" + json_dump > "$job_dir/status.new" && chmod 0600 "$job_dir/status.new" && mv -f "$job_dir/status.new" "$job_dir/status" || exit 1 + logger -t honk-maintenance "$operation completed" 2>/dev/null || : + exit 0 + fi + write_status succeeded complete true "$operation_message" + logger -t honk-maintenance "$operation completed" 2>/dev/null || : + exit 0 +fi +case "$operation_message" in + *rollback_failed*|*recovery_required*|*snapshot_retained*) + write_status rollback_required recovery false "$operation_message" + logger -t honk-maintenance "$operation requires recovery" 2>/dev/null || : + exit 1 + ;; +esac +write_status failed complete false "${operation_message:-operation_failed}" +logger -t honk-maintenance "$operation failed" 2>/dev/null || : +exit 1 diff --git a/honk/files/lifecycle.sh b/honk/files/lifecycle.sh new file mode 100644 index 00000000..5ed71cb0 --- /dev/null +++ b/honk/files/lifecycle.sh @@ -0,0 +1,268 @@ +#!/bin/sh + +HONK_LIFECYCLE_CORE=/usr/bin/honk-core + +honk_lifecycle_procd_pid() { + ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.pid' 2>/dev/null +} + +honk_lifecycle_procd_running() { + ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.running' 2>/dev/null +} + +honk_lifecycle_managed_pid() { + hlp_pid=$(honk_lifecycle_procd_pid) + [ -n "$hlp_pid" ] && [ -r "/proc/$hlp_pid/cmdline" ] || return 1 + hlp_cmd=$(tr '\000' ' ' < "/proc/$hlp_pid/cmdline" 2>/dev/null) || return 1 + case "$hlp_cmd" in + */usr/bin/honk-core\ --config\ /etc/honk/config.dae\ --disable-timestamp*) ;; + *) return 1 ;; + esac + case "$hlp_cmd" in *--mock-ebpf*) return 1 ;; esac + [ "$(readlink -f "/proc/$hlp_pid/exe" 2>/dev/null)" = "$HONK_LIFECYCLE_CORE" ] || return 1 + printf '%s\n' "$hlp_pid" +} + +honk_lifecycle_starttime() { + [ -r "/proc/$1/stat" ] || return 1 + awk '{ sub(/^.*\) /, ""); split($0, fields, /[[:space:]]+/); if (fields[20] ~ /^[0-9]+$/) print fields[20]; else exit 1 }' "/proc/$1/stat" 2>/dev/null +} + +honk_lifecycle_listen() { + config_load honk + config_get config_mode main config_mode managed + if [ "$config_mode" = manual ]; then + [ -f /var/run/honk/readiness ] && [ ! -L /var/run/honk/readiness ] || return 1 + read -r hlp_token _ < /var/run/honk/readiness || return 1 + case "$hlp_token" in ''|*[!0-9a-f-]*) return 1 ;; esac + sed -n 's/^api_listen=//p' "/var/run/honk/core-ready-$hlp_token" 2>/dev/null + return + fi + sed -n "s/^[[:space:]]*listen:[[:space:]]*'\([^']*\)'.*/\1/p" /etc/honk/system.dae 2>/dev/null | head -n 1 +} + +honk_lifecycle_parse_listen() { + config_load honk + config_get config_mode main config_mode managed + if [ "$config_mode" = manual ]; then + HONK_LIFECYCLE_LISTEN=$(honk_lifecycle_listen) + if [ -z "$HONK_LIFECYCLE_LISTEN" ]; then + HONK_LIFECYCLE_PORT=0 + return 0 + fi + case "$HONK_LIFECYCLE_LISTEN" in *:*) ;; *) return 1 ;; esac + HONK_LIFECYCLE_PORT=${HONK_LIFECYCLE_LISTEN##*:} + case "$HONK_LIFECYCLE_LISTEN" in ''|*[!A-Za-z0-9:._-]*) return 1 ;; esac + case "$HONK_LIFECYCLE_PORT" in ''|*[!0-9]*) return 1 ;; esac + [ "$HONK_LIFECYCLE_PORT" -ge 1024 ] && [ "$HONK_LIFECYCLE_PORT" -le 65535 ] + return $? + fi + HONK_LIFECYCLE_LISTEN=$(honk_lifecycle_listen) + case "$HONK_LIFECYCLE_LISTEN" in *:*) ;; *) return 1 ;; esac + HONK_LIFECYCLE_PORT=${HONK_LIFECYCLE_LISTEN##*:} + case "$HONK_LIFECYCLE_LISTEN" in ''|*[!A-Za-z0-9:._-]*) return 1 ;; esac + case "$HONK_LIFECYCLE_PORT" in ''|*[!0-9]*) return 1 ;; esac + [ "$HONK_LIFECYCLE_PORT" -ge 1024 ] && [ "$HONK_LIFECYCLE_PORT" -le 65535 ] +} + +honk_lifecycle_ready_owned() { + hlp_pid=$1 + [ -f /var/run/honk/readiness ] && [ ! -L /var/run/honk/readiness ] || return 1 + read -r hlp_token hlp_hash < /var/run/honk/readiness || return 1 + case "$hlp_token:$hlp_hash" in *[!0-9a-f:-]*|:*|:|*:) return 1 ;; esac + hlp_marker=/var/run/honk/core-ready-$hlp_token + [ -f "$hlp_marker" ] && [ ! -L "$hlp_marker" ] || return 1 + grep -Fqx "pid=$hlp_pid" "$hlp_marker" && \ + grep -Fqx "token=$hlp_token" "$hlp_marker" && \ + grep -Fqx "config_hash=$hlp_hash" "$hlp_marker" && \ + grep -Eq '^api_enabled=(true|false)$' "$hlp_marker" && \ + grep -Eq '^clash_api_secret_set=(true|false)$' "$hlp_marker" +} + +honk_lifecycle_port_open() { + hlp_port=$1 + case "$hlp_port" in ''|*[!0-9]*) return 1 ;; esac + awk -v port="$hlp_port" 'BEGIN { printf "%04X\n", port }' | { + IFS= read -r hlp_hex || exit 1 + for hlp_table in /proc/net/tcp /proc/net/tcp6; do + [ -r "$hlp_table" ] || continue + awk -v port="$hlp_hex" 'NR > 1 && toupper($2) ~ (":" port "$") && $4 == "0A" { found = 1 } END { exit !found }' "$hlp_table" && exit 0 + done + exit 1 + } +} + +honk_lifecycle_listener_owned() { + hlp_pid=$1 + hlp_port=$2 + hlp_hex=$(awk -v port="$hlp_port" 'BEGIN { printf "%04X", port }') || return 1 + for hlp_table in /proc/net/tcp /proc/net/tcp6; do + [ -r "$hlp_table" ] || continue + for hlp_inode in $(awk -v port="$hlp_hex" 'NR > 1 && toupper($2) ~ (":" port "$") && $4 == "0A" { print $10 }' "$hlp_table"); do + for hlp_fd in /proc/"$hlp_pid"/fd/*; do + [ "$(readlink "$hlp_fd" 2>/dev/null)" = "socket:[$hlp_inode]" ] && return 0 + done + done + done + return 1 +} + +honk_lifecycle_api_owned() { + hlp_pid=$1 + honk_lifecycle_parse_listen || return 1 + [ "$HONK_LIFECYCLE_PORT" -gt 0 ] || return 1 + honk_lifecycle_listener_owned "$hlp_pid" "$HONK_LIFECYCLE_PORT" && \ + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://$HONK_LIFECYCLE_LISTEN/api" -o /dev/null 2>/dev/null +} + +honk_lifecycle_runtime_owned() { + hlp_pid=$1 + config_load honk + config_get config_mode main config_mode managed + if [ "$config_mode" = manual ]; then + honk_lifecycle_ready_owned "$hlp_pid" + else + honk_lifecycle_api_owned "$hlp_pid" + fi +} + +honk_lifecycle_resources_clear() { + command -v ip >/dev/null 2>&1 || return 1 + for hlp_link in dae0 dae0peer; do + ip link show "$hlp_link" >/dev/null 2>&1 && return 1 + done + ! ip netns list 2>/dev/null | awk '{print $1}' | grep -qx daens +} + +honk_lifecycle_drain() { + hlp_timeout=${1:-30} + while [ "$hlp_timeout" -gt 0 ]; do + hlp_wait=0 + if [ -n "${HONK_LIFECYCLE_STOP_PID:-}" ] && \ + [ "$(honk_lifecycle_starttime "$HONK_LIFECYCLE_STOP_PID" 2>/dev/null)" = "$HONK_LIFECYCLE_STOP_STARTTIME" ]; then + hlp_wait=1 + fi + hlp_procd=$(honk_lifecycle_procd_pid) + [ -z "$hlp_procd" ] || hlp_wait=1 + hlp_running=$(honk_lifecycle_procd_running) + case "$hlp_running" in true|1) hlp_wait=1 ;; esac + hlp_all=$(pidof honk-core 2>/dev/null) + if [ -n "$hlp_all" ]; then + for hlp_pid in $hlp_all; do + if [ "$hlp_pid" != "${HONK_LIFECYCLE_STOP_PID:-}" ] || \ + [ "$(honk_lifecycle_starttime "$hlp_pid" 2>/dev/null)" != "${HONK_LIFECYCLE_STOP_STARTTIME:-}" ]; then + echo "honk lifecycle: unmanaged honk-core process remains" >&2 + return 1 + fi + done + hlp_wait=1 + fi + honk_lifecycle_resources_clear || hlp_wait=1 + honk_lifecycle_parse_listen || { echo "honk lifecycle: invalid configured listener" >&2; return 1; } + hlp_port=$HONK_LIFECYCLE_PORT + if honk_lifecycle_port_open "$hlp_port"; then hlp_wait=1; fi + if [ "$hlp_wait" = 0 ]; then + return 0 + fi + sleep 1 + hlp_timeout=$((hlp_timeout - 1)) + done + echo "honk lifecycle: old process or network resources did not drain" >&2 + return 1 +} + +honk_lifecycle_stop() { + hlp_timeout=${1:-30} + HONK_LIFECYCLE_STOP_PID=$(honk_lifecycle_managed_pid 2>/dev/null) + if [ -n "$HONK_LIFECYCLE_STOP_PID" ]; then + HONK_LIFECYCLE_STOP_STARTTIME=$(honk_lifecycle_starttime "$HONK_LIFECYCLE_STOP_PID") || return 1 + else + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing to stop unmanaged honk-core" >&2; return 1; } + [ -z "$(honk_lifecycle_procd_pid)" ] || { echo "honk lifecycle: procd instance is not the expected core" >&2; return 1; } + case "$(honk_lifecycle_procd_running)" in true|1) echo "honk lifecycle: procd instance is still running" >&2; return 1 ;; esac + HONK_LIFECYCLE_STOP_STARTTIME= + fi + if [ -n "$HONK_LIFECYCLE_STOP_PID" ]; then /etc/init.d/honk stop >/dev/null 2>&1 || :; fi + honk_lifecycle_drain "$hlp_timeout" +} + +honk_lifecycle_start() { + hlp_timeout=${1:-30} + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing start while a core process exists" >&2; return 1; } + [ -z "$(honk_lifecycle_procd_pid)" ] || { echo "honk lifecycle: procd instance has not stopped" >&2; return 1; } + case "$(honk_lifecycle_procd_running)" in true|1) echo "honk lifecycle: procd instance is still running" >&2; return 1 ;; esac + honk_lifecycle_resources_clear || { echo "honk lifecycle: network resources have not drained" >&2; return 1; } + honk_lifecycle_parse_listen || { echo "honk lifecycle: invalid configured listener" >&2; return 1; } + hlp_listen=$HONK_LIFECYCLE_LISTEN + hlp_port=$HONK_LIFECYCLE_PORT + config_load honk + config_get config_mode main config_mode managed + if [ "$config_mode" != manual ] && honk_lifecycle_port_open "$hlp_port"; then echo "honk lifecycle: listener port remains occupied" >&2; return 1; fi + if [ -n "${HONK_MAINT_CAP:-}" ]; then export HONK_MAINT_CAP; fi + /etc/init.d/honk start >/dev/null 2>&1 + hlp_start_status=$? + unset HONK_MAINT_CAP + [ "$hlp_start_status" = 0 ] || { echo "honk lifecycle: init start failed" >&2; return 1; } + hlp_stable=0 + hlp_pid= + hlp_starttime= + # A config the core cannot run crash-loops through procd: every attempt gets a + # new PID, so a fixed timeout would burn the whole budget before the caller can + # be told the start failed. Count distinct PIDs and give up early instead. + hlp_seen_pid= + hlp_attempts=0 + while [ "$hlp_timeout" -gt 0 ]; do + hlp_new_pid=$(honk_lifecycle_managed_pid 2>/dev/null) + if [ -n "$hlp_new_pid" ] && [ "$hlp_new_pid" != "$hlp_seen_pid" ]; then + hlp_seen_pid=$hlp_new_pid + hlp_attempts=$((hlp_attempts + 1)) + if [ "$hlp_attempts" -gt 3 ]; then + echo "honk lifecycle: core keeps restarting, giving up" >&2 + return 1 + fi + fi + if [ -n "$hlp_new_pid" ]; then + hlp_new_starttime=$(honk_lifecycle_starttime "$hlp_new_pid") || hlp_new_starttime= + if [ -n "$hlp_new_starttime" ] && \ + { [ -z "${HONK_LIFECYCLE_STOP_PID:-}" ] || [ "$hlp_new_pid:$hlp_new_starttime" != "$HONK_LIFECYCLE_STOP_PID:$HONK_LIFECYCLE_STOP_STARTTIME" ]; } && \ + { [ "$config_mode" = manual ] && honk_lifecycle_ready_owned "$hlp_new_pid" || \ + { [ "$config_mode" != manual ] && honk_lifecycle_listener_owned "$hlp_new_pid" "$hlp_port" && \ + curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://$hlp_listen/api" -o /dev/null 2>/dev/null; }; }; then + if [ "$hlp_pid:$hlp_starttime" = "$hlp_new_pid:$hlp_new_starttime" ]; then + hlp_stable=$((hlp_stable + 1)) + else + hlp_pid=$hlp_new_pid + hlp_starttime=$hlp_new_starttime + hlp_stable=1 + fi + if [ "$hlp_stable" -ge 3 ]; then + HONK_LIFECYCLE_STOP_PID=$hlp_new_pid + HONK_LIFECYCLE_STOP_STARTTIME=$hlp_new_starttime + return 0 + fi + else + hlp_stable=0 + hlp_pid= + hlp_starttime= + fi + else + hlp_stable=0 + hlp_pid= + hlp_starttime= + fi + sleep 1 + hlp_timeout=$((hlp_timeout - 1)) + done + echo "honk lifecycle: new managed core did not become stable and own its API listener" >&2 + return 1 +} + +honk_lifecycle_restart() { + if [ -n "$(honk_lifecycle_managed_pid 2>/dev/null)" ]; then + HONK_LIFECYCLE_WAS_RUNNING=1 + else + [ -z "$(pidof honk-core 2>/dev/null)" ] || { echo "honk lifecycle: refusing unmanaged honk-core" >&2; return 1; } + HONK_LIFECYCLE_WAS_RUNNING=0 + fi + honk_lifecycle_stop "${1:-30}" && honk_lifecycle_start "${1:-30}" +} diff --git a/honk/files/lock.sh b/honk/files/lock.sh new file mode 100644 index 00000000..cc77d70f --- /dev/null +++ b/honk/files/lock.sh @@ -0,0 +1,32 @@ +#!/bin/sh + +HONK_LOCK_DIR=/run/honk.lock + +# PID alone is not enough to identify a process (PID reuse race). We store +# "pid starttime" where starttime is field 22 of /proc/pid/stat. +_honk_proc_starttime() { + cut -d' ' -f22 "/proc/$1/stat" 2>/dev/null +} + +honk_lock_acquire() { + mkdir "$HONK_LOCK_DIR" 2>/dev/null || return 1 + chmod 0700 "$HONK_LOCK_DIR" || { rmdir "$HONK_LOCK_DIR"; return 1; } + printf '%s %s\n' "$$" "$(_honk_proc_starttime $$)" > "$HONK_LOCK_DIR/pid" || { rm -f "$HONK_LOCK_DIR/pid"; rmdir "$HONK_LOCK_DIR"; return 1; } +} + +honk_lock_is_stale() { + [ -r "$HONK_LOCK_DIR/pid" ] || return 1 + read -r owner owner_start < "$HONK_LOCK_DIR/pid" || return 1 + case "$owner" in ''|*[!0-9]*) return 1 ;; esac + # Dead if the PID is gone, or if it was reused by a newer process. + ! kill -0 "$owner" 2>/dev/null && return 0 + [ "$(_honk_proc_starttime "$owner")" = "$owner_start" ] || return 0 + return 1 +} + +honk_lock_release() { + [ -r "$HONK_LOCK_DIR/pid" ] || return 1 + read -r owner owner_start < "$HONK_LOCK_DIR/pid" || return 1 + [ "$owner" = "$$" ] && [ "$owner_start" = "$(_honk_proc_starttime $$)" ] || return 1 + rm -f "$HONK_LOCK_DIR/pid" && rmdir "$HONK_LOCK_DIR" +} diff --git a/honk/files/log-level-filter.sh b/honk/files/log-level-filter.sh new file mode 100644 index 00000000..afca4fce --- /dev/null +++ b/honk/files/log-level-filter.sh @@ -0,0 +1,57 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only + +requested=$1 +case "$requested" in all|debug|info|warn|error) ;; *) exit 2 ;; esac + +awk -v requested="$requested" ' +function rank(level) { + if (level == "DEBUG" || level == "TRACE") return 0 + if (level == "INFO" || level == "NOTICE") return 1 + if (level == "WARN" || level == "WARNING") return 2 + if (level == "ERROR" || level == "FATAL" || level == "CRIT" || level == "CRITICAL" || level == "ALERT" || level == "EMERG") return 3 + return -1 +} +function from_syslog(header, token, parts, count, level) { + count = split(header, parts, /[[:space:]]+/) + for (i = 1; i <= count; i++) { + token = parts[i] + if (token ~ /^[[:alnum:]_-]+\.(debug|info|notice|warn|warning|err|error|crit|critical|alert|emerg)$/) { + sub(/^.*\./, "", token) + if (token == "err") token = "error" + return rank(toupper(token)) + } + } + return -1 +} +{ + if (requested == "all" || requested == "debug") { print; next } + line = $0 + header = line + message = "" + separator = index(line, "]: ") + if (separator > 0) { + header = substr(line, 1, separator + 1) + message = substr(line, separator + 3) + } else { + separator = index(line, ": ") + if (separator > 0) { + header = substr(line, 1, separator - 1) + message = substr(line, separator + 2) + } + } + sub(/^[0-9][0-9][0-9][0-9]-[0-9T:.+Z-]+[[:space:]]+/, "", message) + sub(/^[[:space:]]+/, "", message) + if (match(message, /^(TRACE|DEBUG|INFO|NOTICE|WARN|WARNING|ERROR|FATAL|CRIT|CRITICAL|ALERT|EMERG)([[:space:]:]|$)/)) { + level = message + sub(/[[:space:]:].*$/, "", level) + severity = rank(level) + } else if (match(line, /^<[0-7]>/)) { + priority = substr(line, 2, 1) + 0 + severity = priority <= 3 ? 3 : priority == 4 ? 2 : priority <= 6 ? 1 : 0 + } else { + severity = from_syslog(header) + } + threshold = requested == "info" ? 1 : requested == "warn" ? 2 : 3 + if (severity >= threshold) print +}' diff --git a/honk/files/log-lines.sh b/honk/files/log-lines.sh new file mode 100644 index 00000000..5f773973 --- /dev/null +++ b/honk/files/log-lines.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# SPDX-License-Identifier: GPL-3.0-only + +honk_add_log_lines() { + [ -s "$1" ] || return 0 + while IFS= read -r line; do + json_add_string "" "$line" + done < "$1" +} diff --git a/honk/files/maintenance.sh b/honk/files/maintenance.sh new file mode 100755 index 00000000..e5f06e92 --- /dev/null +++ b/honk/files/maintenance.sh @@ -0,0 +1,1207 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 + +HONK_ROOT=/etc/honk +HONK_CONFIG=$HONK_ROOT/config.dae +HONK_SYSTEM=$HONK_ROOT/system.dae +HONK_STATE=$HONK_ROOT/state/honk.db +HONK_CORE=/usr/bin/honk-core +IMPORT_PREVIEW_ROOT=/tmp/honk-v2-import +BACKUP=/tmp/honk-backup.tar.gz +RESTORE=/tmp/honk-maintenance/restore.tar.gz +LOCK_DIR=/run/honk.lock +TXN_DIR=/etc/.honk-maintenance +JOURNAL=$TXN_DIR/journal +MAX_COMPRESSED=33554432 +MAX_EXPANDED=134217728 +MAX_FILE=67108864 +MAX_FILES=512 +CAPABILITY=$TXN_DIR/capability + +if [ "${HONK_MAINT_LIBRARY:-0}" != 1 ]; then + . /usr/share/honk/lock.sh || exit 1 + . /usr/share/honk/lifecycle.sh || exit 1 + . /usr/share/libubox/jshn.sh || exit 1 + # lifecycle.sh 用 config_load/config_get 读模式,job 路径没有 rc.common 帮它加载。 + [ -r /lib/functions.sh ] && . /lib/functions.sh +fi + +json_result() { + json_ok=0 + [ "$1" = true ] && json_ok=1 + json_init + json_add_boolean ok "$json_ok" + [ -n "$2" ] && json_add_string path "$2" + json_add_string message "$3" + json_dump +} + +error() { + json_result false "" "$1" + return 1 +} + +managed_core_pid() { + managed_pid=$(ubus call service list '{"name":"honk"}' 2>/dev/null | jsonfilter -e '@.honk.instances.main.pid' 2>/dev/null) + case "$managed_pid" in ''|*[!0-9]*) return 1 ;; esac + [ -r "/proc/$managed_pid/cmdline" ] || return 1 + managed_cmd=$(tr '\000' ' ' < "/proc/$managed_pid/cmdline" 2>/dev/null) || return 1 + case "$managed_cmd" in + */usr/bin/honk-core\ --config\ /etc/honk/config.dae\ --disable-timestamp*) ;; + *) return 1 ;; + esac + case "$managed_cmd" in *--mock-ebpf*) return 1 ;; esac + [ "$(readlink -f "/proc/$managed_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + printf '%s\n' "$managed_pid" +} + +foreign_core_running() { + managed_pid=$(managed_core_pid 2>/dev/null || true) + for core_pid in $(pidof honk-core 2>/dev/null); do + [ "$core_pid" = "$managed_pid" ] || return 0 + done + return 1 +} + +proc_starttime() { + stat_line=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 + stat_tail=${stat_line##*) } + set -- $stat_tail + [ "$#" -ge 20 ] || return 1 + stat_index=1 + for stat_field do + if [ "$stat_index" = 20 ]; then + case "$stat_field" in ''|*[!0-9]*) return 1 ;; esac + printf '%s\n' "$stat_field" + return 0 + fi + stat_index=$((stat_index + 1)) + done + return 1 +} + +new_nonce() { + nonce_value=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-') || return 1 + case "$nonce_value" in [0-9a-f][0-9a-f]*) [ "${#nonce_value}" = 32 ] || return 1 ;; *) return 1 ;; esac + printf '%s\n' "$nonce_value" +} + +issue_capability() { + cap_phase=$1 + [ "$(id -u 2>/dev/null)" = 0 ] || return 1 + case "$cap_phase:$journal_operation:$journal_phase" in + candidate_health_start:restore:new_installed|rollback_old_start:restore:rollback_old_start|rollback_old_start:reset:rollback_old_start) ;; + *) return 1 ;; + esac + cap_nonce=$(new_nonce) || return 1 + cap_owner=$$ + cap_starttime=$(proc_starttime "$cap_owner") || return 1 + cap_tmp=$TXN_DIR/capability.new.$cap_owner + { + printf 'format=1\nnonce=%s\ntxn_id=%s\nphase=%s\nowner_pid=%s\nowner_starttime=%s\n' \ + "$cap_nonce" "$txn_id" "$cap_phase" "$cap_owner" "$cap_starttime" + } > "$cap_tmp" || return 1 + chmod 0600 "$cap_tmp" || { rm -f "$cap_tmp"; return 1; } + mv -f "$cap_tmp" "$CAPABILITY" || { rm -f "$cap_tmp"; return 1; } + export HONK_MAINT_CAP=$cap_nonce + honk_lifecycle_start 30 + cap_start_rc=$? + unset HONK_MAINT_CAP + return "$cap_start_rc" +} + +stop_managed() { + old_pid=$(managed_core_pid 2>/dev/null || true) + if foreign_core_running; then return 2; fi + if [ -n "$old_pid" ]; then + honk_lifecycle_stop 30 || return 1 + managed_core_pid >/dev/null 2>&1 && return 1 + fi + return 0 +} + +restore_running() { + if [ "$old_running" != 1 ]; then service_stopped=0; return 0; fi + foreign_core_running && return 1 + honk_lifecycle_start 30 || return 1 + service_stopped=0 +} + +capture_boot_links() { + boot_start_path=/etc/rc.d/S95honk + boot_stop_path=/etc/rc.d/K10honk + old_start_link=- + old_stop_link=- + if [ -L "$boot_start_path" ]; then old_start_link=$(readlink "$boot_start_path") || return 1 + elif [ -e "$boot_start_path" ]; then return 1; fi + if [ -L "$boot_stop_path" ]; then old_stop_link=$(readlink "$boot_stop_path") || return 1 + elif [ -e "$boot_stop_path" ]; then return 1; fi + case "$old_start_link:$old_stop_link" in + -:-|../init.d/honk:-|-:../init.d/honk|../init.d/honk:../init.d/honk) ;; + *) return 1 ;; + esac +} + +restore_boot_links() { + for boot_link in /etc/rc.d/S95honk /etc/rc.d/K10honk; do + [ ! -e "$boot_link" ] || [ -L "$boot_link" ] || return 1 + rm -f "$boot_link" || return 1 + done + if [ "$old_start_link" != - ]; then ln -s "$old_start_link" /etc/rc.d/S95honk || return 1; fi + if [ "$old_stop_link" != - ]; then ln -s "$old_stop_link" /etc/rc.d/K10honk || return 1; fi +} + +disable_boot_links() { + for boot_link in /etc/rc.d/S95honk /etc/rc.d/K10honk; do + [ ! -e "$boot_link" ] || [ -L "$boot_link" ] || return 1 + rm -f "$boot_link" || return 1 + done +} + +journal_write() { + journal_next=$TXN_DIR/journal.new + { + printf 'format=1\noperation=%s\nphase=%s\ntxn_id=%s\nold_running=%s\nold_enabled=%s\nold_boot=%s\nold_initialized=%s\nold_start_link=%s\nold_stop_link=%s\n' \ + "$journal_operation" "$1" "$txn_id" "$old_running" "$old_enabled" "$old_boot" "$old_initialized" "$old_start_link" "$old_stop_link" + } > "$journal_next" || return 1 + chmod 0600 "$journal_next" || return 1 + sync + mv -f "$journal_next" "$JOURNAL" || return 1 + sync + journal_phase=$1 +} + +journal_create() { + journal_operation=$1 + txn_id=$(new_nonce) || return 1 + [ ! -e "$TXN_DIR" ] && [ ! -L "$TXN_DIR" ] || return 1 + mkdir "$TXN_DIR" || return 1 + chmod 0700 "$TXN_DIR" || return 1 + cp "$temp_dir/old-uci" "$TXN_DIR/old-uci" || return 1 + chmod 0600 "$TXN_DIR/old-uci" || return 1 + old_uci_snapshot=$TXN_DIR/old-uci + journal_active=1 + journal_write prepared +} + +journal_value() { + sed -n "s/^$1=//p" "$JOURNAL" 2>/dev/null +} + +recover_transaction() { + [ -e "$TXN_DIR" ] || { journal_active=0; return 0; } + [ -d "$TXN_DIR" ] && [ ! -L "$TXN_DIR" ] && [ -f "$JOURNAL" ] && [ ! -L "$JOURNAL" ] || return 1 + journal_operation=$(journal_value operation) + journal_phase=$(journal_value phase) + journal_format=$(journal_value format) + txn_id=$(journal_value txn_id) + old_running=$(journal_value old_running) + old_enabled=$(journal_value old_enabled) + old_boot=$(journal_value old_boot) + old_initialized=$(journal_value old_initialized) + old_start_link=$(journal_value old_start_link) + old_stop_link=$(journal_value old_stop_link) + [ "$journal_format" = 1 ] || return 1 + case "$txn_id" in [0-9a-f][0-9a-f]*) [ "${#txn_id}" = 32 ] || return 1 ;; *) return 1 ;; esac + case "$journal_operation:$journal_phase" in backup:prepared|backup:stopped|backup:committed|restore:prepared|restore:stopped|restore:old_moved|restore:new_installed|restore:uci_applied|restore:service_started|restore:rollback_old_start|restore:committed|reset:prepared|reset:stopped|reset:old_moved|reset:new_installed|reset:uci_applied|reset:rollback_old_start|reset:committed) ;; + *) return 1 ;; + esac + case "$old_running:$old_enabled:$old_boot:$old_initialized" in + 0:0:0:0|0:0:0:1|0:0:1:0|0:0:1:1|0:1:0:0|0:1:0:1|0:1:1:0|0:1:1:1|1:0:0:0|1:0:0:1|1:0:1:0|1:0:1:1|1:1:0:0|1:1:0:1|1:1:1:0|1:1:1:1) ;; + *) return 1 ;; + esac + case "$old_start_link:$old_stop_link" in -:-|../init.d/honk:-|-:../init.d/honk|../init.d/honk:../init.d/honk) ;; *) return 1 ;; esac + old_uci_snapshot=$TXN_DIR/old-uci + [ -f "$old_uci_snapshot" ] && [ ! -L "$old_uci_snapshot" ] || return 1 + if [ "$journal_phase" = committed ]; then + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + swap_started=0 + if [ "$journal_operation" = backup ] && [ "$old_running" = 1 ]; then + honk_lifecycle_start 30 || return 1 + fi + service_stopped=0 + return 0 + fi + foreign_core_running && return 1 + if [ "$journal_operation" = restore ] || [ "$journal_operation" = reset ]; then + if managed_core_pid >/dev/null 2>&1; then + honk_lifecycle_stop 30 || return 1 + fi + if [ -d "$TXN_DIR/old-tree" ] && [ ! -L "$TXN_DIR/old-tree" ]; then + if [ -e "$HONK_ROOT" ]; then + [ ! -e "$TXN_DIR/failed-tree" ] || return 1 + mv "$HONK_ROOT" "$TXN_DIR/failed-tree" || return 1 + fi + mv "$TXN_DIR/old-tree" "$HONK_ROOT" || return 1 + else + [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ] || return 1 + fi + fi + if ! uci import honk < "$old_uci_snapshot" || ! uci commit honk; then return 1; fi + restore_boot_links || return 1 + if [ "$old_running" = 1 ]; then + if ! managed_core_pid >/dev/null 2>&1; then + if [ "$journal_operation" = restore ] || [ "$journal_operation" = reset ]; then + journal_write rollback_old_start || return 1 + issue_capability rollback_old_start || return 1 + else + # A backup never changes the live tree; clear its journal before normal startup. + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + honk_lifecycle_start 30 || return 1 + fi + fi + else + if managed_core_pid >/dev/null 2>&1; then honk_lifecycle_stop 30 || return 1; fi + fi + rm -rf "$TXN_DIR" || return 1 + journal_active=0 + service_stopped=0 + swap_started=0 +} + +backup_abort() { + backup_message=$1 + if [ -n "$mock_pid" ] && ! stop_mock; then + error "${backup_message}_mock_stop_failed_runtime_not_restarted" + return 1 + fi + if [ "$journal_active" = 1 ]; then + recover_transaction || backup_message="${backup_message}_runtime_restore_failed" + elif ! restore_running; then backup_message="${backup_message}_runtime_restore_failed"; fi + error "$backup_message" +} + +restore_abort() { + restore_message=$1 + if [ -n "$mock_pid" ] && ! stop_mock; then + error "${restore_message}_mock_stop_failed" + return 1 + fi + if [ "$journal_active" = 1 ]; then + recover_transaction || restore_message="${restore_message}_rollback_failed" + elif [ "$service_stopped" = 1 ] && ! restore_running; then + restore_message="${restore_message}_runtime_restore_failed" + fi + error "$restore_message" +} + +stop_mock() { + [ -n "$mock_pid" ] || return 0 + if [ ! -r "/proc/$mock_pid/cmdline" ]; then + stop_status=0 + wait "$mock_pid" 2>/dev/null || stop_status=$? + mock_pid= + [ "$stop_status" = 0 ] || return 1 + return 0 + fi + mock_identity=$(tr '\000' ' ' < "/proc/$mock_pid/cmdline" 2>/dev/null) || return 1 + case "$mock_identity" in *"$mock_config"*--mock-ebpf*) ;; *) return 1 ;; esac + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] || return 1 + kill -TERM "$mock_pid" 2>/dev/null || return 1 + stop_forced=0 + stop_tries=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$stop_tries" -lt 10 ]; do sleep 1; stop_tries=$((stop_tries + 1)); done + if kill -0 "$mock_pid" 2>/dev/null; then + kill -KILL "$mock_pid" 2>/dev/null || return 1 + stop_forced=1 + stop_tries=0 + while kill -0 "$mock_pid" 2>/dev/null && [ "$stop_tries" -lt 5 ]; do sleep 1; stop_tries=$((stop_tries + 1)); done + fi + kill -0 "$mock_pid" 2>/dev/null && return 1 + stop_status=0 + wait "$mock_pid" 2>/dev/null || stop_status=$? + mock_pid= + [ "$stop_forced" = 0 ] && [ "$stop_status" = 0 ] +} + +cleanup() { + cleanup_status=$? + cleanup_safe=1 + if [ -n "$mock_pid" ] && ! stop_mock; then cleanup_safe=0; fi + if [ "$cleanup_safe" = 1 ]; then + [ ! -e "$CAPABILITY" ] || rm -f "$CAPABILITY" + if [ "$journal_active" = 1 ]; then + recover_transaction || { cleanup_safe=0; echo "honk maintenance: transaction recovery failed; preserve $TXN_DIR and $temp_dir" >&2; } + elif [ "$service_stopped" = 1 ]; then + restore_running || { cleanup_safe=0; echo "honk maintenance: service restart failed; preserving $temp_dir and lock" >&2; } + fi + fi + if [ "$cleanup_safe" = 1 ]; then + if [ -n "$temp_dir" ] && [ -d "$temp_dir" ]; then rm -rf "$temp_dir"; fi + if [ "$lock_owned" = 1 ]; then honk_lock_release >/dev/null 2>&1; fi + else + echo "honk maintenance: cleanup/rollback not confirmed; keeping lock, temporary data at $temp_dir, and any /etc/.honk.restore.* snapshot" >&2 + fi + return "$cleanup_status" +} + +trap cleanup 0 +trap 'exit 1' HUP INT TERM + +lock_owned=0 +temp_dir= +mock_pid= +mock_config= +old_running=0 +service_stopped=0 +swap_started=0 +old_tree_path= +old_uci_snapshot= +journal_active=0 +journal_operation= +journal_phase= +old_start_link=- +old_stop_link=- +lock_error=operation_in_progress + +acquire_lock() { + if [ -e /etc/.honk-update/journal ] || [ -L /etc/.honk-update/journal ]; then lock_error=update_recovery_required; return 1; fi + if [ -d "$LOCK_DIR" ]; then + if [ -e "$TXN_DIR" ]; then lock_error=recovery_required; else lock_error=operation_in_progress_or_stale_lock; fi + return 1 + fi + honk_lock_acquire || { lock_error=operation_in_progress; return 1; } + lock_owned=1 + if [ -e "$TXN_DIR" ]; then + journal_active=1 + recover_transaction || { lock_error=interrupted_transaction_recovery_failed; return 2; } + fi + lock_error= +} + +recover_action() { + if [ -d "$LOCK_DIR" ]; then + error recovery_required_clear_stale_lock_only_after_confirming_no_honk_core + return 1 + fi + if ! honk_lock_acquire; then error operation_in_progress; return 1; fi + lock_owned=1 + if [ -e "$TXN_DIR" ]; then journal_active=1; fi + recover_transaction || { error interrupted_transaction_recovery_failed; return 1; } + json_result true "" transaction_recovered +} + +check_root_file() { + [ ! -L "$1" ] && [ -f "$1" ] || return 1 + [ "$(id -u 2>/dev/null)" = 0 ] || return 1 + [ "$(ls -ln "$1" 2>/dev/null | awk 'NR == 1 { print $3 }')" = 0 ] || return 1 + chmod 0600 "$1" 2>/dev/null || return 1 + [ ! -L "$1" ] && [ -f "$1" ] || return 1 + file_mode=$(ls -ldn "$1" 2>/dev/null | awk 'NR == 1 { print $1 }') + [ "$file_mode" = '-rw-------' ] +} + +safe_relpath() { + case "$1" in ''|/*|*..*|*[^A-Za-z0-9._/-]*|*//*|*/./*|./*|*/.) return 1 ;; esac + return 0 +} + +size_of() { + wc -c < "$1" | tr -d ' ' +} + +sha_of() { + sha256sum "$1" 2>/dev/null | awk '{print $1}' +} + +core_commit() { + jsonfilter -i /usr/share/honk/provenance.json -e '@.core.commit' 2>/dev/null +} + +load_uci_values() { + uci_bin=${1:-uci} + uci_config=${2:-} + # Run uci with optional -c/-t flags without eval: keep args in "$@" style + # via a helper so later callers can't inject through string concatenation. + if [ -n "$uci_config" ]; then + uci_run() { "$uci_bin" -c "$uci_config" -t "$temp_dir/uci-tmp" "$@"; } + else + uci_run() { "$uci_bin" "$@"; } + fi + uci_show=$(uci_run -q show honk 2>/dev/null) || return 1 + printf '%s\n' "$uci_show" | awk -F= ' + /^honk\.main=/ { main++ ; next } + /^honk\.main\.(enabled|boot_enabled|initialized|config_mode|config_file|listen_port|lan_network|geoip_url|geosite_url|geo_auto|geo_auto_freq|clash_api_enabled|clash_api_port|clash_api_secret)=/ { next } + /^honk\.main\./ { bad=1; next } + /^honk\.[A-Za-z0-9_.-]+=/ { bad=1; next } + END { exit (bad || main != 1) } + ' || return 1 + get_uci() { uci_run -q get "honk.main.$1" 2>/dev/null; } + u_enabled=$(get_uci enabled) || return 1 + u_boot=$(get_uci boot_enabled) || return 1 + u_initialized=$(get_uci initialized) || return 1 + u_config_mode=$(get_uci config_mode 2>/dev/null || printf managed) + u_config=$(get_uci config_file) || return 1 + u_port=$(get_uci listen_port) || return 1 + u_network=$(get_uci lan_network) || return 1 + u_clash_enabled=$(get_uci clash_api_enabled 2>/dev/null || printf 0) + u_clash_port=$(get_uci clash_api_port 2>/dev/null || printf 9090) + u_clash_secret=$(get_uci clash_api_secret 2>/dev/null || true) + u_geo_auto=$(get_uci geo_auto 2>/dev/null || printf 0) + u_geo_auto_freq=$(get_uci geo_auto_freq 2>/dev/null || printf daily) + case "$u_enabled:$u_boot:$u_initialized" in 0:0:0|0:0:1|0:1:0|0:1:1|1:0:0|1:0:1|1:1:0|1:1:1) ;; *) return 1 ;; esac + case "$u_config_mode" in managed|manual) ;; *) return 1 ;; esac + case "$u_geo_auto:$u_geo_auto_freq" in 0:daily|0:weekly|1:daily|1:weekly) ;; *) return 1 ;; esac + [ "$u_config" = /etc/honk/config.dae ] || return 1 + case "$u_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$u_port" -ge 1024 ] && [ "$u_port" -le 65535 ] || return 1 + case "$u_network" in ''|*[!A-Za-z0-9_.-]*) return 1 ;; esac + case "$u_clash_enabled" in 0|1) ;; *) return 1 ;; esac + case "$u_clash_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$u_clash_port" -ge 1024 ] && [ "$u_clash_port" -le 65535 ] || return 1 + if [ "$u_clash_enabled" = 1 ]; then + [ "$u_initialized" = 1 ] && [ "$u_clash_port" != "$u_port" ] || return 1 + case "$u_clash_secret" in ''|*[!A-Za-z0-9._-]*) return 1 ;; esac + [ "${#u_clash_secret}" -ge 6 ] && [ "${#u_clash_secret}" -le 128 ] || return 1 + fi +} + +check_sqlite() { + check_db=$1 + [ ! -L "$check_db" ] && [ -f "$check_db" ] || return 1 + command -v sqlite3 >/dev/null 2>&1 || return 1 + check_result=$(sqlite3 "$check_db" 'PRAGMA quick_check;' 2>/dev/null) || return 1 + [ "$check_result" = ok ] +} + +checkpoint_sqlite() { + [ -e "$HONK_STATE" ] || return 0 + check_sqlite "$HONK_STATE" || return 1 + checkpoint_result=$(sqlite3 "$HONK_STATE" 'PRAGMA wal_checkpoint(TRUNCATE);' 2>/dev/null) || return 1 + case "$checkpoint_result" in + 0\|[0-9]*\|[0-9]*) ;; + *) return 1 ;; + esac + checkpoint_busy=${checkpoint_result%%|*} + checkpoint_rest=${checkpoint_result#*|} + checkpoint_log=${checkpoint_rest%%|*} + checkpoint_done=${checkpoint_rest#*|} + case "$checkpoint_log:$checkpoint_done" in *[!0-9:]*|:*|*:) return 1 ;; esac + [ "$checkpoint_busy" = 0 ] && [ "$checkpoint_log" = "$checkpoint_done" ] || return 1 + for sidecar in "$HONK_STATE-wal" "$HONK_STATE-shm"; do + [ ! -L "$sidecar" ] || return 1 + if [ -e "$sidecar" ]; then + [ -f "$sidecar" ] || return 1 + [ "$(size_of "$sidecar")" -eq 0 ] || [ "$sidecar" = "$HONK_STATE-shm" ] || return 1 + rm -f "$sidecar" || return 1 + fi + done + [ ! -e "$HONK_STATE-wal" ] && [ ! -e "$HONK_STATE-shm" ] +} + +is_excluded() { + case "$1" in + */logs/*|logs/*|*/log/*|log/*|*.log|*.tmp|*.pid|*.lock|*/.lock|*/honk.db-wal|*/honk.db-shm|*/honk.db-journal) + return 0 ;; + *) return 1 ;; + esac +} + +collect_tree() { + collect_root=$1 + collect_out=$2 + [ -d "$collect_root" ] && [ ! -L "$collect_root" ] || return 1 + if find "$collect_root" -type l -print 2>/dev/null | grep -q .; then return 1; fi + if find "$collect_root" ! -type f ! -type d -print 2>/dev/null | grep -q .; then return 1; fi + : > "$collect_out" || return 1 + ( + cd "$collect_root" || exit 1 + find . -type f -print + ) > "$collect_out.raw" || return 1 + : > "$collect_out" + collect_count=0 + collect_total=0 + while IFS= read -r collect_path; do + collect_path=${collect_path#./} + is_excluded "$collect_path" && continue + safe_relpath "$collect_path" || exit 1 + [ -f "$collect_root/$collect_path" ] && [ ! -L "$collect_root/$collect_path" ] || return 1 + collect_size=$(size_of "$collect_root/$collect_path") || return 1 + [ "$collect_size" -le "$MAX_FILE" ] || return 1 + collect_count=$((collect_count + 1)) + collect_total=$((collect_total + collect_size)) + [ "$collect_count" -lt "$MAX_FILES" ] && [ "$collect_total" -le $((MAX_EXPANDED - 1048576)) ] || return 1 + printf '%s\n' "$collect_path" >> "$collect_out" || exit 1 + done < "$collect_out.raw" || return 1 + [ -f "$collect_root/config.dae" ] && [ -f "$collect_root/system.dae" ] || return 1 + return 0 +} + +validate_config_tree() { + config_root=$1 + expected_device=$2 + expected_ip=$3 + expected_port=$4 + expected_initialized=$5 + main=$config_root/config.dae + system=$config_root/system.dae + [ -f "$main" ] && [ ! -L "$main" ] && [ -f "$system" ] && [ ! -L "$system" ] || return 1 + if [ "$u_config_mode" = manual ]; then + "$HONK_CORE" config validate --config "$main" >/dev/null 2>&1 || return 2 + find "$config_root" -type l -print 2>/dev/null | grep -q . && return 2 + find "$config_root" ! -type f ! -type d -print 2>/dev/null | grep -q . && return 2 + return 0 + fi + validate_system_schema "$system" "$expected_device" "$expected_ip" "$expected_port" "$expected_initialized" || return 2 + # `validate_candidate` asks honk-core's own parser to resolve every include + # and captures the complete source tree inside its private shadow. Keep this + # shell check limited to filesystem entry types and managed system fields. + find "$config_root" -type l -print 2>/dev/null | grep -q . && return 2 + find "$config_root" ! -type f ! -type d -print 2>/dev/null | grep -q . && return 2 + return 0 +} + +validate_system_schema() { + trusted_system=$1 + expected_device=$2 + expected_ip=$3 + expected_port=$4 + expected_initialized=$5 + lan_iface=$(sed -n "s/^ lan_interface: '\\([^']*\\)'$/\\1/p" "$trusted_system") + listen_addr=$(sed -n "s/^ listen: '\\([^']*\\)'$/\\1/p" "$trusted_system") + case "$lan_iface" in ''|*[!A-Za-z0-9_.-]*) return 1 ;; esac + case "$listen_addr" in ''|*[!0-9.:]*) return 1 ;; esac + case "$listen_addr" in *.*.*.*:*) ;; *) return 1 ;; esac + listen_host=${listen_addr%:*} + listen_port=${listen_addr##*:} + case "$listen_host" in ''|*[!0-9.]*) return 1 ;; esac + case "$listen_port" in ''|*[!0-9]*) return 1 ;; esac + [ "$listen_port" -ge 1024 ] && [ "$listen_port" -le 65535 ] || return 1 + [ "$lan_iface" = "$expected_device" ] && [ "$listen_port" = "$expected_port" ] || return 1 + [ "$listen_host" = "$expected_ip" ] || [ "$listen_host" = 127.0.0.1 ] || return 1 + store_subscribe=$(sed -n 's/^ store_subscribe: \(true\|false\)$/\1/p' "$trusted_system") + case "$store_subscribe" in true|false) ;; *) return 1 ;; esac + cat > "$temp_dir/system.expected" <> "$temp_dir/system.expected" <> "$temp_dir/system.expected" </dev/null || return 1 + tar -tvzf "$archive" > "$member_list" 2>/dev/null || return 1 + awk ' + { + if (NF < 2 || substr($1,1,1) != "-") exit 1 + name=$NF + if (name !~ /^[A-Za-z0-9_.\/-]+$/ || name ~ /^\// || name ~ /(^|\/)\.\.(\/|$)/ || name ~ /(^|\/)\.($|\/)/ || name ~ /\/\//) exit 1 + seen[name]++ + if (seen[name] > 1) exit 1 + count++ + if (count > 513) exit 1 + print name + } + END { if (!count) exit 1 } + ' "$member_list" > "$member_list.names" || return 1 + grep -qx 'manifest.tsv' "$member_list.names" || return 1 +} + +extract_archive() { + archive=$1 + stage=$2 + member_listing=$temp_dir/members.verbose + check_tar_members "$archive" "$member_listing" || return 1 + mkdir -p "$stage" || return 1 + : > "$stage/manifest.tsv" || return 1 + ulimit -f 262144 2>/dev/null || return 1 + tar -xOzf "$archive" manifest.tsv > "$stage/manifest.tsv" 2>/dev/null || return 1 + [ "$(size_of "$stage/manifest.tsv")" -le 1048576 ] || return 1 + awk -F '\t' ' + NR==1 { if (NF!=2 || $1!="format" || $2!="1") exit 1; next } + NR==2 { if (NF!=2 || $1!="core_commit" || length($2)!=40 || $2 !~ /^[0-9a-f]+$/) exit 1; next } + NR==3 { if (NF!=2 || $1!="state" || ($2!="present" && $2!="absent")) exit 1; next } + $1=="file" { + if (NF!=4 || $2 !~ /^[A-Za-z0-9_.\/-]+$/ || $2 ~ /^\// || $2 ~ /(^|\/)\.\.(\/|$)/ || $2 ~ /(^|\/)\.($|\/)/ || $2 ~ /\/\//) exit 1 + if ($3 !~ /^[0-9]+$/ || $3 > 67108864 || $4 !~ /^[0-9a-f]{64}$/) exit 1 + if (seen[$2]++) exit 1 + total += $3; count++; next + } + { exit 1 } + END { if (NR<4 || count<3 || count>512 || total>133169152) exit 1 } + ' "$stage/manifest.tsv" || return 1 + manifest_commit=$(sed -n '2s/^core_commit\t//p' "$stage/manifest.tsv") + [ -n "$manifest_commit" ] && [ "$manifest_commit" = "$(core_commit)" ] || return 2 + archive_state=$(sed -n '3s/^state\t//p' "$stage/manifest.tsv") + while IFS="$(printf '\t')" read -r kind relpath expected_size expected_sha; do + [ "$kind" = file ] || continue + safe_relpath "$relpath" || return 1 + is_excluded "$relpath" && return 1 + grep -qx "$relpath" "$member_listing.names" || return 1 + case "$relpath" in uci/honk|etc/honk/*) ;; *) return 1 ;; esac + mkdir -p "$stage/$(dirname "$relpath")" || return 1 + : > "$stage/$relpath" || return 1 + tar -xOzf "$archive" "$relpath" > "$stage/$relpath" 2>/dev/null || return 1 + [ "$(size_of "$stage/$relpath")" -eq "$expected_size" ] || return 1 + [ "$(sha_of "$stage/$relpath")" = "$expected_sha" ] || return 1 + done < "$stage/manifest.tsv" + # Every tar member must be declared exactly once in the manifest. + awk -F '\t' '$1=="file" {print $2}' "$stage/manifest.tsv" | sort > "$member_listing.expected" + grep -v '^manifest.tsv$' "$member_listing.names" | sort > "$member_listing.actual" + cmp -s "$member_listing.expected" "$member_listing.actual" || return 1 + [ -f "$stage/etc/honk/config.dae" ] && [ -f "$stage/etc/honk/system.dae" ] && [ -f "$stage/uci/honk" ] || return 1 + if [ "$archive_state" = present ]; then + [ -f "$stage/etc/honk/state/honk.db" ] || return 1 + else + [ ! -e "$stage/etc/honk/state/honk.db" ] || return 1 + fi +} + +parse_uci_file() { + uci_dir=$1 + mkdir -p "$temp_dir/uci-tmp" || return 1 + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" -q export honk >/dev/null 2>&1 || return 1 + load_uci_values uci "$uci_dir" || return 1 + [ "$u_initialized" = 1 ] || [ ! -f "$restore_stage/etc/honk/state/honk.db" ] || return 1 +} + +# 清订阅、节点、策略、历史与缓存,保留管理员记录和 geo 设置。 +purge_business_state() { + purge_db=$1 + [ -f "$purge_db" ] && [ ! -L "$purge_db" ] || return 0 + command -v sqlite3 >/dev/null 2>&1 || return 1 + purge_sql='BEGIN IMMEDIATE;' + for purge_table in head revision subscription_body selector delay_sample dns_answer clash_state listener_secret; do + purge_exists=$(sqlite3 "$purge_db" "SELECT count(*) FROM sqlite_master WHERE type='table' AND name='$purge_table';" 2>/dev/null) || return 1 + [ "$purge_exists" = 1 ] || continue + purge_sql="$purge_sql DELETE FROM $purge_table;" + done + sqlite3 "$purge_db" "$purge_sql COMMIT; PRAGMA wal_checkpoint(TRUNCATE); VACUUM;" >/dev/null 2>&1 || return 1 + check_sqlite "$purge_db" +} + +archive_sqlite_check() { + db=$1 + [ -f "$db" ] || return 0 + check_sqlite "$db" || return 1 +} + +unused_loopback_port() { + command -v netstat >/dev/null 2>&1 || command -v ss >/dev/null 2>&1 || return 1 + for port_candidate in 39127 39129 39131 39133 39135 39137 39139 39141; do + if command -v netstat >/dev/null 2>&1; then + port_listeners=$(netstat -lnt 2>/dev/null) + else + port_listeners=$(ss -lnt 2>/dev/null) + fi + if ! printf '%s\n' "$port_listeners" | awk -v p="$port_candidate" '$4 ~ (":" p "$") {found=1} END {exit !found}'; then + printf '%s\n' "$port_candidate" + return 0 + fi + done + return 1 +} + +rewrite_shadow_system() { + shadow_system=$1 + shadow_data=$2 + shadow_port=$3 + cat > "$shadow_system.new" < "$live_system" <> "$live_system" <> "$live_system" </dev/null) || return 1 + case "$mock_identity" in *"$mock_config"*--mock-ebpf*) ;; *) return 1 ;; esac + [ "$(readlink -f "/proc/$mock_pid/exe" 2>/dev/null)" = "$HONK_CORE" ] +} + +validate_candidate() { + candidate=$1 + uci_dir=$2 + state=$3 + config_tree=$candidate/etc/honk + mkdir -p "$temp_dir/uci-tmp" || return 1 + load_uci_values uci "$uci_dir" || return 1 + if [ "$u_config_mode" = manual ]; then + validate_config_tree "$config_tree" '' '' "$u_port" "$u_initialized" || return 2 + if [ "$state" = present ] || { [ "$state" = preview ] && { [ -e "$config_tree/state/honk.db" ] || [ -L "$config_tree/state/honk.db" ]; }; }; then + archive_sqlite_check "$config_tree/state/honk.db" || return 1 + fi + if [ -f "$config_tree/state/honk.db" ] && [ ! -L "$config_tree/state/honk.db" ]; then + restore_initialized=1 + else + restore_initialized=0 + fi + return 0 + fi + . /lib/functions/network.sh || return 1 + candidate_device= + candidate_ip= + network_get_device candidate_device "$u_network" || return 1 + network_get_ipaddr candidate_ip "$u_network" || return 1 + validate_config_tree "$config_tree" "$candidate_device" "$candidate_ip" "$u_port" "$u_initialized" || return 2 + if [ "$state" = present ] || { [ "$state" = preview ] && { [ -e "$config_tree/state/honk.db" ] || [ -L "$config_tree/state/honk.db" ]; }; }; then + archive_sqlite_check "$config_tree/state/honk.db" || return 1 + fi + mkdir -p "$temp_dir/shadow/etc" "$temp_dir/shadow-data" || return 1 + chmod 0700 "$temp_dir/shadow" "$temp_dir/shadow/etc" "$temp_dir/shadow-data" || return 1 + cp -pR "$config_tree" "$temp_dir/shadow/etc/honk" || return 1 + find "$temp_dir/shadow" -type d -exec chmod 0700 {} + || return 1 + find "$temp_dir/shadow/etc/honk" -type f -exec chmod 0600 {} + || return 1 + find "$temp_dir/shadow/etc/honk" -type l -print 2>/dev/null | grep -q . && return 2 + find "$temp_dir/shadow/etc/honk" ! -type f ! -type d -print 2>/dev/null | grep -q . && return 2 + shadow_port=$(unused_loopback_port) || return 1 + rewrite_shadow_system "$temp_dir/shadow/etc/honk/system.dae" "$temp_dir/shadow-data" "$shadow_port" || return 2 + chmod 0600 "$temp_dir/shadow/etc/honk"/*.dae 2>/dev/null || true + if [ "$state" = present ] || { [ "$state" = preview ] && [ -f "$config_tree/state/honk.db" ]; }; then + mkdir -p "$temp_dir/shadow-data/state" || return 1 + cp "$config_tree/state/honk.db" "$temp_dir/shadow-data/state/honk.db" || return 1 + chmod 0600 "$temp_dir/shadow-data/state/honk.db" || return 1 + fi + mock_config=$temp_dir/shadow/etc/honk/config.dae + mock_log=$temp_dir/mock.log + response_file=$temp_dir/discovery.json + DAE_LOCATION_ASSET=/usr/share/v2ray "$HONK_CORE" --config "$mock_config" --mock-ebpf > "$mock_log" 2>&1 & + mock_pid=$! + mock_ready=0 + attempt=0 + while [ "$attempt" -lt 15 ]; do + if ! kill -0 "$mock_pid" 2>/dev/null; then return 1; fi + if curl -fsS --noproxy '*' --connect-timeout 1 --max-time 2 "http://127.0.0.1:$shadow_port/api" -o "$response_file" 2>/dev/null; then + mock_identity_ok || return 1 + mock_ready=1 + break + fi + sleep 1 + attempt=$((attempt + 1)) + done + [ "$mock_ready" = 1 ] || return 1 + setup_required=$(jsonfilter -i "$response_file" -e '@.auth.setup_required' 2>/dev/null) + case "$setup_required" in true) restore_initialized=0 ;; false) restore_initialized=1 ;; *) return 1 ;; esac + [ "$state" != absent ] || [ "$restore_initialized" = 0 ] || return 1 + mock_identity_ok || return 1 + stop_mock || return 1 + return 0 +} + +make_manifest() { + root=$1 + state=$2 + manifest=$3 + commit=$(core_commit) + case "$commit" in [0-9a-f][0-9a-f]*) [ "${#commit}" -eq 40 ] || return 1 ;; *) return 1 ;; esac + { + printf 'format\t1\ncore_commit\t%s\nstate\t%s\n' "$commit" "$state" + while IFS= read -r relpath; do + [ -n "$relpath" ] || continue + file=$root/$relpath + file_size=$(size_of "$file") || return 1 + file_sha=$(sha_of "$file") || return 1 + [ "$file_size" -le "$MAX_FILE" ] || return 1 + printf 'file\t%s\t%s\t%s\n' "$relpath" "$file_size" "$file_sha" + done < "$temp_dir/files.list" + } > "$manifest" || return 1 + [ "$(size_of "$manifest")" -le 1048576 ] +} + +backup() { + if [ -L "$BACKUP" ] || { [ -e "$BACKUP" ] && [ ! -f "$BACKUP" ]; }; then error unsafe_backup_path; return 1; fi + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + load_uci_values || { error uci_validation_failed; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error uci_export_failed; return 1; } + if [ "$old_running" = 1 ]; then + capture_boot_links || { error boot_link_state_unavailable; return 1; } + journal_create backup || { error transaction_journal_create_failed; return 1; } + service_stopped=1 + stop_managed || { backup_abort service_stop_failed; return 1; } + journal_write stopped || { backup_abort transaction_journal_write_failed; return 1; } + fi + uci export honk > "$temp_dir/uci.honk" 2>/dev/null || { backup_abort uci_export_failed; return 1; } + if [ -f "$HONK_STATE" ]; then + checkpoint_sqlite || { backup_abort sqlite_checkpoint_failed; return 1; } + state=present + [ "$u_initialized" = 1 ] || { backup_abort state_without_initialized_profile; return 1; } + else + state=absent + [ "$u_initialized" = 0 ] || { backup_abort initialized_state_missing; return 1; } + fi + mkdir -p "$temp_dir/archive/etc" "$temp_dir/archive/uci" || { backup_abort temporary_storage_unavailable; return 1; } + collect_tree "$HONK_ROOT" "$temp_dir/files.list" || { backup_abort unsupported_honk_tree_entry; return 1; } + while IFS= read -r relpath; do + [ -n "$relpath" ] || continue + dest=$temp_dir/archive/etc/honk/$relpath + mkdir -p "$(dirname "$dest")" || { backup_abort snapshot_failed; return 1; } + cp "$HONK_ROOT/$relpath" "$dest" || { backup_abort snapshot_failed; return 1; } + chmod 0600 "$dest" || { backup_abort snapshot_failed; return 1; } + done < "$temp_dir/files.list" + cp "$temp_dir/uci.honk" "$temp_dir/archive/uci/honk" || { backup_abort uci_snapshot_failed; return 1; } + chmod 0600 "$temp_dir/archive/uci/honk" || { backup_abort uci_snapshot_failed; return 1; } + if [ "$state" = present ]; then + check_sqlite "$temp_dir/archive/etc/honk/state/honk.db" || { backup_abort sqlite_check_failed; return 1; } + fi + validate_candidate "$temp_dir/archive" "$temp_dir/archive/uci" "$state" + validate_rc=$? + if [ "$validate_rc" -ne 0 ]; then + if ! restore_running; then + [ "$validate_rc" = 2 ] && error unsupported_candidate_config_runtime_restore_failed || error candidate_validation_failed_runtime_restore_failed + else + [ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed + fi + return 1 + fi + [ "$u_initialized" = "$restore_initialized" ] || { backup_abort initialized_state_mismatch; return 1; } + ( + cd "$temp_dir/archive" || exit 1 + find etc uci -type f -print | sort > "$temp_dir/files.list" + ) + make_manifest "$temp_dir/archive" "$state" "$temp_dir/archive/manifest.tsv" || { backup_abort manifest_create_failed; return 1; } + ( + cd "$temp_dir/archive" || exit 1 + { printf '%s\n' manifest.tsv; cat "$temp_dir/files.list"; } > "$temp_dir/tar.list" + ulimit -f 65536 2>/dev/null || exit 1 + tar -czf "$temp_dir/backup.tar.gz" -T "$temp_dir/tar.list" + ) || { backup_abort archive_create_failed; return 1; } + [ "$(size_of "$temp_dir/backup.tar.gz")" -le "$MAX_COMPRESSED" ] || { backup_abort archive_too_large; return 1; } + check_tar_members "$temp_dir/backup.tar.gz" "$temp_dir/output-members" || { backup_abort archive_self_validation_failed; return 1; } + if [ -L "$BACKUP" ]; then backup_abort unsafe_backup_path; return 1; fi + mv -f "$temp_dir/backup.tar.gz" "$BACKUP" || { backup_abort backup_write_failed; return 1; } + chmod 0600 "$BACKUP" || { backup_abort backup_permissions_failed; return 1; } + if [ "$journal_active" = 1 ]; then + journal_write committed || { backup_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error backup_created_journal_cleanup_failed; return 1; } + journal_active=0 + fi + if ! restore_running; then backup_abort backup_created_service_restart_failed; return 1; fi + json_result true "$BACKUP" backup_created +} + +restore() { + check_root_file "$RESTORE" || { error unsafe_restore_path; return 1; } + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + archive_hash=$(sha_of "$RESTORE") + [ -n "$archive_hash" ] || { error archive_read_failed; return 1; } + restore_stage=$temp_dir/extracted + extract_archive "$RESTORE" "$restore_stage" + extract_rc=$? + [ "$extract_rc" = 0 ] || { [ "$extract_rc" = 2 ] && error incompatible_core_version || error invalid_archive; return 1; } + uci_dir=$restore_stage/uci + parse_uci_file "$uci_dir" || { error invalid_uci_config; return 1; } + archive_state=$(sed -n '3s/^state\t//p' "$restore_stage/manifest.tsv") + if [ "$archive_state" = present ]; then + archive_sqlite_check "$restore_stage/etc/honk/state/honk.db" || { error invalid_sqlite_state; return 1; } + fi + load_uci_values || { error current_uci_invalid; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error current_uci_snapshot_failed; return 1; } + capture_boot_links || { error boot_link_state_unavailable; return 1; } + if [ "$archive_state" = absent ] && { [ "$old_enabled" = 1 ] || [ "$old_boot" = 1 ] || [ "$old_running" = 1 ] || [ "$old_start_link" != - ]; }; then + error empty_archive_would_clear_active_state + return 1 + fi + validate_candidate "$restore_stage" "$uci_dir" "$archive_state" + validate_rc=$? + if [ "$validate_rc" -ne 0 ]; then + [ "$validate_rc" = 2 ] && error unsupported_candidate_config || error candidate_validation_failed + return 1 + fi + [ "$old_running" != 1 ] || [ "$restore_initialized" = 1 ] || { error running_service_requires_initialized_restore; return 1; } + [ "$(sha_of "$RESTORE")" = "$archive_hash" ] || { error restore_archive_changed; return 1; } + if [ "$u_config_mode" = managed ]; then + write_live_system "$restore_stage/etc/honk/system.dae" "$candidate_device" "$candidate_ip" "$u_port" "$restore_initialized" || { error system_config_render_failed; return 1; } + chmod 0600 "$restore_stage/etc/honk/system.dae" || { error system_config_render_failed; return 1; } + fi + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.enabled=$old_enabled" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.boot_enabled=$old_boot" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set "honk.main.initialized=$restore_initialized" || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" set honk.main.config_file=/etc/honk/config.dae || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" commit honk || { error restore_profile_stage_failed; return 1; } + load_uci_values uci "$uci_dir" || { error restore_profile_stage_failed; return 1; } + [ "$u_enabled:$u_boot:$u_initialized" = "$old_enabled:$old_boot:$restore_initialized" ] || { error restore_profile_stage_failed; return 1; } + uci -c "$uci_dir" -t "$temp_dir/uci-tmp" export honk > "$temp_dir/new-uci" 2>/dev/null || { error restore_profile_stage_failed; return 1; } + if [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ]; then :; else error current_tree_unavailable; return 1; fi + journal_create restore || { error transaction_journal_create_failed; return 1; } + mkdir "$TXN_DIR/new-tree" || { restore_abort restore_stage_failed; return 1; } + cp -pR "$restore_stage/etc/honk/." "$TXN_DIR/new-tree/" || { restore_abort restore_stage_failed; return 1; } + find "$TXN_DIR/new-tree" -type d -exec chmod 0700 {} \; 2>/dev/null + find "$TXN_DIR/new-tree" -type f -exec chmod 0600 {} \; 2>/dev/null + chmod 0600 "$TXN_DIR/new-tree/system.dae" || { restore_abort restore_stage_failed; return 1; } + if [ "$old_running" = 1 ]; then + service_stopped=1 + stop_managed || { restore_abort service_stop_failed; return 1; } + fi + journal_write stopped || { restore_abort transaction_journal_write_failed; return 1; } + if foreign_core_running; then restore_abort unmanaged_core_running; return 1; fi + old_tree_path=$TXN_DIR/old-tree + new_tree_path=$TXN_DIR/new-tree + if ! mv "$HONK_ROOT" "$old_tree_path"; then restore_abort tree_swap_failed; return 1; fi + swap_started=1 + journal_write old_moved || { restore_abort transaction_journal_write_failed; return 1; } + if ! mv "$new_tree_path" "$HONK_ROOT"; then restore_abort tree_swap_failed; return 1; fi + journal_write new_installed || { restore_abort transaction_journal_write_failed; return 1; } + if ! uci import honk < "$temp_dir/new-uci" || ! uci commit honk; then + apply_failed=1 + else + apply_failed=0 + fi + if [ "$apply_failed" = 0 ]; then + load_uci_values || apply_failed=1 + [ "$apply_failed" = 1 ] || [ "$u_enabled:$u_boot:$u_initialized" = "$old_enabled:$old_boot:$restore_initialized" ] || apply_failed=1 + if [ "$apply_failed" = 0 ] && [ "$old_running" = 1 ]; then + issue_capability candidate_health_start || apply_failed=1 + fi + fi + if [ "$apply_failed" = 1 ]; then + restore_abort restore_failed + return 1 + fi + journal_write service_started || { restore_abort transaction_journal_write_failed; return 1; } + journal_write committed || { restore_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error restore_succeeded_snapshot_cleanup_pending; return 1; } + journal_active=0 + swap_started=0 + service_stopped=0 + json_result true "" restored +} + +reset_data() { + if foreign_core_running; then error unmanaged_core_running; return 1; fi + if managed_core_pid >/dev/null 2>&1; then old_running=1; fi + if ! acquire_lock; then error "$lock_error"; return 1; fi + foreign_core_running && { error unmanaged_core_running; return 1; } + if [ "$old_running" = 1 ]; then managed_core_pid >/dev/null 2>&1 || { error service_state_changed; return 1; } + else managed_core_pid >/dev/null 2>&1 && { error service_state_changed; return 1; }; fi + temp_dir=$(mktemp -d /tmp/honk-maint.XXXXXX) || { error temporary_storage_unavailable; return 1; } + chmod 0700 "$temp_dir" || { error temporary_storage_unavailable; return 1; } + load_uci_values || { error current_uci_invalid; return 1; } + old_enabled=$u_enabled + old_boot=$u_boot + old_initialized=$u_initialized + uci export honk > "$temp_dir/old-uci" 2>/dev/null || { error current_uci_snapshot_failed; return 1; } + capture_boot_links || { error boot_link_state_unavailable; return 1; } + [ -d "$HONK_ROOT" ] && [ ! -L "$HONK_ROOT" ] || { error current_tree_unavailable; return 1; } + reset_stage=$temp_dir/reset-stage + reset_uci=$temp_dir/reset-uci + mkdir -p "$reset_stage/etc/honk" "$reset_uci" || { error temporary_storage_unavailable; return 1; } + [ -f /usr/share/honk/default-config.dae ] && [ ! -L /usr/share/honk/default-config.dae ] || { error default_config_unavailable; return 1; } + cp /usr/share/honk/default-config.dae "$reset_stage/etc/honk/config.dae" || { error reset_stage_failed; return 1; } + chmod 0600 "$reset_stage/etc/honk/config.dae" || { error reset_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" import honk < "$temp_dir/old-uci" || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.enabled=0 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.boot_enabled=0 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.config_mode=managed || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.listen_port=9527 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.lan_network=lan || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.clash_api_enabled=0 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.clash_api_port=9090 || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" set honk.main.clash_api_secret= || { error reset_profile_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" commit honk || { error reset_profile_stage_failed; return 1; } + load_uci_values uci "$reset_uci" || { error reset_profile_stage_failed; return 1; } + . /lib/functions/network.sh || { error lan_network_unavailable; return 1; } + reset_device= + reset_ip= + network_get_device reset_device "$u_network" || { error lan_network_unavailable; return 1; } + network_get_ipaddr reset_ip "$u_network" || { error lan_network_unavailable; return 1; } + write_live_system "$reset_stage/etc/honk/system.dae" "$reset_device" "$reset_ip" "$u_port" "$u_initialized" || { error system_config_render_failed; return 1; } + chmod 0600 "$reset_stage/etc/honk/system.dae" || { error system_config_render_failed; return 1; } + reset_candidate=$temp_dir/reset-candidate + mkdir -p "$reset_candidate/etc" || { error reset_stage_failed; return 1; } + cp -pR "$reset_stage/etc/honk" "$reset_candidate/etc/honk" || { error reset_stage_failed; return 1; } + uci -c "$reset_uci" -t "$temp_dir/uci-tmp" export honk > "$temp_dir/new-uci" 2>/dev/null || { error reset_profile_stage_failed; return 1; } + validate_candidate "$reset_candidate" "$reset_uci" absent + validate_rc=$? + [ "$validate_rc" = 0 ] || { [ "$validate_rc" = 2 ] && error default_config_unsupported || error default_config_validation_failed; return 1; } + journal_create reset || { error transaction_journal_create_failed; return 1; } + if [ "$old_running" = 1 ]; then + service_stopped=1 + stop_managed || { restore_abort service_stop_failed; return 1; } + fi + journal_write stopped || { restore_abort transaction_journal_write_failed; return 1; } + foreign_core_running && { restore_abort unmanaged_core_running; return 1; } + checkpoint_sqlite || { restore_abort sqlite_checkpoint_failed; return 1; } + rm -rf "$reset_stage/etc/honk" || { restore_abort reset_stage_failed; return 1; } + stage_reset_tree "$HONK_ROOT" "$reset_stage/etc/honk" "$reset_candidate/etc/honk/system.dae" /usr/share/honk/default-config.dae || { restore_abort reset_stage_failed; return 1; } + purge_business_state "$reset_stage/etc/honk/state/honk.db" || { restore_abort state_reset_failed; return 1; } + reset_archive_state=absent + if [ -f "$reset_stage/etc/honk/state/honk.db" ]; then reset_archive_state=present; fi + if [ "$reset_archive_state" = present ]; then archive_sqlite_check "$reset_stage/etc/honk/state/honk.db" || { restore_abort state_reset_validation_failed; return 1; }; fi + rm -rf "$reset_candidate" || { restore_abort reset_stage_failed; return 1; } + mkdir -p "$reset_candidate/etc" || { restore_abort reset_stage_failed; return 1; } + cp -pR "$reset_stage/etc/honk" "$reset_candidate/etc/honk" || { restore_abort reset_stage_failed; return 1; } + mkdir "$TXN_DIR/new-tree" || { restore_abort reset_stage_failed; return 1; } + cp -pR "$reset_candidate/etc/honk/." "$TXN_DIR/new-tree/" || { restore_abort reset_stage_failed; return 1; } + find "$TXN_DIR/new-tree" -type d -exec chmod 0700 {} \; 2>/dev/null + find "$TXN_DIR/new-tree" -type f -exec chmod 0600 {} \; 2>/dev/null + chmod 0600 "$TXN_DIR/new-tree/system.dae" || { restore_abort reset_stage_failed; return 1; } + foreign_core_running && { restore_abort unmanaged_core_running; return 1; } + old_tree_path=$TXN_DIR/old-tree + new_tree_path=$TXN_DIR/new-tree + mv "$HONK_ROOT" "$old_tree_path" || { restore_abort tree_swap_failed; return 1; } + swap_started=1 + journal_write old_moved || { restore_abort transaction_journal_write_failed; return 1; } + mv "$new_tree_path" "$HONK_ROOT" || { restore_abort tree_swap_failed; return 1; } + journal_write new_installed || { restore_abort transaction_journal_write_failed; return 1; } + uci import honk < "$temp_dir/new-uci" && uci commit honk || { restore_abort reset_failed; return 1; } + load_uci_values || { restore_abort reset_failed; return 1; } + [ "$u_enabled:$u_boot:$u_initialized:$u_network:$u_port" = "0:0:$old_initialized:lan:9527" ] || { restore_abort reset_failed; return 1; } + disable_boot_links || { restore_abort boot_link_update_failed; return 1; } + journal_write uci_applied || { restore_abort transaction_journal_write_failed; return 1; } + journal_write committed || { restore_abort transaction_journal_write_failed; return 1; } + rm -rf "$TXN_DIR" || { error reset_succeeded_snapshot_cleanup_pending; return 1; } + journal_active=0 + swap_started=0 + service_stopped=0 + json_result true "" data_reset +} + +stage_reset_tree() { + stage_source=$1 + stage_target=$2 + stage_system=$3 + stage_default=$4 + [ -d "$stage_source" ] && [ ! -L "$stage_source" ] || return 1 + [ -f "$stage_system" ] && [ ! -L "$stage_system" ] || return 1 + [ -f "$stage_default" ] && [ ! -L "$stage_default" ] || return 1 + mkdir -p "$stage_target/state" || return 1 + cp "$stage_default" "$stage_target/config.dae" || return 1 + cp "$stage_system" "$stage_target/system.dae" || return 1 + chmod 0600 "$stage_target/config.dae" || return 1 + chmod 0600 "$stage_target/system.dae" || return 1 + chmod 0700 "$stage_target/state" || return 1 + if [ -e "$stage_source/state/honk.db" ] || [ -L "$stage_source/state/honk.db" ]; then + [ -f "$stage_source/state/honk.db" ] && [ ! -L "$stage_source/state/honk.db" ] || return 1 + cp -p "$stage_source/state/honk.db" "$stage_target/state/honk.db" || return 1 + chmod 0600 "$stage_target/state/honk.db" || return 1 + fi +} + +if [ "${HONK_MAINT_LIBRARY:-0}" = 1 ]; then + trap - 0 HUP INT TERM + return 0 2>/dev/null || exit 0 +fi + +case "$1" in + backup) backup ;; + restore) restore ;; + reset) reset_data ;; + recover) recover_action ;; + *) error invalid_action ;; +esac diff --git a/honk/files/redact-log.sh b/honk/files/redact-log.sh new file mode 100644 index 00000000..0319b8eb --- /dev/null +++ b/honk/files/redact-log.sh @@ -0,0 +1,6 @@ +#!/bin/sh + +exec sed -E \ + -e 's#([[:alnum:]+.-]+://)[^[:space:]"<>]+#\1[redacted]#g' \ + -e 's/([Bb]earer[[:space:]]+)[^[:space:],;]+/\1[redacted]/g' \ + -e 's/([Pp][Aa][Ss][Ss][Ww][Oo][Rr][Dd]|[Tt][Oo][Kk][Ee][Nn]|[Ss][Ee][Cc][Rr][Ee][Tt]|[Aa][Uu][Tt][Hh][Oo][Rr][Ii][Zz][Aa][Tt][Ii][Oo][Nn])[^:=]*[=:][[:space:]]*.*/\1=[redacted]/' diff --git a/honk/files/system.dae b/honk/files/system.dae new file mode 100644 index 00000000..e85a994b --- /dev/null +++ b/honk/files/system.dae @@ -0,0 +1,22 @@ +global { + wan_interface: auto + lan_interface: br-lan + log_level: info + dial_mode: domain + allow_insecure: false + auto_config_kernel_parameter: true + data_dir: '/etc/honk' + store_subscribe: true + nfqueue_enable: true +} + +experimental { + native_api { + enabled: true + listen: '127.0.0.1:9527' + password_auth: true + allow_anonymous_loopback: false + config_write: true + ui: '/usr/share/doona' + } +} diff --git a/honk/files/update.sh b/honk/files/update.sh new file mode 100644 index 00000000..cab15e4f --- /dev/null +++ b/honk/files/update.sh @@ -0,0 +1,292 @@ +#!/bin/sh + +PATH=/usr/sbin:/usr/bin:/sbin:/bin +export PATH +umask 077 +. /usr/share/libubox/jshn.sh + +UPDATE_ROOT=/tmp/honk-update +TRUST_ROOT=/etc/honk/update-trust +FEED_FILE=$TRUST_ROOT/feed.url +MANIFEST_KEY=$TRUST_ROOT/manifest.pub +MANIFEST=$UPDATE_ROOT/manifest.json +SIGNATURE=$UPDATE_ROOT/manifest.json.sig +RECORDS=$UPDATE_ROOT/packages.tsv +UPDATE_LOCK_OWNED=0 +CHECK_DIR= + +update_lock_acquire() { + . /usr/share/honk/lock.sh || return 1 + honk_lock_acquire || return 1 + UPDATE_LOCK_OWNED=1 +} + +update_lock_release() { + [ "$UPDATE_LOCK_OWNED" = 1 ] || return 0 + honk_lock_release >/dev/null 2>&1 || return 1 + UPDATE_LOCK_OWNED=0 +} + +discard_check_dir() { + case "$CHECK_DIR" in + "$UPDATE_ROOT"/.check.*) + [ -d "$CHECK_DIR" ] && [ ! -L "$CHECK_DIR" ] && rm -rf "$CHECK_DIR" + ;; + esac + CHECK_DIR= +} + +cleanup_update() { + discard_check_dir + update_lock_release || : +} + +emit_update() { + json_init + json_add_boolean ok "$1" + json_add_boolean available "$2" + json_add_boolean apply_enabled "$3" + json_add_string message "$4" + json_add_string reason "$5" + [ -z "$6" ] || json_add_string version "$6" + json_dump +} + +safe_file() { + [ -f "$1" ] && [ ! -L "$1" ] && [ "$(stat -c '%u:%a' "$1" 2>/dev/null)" = "0:$2" ] +} + +prepare_update_root() { + if [ -e "$UPDATE_ROOT" ] || [ -L "$UPDATE_ROOT" ]; then + [ -d "$UPDATE_ROOT" ] && [ ! -L "$UPDATE_ROOT" ] && [ "$(stat -c '%u:%a' "$UPDATE_ROOT" 2>/dev/null)" = 0:700 ] || return 1 + else + mkdir "$UPDATE_ROOT" && chmod 0700 "$UPDATE_ROOT" || return 1 + fi +} + +read_trust() { + [ -d "$TRUST_ROOT" ] && [ ! -L "$TRUST_ROOT" ] && [ "$(stat -c '%u:%a' "$TRUST_ROOT" 2>/dev/null)" = 0:700 ] || return 1 + safe_file "$FEED_FILE" 600 && safe_file "$MANIFEST_KEY" 600 || return 1 + [ "$(wc -c < "$FEED_FILE" | tr -d ' ')" -le 512 ] || return 1 + feed_url=$(cat "$FEED_FILE") || return 1 + [ -n "$feed_url" ] || return 1 + case "$feed_url" in *'?'*|*'#'*|*'@'*|*' '*|*'\'*|*'"'*|*"'"*) return 1 ;; esac + echo "$feed_url" | grep -Eq '^https://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/%+-]*)?$' || return 1 + return 0 +} + +manifest_field() { + jsonfilter -i "$MANIFEST" -e "$1" 2>/dev/null +} + +installed_version() { + awk -v wanted="$1" ' + $0 == "P:" wanted { found=1; next } + found && /^V:/ { print substr($0, 3); exit } + found && /^P:/ { exit } + ' /lib/apk/db/installed 2>/dev/null +} + +validate_manifest() { + validate_url=$1 + validate_json=$(cat "$MANIFEST") || return 1 + json_load "$validate_json" 2>/dev/null || return 1 + json_get_type validate_packages_type packages + [ "$validate_packages_type" = array ] || return 1 + json_select packages || return 1 + json_get_keys validate_package_keys + [ "$(printf '%s\n' "$validate_package_keys" | wc -w | tr -d ' ')" = 2 ] || return 1 + json_select .. || return 1 + [ "$(manifest_field '@.schema')" = 1 ] || return 1 + [ "$(manifest_field '@.product')" = openwrt-honk ] || return 1 + # Target must match this device: map uname -m to manifest arch names. + case "$(uname -m)" in + x86_64) _local_arch=x86_64 ;; + aarch64) _local_arch=aarch64 ;; + armv7l) _local_arch=armv7 ;; + *) return 1 ;; + esac + [ "$(manifest_field '@.target.architecture')" = "$_local_arch" ] || return 1 + # OpenWrt version must match: 24.10 (opkg/ipk) or 25.12 (apk). + _local_owrt=$(sed -n 's/^DISTRIB_RELEASE="\([0-9]*\.[0-9]*\).*/\1/p' /etc/openwrt_release 2>/dev/null | head -n 1) + case "$_local_owrt" in 24.10|25.12) ;; *) return 1 ;; esac + [ "$(manifest_field '@.target.openwrt')" = "$_local_owrt" ] || return 1 + case "$_local_owrt" in + 24.10) [ "$(manifest_field '@.target.package_format')" = ipk ] || return 1 ;; + 25.12) [ "$(manifest_field '@.target.package_format')" = apk ] || return 1 ;; + esac + [ "$(manifest_field '@.target.kernel_min')" = 6.12 ] || return 1 + [ "$(manifest_field '@.target.requires_btf')" = true ] || return 1 + [ "$(manifest_field '@.min_updater_api')" = 1 ] || return 1 + [ "$(manifest_field '@.state_schema')" = 2 ] || return 1 + # (arch and OpenWrt version already validated against local system above) + [ -r /sys/kernel/btf/vmlinux ] || return 1 + kernel_version=$(uname -r | sed 's/-.*//') + kernel_major=${kernel_version%%.*} + kernel_minor=${kernel_version#*.} + kernel_minor=${kernel_minor%%.*} + case "$kernel_major:$kernel_minor" in *[!0-9:]*|:*) return 1 ;; esac + [ "$kernel_major" -gt 6 ] || { [ "$kernel_major" = 6 ] && [ "$kernel_minor" -ge 12 ]; } || return 1 + validate_commit=$(manifest_field '@.core.commit') + case "$validate_commit" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_commit}" -eq 40 ] || return 1 + validate_commit=$(manifest_field '@.doona.commit') + case "$validate_commit" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_commit}" -eq 40 ] || return 1 + for validate_sha in \ + "$(manifest_field '@.core.artifact_sha256')" \ + "$(manifest_field '@.doona.patch_sha256')" \ + "$(manifest_field '@.doona.artifact_sha256')" \ + "$(manifest_field '@.sdk_sha256')"; do + case "$validate_sha" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_sha}" -eq 64 ] || return 1 + done + validate_version=$(manifest_field '@.packages[0].version') + case "$validate_version" in ''|*[!A-Za-z0-9.+~-]*) return 1 ;; esac + : > "$RECORDS" || return 1 + validate_seen=' ' + validate_available=0 + for validate_index in 0 1; do + validate_name=$(manifest_field "@.packages[$validate_index].name") + validate_version=$(manifest_field "@.packages[$validate_index].version") + validate_arch=$(manifest_field "@.packages[$validate_index].arch") + validate_filename=$(manifest_field "@.packages[$validate_index].filename") + validate_size=$(manifest_field "@.packages[$validate_index].size") + validate_sha=$(manifest_field "@.packages[$validate_index].sha256") + validate_package_url=$(manifest_field "@.packages[$validate_index].url") + case "$validate_name" in honk|luci-app-honk) ;; *) return 1 ;; esac + case "$validate_seen" in *" $validate_name "*) return 1 ;; esac + validate_seen="$validate_seen$validate_name " + case "$validate_version" in ''|*[!A-Za-z0-9.+~-]*) return 1 ;; esac + case "$validate_arch" in x86_64|aarch64|armv7|noarch) ;; *) return 1 ;; esac + case "$validate_name:$validate_arch" in honk:x86_64|honk:aarch64|honk:armv7|luci-app-honk:x86_64|luci-app-honk:aarch64|luci-app-honk:armv7|luci-app-honk:noarch) ;; *) return 1 ;; esac + [ "$validate_filename" = "$validate_name-$validate_version.apk" ] || [ "$validate_filename" = "${validate_name}_${validate_version}_${validate_arch}.ipk" ] || return 1 + case "$validate_filename" in *'/'*|*'..'*|*[!A-Za-z0-9.+_-]*) return 1 ;; esac + case "$validate_size" in ''|*[!0-9]*) return 1 ;; esac + [ "$validate_size" -gt 0 ] && [ "$validate_size" -le 134217728 ] || return 1 + case "$validate_sha" in *[!0-9a-f]*|'') return 1 ;; esac + [ "${#validate_sha}" -eq 64 ] || return 1 + [ "$validate_package_url" = "${validate_url%/}/packages/$validate_filename" ] || return 1 + printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\n' "$validate_name" "$validate_version" "$validate_arch" "$validate_filename" "$validate_size" "$validate_sha" "$validate_package_url" >> "$RECORDS" || return 1 + [ "$(installed_version "$validate_name")" = "$validate_version" ] || validate_available=1 + done + [ "$(wc -l < "$RECORDS" | tr -d ' ')" = 2 ] || return 1 + for validate_required in honk luci-app-honk; do + case "$validate_seen" in *" $validate_required "*) ;; *) return 1 ;; esac + done + update_version=$(awk -F '\t' '$1 == "honk" { print $2; exit }' "$RECORDS") + return 0 +} + +verify_signed_manifest() { + read_trust || return 2 + prepare_update_root || return 3 + CHECK_DIR=$(mktemp -d "$UPDATE_ROOT/.check.XXXXXX") || return 3 + check_dir=$CHECK_DIR + chmod 0700 "$check_dir" || { discard_check_dir; return 3; } + check_manifest=$check_dir/manifest.json + check_signature=$check_dir/manifest.json.sig + if ! curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 4 --max-time 12 --max-filesize 65536 \ + "${feed_url%/}/manifest.json" -o "$check_manifest" 2>/dev/null || + ! curl -fLsS --proto '=https' --proto-redir '=https' --tlsv1.2 --connect-timeout 4 --max-time 8 --max-filesize 4096 \ + "${feed_url%/}/manifest.json.sig" -o "$check_signature" 2>/dev/null || + ! usign -V -m "$check_manifest" -p "$MANIFEST_KEY" -x "$check_signature" >/dev/null 2>&1; then + discard_check_dir + return 4 + fi + MANIFEST=$check_manifest + RECORDS=$check_dir/packages.tsv + if ! validate_manifest "$feed_url"; then + discard_check_dir + return 5 + fi + cp "$check_manifest" "$UPDATE_ROOT/manifest.json.new" && cp "$check_signature" "$UPDATE_ROOT/manifest.json.sig.new" || { discard_check_dir; return 3; } + cp "$RECORDS" "$UPDATE_ROOT/packages.tsv.new" || { discard_check_dir; return 3; } + printf '%s\n' "$(date +%s)" > "$UPDATE_ROOT/checked-at.new" || { discard_check_dir; return 3; } + chmod 0600 "$UPDATE_ROOT/manifest.json.new" "$UPDATE_ROOT/manifest.json.sig.new" "$UPDATE_ROOT/packages.tsv.new" "$UPDATE_ROOT/checked-at.new" || { discard_check_dir; return 3; } + mv -f "$UPDATE_ROOT/manifest.json.new" "$UPDATE_ROOT/manifest.json" && + mv -f "$UPDATE_ROOT/manifest.json.sig.new" "$UPDATE_ROOT/manifest.json.sig" && + mv -f "$UPDATE_ROOT/packages.tsv.new" "$UPDATE_ROOT/packages.tsv" && + mv -f "$UPDATE_ROOT/checked-at.new" "$UPDATE_ROOT/checked-at" || { discard_check_dir; return 3; } + discard_check_dir + return 0 +} + +update_check() { + read_trust || { emit_update true false false no_release trusted_feed_unavailable; return 0; } + update_lock_acquire || { emit_update false false false update_check_failed operation_in_progress; return 1; } + verify_signed_manifest + check_rc=$? + update_lock_release + case "$check_rc" in + 2) emit_update true false false no_release trusted_feed_unavailable; return 0 ;; + 4) emit_update false false false update_check_failed signed_manifest_unavailable; return 1 ;; + 3) emit_update false false false update_check_failed update_storage_unavailable; return 1 ;; + 5) emit_update false false false update_check_failed manifest_incompatible; return 1 ;; + 0) ;; + *) emit_update false false false update_check_failed manifest_invalid; return 1 ;; + esac + if [ "$validate_available" = 1 ]; then + emit_update true true false update_available rollback_tuple_unavailable "$update_version" + else + emit_update true false false no_update already_current "$update_version" + fi +} + +update_gate() { + read_trust || { emit_update true false false no_release trusted_feed_unavailable; return 0; } + update_lock_acquire || { emit_update false false false update_check_failed operation_in_progress; return 1; } + gate_ok=1 + gate_available=0 + gate_reason= + prepare_update_root || { gate_ok=0; gate_reason=update_storage_unavailable; } + if [ "$gate_ok" = 1 ]; then + for gate_file in "$UPDATE_ROOT/manifest.json" "$UPDATE_ROOT/manifest.json.sig" "$UPDATE_ROOT/packages.tsv" "$UPDATE_ROOT/checked-at"; do + safe_file "$gate_file" 600 || { gate_reason=signed_manifest_missing; break; } + done + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + checked_at=$(cat "$UPDATE_ROOT/checked-at") + case "$checked_at" in ''|*[!0-9]*) gate_reason=signed_manifest_expired ;; esac + if [ -z "$gate_reason" ] && [ $(( $(date +%s) - checked_at )) -gt 600 ]; then gate_reason=signed_manifest_expired; fi + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + usign -V -m "$UPDATE_ROOT/manifest.json" -p "$MANIFEST_KEY" -x "$UPDATE_ROOT/manifest.json.sig" >/dev/null 2>&1 || { gate_ok=0; gate_reason=manifest_signature_invalid; } + fi + if [ "$gate_ok" = 1 ] && [ -z "$gate_reason" ]; then + MANIFEST=$UPDATE_ROOT/manifest.json + RECORDS=$UPDATE_ROOT/packages.tsv + validate_manifest "$feed_url" || { gate_ok=0; gate_reason=manifest_incompatible; } + fi + gate_available=${validate_available:-0} + gate_version=${update_version:-} + update_lock_release + if [ "$gate_ok" != 1 ]; then emit_update false false false update_check_failed "$gate_reason"; return 1; fi + if [ -n "$gate_reason" ]; then emit_update true false false no_release "$gate_reason"; return 0; fi + if [ "$gate_available" = 1 ]; then + emit_update true true false update_available rollback_tuple_unavailable "$gate_version" + else + emit_update true false false no_update already_current "$gate_version" + fi +} + +update_apply() { + update_gate >/dev/null || { emit_update false false false update_apply_failed trusted_manifest_invalid; return 1; } + [ "$validate_available" = 1 ] || { emit_update false false false update_apply_failed no_update; return 1; } + # No previous signed package tuple or exercised downgrade path is available yet. + # Keep the backend gate closed even when the update manifest itself is compatible. + emit_update false false false update_apply_failed rollback_tuple_unavailable + return 1 +} + +trap cleanup_update EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +case "$1" in + check) update_check ;; + gate) update_gate ;; + apply) update_apply ;; + *) emit_update false false false update_check_failed invalid_action; exit 2 ;; +esac diff --git a/luci-app-honk/Makefile b/luci-app-honk/Makefile index 518ded3e..ba436484 100644 --- a/luci-app-honk/Makefile +++ b/luci-app-honk/Makefile @@ -1,19 +1,112 @@ -# Copyright (C) 2016 Openwrt.org -# -# This is free software, licensed under the Apache License, Version 2.0 . +# SPDX-License-Identifier: GPL-3.0-only # +# Copyright (C) 2026 kenzok8 include $(TOPDIR)/rules.mk PKG_NAME:=luci-app-honk -PKG_VERSION:=1.1.4 -PKG_RELEASE:=11 +PKG_VERSION:=2026.11.01 +PKG_RELEASE:=1 +PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME) -LUCI_TITLE:=LuCI support for honk -LUCI_PKGARCH:=all -LUCI_DEPENDS:=+honk +luci-base +rpcd-mod-ucode +unzip +PKG_SOURCE:=doona-4569bd91-57b2b25287ba.tar.gz +# Each package Makefile is evaluated on its own, so this tag is defined here too +# instead of relying on the honk package's variable. It must match honk/Makefile: +# scripts/check.sh enforces that. +HONK_SOURCE_TAG?=source-41281523460 +PKG_SOURCE_URL?=https://github.com/kenzok8/openwrt-honk/releases/download/$(HONK_SOURCE_TAG) +PKG_SOURCE_SUBDIR:=doona +PKG_HASH:=57b2b25287bae791e9a01e14e0c2deb1351e4135933959214d8996ed05458838 -include $(TOPDIR)/feeds/luci/luci.mk +-include $(CURDIR)/generated-doona-stage.mk -# call BuildPackage - OpenWrt buildroot signature +PKG_BUILD_DEPENDS:=luci-base/host +include $(INCLUDE_DIR)/package.mk + +define Package/luci-app-honk + SECTION:=luci + CATEGORY:=LuCI + SUBMENU:=3. Applications + TITLE:=LuCI support for Honk + DEPENDS:=+honk +rpcd +luci-base +cgi-io +jsonfilter + PKGARCH:=all + URL:=https://github.com/kenzok8/openwrt-honk + MAINTAINER:=kenzok8 +endef + +define Package/luci-app-honk/description + LuCI web interface for managing Honk. +endef + +define Build/Prepare + mkdir -p $(PKG_BUILD_DIR)/root/usr/share/doona + $(TAR) --strip-components=1 -C $(PKG_BUILD_DIR)/root/usr/share/doona -xzf $(DL_DIR)/$(PKG_SOURCE) + $(SED) 's#^Build patch: .*#Build patch: ci/patches/doona-openwrt.patch#' $(PKG_BUILD_DIR)/root/usr/share/doona/SOURCE + $(INSTALL_DIR) $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona + $(INSTALL_DATA) $(PKG_BUILD_DIR)/root/usr/share/doona/LICENSE $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/LICENSE + $(INSTALL_DATA) $(PKG_BUILD_DIR)/root/usr/share/doona/NOTICE $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/NOTICE + $(INSTALL_DATA) $(PKG_BUILD_DIR)/root/usr/share/doona/THIRD-PARTY-NOTICES.txt $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/THIRD-PARTY-NOTICES.txt + $(INSTALL_DIR) $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/LICENSES + $(CP) $(PKG_BUILD_DIR)/root/usr/share/doona/LICENSES/* $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/LICENSES/ + po2lmo ./po/zh-cn/honk.po $(PKG_BUILD_DIR)/honk.zh-cn.lmo +endef + +define Build/Configure +endef + +define Build/Compile +endef + +define Package/luci-app-honk/install + $(INSTALL_DIR) $(1)/usr/libexec/rpcd + $(INSTALL_BIN) ./root/usr/libexec/rpcd/honk $(1)/usr/libexec/rpcd/honk + + $(INSTALL_DIR) $(1)/usr/share/luci-app-honk + $(INSTALL_BIN) ./root/usr/share/luci-app-honk/pkg-info.sh $(1)/usr/share/luci-app-honk/pkg-info.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-honk/refresh-index.sh $(1)/usr/share/luci-app-honk/refresh-index.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-honk/update-pkg.sh $(1)/usr/share/luci-app-honk/update-pkg.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-honk/update-geo.sh $(1)/usr/share/luci-app-honk/update-geo.sh + $(INSTALL_BIN) ./root/usr/share/luci-app-honk/geo-cron.sh $(1)/usr/share/luci-app-honk/geo-cron.sh + + $(INSTALL_DIR) $(1)/usr/share/luci/menu.d + $(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-honk.json $(1)/usr/share/luci/menu.d/luci-app-honk.json + + $(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d + $(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-honk.json $(1)/usr/share/rpcd/acl.d/luci-app-honk.json + + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/honk + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/honk/*.js $(1)/www/luci-static/resources/view/honk/ + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/honk/*.css $(1)/www/luci-static/resources/view/honk/ + $(INSTALL_DIR) $(1)/www/luci-static/resources/view/honk/vendor + $(INSTALL_DATA) ./htdocs/luci-static/resources/view/honk/vendor/* $(1)/www/luci-static/resources/view/honk/vendor/ + + $(INSTALL_DIR) $(1)/usr/share/doona + $(CP) $(PKG_BUILD_DIR)/root/usr/share/doona/* $(1)/usr/share/doona/ + $(INSTALL_DIR) $(1)/usr/share/licenses/honk-doona + $(CP) $(PKG_BUILD_DIR)/root/usr/share/licenses/honk-doona/* $(1)/usr/share/licenses/honk-doona/ + + $(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n + $(INSTALL_DATA) $(PKG_BUILD_DIR)/honk.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/honk.zh-cn.lmo +endef + +define Package/luci-app-honk/postinst +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { \ + rm -f /tmp/luci-indexcache.* + rm -rf /tmp/luci-modulecache/ + /etc/init.d/rpcd reload 2>/dev/null + /etc/init.d/uhttpd restart 2>/dev/null + exit 0 +} +endef + +define Package/luci-app-honk/postrm +#!/bin/sh +[ -n "$${IPKG_INSTROOT}" ] || { \ + rm -rf /tmp/luci-indexcache /tmp/luci-indexcache.* /tmp/luci-modulecache + exit 0 +} +endef + +$(eval $(call BuildPackage,luci-app-honk)) diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/configuration.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/configuration.js new file mode 100644 index 00000000..0e5c2938 --- /dev/null +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/configuration.js @@ -0,0 +1,798 @@ +// SPDX-License-Identifier: GPL-3.0-only + +'use strict'; +'require rpc'; +'require view'; +'require view.honk.rpc as honk'; +'require view.honk.converter as converter'; +'require ui'; + +const DAE_LIMIT = 2 * 1024 * 1024; +const PASTE_LIMIT = 2 * 1024 * 1024; + +function utf8Length(value) { + return new TextEncoder().encode(value).length; +} + +function isHttpUrl(value) { + try { + const url = new URL(value); + return (url.protocol === 'http:' || url.protocol === 'https:') && !!url.hostname; + } + catch (e) { + return false; + } +} + +function loadYamlParser() { + if (window.jsyaml && window.jsyaml.load) + return Promise.resolve(window.jsyaml); + + return new Promise(function(resolve, reject) { + const script = document.createElement('script'); + script.src = L.resource('view/honk/vendor/js-yaml.min.js'); + script.onload = function() { + if (window.jsyaml && window.jsyaml.load) + resolve(window.jsyaml); + else + reject(new Error(_('YAML parser failed to load'))); + }; + script.onerror = function() { reject(new Error(_('YAML parser failed to load'))); }; + document.head.appendChild(script); + }); +} + +function parsePasted(content) { + let text = String(content || '').trim(); + if (!text) + return Promise.resolve({ links: [], rejected: 1 }); + + if (!converter.looksLikeNodeList(text)) { + const decoded = converter.tryBase64Decode(text); + if (decoded && (converter.looksLikeNodeList(decoded) || + converter.looksLikeClashYaml(decoded) || converter.looksLikeSurge(decoded))) + text = decoded; + } + + if (converter.looksLikeMihomoJson(text)) + return Promise.resolve(converter.parseMihomoJson(text)); + + if (converter.looksLikeClashYaml(text)) { + return loadYamlParser().then(function(yaml) { + const doc = yaml.load(text); + const proxies = (doc && doc.proxies) || []; + const links = []; + let rejected = 0; + proxies.forEach(function(node) { + if (converter.isMetadataProxy(node)) { + rejected++; + return; + } + const result = converter.convertProxy(node); + if (result.ok) links.push(result.link); + else rejected++; + }); + return { links: links, rejected: rejected }; + }); + } + + if (converter.looksLikeSurge(text)) + return Promise.resolve(converter.parseSurgeProxies(text)); + + return Promise.resolve(converter.parseUriList(text)); +} + + +return view.extend({ + handleSave: null, + handleSaveApply: null, + handleReset: null, + + render: function() { + honk.installStyles(); + + const page = E('div', { 'class': 'cbi-map honk-page' }); + page.appendChild(E('div', { 'class': 'honk-header' }, [ + E('h2', {}, _('Configuration')), + E('p', { 'class': 'honk-header-sub' }, _('Honk reads its nodes, subscriptions and routing from /etc/honk/config.dae.')) + ])); + + const modeText = E('span', {}, ''); + const modeNote = E('p', { 'class': 'honk-note honk-mode-note' }, [ modeText ]); + page.appendChild(modeNote); + + /* --- Add subscription --- */ + const subStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, ''); + const nameInput = E('input', { + 'class': 'cbi-input-text', 'name': 'sub_name', + 'placeholder': _('Name, e.g. MyAirport'), 'maxlength': '256' + }); + const urlInput = E('input', { + 'class': 'cbi-input-text', 'name': 'sub_url', + 'placeholder': 'https://…', 'inputmode': 'url' + }); + const subBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Add subscription') ]); + + subBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + const name = nameInput.value.trim(); + const url = urlInput.value.trim(); + if (!name) { subStatus.textContent = _('Name is required.'); nameInput.focus(); return; } + if (!isHttpUrl(url)) { subStatus.textContent = _('A valid HTTP or HTTPS URL is required.'); urlInput.focus(); return; } + subBtn.disabled = true; + preflightSubscription(url, subStatus).then(function(proceed) { + if (!proceed) + return null; + subStatus.textContent = _('Adding subscription…'); + return withLongTimeout(function() { + return honk.addSubscription(name, url).then(honk.ensureOk); + }).then(function(result) { + subStatus.textContent = honk.resultMessage(result, _('Subscription saved with usable nodes.')); + nameInput.value = ''; + urlInput.value = ''; + }).catch(function(error) { + subStatus.textContent = honk.errorMessage(error, _('Could not add the subscription.')); + }); + }).finally(function() { subBtn.disabled = false; }); + }); + + const subModeHint = E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: this tool is disabled until you restore managed mode (link at the top).')); + subModeHint.hidden = true; + const subSection = E('section', { 'class': 'honk-card' }, [ + E('p', { 'class': 'honk-note' }, _('Enter a name and subscription URL. Honk checks for usable nodes before saving.')), + subModeHint, + E('div', { 'class': 'honk-subscription-add' }, [ nameInput, urlInput, subBtn ]), + subStatus + ]); + + /* LuCI 的 rpc 默认 20s 超时;核心校验节点、重启服务都可能超过它, + 期间客户端一断,服务端处理器会被杀掉(留下半完成状态)。 */ + function withLongTimeout(fn) { + const previous = L.env.rpctimeout; + L.env.rpctimeout = 180; + return Promise.resolve().then(fn).finally(function() { + L.env.rpctimeout = previous; + }); + } + + /* honk 内核支持的节点类型(见 core 的 clash.rs protocol_from_name): + ssr / snell / 带插件的 ss / 小众协议都会被内核判为不支持。 */ + const SUPPORTED_PROTOCOLS = { + 'socks': 1, 'socks5': 1, 'ss': 1, 'shadowsocks': 1, 'trojan': 1, + 'vmess': 1, 'vless': 1, 'hysteria': 1, 'hysteria2': 1, 'hy2': 1, + 'tuic': 1, 'juicity': 1, 'anytls': 1 + }; + /* JSON 订阅里 outbounds 会混进这些非节点类型,不能算作节点。 */ + const NON_NODE_TYPES = { + 'direct': 1, 'block': 1, 'dns': 1, 'selector': 1, 'urltest': 1, + 'url-test': 1, 'fallback': 1, 'load-balance': 1, 'relay': 1, 'reject': 1 + }; + + function analyzeProbe(probe) { + const counts = {}; + let total = 0; + let usable = 0; + let unsupported = 0; + const bump = function(type) { + const name = String(type || '').toLowerCase(); + if (!name || NON_NODE_TYPES[name]) + return; + counts[name] = (counts[name] || 0) + 1; + total++; + if (SUPPORTED_PROTOCOLS[name]) + usable++; + else + unsupported++; + }; + + if (probe.format === 'base64' && probe.content) { + const decoded = converter.tryBase64Decode(probe.content) || ''; + (decoded.match(/^[A-Za-z0-9+.-]+:\/\//gim) || []).forEach(function(scheme) { + bump(scheme.replace(/:\/\/$/, '')); + }); + } + else if (probe.types) { + String(probe.types).trim().split(/\s+/).forEach(function(pair) { + const parts = pair.split(':'); + if (parts.length === 2) { + const n = parseInt(parts[1], 10); + for (let i = 0; i < (isNaN(n) ? 0 : n); i++) + bump(parts[0]); + } + }); + } + + const labels = { + clash: 'Clash / mihomo YAML', + json: 'JSON(sing-box 等)', + records: 'Surge / Loon 记录式 INI', + uri: 'URI 列表', + base64: 'Base64' + }; + const label = labels[probe.format] || _('unknown format'); + const detail = Object.keys(counts).sort().map(function(type) { + return type + ' ×' + counts[type] + (SUPPORTED_PROTOCOLS[type] ? '' : ' ' + _('unsupported')); + }).join(', '); + const text = total + ? _('Detected %s · %d node(s): %s').format(label, total, detail) + : _('Detected %s.').format(label); + + return { text: text, total: total, usable: usable, unsupported: unsupported }; + } + + /* 保存前预检:能全部用就直接保存;全不可用就拦下;部分可用问一句。 + 预检本身失败(网络抖动等)不拦保存,退回原来的行为。 */ + function preflightSubscription(url, status) { + status.textContent = _('Checking the subscription before saving…'); + return honk.probeSubscription(url).then(function(probe) { + const verdict = analyzeProbe(probe); + if (!verdict.total) + return true; + status.textContent = verdict.text; + if (verdict.usable === 0) { + status.textContent = verdict.text + ' ' + _('This core cannot use any of these nodes, so the subscription was not saved.'); + return false; + } + if (verdict.unsupported && !confirm(verdict.text + '\n\n' + _('Save the subscription anyway?'))) + return false; + return true; + }).catch(function() { + return true; + }); + } + + /* --- Paste nodes --- */ + const pasteStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, ''); + const pasteInput = E('textarea', { + 'class': 'cbi-input-textarea', 'name': 'paste', + 'rows': '10', 'spellcheck': 'false', + 'placeholder': _('Paste Clash YAML, Surge INI, Base64 or share links.') + }); + const pasteBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Import nodes') ]); + + pasteBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + const content = pasteInput.value.trim(); + if (!content) { pasteStatus.textContent = _('Paste nodes or subscription content first.'); pasteInput.focus(); return; } + if (utf8Length(content) > PASTE_LIMIT) { pasteStatus.textContent = _('The pasted content is too large.'); return; } + pasteBtn.disabled = true; + pasteStatus.textContent = _('Parsing nodes…'); + parsePasted(content).then(function(parsed) { + const links = (parsed && parsed.links) || []; + if (!links.length) { + pasteStatus.textContent = _('No nodes could be parsed from the input.'); + return; + } + pasteStatus.textContent = _('Importing %d node(s)…').format(links.length); + return honk.addNodes(links.join('\n')).then(honk.ensureOk).then(function(result) { + pasteStatus.textContent = honk.resultMessage(result, _('%d node(s) imported.').format(links.length)); + pasteInput.value = ''; + }); + }).catch(function(error) { + pasteStatus.textContent = honk.errorMessage(error, _('Could not import nodes.')); + }).finally(function() { pasteBtn.disabled = false; }); + }); + + const pasteModeHint = E('p', { 'class': 'honk-note honk-mode-hint' }, _('Manual mode: this tool is disabled until you restore managed mode (link at the top).')); + pasteModeHint.hidden = true; + const pasteSection = E('section', { 'class': 'honk-card' }, [ + E('p', { 'class': 'honk-note' }, _('Paste Clash YAML, Surge INI, Base64 or share links. They are parsed locally into nodes and appended to /etc/honk/config.dae.')), + pasteModeHint, + pasteInput, + E('div', { 'class': 'honk-actions' }, [ pasteBtn ]), + pasteStatus + ]); + + /* --- Import a complete configuration and start Honk with it --- */ + const importStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, ''); + /* 原生 file 控件在各主题下都被拉成一个大盒子、内嵌按钮贴着左上角, + 和右边的按钮对不齐。改成隐藏原生控件、用 label 当按钮。 */ + const fileInput = E('input', { 'type': 'file', 'name': 'dae', 'accept': '.dae,text/plain', 'hidden': 'hidden' }); + const fileName = E('span', { 'class': 'honk-file-name' }, _('No file chosen')); + const filePick = E('label', { 'class': 'cbi-button honk-file-pick' }, [ _('Choose file'), fileInput ]); + fileInput.addEventListener('change', function() { + const picked = fileInput.files && fileInput.files[0]; + fileName.textContent = picked ? picked.name : _('No file chosen'); + }); + const importBtn = E('button', { 'class': 'cbi-button cbi-button-apply', 'type': 'button' }, [ _('Import and start') ]); + + const importSection = E('section', { 'class': 'honk-card' }, [ + E('p', { 'class': 'honk-note' }, _('Upload a complete dae file (subscriptions, nodes and routing included) and Honk starts with it: the file is validated, written to /etc/honk/config.dae and the service is restarted. To hand-edit or paste instead, use the editor below.')), + E('div', { 'class': 'honk-file-row' }, [ filePick, fileName, importBtn ]), + importStatus + ]); + + /* --- Direct configuration --- */ + const editorStatus = E('p', { 'class': 'honk-note', 'role': 'status', 'aria-live': 'polite' }, ''); + const editor = E('textarea', { + 'class': 'cbi-input-textarea honk-config-editor', 'rows': '26', + 'spellcheck': 'false', 'wrap': 'off', 'aria-label': _('Full dae configuration') + }); + const saveBtn = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'button' }, [ _('Save and apply') ]); + const reloadBtn = E('button', { 'class': 'cbi-button', 'type': 'button' }, [ _('Reload from disk') ]); + const validateBtn = E('button', { 'class': 'cbi-button cbi-button-link', 'type': 'button' }, [ _('Check configuration') ]); + const managedBtn = E('a', { 'href': '#', 'class': 'honk-inline-link', 'role': 'button' }, [ _('Restore managed mode') ]); + managedBtn.hidden = true; + modeNote.appendChild(managedBtn); + + let editorBaseline = ''; + + function setEditorContent(content, message) { + editor.value = content || ''; + editorBaseline = editor.value; + editorStatus.textContent = message || ''; + } + + function confirmEditorReplace() { + return editor.value === editorBaseline || confirm(_('Discard the unsaved editor changes?')); + } + + function isOk(result) { + return !!result && (result.ok === true || result.ok === 1); + } + + /* Validation result -> one readable line. Valid configs also report the + runtime facts the core derived from the file, which is what users + actually want to confirm before touching a live router. */ + function validationText(result) { + if (isOk(result)) { + const runtime = result.runtime || {}; + const facts = []; + if (Array.isArray(runtime.lan_interfaces) && runtime.lan_interfaces.length) + facts.push(_('LAN %s').format(runtime.lan_interfaces.join(', '))); + if (runtime.native_api_enabled) + facts.push(_('Doona %s').format(runtime.native_api_listen || '—')); + if (runtime.clash_api_listen) + facts.push(_('Clash API %s').format(runtime.clash_api_listen)); + return facts.length + ? _('Configuration is valid: %s').format(facts.join(' · ')) + : _('Configuration is valid.'); + } + const diagnostics = Array.isArray(result && result.diagnostics) ? result.diagnostics : []; + if (!diagnostics.length) + return honk.resultMessage(result, _('Configuration validation failed.')); + return diagnostics.map(function(item) { + const line = Number.isInteger(item.line) ? _('Line %d').format(item.line) : ''; + const setting = item.setting ? String(item.setting) : String(item.code || ''); + const message = String(item.message || ''); + return [ line, setting, message ].filter(Boolean).join(' · '); + }).join(' | '); + } + + /* 本机事实:LuCI 的 Doona 端口、活跃 L3 候选(含所属 netifd 网络)。取一次复用。 */ + let runtimeFacts = null; + function runtimeContext() { + if (!runtimeFacts) + runtimeFacts = Promise.all([ + honk.status().catch(function() { return {}; }), + honk.lanCandidates().catch(function() { return {}; }) + ]).then(function(results) { + const status = results[0] || {}; + const candidates = ((results[1] && results[1].candidates) || []).filter(function(item) { + return item && item.l3_device; + }); + return { + listenPort: String(status.listen_port || 9527), + lanNetwork: status.lan_network || 'lan', + candidates: candidates, + devices: candidates.map(function(item) { return item.l3_device; }) + }; + }); + return runtimeFacts; + } + + /* 只在 native_api 块里改 ui,而且值必须**正好**是 embedded(embedded-custom 不算)。 */ + function fixNativeUi(text) { + const lines = text.split('\n'); + const start = lines.findIndex(function(line) { return /^[ \t]*native_api[ \t]*\{/.test(line); }); + if (start < 0) + return null; + const baseIndent = lines[start].match(/^[ \t]*/)[0].length; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.trim()) + continue; + const indent = line.match(/^[ \t]*/)[0].length; + if (indent <= baseIndent && /^[ \t]*\}/.test(line)) + break; + const match = /^([ \t]*ui[ \t]*:[ \t]*)(['"]?)embedded\2[ \t]*(#.*)?$/.exec(line); + if (match) { + lines[i] = match[1] + "'/usr/share/doona'" + (match[3] ? ' ' + match[3] : ''); + return { text: lines.join('\n') }; + } + } + return null; + } + + function locateLanInterface(text) { + const lines = text.split('\n'); + for (let i = 0; i < lines.length; i++) { + const match = /^([ \t]*)lan_interface[ \t]*:[ \t]*([^\n#]+?)[ \t]*(#.*)?$/.exec(lines[i]); + if (match) + return { index: i, indent: match[1], value: match[2], comment: match[3] }; + } + return null; + } + + /* 优先 netifd 里配置的 LAN 网络;不做「取第一个活跃设备」,那可能是 WAN / VPN。 */ + function preferredLanDevice(ctx) { + const order = [ ctx.lanNetwork, 'lan' ]; + for (let i = 0; i < order.length; i++) { + const hit = ctx.candidates.find(function(item) { return item.network === order[i]; }); + if (hit) + return { device: hit.l3_device, source: hit.network }; + } + return null; + } + + /* 多候选且无法确定时让用户挑;取消返回 null(表示不改这一项)。 + ui.showModal() 返回的是 DOM 元素、不是 Promise,所以只能靠按钮回调 + resolve;Esc 会被 LuCI 转成点击 .right 里第一个按钮(即取消)。 */ + function chooseLanDevice(ctx) { + return new Promise(function(resolve) { + const select = E('select', { 'class': 'cbi-input-select' }, ctx.candidates.map(function(item) { + const label = [ item.network, item.l3_device, item.ipv4 ].filter(Boolean).join(' · '); + return E('option', { 'value': item.l3_device }, label); + })); + ui.showModal(_('Choose the LAN interface'), [ + E('div', {}, [ + E('p', {}, _('None of the active interfaces belongs to the configured LAN network. Pick the one Honk should see as LAN:')), + select + ]), + E('div', { 'class': 'right' }, [ + E('button', { 'class': 'cbi-button', 'click': function(ev) { + ev.preventDefault(); + ui.hideModal(); + resolve(null); + } }, [ _('Cancel') ]), + ' ', + E('button', { 'class': 'cbi-button cbi-button-positive', 'click': function(ev) { + ev.preventDefault(); + ui.hideModal(); + resolve(select.value || null); + } }, [ _('Use this interface') ]) + ]) + ]); + }); + } + + /* 本机适配计划:算出「要改哪几处」和「改完的文本」,但**不落盘**。 + allowPrompt = true 时才会弹选择框(校验时不要弹)。 */ + function planAdaptations(text, allowPrompt) { + return runtimeContext().then(function(ctx) { + const notes = []; + const warnings = []; + let next = text; + + const uiFix = fixNativeUi(next); + if (uiFix) { + next = uiFix.text; + notes.push("native_api.ui: embedded → '/usr/share/doona'"); + } + + const lan = locateLanInterface(next); + if (!lan) + return { notes: notes, warnings: warnings, text: next }; + + const named = lan.value.split(',').map(function(item) { + return item.trim().replace(/^['"]|['"]$/g, ''); + }).filter(Boolean); + if (!named.length || !ctx.devices.length || named.some(function(name) { return ctx.devices.indexOf(name) >= 0; })) + return { notes: notes, warnings: warnings, text: next }; + + const preferred = preferredLanDevice(ctx); + let pick; + if (preferred) + pick = Promise.resolve(preferred.device); + else if (ctx.candidates.length === 1) + pick = Promise.resolve(ctx.candidates[0].l3_device); + else if (allowPrompt) + pick = chooseLanDevice(ctx); + else + pick = Promise.resolve(null); + + return pick.then(function(device) { + if (!device) { + /* 用户在选择框里按了取消:直接中止这次保存,不要再问一遍。 */ + if (allowPrompt && ctx.candidates.length > 1) + return { notes: notes, warnings: warnings, text: next, cancelled: true }; + warnings.push(_('lan_interface: cannot tell which interface is the LAN here (candidates: %s); set it yourself').format(ctx.devices.join(', ') || '—')); + return { notes: notes, warnings: warnings, text: next }; + } + const lines = next.split('\n'); + lines[lan.index] = lan.indent + "lan_interface: '" + device + "'" + (lan.comment ? ' ' + lan.comment : ''); + notes.push('lan_interface: ' + named.join(',') + ' → ' + device); + return { notes: notes, warnings: warnings, text: lines.join('\n') }; + }); + }); + } + + /* 保存前把适配写进编辑器。返回 false 表示用户拒绝或内容已变,调用方应中止。 */ + function adaptEditor() { + const snapshot = editor.value; + return planAdaptations(snapshot, true).then(function(plan) { + if (plan.cancelled) { + editorStatus.textContent = _('Save cancelled.'); + return false; + } + if (!plan.notes.length && !plan.warnings.length) + return true; + const blocks = []; + if (plan.notes.length) + blocks.push(_('These lines will be changed for this router:') + '\n' + plan.notes.join('\n')); + if (plan.warnings.length) + blocks.push(_('Needs your attention:') + '\n' + plan.warnings.join('\n')); + blocks.push(_('Apply them in the editor now?')); + if (!confirm(blocks.join('\n\n'))) + return false; + /* 等设备查询 / 用户选择期间编辑器可能被改过,别用旧结果覆盖。 */ + if (editor.value !== snapshot) { + editorStatus.textContent = _('The editor changed while checking. Nothing was applied; check again.'); + return false; + } + if (plan.notes.length) { + editor.value = plan.text; + editor.dispatchEvent(new Event('input')); + editorStatus.textContent = _('Adapted in the editor: %s').format(plan.notes.join('; ')); + } + return true; + }); + } + + function withNotes(text) { + return planAdaptations(editor.value, false).then(function(plan) { + const parts = []; + if (plan.notes.length) + parts.push(_('Saving will adapt for this router: %s').format(plan.notes.join(', '))); + if (plan.warnings.length) + parts.push(plan.warnings.join(' ')); + return parts.length ? text + ' ' + parts.join(' ') : text; + }); + } + + function reportValidation(result, target) { + return withNotes(validationText(result)).then(function(text) { + target.textContent = text; + }); + } + + function pollStatus() { + return honk.status().catch(function() { return null; }); + } + + /* Validate -> confirm -> write -> start, shared by the file import and the + editor. `target` is the status line the caller wants the outcome in. */ + function applyContent(content, target) { + target.textContent = _('Checking the configuration…'); + return honk.configValidate(content).then(function(result) { + if (!isOk(result)) { + target.textContent = validationText(result); + return; + } + return Promise.resolve().then(function() { + const question = _('Write this configuration to /etc/honk/config.dae and start Honk? The previous file is restored if startup fails.'); + if (!confirm(question)) + return; + return commitContent(content, target); + }); + }).catch(function(error) { + target.textContent = honk.errorMessage(error, _('Configuration validation failed.')); + }); + } + + function commitContent(content, target) { + target.textContent = _('Writing the configuration…'); + return withLongTimeout(function() { return honk.configSave(content); }).then(function(result) { + if (!isOk(result)) { + target.textContent = validationText(result); + return; + } + if (content === editor.value) + editorBaseline = editor.value; + target.textContent = _('Starting Honk…'); + return withLongTimeout(function() { return honk.configApply().then(honk.ensureOk); }).then(function(applied) { + target.textContent = honk.resultMessage(applied, _('Configuration applied.')); + return pollStatus().then(function(status) { + if (status && status.running) + target.textContent = _('Configuration applied and Honk is running.'); + else + target.textContent = _('Configuration was written, but Honk is not running: %s').format( + ((status && status.errors) || []).map(honk.statusIssue).join('; ') || _('unknown reason')); + }); + }).catch(function(error) { + target.textContent = honk.errorMessage(error, _('Could not apply the configuration.')); + }); + }).catch(function(error) { + target.textContent = honk.errorMessage(error, _('Could not save the configuration.')); + }).then(function() { + /* A failed apply rolls the file back, so reload what is really on + disk — but keep the outcome message the user needs to read. */ + const outcome = target.textContent; + return refreshMode().then(function() { + return loadCurrent(outcome === editorStatus.textContent ? outcome : '', true).catch(function() {}); + }); + }); + } + + function refreshMode() { + return honk.status().then(function(status) { + const manual = !!status && status.config_mode === 'manual'; + managedBtn.hidden = !manual; + const tabState = page._honkTabs; + if (tabState) { + tabState.tabs.forEach(function(tab) { + const disabled = manual && tab.managedOnly; + tab.button.disabled = disabled; + tab.panel.hidden = tab.id !== tabState.getActive(); + }); + if (manual && ['sub', 'nodes'].indexOf(tabState.getActive()) >= 0) + tabState.select('edit'); + } + subModeHint.hidden = !manual; + pasteModeHint.hidden = !manual; + subBtn.disabled = manual; + pasteBtn.disabled = manual; + nameInput.disabled = manual; + urlInput.disabled = manual; + pasteInput.disabled = manual; + modeText.textContent = manual + ? _('Manual mode: the file below owns nodes, groups, routing and DNS.') + : _('Managed mode: the subscription and paste-nodes tools are active. Saving here switches Honk to manual mode.'); + return status; + }).catch(function() { + modeText.textContent = ''; + }); + } + + /* force = false 用于页面初次载入:如果用户已经抢先开始输入,不要用磁盘内容 + 把他的编辑冲掉(异步回填比手速快)。apply 之后要的是磁盘真身,用 force。 */ + function loadCurrent(message, force) { + return honk.configRead().then(honk.ensureOk).then(function(result) { + if (!force && editor.value !== editorBaseline && editor.value !== '') + return result; + setEditorContent(result.content || '', message || _('Current configuration loaded from /etc/honk/config.dae.')); + return result; + }); + } + + importBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + const file = fileInput.files && fileInput.files[0]; + if (!file) { importStatus.textContent = _('Choose a .dae file first.'); return; } + if (file.size > DAE_LIMIT) { importStatus.textContent = _('The dae file exceeds the 2 MiB limit.'); return; } + importBtn.disabled = true; + importStatus.textContent = _('Reading %s…').format(file.name); + file.text().then(function(content) { + if (!String(content || '').trim()) + throw new Error(_('The file is empty.')); + fileInput.value = ''; + setEditorContent(content, _('Loaded %s into the editor.').format(file.name)); + const snapshot = editor.value; + return planAdaptations(content, true).then(function(plan) { + if (plan.cancelled) { + importStatus.textContent = _('Import cancelled.'); + return null; + } + const blocks = []; + if (plan.notes.length) + blocks.push(_('These lines will be changed for this router:') + '\n' + plan.notes.join('\n')); + if (plan.warnings.length) + blocks.push(_('Needs your attention:') + '\n' + plan.warnings.join('\n')); + if (blocks.length) { + blocks.push(_('Apply them in the editor now?')); + if (!confirm(blocks.join('\n\n'))) + return null; + } + if (editor.value !== snapshot) { + importStatus.textContent = _('The editor changed while checking. Nothing was applied; import the file again.'); + return null; + } + if (plan.notes.length) + setEditorContent(plan.text, _('Adapted in the editor: %s').format(plan.notes.join('; '))); + return applyContent(editor.value, importStatus).then(function() { + if (importStatus.textContent === _('Configuration applied and Honk is running.')) + importStatus.textContent = _('%s imported and Honk is running.').format(file.name); + }); + }); + }).catch(function(error) { + importStatus.textContent = honk.errorMessage(error, _('Could not read the configuration file.')); + }).finally(function() { importBtn.disabled = false; }); + }); + + reloadBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + if (!confirmEditorReplace()) return; + reloadBtn.disabled = true; + loadCurrent(null, true).catch(function(error) { + editorStatus.textContent = honk.errorMessage(error, _('Could not read the saved configuration.')); + }).finally(function() { reloadBtn.disabled = false; }); + }); + + validateBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + if (!editor.value.trim()) { editorStatus.textContent = _('The editor is empty.'); return; } + validateBtn.disabled = true; + editorStatus.textContent = _('Checking the configuration…'); + honk.configValidate(editor.value).then(function(result) { + return reportValidation(result, editorStatus); + }).catch(function(error) { + editorStatus.textContent = honk.errorMessage(error, _('Configuration validation failed.')); + }).finally(function() { validateBtn.disabled = false; }); + }); + + saveBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + if (!editor.value.trim()) { editorStatus.textContent = _('The editor is empty.'); return; } + saveBtn.disabled = true; + adaptEditor().then(function(proceed) { + if (!proceed) { editorStatus.textContent = _('Save cancelled.'); return null; } + return applyContent(editor.value, editorStatus); + }).finally(function() { saveBtn.disabled = false; }); + }); + + managedBtn.addEventListener('click', function(ev) { + ev.preventDefault(); + if (!confirm(_('Restore the managed configuration and return to the subscription tools? Honk restarts if it is running.'))) return; + managedBtn.disabled = true; + editorStatus.textContent = _('Restoring the managed configuration…'); + withLongTimeout(function() { return honk.configUseManaged().then(honk.ensureOk); }).then(function(result) { + editorStatus.textContent = honk.resultMessage(result, _('Managed mode restored.')); + return refreshMode().then(function() { return loadCurrent(null, true); }); + }).catch(function(error) { + editorStatus.textContent = honk.errorMessage(error, _('Could not restore managed mode.')); + }).finally(function() { managedBtn.disabled = false; }); + }); + + const editorSection = E('section', { 'class': 'honk-card' }, [ + E('p', { 'class': 'honk-note' }, _('Edit or paste a complete dae file. Check configuration only parses the text; Save and apply writes /etc/honk/config.dae, restarts Honk, and rolls back if startup fails.')), + E('div', { 'class': 'honk-subscription-add' }, [ saveBtn, reloadBtn, validateBtn ]), + editor, + editorStatus + ]); + + loadCurrent().catch(function(error) { + editorStatus.textContent = honk.errorMessage(error, _('Could not read the saved configuration.')); + }); + refreshMode(); + + /* 一排标签页,只显示当前板块:四张卡片竖着堆太占屏,也不好一眼看出"有几种做法"。 */ + const tabs = [ + { id: 'sub', label: _('Add subscription'), panel: subSection, managedOnly: true }, + { id: 'nodes', label: _('Add nodes'), panel: pasteSection, managedOnly: true }, + { id: 'import', label: _('Import config'), panel: importSection, managedOnly: false }, + { id: 'edit', label: _('Edit config'), panel: editorSection, managedOnly: false } + ]; + let activeTab = 'sub'; + const tabBar = E('div', { 'class': 'honk-seg honk-tabs', 'role': 'tablist' }); + function selectTab(id) { + activeTab = id; + tabs.forEach(function(tab) { + const active = tab.id === id; + tab.panel.hidden = !active; + tab.button.setAttribute('aria-selected', active ? 'true' : 'false'); + tab.button.classList.toggle('is-active', active); + }); + } + tabs.forEach(function(tab) { + tab.button = E('button', { + 'class': 'honk-seg-btn', 'type': 'button', 'role': 'tab', + 'aria-selected': 'false' + }, [ tab.label ]); + tab.button.addEventListener('click', function(ev) { + ev.preventDefault(); + if (tab.button.disabled) + return; + selectTab(tab.id); + }); + tab.panel.hidden = true; + tabBar.appendChild(tab.button); + }); + page.appendChild(E('div', { 'class': 'honk-tabs-wrap' }, [ tabBar ])); + tabs.forEach(function(tab) { page.appendChild(tab.panel); }); + selectTab(activeTab); + + /* 曝光给 refreshMode:手动模式下订阅/节点两个标签不可用,自动切到编辑配置。 */ + page._honkTabs = { tabs: tabs, select: selectTab, getActive: function() { return activeTab; } }; + return page; + } +}); diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/converter.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/converter.js new file mode 100644 index 00000000..749d9085 --- /dev/null +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/converter.js @@ -0,0 +1,501 @@ +// SPDX-License-Identifier: GPL-3.0-only + +'use strict'; +'require baseclass'; + +function utf8Base64(value) { + const bytes = new TextEncoder().encode(String(value)); + let binary = ''; + for (let i = 0; i < bytes.length; i++) + binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +function utf8FromBase64(value) { + const binary = atob(value); + const bytes = Uint8Array.from(binary, function(ch) { return ch.charCodeAt(0); }); + return new TextDecoder('utf-8').decode(bytes); +} + +function base64Url(value) { + return utf8Base64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function endpoint(server, port) { + const host = String(server).indexOf(':') >= 0 && String(server)[0] !== '[' + ? '[' + server + ']' + : server; + return host + ':' + port; +} + +function requireFields(node, fields) { + for (let i = 0; i < fields.length; i++) { + const key = fields[i]; + if (node[key] === undefined || node[key] === null || node[key] === '') + throw new Error('Missing required field: ' + key); + } +} + +function setIf(params, key, value) { + if (value !== undefined && value !== null && value !== '') + params.set(key, String(value)); +} + +function arrayValue(value) { + return Array.isArray(value) ? value.join(',') : value; +} + +function addTransport(params, node) { + const network = node.network || 'tcp'; + params.set('type', network); + + if (network === 'ws') { + const opts = node['ws-opts'] || {}; + setIf(params, 'path', opts.path); + setIf(params, 'host', opts.headers && (opts.headers.Host || opts.headers.host)); + } else if (network === 'grpc') { + const opts = node['grpc-opts'] || {}; + setIf(params, 'serviceName', opts['grpc-service-name'] || opts.serviceName); + } else if (network === 'http' || network === 'h2') { + const opts = node['h2-opts'] || node['http-opts'] || {}; + setIf(params, 'path', Array.isArray(opts.path) ? opts.path[0] : opts.path); + setIf(params, 'host', arrayValue(opts.host)); + } +} + +function convertSs(node) { + requireFields(node, [ 'server', 'port', 'cipher', 'password' ]); + const auth = base64Url(node.cipher + ':' + node.password); + let query = ''; + if (node.plugin) { + const fields = [ String(node.plugin) ]; + const opts = node['plugin-opts'] || {}; + Object.keys(opts).forEach(function(key) { + const value = opts[key]; + if (value === true) + fields.push(key); + else if (value !== false && value !== undefined && value !== null && value !== '') + fields.push(key + '=' + value); + }); + const params = new URLSearchParams(); + params.set('plugin', fields.join(';')); + query = '?' + params.toString(); + } + return 'ss://' + auth + '@' + endpoint(node.server, node.port) + query + '#' + encodeURIComponent(node.name || 'Node'); +} + +function convertSsr(node) { + requireFields(node, [ 'server', 'port', 'cipher', 'password', 'protocol', 'obfs' ]); + const query = []; + query.push('remarks=' + base64Url(node.name || 'Node')); + if (node['protocol-param']) + query.push('protoparam=' + base64Url(node['protocol-param'])); + if (node['obfs-param']) + query.push('obfsparam=' + base64Url(node['obfs-param'])); + + const payload = [ + node.server, + node.port, + node.protocol, + node.cipher, + node.obfs, + base64Url(node.password) + ].join(':') + '/?' + query.join('&'); + return 'ssr://' + base64Url(payload); +} + +function convertVmess(node) { + requireFields(node, [ 'server', 'port', 'uuid' ]); + const network = node.network || 'tcp'; + const ws = node['ws-opts'] || {}; + const grpc = node['grpc-opts'] || {}; + const h2 = node['h2-opts'] || {}; + const payload = { + v: '2', + ps: String(node.name || 'Node'), + add: String(node.server), + port: String(node.port), + id: String(node.uuid), + aid: Number(node.alterId || node.alter_id || 0), + scy: String(node.cipher || 'auto'), + net: network, + type: 'none', + host: String((ws.headers && (ws.headers.Host || ws.headers.host)) || arrayValue(h2.host) || ''), + path: String(ws.path || grpc['grpc-service-name'] || (Array.isArray(h2.path) ? h2.path[0] : h2.path) || ''), + tls: node.tls ? 'tls' : 'none', + sni: String(node.servername || node.sni || ''), + alpn: String(arrayValue(node.alpn) || ''), + fp: String(node.fingerprint || node['client-fingerprint'] || '') + }; + return 'vmess://' + utf8Base64(JSON.stringify(payload)); +} + +function convertVless(node) { + requireFields(node, [ 'server', 'port', 'uuid' ]); + const params = new URLSearchParams(); + addTransport(params, node); + params.set('encryption', String(node.encryption || 'none')); + setIf(params, 'flow', node.flow); + + const reality = node['reality-opts']; + if (reality || node.tls) { + params.set('security', reality ? 'reality' : 'tls'); + setIf(params, 'sni', node.servername || node.sni); + setIf(params, 'fp', node.fingerprint || node['client-fingerprint']); + setIf(params, 'alpn', arrayValue(node.alpn)); + if (node['skip-cert-verify']) + params.set('allowInsecure', '1'); + if (reality) { + setIf(params, 'pbk', reality['public-key']); + setIf(params, 'sid', reality['short-id']); + setIf(params, 'spx', reality['spider-x']); + } + } + + return 'vless://' + encodeURIComponent(node.uuid) + '@' + endpoint(node.server, node.port) + + '?' + params.toString() + '#' + encodeURIComponent(node.name || 'Node'); +} + +function convertTrojan(node) { + requireFields(node, [ 'server', 'port', 'password' ]); + const params = new URLSearchParams(); + addTransport(params, node); + setIf(params, 'sni', node.sni || node.servername); + setIf(params, 'fp', node.fingerprint || node['client-fingerprint']); + setIf(params, 'alpn', arrayValue(node.alpn)); + if (node['skip-cert-verify']) + params.set('allowInsecure', '1'); + return 'trojan://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) + + '?' + params.toString() + '#' + encodeURIComponent(node.name || 'Node'); +} + +function convertTuic(node) { + requireFields(node, [ 'server', 'port', 'uuid', 'password' ]); + const params = new URLSearchParams(); + setIf(params, 'sni', node.sni); + setIf(params, 'alpn', arrayValue(node.alpn)); + setIf(params, 'congestion_control', node['congestion-controller'] || node['congestion-control']); + setIf(params, 'udp_relay_mode', node['udp-relay-mode']); + if (node['skip-cert-verify']) + params.set('allow_insecure', '1'); + return 'tuic://' + encodeURIComponent(node.uuid) + ':' + encodeURIComponent(node.password) + '@' + + endpoint(node.server, node.port) + (params.toString() ? '?' + params.toString() : '') + + '#' + encodeURIComponent(node.name || 'Node'); +} + +function convertHysteria2(node) { + requireFields(node, [ 'server', 'port', 'password' ]); + const params = new URLSearchParams(); + setIf(params, 'sni', node.sni); + setIf(params, 'alpn', arrayValue(node.alpn)); + setIf(params, 'obfs', node.obfs); + setIf(params, 'obfs-password', node['obfs-password']); + if (node['skip-cert-verify']) + params.set('insecure', '1'); + return 'hysteria2://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) + + (params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node'); +} + +function convertAnytls(node) { + requireFields(node, [ 'server', 'port', 'password' ]); + const params = new URLSearchParams(); + setIf(params, 'sni', node.sni || node.servername); + setIf(params, 'alpn', arrayValue(node.alpn)); + setIf(params, 'client-fingerprint', node['client-fingerprint'] || node.fingerprint); + if (node['skip-cert-verify']) + params.set('insecure', '1'); + if (node.udp) + params.set('udp', '1'); + return 'anytls://' + encodeURIComponent(node.password) + '@' + endpoint(node.server, node.port) + + (params.toString() ? '?' + params.toString() : '') + '#' + encodeURIComponent(node.name || 'Node'); +} + +function isMetadataProxy(node) { + const name = String(node && node.name || '').trim(); + if (!name) + return false; + + return /(?:官网|QQ|流量|续费|应急|重置|到期|过期|剩余|套餐)/i.test(name) || + /^(?:traffic|bandwidth|expire|expiry|expiration|subscription(?:\s+info)?|official\s+website|website)\s*[::|]/i.test(name) || + /^(?:(?:剩余|可用|已用|总)?流量|套餐到期|到期时间|到期日|过期时间|有效期|订阅信息)\s*[::|]/i.test(name) || + /^(?:官方网站|官网地址|官方网址|网站地址)\s*[::|]/i.test(name) || + /^(?:加入|联系|客服)?\s*QQ\s*(?:群|交流群|客服|联系)\s*[::|]/i.test(name); +} + +function convertProxy(node) { + const type = String(node && node.type || '').toLowerCase(); + const converters = { + ss: convertSs, + ssr: convertSsr, + vmess: convertVmess, + vless: convertVless, + trojan: convertTrojan, + tuic: convertTuic, + hysteria2: convertHysteria2, + hy2: convertHysteria2, + anytls: convertAnytls + }; + + try { + if (!node || typeof node !== 'object') + throw new Error('Node must be an object'); + if (!converters[type]) + throw new Error('Unsupported protocol: ' + (type || 'unknown')); + return { + ok: true, + name: String(node.name || 'Node'), + type: type, + link: converters[type](node), + error: '' + }; + } catch (e) { + return { + ok: false, + name: String(node && node.name || 'Node'), + type: type || 'unknown', + link: '', + error: String(e && e.message || e) + }; + } +} + +function convertProxies(proxies) { + return Array.isArray(proxies) ? proxies.map(convertProxy) : []; +} + +// --- Paste-import helpers ------------------------------------------------- + +const NODE_SCHEMES = /^(ss|ssr|vmess|vless|trojan|tuic|hysteria2?|hy2|anytls|socks5|socks4|juicity|snell):\/\//i; + +function looksLikeNodeList(text) { + const lines = String(text || '').split('\n'); + for (let i = 0; i < lines.length; i++) { + const line = lines[i].trim(); + if (line && NODE_SCHEMES.test(line)) + return true; + } + return false; +} + +function looksLikeClashYaml(text) { + return /\bproxies\s*:/.test(String(text || '')); +} + +function looksLikeSurge(text) { + return /^\s*\[Proxy\]\s*$/m.test(String(text || '')); +} + +function tryBase64Decode(text) { + const compact = String(text || '').replace(/\s+/g, ''); + if (compact.length < 4 || !/^[A-Za-z0-9+/_-]+={0,2}$/.test(compact)) + return ''; + let normalized = compact.replace(/-/g, '+').replace(/_/g, '/'); + while (normalized.length % 4) + normalized += '='; + try { + return utf8FromBase64(normalized); + } catch (e) { + return ''; + } +} + +function parseUriList(text) { + const links = []; + let rejected = 0; + String(text || '').split('\n').forEach(function(raw) { + const line = raw.trim(); + if (!line || line[0] === '#' || line[0] === ';' || line.indexOf('//') === 0) + return; + if (NODE_SCHEMES.test(line)) + links.push(line); + else + rejected++; + }); + return { links: links, rejected: rejected }; +} + +function splitSurgeFields(value) { + const fields = []; + let current = ''; + let quote = ''; + for (let i = 0; i < value.length; i++) { + const ch = value[i]; + if (ch === '"' || ch === '\'') { + if (quote === ch) quote = ''; + else if (quote === '') quote = ch; + current += ch; + } else if (ch === ',' && quote === '') { + fields.push(surgeScalar(current)); + current = ''; + } else { + current += ch; + } + } + fields.push(surgeScalar(current)); + return fields; +} + +function surgeScalar(value) { + let t = String(value || '').trim(); + if (t.length >= 2 && (t[0] === '"' || t[0] === '\'') && t[0] === t[t.length - 1]) + t = t.slice(1, -1); + try { return decodeURIComponent(t); } catch (e) { return t; } +} + +function firstNonEmpty() { + for (let i = 0; i < arguments.length; i++) + if (arguments[i] !== undefined && arguments[i] !== null && arguments[i] !== '') + return arguments[i]; + return ''; +} + +// Parse a Surge/Shadowrocket [Proxy] section. Only Shadowsocks lines are +// understood, matching the upstream parser; other protocols are rejected so an +// ordinary URL in the file is never mistaken for a node. +function parseSurgeProxies(text) { + const links = []; + let rejected = 0; + let inside = false; + String(text || '').split('\n').forEach(function(raw) { + const line = raw.trim(); + if (!line || line[0] === '#' || line[0] === ';' || line.indexOf('//') === 0) + return; + if (line[0] === '[' && line[line.length - 1] === ']' && line.indexOf('=') < 0) { + inside = line.toLowerCase() === '[proxy]'; + return; + } + if (!inside) + return; + const sep = line.indexOf('='); + if (sep < 0) + return; + const name = line.slice(0, sep).trim(); + const fields = splitSurgeFields(line.slice(sep + 1)); + if (!name || fields.length < 3 || fields[0].toLowerCase() !== 'ss' || !fields[1]) { + rejected++; + return; + } + const port = Number(fields[2]); + if (!Number.isInteger(port) || port <= 0 || port > 65535) { + rejected++; + return; + } + const options = {}; + for (let i = 3; i < fields.length; i++) { + const eq = fields[i].indexOf('='); + if (eq < 0) + continue; + options[fields[i].slice(0, eq).trim().toLowerCase()] = fields[i].slice(eq + 1).trim(); + } + const node = { + name: name, + type: 'ss', + server: fields[1], + port: port, + cipher: firstNonEmpty(options['encrypt-method'], options.method, options.cipher), + password: options.password, + plugin: options.plugin || options['obfs'], + 'plugin-opts': {} + }; + if (node.plugin) { + node['plugin-opts'].mode = options['obfs'] || 'http'; + if (options['obfs-host']) + node['plugin-opts'].host = options['obfs-host']; + } + if (!node.cipher || !node.password) { + rejected++; + return; + } + const result = convertProxy(node); + if (result.ok) links.push(result.link); + else rejected++; + }); + if (!links.length && !rejected) + rejected = 1; + return { links: links, rejected: rejected }; +} + +// Mihomo/mihomo-format JSON keeps nodes in `outbounds` and names them with +// `tag` instead of `name`, with nested `tls`/`transport` objects. Normalize to +// the Clash shape so the existing convertProxy converters work unchanged. +function looksLikeMihomoJson(text) { + const t = String(text || '').trim(); + if (!t || t[0] !== '{') + return false; + try { + const doc = JSON.parse(t); + return !!(doc && Array.isArray(doc.outbounds)); + } + catch (e) { + return false; + } +} + +function normalizeMihomoNode(node) { + const n = Object.assign({}, node); + if (node.tag !== undefined && node.name === undefined) n.name = node.tag; + if (node.server_port !== undefined && node.port === undefined) n.port = node.server_port; + + const tls = node.tls; + if (tls && typeof tls === 'object') { + if (tls.server_name !== undefined && n.servername === undefined) n.servername = tls.server_name; + if (tls.insecure && n['skip-cert-verify'] === undefined) n['skip-cert-verify'] = true; + if (Array.isArray(tls.alpn) && n.alpn === undefined) n.alpn = tls.alpn; + if (tls.utls && tls.utls.fingerprint !== undefined && n['client-fingerprint'] === undefined) + n['client-fingerprint'] = tls.utls.fingerprint; + if (tls.reality && typeof tls.reality === 'object') { + if (!n['reality-opts']) n['reality-opts'] = {}; + if (tls.reality.public_key !== undefined) n['reality-opts']['public-key'] = tls.reality.public_key; + if (tls.reality.short_id !== undefined) n['reality-opts']['short-id'] = tls.reality.short_id; + } + } + + const transport = node.transport; + if (transport && typeof transport === 'object') { + if (transport.type && n.network === undefined) n.network = transport.type; + if (transport.type === 'ws' && transport.path && !n['ws-opts']) { + n['ws-opts'] = { path: transport.path }; + if (transport.headers && transport.headers.Host) + n['ws-opts'].headers = { Host: transport.headers.Host }; + } + } + return n; +} + +function parseMihomoJson(text) { + let doc; + try { + doc = JSON.parse(String(text || '')); + } + catch (e) { + return { links: [], rejected: 1 }; + } + const outbounds = (doc && doc.outbounds) || []; + const links = []; + let rejected = 0; + outbounds.forEach(function(node) { + if (isMetadataProxy(node)) { + rejected++; + return; + } + const result = convertProxy(normalizeMihomoNode(node)); + if (result.ok) links.push(result.link); + else rejected++; + }); + return { links: links, rejected: rejected }; +} + +return baseclass.extend({ + convertProxy: convertProxy, + convertProxies: convertProxies, + isMetadataProxy: isMetadataProxy, + looksLikeNodeList: looksLikeNodeList, + looksLikeClashYaml: looksLikeClashYaml, + looksLikeSurge: looksLikeSurge, + looksLikeMihomoJson: looksLikeMihomoJson, + tryBase64Decode: tryBase64Decode, + parseUriList: parseUriList, + parseSurgeProxies: parseSurgeProxies, + parseMihomoJson: parseMihomoJson, + normalizeMihomoNode: normalizeMihomoNode +}); diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/dashboard.js b/luci-app-honk/htdocs/luci-static/resources/view/honk/dashboard.js new file mode 100644 index 00000000..fd51abfb --- /dev/null +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/dashboard.js @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: GPL-3.0-only + +'use strict'; +'require view'; +'require view.honk.rpc as honk'; + +return view.extend({ + handleSave: null, + handleSaveApply: null, + handleReset: null, + + load: function() { + return honk.status().then(function(status) { + return { status: status }; + }).catch(function() { + return { error: true }; + }); + }, + + render: function(data) { + honk.installStyles(); + const page = E('div', { 'class': 'cbi-map honk-page' }); + + function renderInitialized(status) { + const section = E('div', { 'class': 'honk-card' }); + const webUi = honk.localWebUi(status); + + section.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Doona'))); + + if (webUi) { + section.appendChild(E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': webUi, + 'target': '_blank', + 'rel': 'noopener noreferrer' + }, _('Open Doona'))); + } + else if (!status.running) { + section.appendChild(E('p', {}, _('Honk is stopped. Start it from the overview to open Doona.'))); + section.appendChild(E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': L.url('admin/services/honk/overview') + }, _('Go to overview'))); + } + else { + section.appendChild(E('p', {}, _('Doona is not ready yet. Check the overview for status.'))); + section.appendChild(E('a', { + 'class': 'cbi-button cbi-button-action', + 'href': L.url('admin/services/honk/overview') + }, _('Go to overview'))); + } + + page.appendChild(section); + } + + if (data.error) { + page.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read Honk status.'))); + return page; + } + + if (data.status.initialized === true) { + renderInitialized(data.status); + return page; + } + + const form = E('form', { 'class': 'honk-card' }); + const username = E('input', { + 'type': 'text', + 'name': 'username', + 'autocomplete': 'username', + 'class': 'cbi-input-text' + }); + const password = E('input', { + 'type': 'password', + 'name': 'password', + 'autocomplete': 'new-password', + 'class': 'cbi-input-password' + }); + const message = E('p', { 'role': 'status' }, ''); + const submit = E('button', { 'class': 'cbi-button cbi-button-positive', 'type': 'submit' }, _('Initialize Honk')); + + form.appendChild(E('h3', { 'class': 'honk-card-title' }, _('Initial setup'))); + form.appendChild(E('p', {}, _('Create the Honk administrator account. The password is sent only for this request and is not saved by this page.'))); + form.appendChild(E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title', 'for': 'honk-init-username' }, _('Username')), + E('div', { 'class': 'cbi-value-field' }, username) + ])); + username.id = 'honk-init-username'; + form.appendChild(E('div', { 'class': 'cbi-value' }, [ + E('label', { 'class': 'cbi-value-title', 'for': 'honk-init-password' }, _('Password')), + E('div', { 'class': 'cbi-value-field' }, password) + ])); + password.id = 'honk-init-password'; + form.appendChild(E('div', { 'class': 'cbi-page-actions' }, submit)); + form.appendChild(message); + form.addEventListener('submit', function(ev) { + ev.preventDefault(); + const user = username.value.trim(); + const secret = password.value; + if (!user || !secret) { + message.textContent = _('Enter both a username and password.'); + return; + } + + submit.disabled = true; + message.textContent = _('Initializing…'); + honk.initialize(user, secret).then(honk.ensureOk).then(function(result) { + username.value = ''; + password.value = ''; + message.textContent = honk.resultMessage(result, _('Honk initialized.')); + form.remove(); + page.appendChild(E('p', { 'role': 'status' }, _('Refreshing status…'))); + return honk.status().then(function(status) { + while (page.firstChild) + page.removeChild(page.firstChild); + renderInitialized(status); + }).catch(function() { + while (page.firstChild) + page.removeChild(page.firstChild); + page.appendChild(E('div', { 'class': 'alert-message' }, _('Could not read Honk status.'))); + }); + }).catch(function() { + password.value = ''; + message.textContent = _('Initialization failed. Check the system status.'); + }).finally(function() { + submit.disabled = false; + }); + }); + + page.appendChild(form); + return page; + } +}); diff --git a/luci-app-honk/htdocs/luci-static/resources/view/honk/honk.css b/luci-app-honk/htdocs/luci-static/resources/view/honk/honk.css new file mode 100644 index 00000000..6c3a2c72 --- /dev/null +++ b/luci-app-honk/htdocs/luci-static/resources/view/honk/honk.css @@ -0,0 +1,1049 @@ +/* SPDX-License-Identifier: GPL-3.0-only */ + +/* Honk — Apple-style design system. + Cards, pill badges, iOS segmented controls and soft-tinted buttons on a + translucent surface, so the UI adapts to every LuCI theme (light or dark). */ + +.honk-page { + /* Accent — Apple system blue */ + --hk-accent-rgb: 0, 122, 255; + --hk-accent: rgba(var(--hk-accent-rgb), 0.9); + --hk-accent-strong: rgba(var(--hk-accent-rgb), 1); + --hk-accent-border: rgba(var(--hk-accent-rgb), 0.35); + --hk-accent-soft: rgba(var(--hk-accent-rgb), 0.09); + --hk-accent-soft-hover: rgba(var(--hk-accent-rgb), 0.16); + + /* Status — Apple system green / red */ + --hk-green: #34c759; + --hk-red: #ff3b30; + --hk-orange: #ff9500; + + /* Surfaces (translucent → adapts to any theme) */ + --hk-card-bg: rgba(128, 128, 128, 0.04); + --hk-card-border: rgba(128, 128, 128, 0.14); + --hk-field-bg: rgba(128, 128, 128, 0.05); + --hk-field-border: rgba(128, 128, 128, 0.22); + --hk-hover: rgba(128, 128, 128, 0.08); + --hk-shadow: 0 4px 20px rgba(0, 0, 0, 0.05); + --hk-shadow-hover: 0 8px 28px rgba(0, 0, 0, 0.08); + --hk-muted: rgba(74, 86, 106, 0.62); + + --hk-radius: 14px; + --hk-radius-sm: 9px; + --hk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", + "Hiragino Sans GB", "Helvetica Neue", "Microsoft YaHei", sans-serif; + --hk-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", monospace; + + font-family: var(--hk-font); + width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.honk-page, +.honk-page * { + box-sizing: border-box; +} + +/* LuCI themes (Argon/Bootstrap) set #view>div:first-child and .cbi-map to flex; + force block so our card/segmented-control layout behaves identically across + themes (ID selector specificity requires !important). */ +.cbi-map.honk-page { + display: block !important; +} + +/* ---------- Hidden attribute guard ---------- */ + +.honk-page [hidden] { + display: none !important; +} + +/* ---------- Page header ---------- */ + +.honk-header { + margin: 4px 0 18px; +} + +.honk-header h2 { + margin: 0; + padding: 0; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + line-height: 1.15; + background: transparent !important; + box-shadow: none !important; + border-radius: 0 !important; +} + +.honk-header-sub { + margin: 6px 0 0; + font-size: 13px; + line-height: 1.5; + color: var(--hk-muted); + max-width: 68ch; +} + +/* ---------- Cards ---------- */ + +.honk-page .cbi-section, +.honk-page .cbi-section-node { + background: transparent !important; + border: 0 !important; + box-shadow: none !important; + margin: 0; + padding: 0; +} + +.honk-card { + background: var(--hk-card-bg); + border: 1px solid var(--hk-card-border); + border-radius: var(--hk-radius); + padding: 18px 20px; + margin-bottom: 16px; + box-shadow: var(--hk-shadow); + backdrop-filter: blur(6px); + transition: box-shadow 0.25s ease; +} + +.honk-card:hover { + box-shadow: var(--hk-shadow-hover); +} + +.honk-card-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.honk-card-title { + margin: 0 0 6px; + padding: 0 !important; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + opacity: 0.55; + background: transparent !important; + box-shadow: none !important; + border-radius: 0 !important; + width: auto !important; +} + +.honk-card-head .honk-card-title { + margin-bottom: 4px; +} + +/* ---------- Status badge (pill) ---------- */ + +.honk-badge { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 4px 14px; + border-radius: 999px; + font-size: 12.5px; + font-weight: 600; + line-height: 1.3; + border: 1px solid transparent; + white-space: nowrap; +} + +.honk-badge::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; + flex: none; +} + +.honk-badge--run { + color: var(--hk-green); + border-color: rgba(52, 199, 89, 0.5); +} + +.honk-badge--stop { + color: var(--hk-red); + border-color: rgba(255, 59, 48, 0.55); +} + +.honk-badge--idle { + color: var(--hk-muted); + border-color: rgba(128, 128, 128, 0.35); +} + +/* ---------- Buttons ---------- */ + +.honk-page .cbi-button { + font-family: var(--hk-font); + font-size: 13px !important; + font-weight: 500; + line-height: 1.35 !important; + min-height: 0 !important; + height: auto !important; + padding: 7px 16px !important; + border-radius: 10px !important; + border: 1px solid rgba(128, 128, 128, 0.32) !important; + background: transparent !important; + color: inherit !important; + box-shadow: none !important; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.18s ease, border-color 0.18s ease, + opacity 0.18s ease, transform 0.12s ease; +} + +.honk-page .cbi-button:hover { + background: var(--hk-hover) !important; +} + +.honk-page .cbi-button:active { + transform: scale(0.98); +} + +.honk-page .cbi-button:disabled { + opacity: 0.42; + cursor: not-allowed; +} + +/* Primary — soft Apple blue */ +.honk-page .cbi-button-action { + border-color: var(--hk-accent-border) !important; + color: var(--hk-accent) !important; + background: var(--hk-accent-soft) !important; +} + +.honk-page .cbi-button-action:hover { + background: var(--hk-accent-soft-hover) !important; + border-color: var(--hk-accent) !important; +} + +/* Positive — soft green */ +.honk-page .cbi-button-positive { + border-color: rgba(52, 199, 89, 0.5) !important; + color: var(--hk-green) !important; + background: rgba(52, 199, 89, 0.09) !important; +} + +.honk-page .cbi-button-positive:hover { + background: rgba(52, 199, 89, 0.16) !important; + border-color: var(--hk-green) !important; +} + +/* Negative — soft red */ +.honk-page .cbi-button-negative { + border-color: rgba(255, 59, 48, 0.5) !important; + color: var(--hk-red) !important; + background: rgba(255, 59, 48, 0.08) !important; +} + +.honk-page .cbi-button-negative:hover { + background: rgba(255, 59, 48, 0.15) !important; + border-color: var(--hk-red) !important; +} + +/* ---------- Button groups ---------- */ + +.honk-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.honk-subscription-add { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-top: 10px; +} + +/* 加 .honk-page 前缀压过后面的 `.honk-page .cbi-input-text{min-width:0}`, + 否则手机上两个输入框会被挤成一条窄缝而不是换行。 */ +.honk-page .honk-subscription-add .cbi-input-text { + flex: 1 1 12rem; + min-width: 12rem; +} + +/* ---------- Meta line (mono, muted) ---------- */ + +.honk-meta { + display: flex; + flex-wrap: wrap; + gap: 6px 14px; + font-family: var(--hk-mono); + font-size: 12px; + color: var(--hk-muted); +} + +.honk-meta-item { + display: inline-flex; + align-items: baseline; + gap: 5px; +} + +.honk-meta-label { + opacity: 0.55; +} + +/* ---------- Forms / fields ---------- */ + +/* 两栏表单:左栏固定宽度、标签右对齐贴近控件(和最早的设计一致)。 + 窄屏(手机)自动堆叠,标签改成左对齐放在控件上方。 */ +.honk-page .cbi-value { + display: flex; + align-items: center; + gap: 12px; + padding: 9px 0; + margin: 0; + border: 0; + background: transparent; +} + +/* 用 `.cbi-value > label.cbi-value-title` 压过 Bootstrap 的 + `.cbi-value label.cbi-value-title`(它把标签设成 flex 定宽 + 右对齐)。 */ +.honk-page .cbi-value > label.cbi-value-title { + float: none; + display: block; + flex: 0 0 168px; + width: auto; + padding: 0 8px 0 0; + text-align: right; + line-height: 1.5; + font-size: 13px; + font-weight: 600; + opacity: 0.85; + white-space: nowrap; +} + +.honk-page .cbi-value-field { + display: block; + flex: 1 1 auto; + width: auto; + margin-left: 0; + padding: 0; + min-width: 0; +} + +@media (max-width: 719px) { + .honk-page .cbi-value { + flex-direction: column; + align-items: stretch; + gap: 6px; + } + + .honk-page .cbi-value > label.cbi-value-title { + flex: none; + padding: 0; + text-align: left; + white-space: normal; + } +} + +.honk-page .cbi-value-description { + font-size: 12px; + color: var(--hk-muted); + line-height: 1.5; + padding-top: 6px; +} + +.honk-page .cbi-input-text, +.honk-page .cbi-input-password, +.honk-page .cbi-input-select, +.honk-page .cbi-input-textarea, +.honk-page select, +.honk-page input[type="text"], +.honk-page input[type="password"], +.honk-page input[type="number"], +.honk-page input[type="url"], +.honk-page textarea { + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + font-family: var(--hk-font); + font-size: 13.5px; + height: 38px; + padding: 7px 12px; + border: 1px solid var(--hk-field-border); + border-radius: var(--hk-radius-sm); + background: var(--hk-field-bg); + color: inherit; + transition: border-color 0.18s ease, box-shadow 0.18s ease, + background-color 0.18s ease; +} + +.honk-page textarea, +.honk-page .cbi-input-textarea { + height: auto; + min-height: 96px; + resize: vertical; + font-family: var(--hk-mono); + line-height: 1.5; +} + +/* Full dae configuration editor: keep dae source lines unwrapped so line + numbers and indentation stay meaningful while scrolling horizontally. */ +.honk-page .honk-config-editor { + min-height: 420px; + white-space: pre; + overflow-wrap: normal; + overflow-x: auto; + tab-size: 4; +} + +.honk-page .cbi-input-text:focus, +.honk-page .cbi-input-password:focus, +.honk-page .cbi-input-select:focus, +.honk-page .cbi-input-textarea:focus, +.honk-page select:focus, +.honk-page input[type="text"]:focus, +.honk-page input[type="password"]:focus, +.honk-page input[type="number"]:focus, +.honk-page input[type="url"]:focus, +.honk-page textarea:focus { + outline: 0; + border-color: var(--hk-accent-border); + box-shadow: 0 0 0 3px var(--hk-accent-soft); + background: transparent; +} + +.honk-page .cbi-input-text::placeholder, +.honk-page textarea::placeholder, +.honk-page input::placeholder { + opacity: 0.5; +} + +/* Comfortable field widths inside full-width cards */ +.honk-page .cbi-value-field .cbi-input-text, +.honk-page .cbi-value-field .cbi-input-password, +.honk-page .cbi-value-field input[type="number"], +.honk-page .cbi-value-field input[type="url"], +.honk-page .cbi-value-field input[type="text"], +.honk-page .cbi-value-field .cbi-input-select, +.honk-page .cbi-value-field select, +.honk-page .cbi-value-field input[type="file"] { + max-width: 400px; +} + +.honk-page .cbi-value-field textarea, +.honk-page .cbi-value-field .cbi-input-textarea { + max-width: 640px; +} + +/* 导入配置:文件选择行。原生的 file 控件在 Argon/Bootstrap 下都会被拉成 + 一个大盒子、内嵌按钮贴着左上角,所以隐藏它,用 label 冒充按钮。 */ +.honk-page .honk-file-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 10px; +} + +.honk-page .honk-file-pick { + display: inline-flex; + align-items: center; + cursor: pointer; + margin: 0; + white-space: nowrap; +} + +.honk-page .honk-file-pick input[type="file"] { + display: none; +} + +.honk-page .honk-file-name { + /* 只占自身宽度,别把「导入并启动」推到屏幕最右边 */ + flex: 0 1 auto; + max-width: 40ch; + min-width: 0; + font-size: 12.5px; + color: var(--hk-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* 配置页的标签栏:一排切换,只显示当前板块 */ +.honk-tabs-wrap { + margin: 4px 0 14px; +} + +.honk-page .honk-tabs { + display: flex; + flex-wrap: wrap; + max-width: 100%; +} + +.honk-page .honk-tabs .honk-seg-btn:disabled { + opacity: 0.32; + cursor: not-allowed; +} + +/* 分段/标签控件:选中态原来用纯白药丸,深色主题下非常刺眼。 + Argon 的暗色是额外加载 dark.css、不在 上留标记,所以不能靠 + 属性选择器区分明暗;这里统一用中性灰的半透明层次,明暗两种背景都成立, + 同时把主题给