Compare commits

...
102 Commits
Author SHA1 Message Date
action fcd7d31b29 update 2026-09-14 17:28:54 2026-09-14 17:28:54 +08:00
action 87faec277a update 2026-09-14 10:58:48 2026-09-14 10:58:48 +08:00
action d341a782d4 update 2026-09-14 06:01:57 2026-09-14 06:01:57 +08:00
action 205be274cd update 2026-09-14 02:28:52 2026-09-14 02:28:52 +08:00
action 622dd92021 update 2026-09-13 23:28:56 2026-09-13 23:28:56 +08:00
action 141863e6d4 update 2026-09-13 16:42:22 2026-09-13 16:42:22 +08:00
action 2e55e363ac update 2026-09-13 10:43:38 2026-09-13 10:43:38 +08:00
action 86d911baf4 update 2026-09-13 05:53:41 2026-09-13 05:53:41 +08:00
action 65075cb38f update 2026-09-13 02:01:59 2026-09-13 02:01:59 +08:00
action aff13cf0c6 update 2026-09-12 22:51:39 2026-09-12 22:51:39 +08:00
action 7497dff620 update 2026-09-12 16:17:58 2026-09-12 16:17:58 +08:00
action 639738b05d update 2026-09-12 10:45:42 2026-09-12 10:45:42 +08:00
action 419c580042 update 2026-09-12 06:09:18 2026-09-12 06:09:18 +08:00
action 7a00e14e57 update 2026-09-12 02:55:35 2026-09-12 02:55:35 +08:00
action 8daa9142b3 update 2026-09-11 23:43:33 2026-09-11 23:43:33 +08:00
action 42846bd15f update 2026-09-11 16:29:42 2026-09-11 16:29:42 +08:00
action e964d3fab4 update 2026-09-11 10:36:18 2026-09-11 10:36:18 +08:00
action 7a1c066f8e update 2026-09-11 06:12:18 2026-09-11 06:12:18 +08:00
action 7f998ecef4 update 2026-09-11 02:53:12 2026-09-11 02:53:12 +08:00
action eb846719e6 update 2026-09-10 23:42:03 2026-09-10 23:42:03 +08:00
action 11dbae0135 update 2026-09-10 16:35:06 2026-09-10 16:35:06 +08:00
action 52a65b099b update 2026-09-10 10:43:10 2026-09-10 10:43:10 +08:00
action eda104d43a update 2026-09-10 06:10:14 2026-09-10 06:10:14 +08:00
action be31648233 update 2026-09-10 03:01:51 2026-09-10 03:01:51 +08:00
action 542084a121 update 2026-09-09 23:46:59 2026-09-09 23:47:00 +08:00
action fe313e7d23 update 2026-09-09 16:33:06 2026-09-09 16:33:06 +08:00
action 09f0910b78 update 2026-09-09 03:12:17 2026-09-09 03:12:17 +08:00
action 8778465a55 update 2026-09-08 23:51:28 2026-09-08 23:51:28 +08:00
action b81fb0f1c5 update 2026-09-08 16:30:31 2026-09-08 16:30:31 +08:00
action f12aa6537d update 2026-09-08 10:40:39 2026-09-08 10:40:39 +08:00
action 9e7a5c5caf update 2026-09-08 06:27:12 2026-09-08 06:27:12 +08:00
action 28c0067490 update 2026-09-08 01:05:17 2026-09-08 01:05:17 +08:00
action 2ff8b68fd7 update 2026-09-07 16:50:29 2026-09-07 16:50:29 +08:00
action e7e55d191d update 2026-09-07 10:27:50 2026-09-07 10:27:50 +08:00
action 344aea7ec2 update 2026-09-07 05:54:25 2026-09-07 05:54:25 +08:00
action 4a4dfac1a2 update 2026-09-07 01:57:13 2026-09-07 01:57:13 +08:00
action 7b5d12b8ec update 2026-09-06 22:47:17 2026-09-06 22:47:17 +08:00
action 51ed2622f8 update 2026-09-06 16:17:35 2026-09-06 16:17:35 +08:00
action 35c80a77bc update 2026-09-06 10:30:14 2026-09-06 10:30:14 +08:00
action ec44f6fe2e update 2026-09-06 05:51:06
marry-jell / merge (push) Canceled after 0s
2026-09-06 05:51:06 +08:00
action 37eb246de8 update 2026-09-06 01:51:51 2026-09-06 01:51:51 +08:00
action 47831f020d update 2026-09-05 23:35:35 2026-09-05 23:35:35 +08:00
cwx 02766f9a2f Remove 'luci-app-daede' from git_sparse_clone 2026-09-05 23:35:06 +08:00
action 825070ba95 update 2026-09-05 22:43:26
marry-jell / merge (push) Canceled after 0s
2026-09-05 22:43:26 +08:00
cwx ccd677de3a Update jell.yml to modify git_sparse_clone commands
Comment out git_sparse_clone commands for luci-app-daed and luci-app-dae, and add a new git_sparse_clone for small repository.
2026-09-05 22:43:00 +08:00
action 0b8a2fa292 update 2026-09-05 22:25:21 2026-09-05 22:25:21 +08:00
action 063eca5fe2 update 2026-09-05 22:03:25 2026-09-05 22:03:25 +08:00
cwx f3074ed22d Add sparse clone for luci-app-clashoo 2026-09-05 22:02:58 +08:00
action 995946de02 update 2026-09-05 19:45:32 2026-09-05 19:45:32 +08:00
action 2cd4730a13 update 2026-09-05 15:59:29 2026-09-05 15:59:29 +08:00
action 440cac507b update 2026-09-05 11:12:16
marry-jell / merge (push) Canceled after 0s
2026-09-05 11:12:16 +08:00
cwx ec01a08d9d Update sparse clone arguments in jell.yml 2026-09-05 11:11:50 +08:00
action 32c534ae26 update 2026-09-05 02:45:56 2026-09-05 02:45:56 +08:00
action d0a6e27e6e update 2026-09-04 23:37:45 2026-09-04 23:37:45 +08:00
action b37f8cba7c update 2026-09-04 16:24:21 2026-09-04 16:24:21 +08:00
action d97c0d5630 update 2026-09-04 10:32:27 2026-09-04 10:32:27 +08:00
action 6c8d00d18c update 2026-09-04 06:09:30 2026-09-04 06:09:30 +08:00
action e28fd0631a update 2026-09-04 03:02:09 2026-09-04 03:02:09 +08:00
action 029903be2e update 2026-09-03 23:39:11 2026-09-03 23:39:11 +08:00
action ae2ca8d80b update 2026-09-03 16:28:40 2026-09-03 16:28:40 +08:00
action c0693a7678 update 2026-09-03 10:36:42 2026-09-03 10:36:42 +08:00
action 3441d66762 update 2026-09-03 06:16:07 2026-09-03 06:16:08 +08:00
action 3003d5612b update 2026-09-03 03:09:55 2026-09-03 03:09:55 +08:00
action beae9d285e update 2026-09-02 23:49:44 2026-09-02 23:49:44 +08:00
action 86b8972e8e update 2026-09-02 16:19:19 2026-09-02 16:19:19 +08:00
action e9e78405e1 update 2026-09-02 10:29:04 2026-09-02 10:29:04 +08:00
action 030ff01603 update 2026-09-02 06:19:09 2026-09-02 06:19:09 +08:00
action 5eaaccaf0c update 2026-09-02 03:13:12 2026-09-02 03:13:12 +08:00
action 6041d81065 update 2026-09-01 17:05:42 2026-09-01 17:05:42 +08:00
action 61fbf62840 update 2026-09-01 11:10:03 2026-09-01 11:10:03 +08:00
action b7127b27d1 update 2026-09-01 07:34:07 2026-09-01 07:34:07 +08:00
action d53ac45366 update 2026-09-01 02:38:12 2026-09-01 02:38:12 +08:00
action f959ff8b68 update 2026-08-31 18:23:00 2026-08-31 18:23:00 +08:00
action be2557c9dc update 2026-08-31 11:03:24 2026-08-31 11:03:24 +08:00
action 414512419c update 2026-08-31 06:18:42 2026-08-31 06:18:42 +08:00
action 72ace44e3b update 2026-08-31 02:59:36 2026-08-31 02:59:36 +08:00
action 4fe683a80b update 2026-08-30 23:54:02 2026-08-30 23:54:02 +08:00
action 0751a1e7d3 update 2026-08-30 17:40:26 2026-08-30 17:40:26 +08:00
action a8ecc43b8a update 2026-08-30 11:09:43 2026-08-30 11:09:43 +08:00
action b09780b109 update 2026-08-30 06:12:28 2026-08-30 06:12:28 +08:00
action a93b4d6f7f update 2026-08-30 00:04:48 2026-08-30 00:04:48 +08:00
action 8e9cbe68d9 update 2026-08-29 18:40:18 2026-08-29 18:40:18 +08:00
action 0ef940bda7 update 2026-08-29 10:39:08 2026-08-29 10:39:08 +08:00
action 79f234d9d7 update 2026-08-29 00:27:35 2026-08-29 00:27:35 +08:00
action 1de7860148 update 2026-08-28 11:58:47 2026-08-28 11:58:47 +08:00
action 587815b344 update 2026-08-28 02:44:45 2026-08-28 02:44:45 +08:00
action 9a3f194de9 update 2026-08-27 14:50:16 2026-08-27 14:50:16 +08:00
action 377119697a update 2026-08-27 06:39:30 2026-08-27 06:39:30 +08:00
action f5a34d6fcf update 2026-08-27 00:43:46 2026-08-27 00:43:46 +08:00
action 1bfa02b7c5 update 2026-08-26 20:40:17 2026-08-26 20:40:17 +08:00
action d64e131165 update 2026-08-26 16:36:18 2026-08-26 16:36:18 +08:00
action cf3357ea3a update 2026-08-26 12:33:54 2026-08-26 12:33:54 +08:00
action ba70403e29 update 2026-08-26 08:58:30 2026-08-26 08:58:30 +08:00
action 51af012771 update 2026-08-26 04:21:43 2026-08-26 04:21:43 +08:00
action d6f55c5f93 update 2026-08-26 00:30:15 2026-08-26 00:30:15 +08:00
action d16e9820fe update 2026-08-25 20:36:51 2026-08-25 20:36:51 +08:00
action a3b0b250ea update 2026-08-25 16:35:13 2026-08-25 16:35:13 +08:00
action a438ed414d update 2026-08-25 12:33:18 2026-08-25 12:33:18 +08:00
action e11e6e5941 update 2026-08-25 08:57:18 2026-08-25 08:57:18 +08:00
action 58ae8797b2 update 2026-08-25 04:23:01 2026-08-25 04:23:01 +08:00
action c37bb21446 update 2026-08-25 00:29:42 2026-08-25 00:29:42 +08:00
action 0f2308381e update 2026-08-24 20:37:43 2026-08-24 20:37:43 +08:00
879 changed files with 288988 additions and 40845 deletions
+9 -5
View File
@@ -114,7 +114,7 @@ jobs:
git_clone https://github.com/peter-tank/luci-app-autorepeater
git_clone https://github.com/sirpdboy/luci-app-cupsd cupsd1 && mv -n cupsd1/{luci-app-cupsd,cups} ./ ; rm -rf cupsd1
git_sparse_clone v5 https://github.com/sbwml/luci-app-mosdns \
mosdns luci-app-mosdns v2dat
mosdns luci-app-mosdns geo2txt
) &
(
git_clone https://github.com/esirplayground/LingTiGameAcc
@@ -149,10 +149,12 @@ jobs:
) &
(
git_clone https://github.com/QiuSimons/vmlinux-btf
git_sparse_clone kix https://github.com/QiuSimons/luci-app-daed \
luci-app-daed daed
git_sparse_clone kix https://github.com/QiuSimons/luci-app-dae \
luci-app-dae dae
# git_sparse_clone kix https://github.com/QiuSimons/luci-app-daed \
# luci-app-daed daed
# git_sparse_clone kix https://github.com/QiuSimons/luci-app-dae \
# luci-app-dae dae
git_sparse_clone master https://github.com/kenzok8/small \
dae daed luci-app-daede
git_sparse_clone main https://github.com/djylb/nps-openwrt \
npc luci-app-npc
git_sparse_clone main https://github.com/smallprogram/openwrt-ghfu \
@@ -175,6 +177,8 @@ jobs:
msd_lite luci-app-msd_lite
git_sparse_clone master https://github.com/fengqi/luci-app-uugamebooster \
uugamebooster luci-app-uugamebooster
git_sparse_clone master https://github.com/kenzok8/small \
clashoo luci-app-clashoo
) &
(
git_clone https://github.com/muink/luci-app-dnsproxy
+4 -4
View File
@@ -7,13 +7,13 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=alist
PKG_VERSION:=3.63.0
PKG_WEB_VERSION:=3.63.0
PKG_VERSION:=3.64.0
PKG_WEB_VERSION:=3.64.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/AlistGo/alist/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=a850506424e695bbbf411a928711588bcb8274d64b88029ecf0856cc4c21763b
PKG_HASH:=599065ec4b26375e4012cf212415f4195c9b92864bdcde4105356261920563f4
PKG_LICENSE:=GPL-3.0
PKG_LICENSE_FILE:=LICENSE
@@ -23,7 +23,7 @@ define Download/$(PKG_NAME)-web
FILE:=$(PKG_NAME)-web-$(PKG_WEB_VERSION).tar.gz
URL_FILE:=dist.tar.gz
URL:=https://github.com/AlistGo/alist-web/releases/download/$(PKG_WEB_VERSION)/
HASH:=336f86ec867045c8b401a5d80a1e51af495d7d2487e23f48b925030bbebffe2e
HASH:=b80550662de42a2f8a72d35bca8ed66ec9ad0c24a9a02ceec6a01bf5038cf6f4
endef
PKG_BUILD_DEPENDS:=golang/host
+1 -1
View File
@@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk
PKG_NAME:=BaiduPCS-Go
PKG_VERSION:=3.9.0
PKG_RELEASE:=1
PKG_RELEASE:=2
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/qjfoidnh/BaiduPCS-Go/tar.gz/v$(PKG_VERSION)?
@@ -0,0 +1,25 @@
From: coolsnowwolf <coolsnowwolf@gmail.com>
Subject: [PATCH] cachepool: stop using runtime.rawbyteslice
Recent Go releases reject external linkname references to rawbyteslice.
Use the language-provided slice allocator for the zeroed allocation path.
Signed-off-by: coolsnowwolf <coolsnowwolf@gmail.com>
---
--- a/pcsutil/cachepool/malloc.go
+++ b/pcsutil/cachepool/malloc.go
@@ -8,12 +8,9 @@
//go:linkname mallocgc runtime.mallocgc
func mallocgc(size uintptr, typ uintptr, needzero bool) unsafe.Pointer
-//go:linkname rawbyteslice runtime.rawbyteslice
-func rawbyteslice(size int) (b []byte)
-
-// RawByteSlice point to runtime.rawbyteslice
+// RawByteSlice allocates a zeroed byte slice.
func RawByteSlice(size int) (b []byte) {
- return rawbyteslice(size)
+ return make([]byte, size)
}
// RawMalloc allocates a new slice. The slice is not zeroed.
+111
View File
@@ -0,0 +1,111 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=clashoo
PKG_SHORT_SHA:=dc66a8a
PKG_COMMIT_DATE:=2026.09.14
PKG_VERSION:=$(PKG_COMMIT_DATE)~$(PKG_SHORT_SHA)
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA).tar.gz
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-alpha-$(PKG_SHORT_SHA)
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-clashoo/releases/download/mihomo-src
PKG_SOURCE_VERSION:=dc66a8a180c0fa1c2b91cf5413426e68dbca320e
PKG_HASH:=e7003359dc55a62a6f31b45f9123fc14a0a58f66c8cc4a3514ab15e94f08008b
PKG_BUILD_VERSION:=alpha-$(PKG_SHORT_SHA)
PKG_LICENSE:=GPL3.0+
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DEPENDS:=golang/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
PKG_BUILD_TIME:=$(shell date -u -Iseconds)
GO_PKG:=github.com/metacubex/mihomo
GO_PKG_LDFLAGS_X:=$(GO_PKG)/constant.Version=$(PKG_BUILD_VERSION) $(GO_PKG)/constant.BuildTime=$(PKG_BUILD_TIME)
GO_PKG_TAGS:=with_gvisor
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/clashoo
SECTION:=net
CATEGORY:=Network
TITLE:=Clashoo (Mihomo)
DEPENDS:=$(GO_ARCH_DEPENDS) +ca-bundle +yq +firewall4 +ip-full +coreutils-nohup +coreutils-timeout +kmod-inet-diag +kmod-nft-socket +kmod-nft-tproxy +kmod-tun +kmod-dummy +unzip
PROVIDES:=mihomo clash-meta
endef
define Package/clashoo/description
Mihomo proxy core with runtime environment for Clashoo.
endef
define Package/clashoo/conffiles
/etc/config/clashoo
endef
define Package/clashoo/install
$(call GoPackage/Package/Install/Bin,$(1))
$(INSTALL_DIR) $(1)/usr/bin
ln -sf /usr/bin/mihomo $(1)/usr/bin/clash-meta
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_DIR) $(1)/etc/clashoo
$(INSTALL_DIR) $(1)/etc/clashoo/provider
$(INSTALL_DIR) $(1)/etc/clashoo/proxyprovider
$(INSTALL_DIR) $(1)/etc/clashoo/ruleprovider
$(INSTALL_DIR) $(1)/usr/share/clashoo
$(INSTALL_DIR) $(1)/usr/share/clashbackup
$(INSTALL_DIR) $(1)/usr/share/clashoo/config
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/sub
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/upload
$(INSTALL_DIR) $(1)/usr/share/clashoo/config/custom
$(INSTALL_BIN) $(CURDIR)/files/etc/init.d/clashoo $(1)/etc/init.d/clashoo
$(INSTALL_CONF) $(CURDIR)/files/etc/config/clashoo $(1)/etc/config/clashoo
$(INSTALL_DATA) $(CURDIR)/files/etc/config/clashoo $(1)/usr/share/clashoo/clashoo.default
$(CP) $(CURDIR)/files/usr/share/clashoo/* $(1)/usr/share/clashoo/
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/migrate_lan_acl.sh $(1)/usr/share/clashoo/migrate_lan_acl.sh
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/migrate_fake_ip_filter.sh $(1)/usr/share/clashoo/migrate_fake_ip_filter.sh
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/detect_core_arch.sh $(1)/usr/share/clashoo/detect_core_arch.sh
$(INSTALL_BIN) $(CURDIR)/files/usr/share/clashoo/diagnose_bypass.sh $(1)/usr/share/clashoo/diagnose_bypass.sh
$(RM) $(1)/usr/share/clashoo/dashboard
$(RM) $(1)/usr/share/clashoo/yacd
$(RM) $(1)/usr/share/clashoo/update.log
$(RM) $(1)/usr/share/clashoo/geoip.log
$(RM) $(1)/usr/share/clashoo/core_down_complete
endef
define Package/clashoo/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
[ -x /usr/bin/mihomo ] && [ ! -x /usr/bin/clash-meta ] && ln -sf /usr/bin/mihomo /usr/bin/clash-meta
rm -f /usr/share/clashoo/check_luci_version.sh /usr/share/clashoo/check_clash_meta_version.sh /usr/share/clashoo/check_mihomo_core_version.sh
rm -f /usr/share/clashoo/cuslist.sh /usr/share/clashoo/groups.sh /usr/share/clashoo/load_groups.sh /usr/share/clashoo/panel_diag.sh
rm -f /usr/share/clashoo/rmlist.sh /usr/share/clashoo/uplist.sh /usr/share/clashoo/rpc_restart_async.sh /usr/share/clashoo/rpc_update_china_ip_async.sh
rm -rf /usr/share/clashoo/create
/usr/share/clashoo/migrate_lan_acl.sh >/dev/null 2>&1 || true
/usr/share/clashoo/migrate_fake_ip_filter.sh >/dev/null 2>&1 || true
/usr/share/clashoo/detect_core_arch.sh >/dev/null 2>&1 || true
if [ "$$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ]; then
/etc/init.d/clashoo enable 2>/dev/null || true
else
/etc/init.d/clashoo disable 2>/dev/null || true
fi
fi
exit 0
endef
define Build/Prepare
$(Build/Prepare/Default)
$(RM) -r $(PKG_BUILD_DIR)/rules/logic_test
endef
$(eval $(call GoBinPackage,clashoo))
$(eval $(call BuildPackage,clashoo))
+158
View File
@@ -0,0 +1,158 @@
config clashoo 'config'
option redir_port '7891'
option http_port '8080'
option socks_port '1080'
option mixed_port '7890'
option enable_ipv6 'true'
option enable '0'
option dash_port '9090'
option dashboard_panel 'zashboard'
option level 'info'
option core '3'
option allow_lan '1'
option dnsforwader '1'
option subcri 'meta'
option cusrule '0'
option dnscache '1'
option p_mode 'rule'
option append_rules '0'
option enhanced_mode 'fake-ip'
option fake_ip_range '198.18.0.1/16'
option fake_ip_range6 'fc00::/18'
list fake_ip_filter '+.lan'
list fake_ip_filter '+.local'
list fake_ip_filter 'localhost.ptlogin2.qq.com'
list fake_ip_filter 'rule-set:cn_domain'
option fake_ip_filter_migrated '1'
option listen_port '1053'
option interf '0'
option download_core 'amd64-compatible'
option dcore '3'
option same_tag '1'
option geoip_source '3'
option geoip_format 'mmdb'
option geodata_loader 'standard'
option tproxy_port '7982'
option access_control '0'
option acl_migrated '1'
option tcp_mode 'redirect'
option tun_mode '0'
option core_mirror_prefix 'https://gh-proxy.com/'
option core_download_advanced '0'
list default_nameserver '223.5.5.5'
list default_nameserver '119.29.29.29'
option dns_ecs ''
option dns_ecs_override '0'
option fallback_filter_geoip '0'
list fallback_filter_ipcidr '240.0.0.0/4'
option dns_respect_rules '1'
option dns_loopback_compat '0'
list dns_loopback_compat_resolver 'udp://223.5.5.5:53'
list dns_loopback_compat_resolver 'udp://119.29.29.29:53'
option dns_migrated '1'
option block_quic '0'
option singbox_independent_cache '0'
option udp_mode 'tproxy'
option ipv4_dns_hijack '1'
option ipv6_dns_hijack '1'
option ipv4_proxy '1'
option ipv6_proxy '1'
option fake_ip_ping_hijack '1'
option dns_leak_protect '0'
option geoip_mmdb_url 'https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb'
option geosite_url 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geosite.dat'
option geoip_dat_url 'https://github.com/MetaCubeX/meta-rules-dat/releases/download/latest/geoip.dat'
option enable_udp '1'
option stack 'gvisor'
option bypass_china '1'
option bypass_china_ipv6 '1'
option bypass_port_mode 'common'
option sniffer_streaming '1'
option selection_cache '1'
option fake_ip_cache '1'
option enable_dns '1'
option disable_quic_gso '1'
option smart_policy_priority 'Premium:0.9;SG:1.3'
option smart_prefer_asn '1'
option smart_uselightgbm '1'
option smart_collectdata '1'
option smart_collect_size '100'
option smart_collect_rate '1'
option smart_lgbm_auto_update '1'
option smart_lgbm_update_interval '72'
option smart_lgbm_url 'https://github.com/vernesong/mihomo/releases/download/LightGBM-Model/Model.bin'
list defaul_nameserver '114.114.114.114'
list defaul_nameserver '223.5.5.5'
option auto_update '1'
option auto_update_time '72'
option auto_subscription_update '0'
option subscription_update_interval '72'
option auto_clear_log '1'
option clear_time '72'
option core_type 'mihomo'
option smart_auto_switch '0'
option core_only '0'
config hosts
option enabled '0'
option adress '*.clash.dev'
option ip '127.0.0.1'
config hosts
option enabled '0'
option adress 'alpha.clash.dev'
option ip '::1'
config dnsservers
option enabled '1'
option ser_type 'nameserver'
option ser_address 'https://dns.alidns.com/dns-query'
option protocol 'none'
config dnsservers
option enabled '1'
option ser_type 'nameserver'
option ser_address 'https://doh.pub/dns-query'
option protocol 'none'
config dnsservers
option enabled '1'
option ser_type 'proxy-server-nameserver'
option ser_address '1.1.1.1'
option protocol 'tls://'
option ser_port '853'
config dnsservers
option enabled '1'
option ser_type 'fallback'
option ser_address 'https://cloudflare-dns.com/dns-query'
option protocol 'none'
config dnsservers
option enabled '1'
option ser_type 'fallback'
option ser_address 'https://dns.google/dns-query'
option protocol 'none'
config dnsservers
option enabled '1'
option ser_type 'direct-nameserver'
option ser_address '223.5.5.5'
option protocol 'udp://'
config dns_policy
option enabled '1'
option policy_type 'nameserver-policy'
option matcher 'geosite:cn'
list nameserver 'https://dns.alidns.com/dns-query'
list nameserver 'https://doh.pub/dns-query'
config authentication
# default empty custom-rule row so the section shows the input form, not just an add
# button; injectors skip rules with an empty ipaaddr, so it generates nothing until filled
config addtype
option type 'DOMAIN-SUFFIX'
option pgroup 'DIRECT'
option ipaaddr ''
+2403
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
[ -n "$(uci -q get clashoo.config.download_core 2>/dev/null)" ] && exit 0
raw="$(uname -m 2>/dev/null)"
[ -n "$raw" ] || raw="$(apk --print-arch 2>/dev/null)"
[ -n "$raw" ] || raw="$(opkg status libc 2>/dev/null | awk -F': ' '/^Architecture/{print $2; exit}')"
[ -n "$raw" ] || exit 0
case "$raw" in
x86_64|amd64) arch="amd64-compatible" ;;
aarch64*|arm64) arch="arm64" ;;
armv7*|arm_cortex-a[7-9]*|arm_cortex-a1[0-9]*) arch="armv7" ;;
armv6*|arm_cortex-a[56]*) arch="armv6" ;;
arm*) arch="armv5" ;;
i[3-6]86) arch="386" ;;
mips64el*) arch="mips64le" ;;
mips64*) arch="mips64" ;;
mipsel*) arch="mipsle" ;;
mips*) arch="mips" ;;
*) exit 0 ;;
esac
uci -q set clashoo.config.download_core="$arch"
uci -q commit clashoo
exit 0
@@ -0,0 +1,181 @@
#!/bin/sh
umask 077
CONFIG_YAML="${CONFIG_YAML:-/etc/clashoo/config.yaml}"
NFT_V4="${NFT_V4:-/usr/share/clashoo/nftables/geoip_cn.nft}"
NFT_V6="${NFT_V6:-/usr/share/clashoo/nftables/geoip6_cn.nft}"
MAX_CONN=200
TMPDIR_D="$(mktemp -d /tmp/.clashoo_diag.XXXXXX 2>/dev/null)" || exit 1
trap 'rm -rf "$TMPDIR_D" 2>/dev/null' EXIT INT TERM
uci_get() {
uci -q get "clashoo.config.$1" 2>/dev/null
}
section() {
printf '\n===== %s =====\n' "$1"
}
yesno() {
[ -n "$1" ] && [ "$1" != "0" ] && [ "$1" != "false" ] && echo "on" || echo "off"
}
bool_on() {
case "$1" in
1|true|TRUE|yes|on) return 0 ;;
*) return 1 ;;
esac
}
bypass4="$(uci_get bypass_china)"
bypass6="$(uci_get bypass_china_ipv6)"
[ -n "$bypass6" ] || bypass6="$bypass4"
section "配置摘要"
printf '服务开关 : %s\n' "$(yesno "$(uci_get enable)")"
printf '运行模式 : %s\n' "$(uci_get enhanced_mode)"
printf 'TCP / UDP : %s / %s\n' "$(uci_get tcp_mode)" "$(uci_get udp_mode)"
printf '大陆绕过 v4 : %s\n' "$(yesno "$bypass4")"
printf '大陆绕过 v6 : %s%s\n' "$(yesno "$bypass6")" \
"$([ -z "$(uci_get bypass_china_ipv6)" ] && echo '(未设置,跟随 v4')"
printf '接管端口 : %s' "$(uci_get bypass_port_mode)"
[ "$(uci_get bypass_port_mode)" = "custom" ] && printf ' (%s)' "$(uci_get bypass_port_custom)"
printf '\n'
printf 'IPv6 代理 : %s\n' "$(yesno "$(uci_get ipv6_proxy)")"
printf '阻断 QUIC : %s\n' "$(yesno "$(uci_get block_quic)")"
if [ "$(uci_get bypass_port_mode)" = "common" ]; then
printf '\n注意: 接管端口为 common,只代理 22/53/80/443/8080/8443\n'
printf ' 游戏与 P2P 常用的高位端口不会走代理。\n'
fi
section "fake-ip 过滤"
if [ -s "$CONFIG_YAML" ]; then
if grep -qE '^[[:space:]]*fake-ip-filter:' "$CONFIG_YAML" 2>/dev/null; then
filter_mode="$(uci_get fake_ip_filter_mode)"
[ -n "$filter_mode" ] || filter_mode="$(yq -M e '(.dns."fake-ip-filter-mode" // "blacklist") | tostring' "$CONFIG_YAML" 2>/dev/null)"
if [ "$filter_mode" = "whitelist" ]; then
echo "白名单模式:列表中的域名使用 fake-IP"
elif grep -qiE 'rule-set:cn_domain|RULE-SET,[[:space:]]*cn_domain,[[:space:]]*real-ip' "$CONFIG_YAML" 2>/dev/null; then
echo "含 rule-set:cn_domain (国内域名返回真实 IP)"
else
echo "缺少 rule-set:cn_domain"
echo " 国内域名会拿到 fake-ip 走代理,与硬编码国内 IP 分成两条路。"
fi
else
echo "配置中无 fake-ip-filter"
fi
else
echo "找不到 $CONFIG_YAML"
fi
section "内核分流规则"
if [ -s "$CONFIG_YAML" ]; then
echo "-- rules 末尾 5 条 --"
grep -E '^[[:space:]]*-[[:space:]]*["'"'"']?(GEOIP|RULE-SET|MATCH|IP-CIDR|DOMAIN)' "$CONFIG_YAML" 2>/dev/null | tail -5
fi
section "IP 库"
for f in "$NFT_V4" "$NFT_V6"; do
if [ -s "$f" ]; then
printf '%-46s %6s 条 %s\n' "$f" \
"$(grep -cE '^[[:space:]]*[0-9a-fA-F].*/' "$f" 2>/dev/null)" \
"$(date -r "$f" '+%Y-%m-%d %H:%M' 2>/dev/null)"
else
printf '%-46s 缺失\n' "$f"
fi
done
for f in /etc/clashoo/Country.mmdb /etc/clashoo/geoip.metadb; do
[ -f "$f" ] && printf '%-46s %s\n' "$f" "$(ls -lh "$f" 2>/dev/null | awk '{print $5}')"
done
set_count() {
nft list set inet fw4 "$1" 2>/dev/null | tr ',' '\n' | grep -cE '[0-9a-fA-F]+[.:].*/'
}
section "防火墙绕过规则"
if nft list sets inet fw4 2>/dev/null | grep -q clashoo_china; then
for ch in dstnat mangle_prerouting; do
nft -a list chain inet fw4 "$ch" 2>/dev/null |
grep -E '@clashoo_china6? ' | sed "s|^[[:space:]]*| ${ch}: |"
done
echo
echo "已加载元素数(nft auto-merge 会合并相邻网段,少于文件行数属正常):"
bool_on "$bypass4" && printf ' clashoo_china : %s\n' "$(set_count clashoo_china)"
bool_on "$bypass6" && printf ' clashoo_china6 : %s\n' "$(set_count clashoo_china6)"
else
echo "当前 nftables 中没有 clashoo_china 集合(绕过未启用或服务未运行)"
fi
section "绕过命中检查"
if ! bool_on "$bypass4" && ! bool_on "$bypass6"; then
echo "两个绕过均未启用,所有被接管的流量都交给内核判定,无需检查"
else
SECRET="$(uci_get dash_pass)"
PORT="$(uci_get dash_port)"
LAN_IP="$(uci -q get network.lan.ipaddr 2>/dev/null | awk -F/ '{print $1}')"
[ -n "$LAN_IP" ] || LAN_IP="127.0.0.1"
CONN_JSON="$TMPDIR_D/connections.json"
if [ -z "$PORT" ]; then
echo "未配置控制端口,跳过"
elif ! curl -m 5 -s -H "Authorization: Bearer ${SECRET}" \
"http://${LAN_IP}:${PORT}/connections" -o "$CONN_JSON" 2>/dev/null; then
echo "无法访问内核 API (http://${LAN_IP}:${PORT}),跳过"
elif [ ! -s "$CONN_JSON" ]; then
echo "内核 API 返回为空,跳过"
else
jsonfilter -i "$CONN_JSON" -e '@["connections"][*]["metadata"]["destinationIP"]' \
2>/dev/null > "${CONN_JSON}.ip"
jsonfilter -i "$CONN_JSON" -e '@["connections"][*]["chains"][0]' \
2>/dev/null > "${CONN_JSON}.ch"
total="$(wc -l < "${CONN_JSON}.ip" 2>/dev/null | tr -d ' ')"
[ -n "$total" ] || total=0
if [ "$total" -eq 0 ]; then
echo "当前无活动连接"
elif [ "$total" != "$(wc -l < "${CONN_JSON}.ch" 2>/dev/null | tr -d ' ')" ]; then
echo "内核 API 字段数不一致,跳过"
else
awk 'NR==FNR { a[FNR] = $0; next } { print a[FNR] "\t" $0 }' \
"${CONN_JSON}.ip" "${CONN_JSON}.ch" 2>/dev/null \
| awk -F'\t' '$2 != "DIRECT" && $1 != "" && $1 !~ /^(198\.18\.|::|fc00:|fd)/ { print $1 "\t" $2 }' \
| sort -u | head -n "$MAX_CONN" > "${CONN_JSON}.chk"
hit=0
: > "${CONN_JSON}.bad"
while IFS="$(printf '\t')" read -r dip chain; do
[ -n "$dip" ] || continue
case "$dip" in
*:*) bool_on "$bypass6" || continue
set_name=clashoo_china6 ;;
*) bool_on "$bypass4" || continue
set_name=clashoo_china ;;
esac
if nft get element inet fw4 "$set_name" "{ $dip }" >/dev/null 2>&1; then
hit=$((hit + 1))
printf ' %-40s -> %s\n' "$dip" "$chain" >> "${CONN_JSON}.bad"
fi
done < "${CONN_JSON}.chk"
printf '活动连接 %s 条,其中走代理的去重目的地 %s 个(上限 %s)\n' \
"$total" "$(wc -l < "${CONN_JSON}.chk" 2>/dev/null | tr -d ' ')" "$MAX_CONN"
if [ "$hit" -gt 0 ]; then
printf '命中绕过白名单却仍走代理: %s 个\n\n' "$hit"
head -20 "${CONN_JSON}.bad"
echo
echo "这些目的地在防火墙白名单内,本应直连,却出现在代理链路上。"
echo "可能是连接建立于规则变更之前,或该地址族的绕过规则未生效。"
else
echo "未发现白名单地址走代理的连接"
fi
rm -f "${CONN_JSON}.chk" "${CONN_JSON}.bad" 2>/dev/null
fi
rm -f "${CONN_JSON}.ip" "${CONN_JSON}.ch" 2>/dev/null
fi
fi
printf '\n'
@@ -0,0 +1,385 @@
#!/usr/bin/ucode
'use strict';
import { readfile, writefile } from 'fs';
// Standalone sing-box config version-migration, used by the init.d core-only
// path to upgrade an imported config (momo/homeproxy/etc.) to the format the
// current sing-box core accepts, WITHOUT clashoo normalize/takeover.
//
// IMPORTANT: migrate_singbox() below is kept byte-for-byte in sync with the
// copy in luci-app-clashoo/.../luci.clashoo (the UI "migrate" button). If you
// change one, change the other.
function migrate_singbox(cfg) {
// --- Fix DNS servers: address-based → type-based, remove detour ---
if (cfg.dns && cfg.dns.servers) {
cfg.dns.servers = map(cfg.dns.servers || [], function(srv) {
let addr = srv.address || '';
let new_srv = {};
let skip = { address: 1, detour: 1, address_resolver: 1, address_strategy: 1 };
for (let k in srv) if (!skip[k]) new_srv[k] = srv[k];
if (addr === 'fakeip') {
new_srv.type = 'fakeip';
} else if (addr === 'local' || addr === '') {
new_srv.type = new_srv.type || 'local';
} else if (match(addr, /^rcode:\/\//) || addr === 'rcode') {
new_srv._rcode = true; // mark for removal, handled via dns rule action
} else if (match(addr, /^h3:\/\//)) {
let host = replace(replace(addr, /^h3:\/\//, ''), /\/.*$/, '');
new_srv.type = 'h3'; new_srv.server = host;
} else if (match(addr, /^https:\/\//)) {
let host = replace(replace(addr, /^https:\/\//, ''), /\/.*$/, '');
new_srv.type = 'https'; new_srv.server = host;
} else if (match(addr, /^tls:\/\//)) {
new_srv.type = 'tls'; new_srv.server = replace(addr, /^tls:\/\//, '');
} else if (match(addr, /^tcp:\/\//)) {
new_srv.type = 'tcp'; new_srv.server = replace(addr, /^tcp:\/\//, '');
} else if (addr && !new_srv.type) {
new_srv.type = 'udp'; new_srv.server = addr; // plain IP → UDP
} else if (addr) {
new_srv.address = addr; // unknown format, keep as-is
}
return new_srv;
});
}
// --- Remove rcode servers (unsupported in 1.12+ new format), convert referencing rules to reject ---
let rcode_tags = {};
if (cfg.dns && cfg.dns.servers) {
let good = [];
for (let srv in cfg.dns.servers) {
if (srv._rcode) rcode_tags[srv.tag] = true;
else push(good, srv);
}
cfg.dns.servers = good;
}
if (cfg.dns && cfg.dns.rules && length(keys(rcode_tags)) > 0) {
cfg.dns.rules = map(cfg.dns.rules || [], function(rule) {
if (rule.server && rcode_tags[rule.server]) {
let r = {};
for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k];
r.action = 'reject';
return r;
}
return rule;
});
}
// --- Remove legacy dns.fakeip.enabled (1.12+ fakeip is configured via type:"fakeip" server) ---
if (cfg.dns && cfg.dns.fakeip) {
let fp = cfg.dns.fakeip;
let new_fp = {};
for (let k in fp) if (k !== 'enabled') new_fp[k] = fp[k];
if (length(keys(new_fp)) > 0) cfg.dns.fakeip = new_fp;
else delete cfg.dns['fakeip'];
}
// --- Fix DNS rules: add action:"route" when server is set ---
if (cfg.dns && cfg.dns.rules) {
cfg.dns.rules = map(cfg.dns.rules || [], function(rule) {
if (rule.server && !rule.action) {
let r = {};
for (let k in rule) r[k] = rule[k];
r.action = 'route';
return r;
}
return rule;
});
}
// --- Fix legacy special outbounds (block / dns types) ---
let special = {};
if (cfg.outbounds) {
let kept = [];
for (let ob in cfg.outbounds) {
if (ob.type === 'block' || ob.type === 'dns') {
special[ob.tag] = ob.type;
} else {
push(kept, ob);
}
}
cfg.outbounds = kept;
}
// Patch route rules that pointed to removed special outbounds
if (cfg.route && cfg.route.rules && length(keys(special)) > 0) {
cfg.route.rules = map(cfg.route.rules || [], function(rule) {
let sp = rule.outbound && special[rule.outbound];
if (!sp) return rule;
let r = {};
for (let k in rule) if (k !== 'outbound') r[k] = rule[k];
r.action = (sp === 'dns') ? 'hijack-dns' : 'reject';
return r;
});
}
// --- Migrate geoip/geosite database refs to rule_set (removed in sing-box 1.12) ---
let needed_rule_sets = {};
let migrate_geo_rule = function(rule) {
let has_geo = rule.geoip || rule.geosite;
if (!has_geo) return rule;
let r = {};
let new_rs = [];
for (let k in rule) {
if (k === 'geoip') {
for (let n in rule[k]) {
let tag = 'geoip-' + n;
needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geoip@rule-set/geoip-' + n + '.srs';
push(new_rs, tag);
}
} else if (k === 'geosite') {
for (let n in rule[k]) {
let tag = 'geosite-' + n;
needed_rule_sets[tag] = 'https://cdn.jsdelivr.net/gh/SagerNet/sing-geosite@rule-set/geosite-' + n + '.srs';
push(new_rs, tag);
}
} else {
r[k] = rule[k];
}
}
// Merge new rule_set tags with any existing ones
let existing_rs = type(r.rule_set) === 'array' ? r.rule_set :
(r.rule_set ? [r.rule_set] : []);
for (let t in new_rs) push(existing_rs, t);
r.rule_set = existing_rs;
return r;
};
if (cfg.route && cfg.route.rules)
cfg.route.rules = map(cfg.route.rules, migrate_geo_rule);
if (cfg.dns && cfg.dns.rules)
cfg.dns.rules = map(cfg.dns.rules, migrate_geo_rule);
// download_detour must be DIRECT: at first start rule-sets aren't loaded, so
// routing it via a proxy deadlocks (DNS recursion -> sing-box FATAL).
let pick_dl_detour = function() {
if (cfg.outbounds) {
for (let ob in cfg.outbounds) {
if (ob && ob.type === 'direct' && ob.tag) return ob.tag;
}
}
return 'DIRECT';
};
// Add rule_set download entries for each referenced geo tag
if (length(keys(needed_rule_sets)) > 0) {
if (!cfg.route) cfg.route = {};
if (!cfg.route.rule_set) cfg.route.rule_set = [];
let existing_tags = {};
for (let rs in cfg.route.rule_set) existing_tags[rs.tag] = true;
let dl_detour = pick_dl_detour();
for (let tag in keys(needed_rule_sets)) {
if (!existing_tags[tag]) {
push(cfg.route.rule_set, {
tag: tag,
type: 'remote',
format: 'binary',
url: needed_rule_sets[tag],
download_detour: dl_detour
});
}
}
}
// --- Remove deprecated 'outbound' DNS rule items (fatal in sing-box 1.13) ---
// Replacement: set route.default_domain_resolver to the plain IP-based resolver
let dns_resolver_tag = '';
if (cfg.dns && cfg.dns.servers) {
for (let srv in cfg.dns.servers) {
if ((srv.type === 'udp' || srv.type === 'local') && srv.server &&
match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) {
dns_resolver_tag = srv.tag;
break;
}
}
}
if (cfg.dns && cfg.dns.rules) {
let filtered = [];
for (let rule in cfg.dns.rules) {
if (!rule.outbound) push(filtered, rule);
}
cfg.dns.rules = filtered;
}
// Set route.default_domain_resolver (replaces outbound:any DNS routing)
if (dns_resolver_tag) {
if (!cfg.route) cfg.route = {};
if (!cfg.route.default_domain_resolver)
cfg.route.default_domain_resolver = dns_resolver_tag;
}
// --- Fix DNS servers with domain-name server field: add domain_resolver ---
// sing-box 1.12+ requires domain_resolver when server= is a hostname (not IP)
if (cfg.dns && cfg.dns.servers && dns_resolver_tag) {
let domain_types = { https: 1, h3: 1, tls: 1, tcp: 1 };
cfg.dns.servers = map(cfg.dns.servers, function(srv) {
if (!domain_types[srv.type]) return srv;
if (!srv.server || match(srv.server, /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/)) return srv;
if (srv.domain_resolver) return srv;
let r = {};
for (let k in srv) r[k] = srv[k];
r.domain_resolver = dns_resolver_tag;
return r;
});
}
// --- Fix DNS rules referencing non-existent server tags → action: reject ---
if (cfg.dns && cfg.dns.servers && cfg.dns.rules) {
let valid_servers = {};
for (let srv in cfg.dns.servers) valid_servers[srv.tag] = true;
cfg.dns.rules = map(cfg.dns.rules, function(rule) {
if (!rule.server || valid_servers[rule.server]) return rule;
let r = {};
for (let k in rule) if (k !== 'server' && k !== 'action') r[k] = rule[k];
r.action = 'reject';
return r;
});
}
// --- Fix reject method: sing-box 1.14 rejects "dropped", only "default"/"drop" ---
if (cfg.dns && cfg.dns.rules) {
cfg.dns.rules = map(cfg.dns.rules, function(rule) {
if (rule.action !== 'reject' || !rule.method) return rule;
let m = rule.method;
if (m === 'dropped') m = 'drop';
if (m !== 'default' && m !== 'drop') {
// unknown value: drop it, sing-box defaults to NXDOMAIN
let r = {};
for (let k in rule) if (k !== 'method') r[k] = rule[k];
return r;
}
if (m === rule.method) return rule;
let r = {};
for (let k in rule) r[k] = rule[k];
r.method = m;
return r;
});
}
// --- Migrate TUN inet4_address/inet6_address -> address array (1.12+), and
// strip legacy inbound fields removed in 1.13 (sniff / sniff_override_destination /
// sniff_timeout / domain_strategy). If any inbound had sniff on, prepend a
// { "action": "sniff" } route rule as the equivalent. ---
let had_sniff = false;
if (cfg.inbounds) {
cfg.inbounds = map(cfg.inbounds, function(ib) {
let r = {};
let addrs = [];
for (let k in ib) {
if (k === 'inet4_address') {
if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); }
else push(addrs, ib[k]);
} else if (k === 'inet6_address') {
if (type(ib[k]) === 'array') { for (let a in ib[k]) push(addrs, a); }
else push(addrs, ib[k]);
} else if (k === 'sniff') {
if (ib[k]) had_sniff = true;
} else if (k === 'sniff_override_destination' || k === 'sniff_timeout' ||
k === 'domain_strategy') {
// drop — moved to route.rules actions in 1.13
} else {
r[k] = ib[k];
}
}
if (length(addrs) > 0) r.address = addrs;
return r;
});
}
if (had_sniff) {
if (!cfg.route) cfg.route = {};
if (!cfg.route.rules) cfg.route.rules = [];
let has_sniff_rule = false;
for (let rl in cfg.route.rules)
if (rl.action === 'sniff') { has_sniff_rule = true; break; }
if (!has_sniff_rule) {
let new_rules = [ { action: 'sniff' } ];
for (let rl in cfg.route.rules) push(new_rules, rl);
cfg.route.rules = new_rules;
}
}
// --- sing-box 1.14 requires route.default_domain_resolver; add one if missing,
// preferring dns.final, else the first dns.servers entry with a tag. ---
if (cfg.route && !cfg.route.default_domain_resolver) {
let resolver_tag = '';
if (cfg.dns && cfg.dns.final) resolver_tag = cfg.dns.final;
else if (cfg.dns && cfg.dns.servers) {
for (let srv in cfg.dns.servers)
if (srv.tag) { resolver_tag = srv.tag; break; }
}
if (resolver_tag)
cfg.route.default_domain_resolver = { server: resolver_tag };
}
// --- Remove dangling outbound refs from selectors/urltest, and drop airline
// pseudo-nodes. Two cases: (1) subconverter may reference a missing tag (e.g.
// REJECT); (2) "Traffic:.../Expire:.../quota" pseudo-nodes are real SS/Vmess
// outbounds (kept so the UI can read traffic/expiry) but don't forward — in a
// selector/urltest they win (0ms) and swallow all foreign traffic. ---
let is_pseudo_tag = function(t) {
if (!t) return false;
return match(t, /^Traffic[:]/) ||
match(t, /^Expire[:]/) ||
match(t, /剩余流量|剩余[:]/) ||
match(t, /距离下次重置/) ||
match(t, /到期(时间|日期)?[:]/) ||
match(t, /官网[:]|网站[:]|套餐[:]?|客服[:]/) ||
match(t, /QQ[群]?[:]/) ||
match(t, /Telegram|TG群|官方群/) ||
match(t, /续费|订阅地址|流量重置/);
};
if (cfg.outbounds) {
let defined_tags = {};
for (let ob in cfg.outbounds) defined_tags[ob.tag] = true;
cfg.outbounds = map(cfg.outbounds, function(ob) {
if ((ob.type !== 'selector' && ob.type !== 'urltest' && ob.type !== 'loadbalance') ||
!ob.outbounds) return ob;
let valid = [];
for (let t in ob.outbounds) {
if (!defined_tags[t]) continue;
if (is_pseudo_tag(t)) continue;
push(valid, t);
}
if (length(valid) === length(ob.outbounds)) return ob;
let r = {};
for (let k in ob) r[k] = ob[k];
r.outbounds = valid;
return r;
});
}
// --- Force download_detour=DIRECT on every remote rule_set ---
// subconverter often emits download_detour="auto", but the proxy isn't ready at
// first start -> urltest deadlock ("fetch rule-set: deadline exceeded") -> FATAL.
// The srs URLs already use a CN-reachable mirror, so DIRECT is always correct.
if (cfg.route && cfg.route.rule_set) {
let dl_detour = pick_dl_detour();
cfg.route.rule_set = map(cfg.route.rule_set, function(rs) {
if (!rs || rs.type !== 'remote') return rs;
let r = {};
for (let k in rs) r[k] = rs[k];
r.download_detour = dl_detour;
return r;
});
}
return cfg;
}
// ---- CLI entry: ucode migrate_singbox.uc <config.json> ----
let _path = ARGV[0] || '';
if (!_path) { print("missing path\n"); exit(1); }
let _raw = readfile(_path);
if (!_raw) { print("read failed\n"); exit(1); }
let _cfg = json(_raw);
if (!_cfg) { print("json parse failed\n"); exit(1); }
let _before = sprintf('%J', _cfg);
_cfg = migrate_singbox(_cfg);
let _after = sprintf('%J', _cfg);
if (_before === _after) { print("nochange\n"); exit(0); }
if (writefile(_path, _after) === null) { print("write failed\n"); exit(1); }
print("migrated\n");
@@ -0,0 +1,939 @@
#!/usr/bin/ucode
'use strict';
import { readfile, writefile, access, popen } from 'fs';
let path = ARGV[0] || '';
let redir_port = +(ARGV[1] || '7891');
let tproxy_port = +(ARGV[2] || '7982');
let mixed_port = +(ARGV[3] || '7890');
let has_tun_device = (ARGV[4] || '1') == '1';
if (has_tun_device && system('(ip tuntap add mode tun name cotuntest >/dev/null 2>&1 && ip link del cotuntest >/dev/null 2>&1)') != 0)
has_tun_device = false;
// default 6666 must match fw4.sh CORE_ROUTING_MARK (0x1a0a)
let routing_mark = +(ARGV[5] || '6666');
let dns_port = +(ARGV[6] || '1053');
let dash_port = +(ARGV[7] || '9090');
let dash_secret = ARGV[8] != null ? (ARGV[8] + '') : '';
if (!path) {
print("missing path\n");
exit(1);
}
let raw = readfile(path);
if (!raw) {
print("read failed\n");
exit(1);
}
let cfg = json(raw);
if (!cfg) {
print("json parse failed\n");
exit(1);
}
function s_len(s) {
return length(s || '');
}
function s_sub(s, start, count) {
if (count == null)
return substr(s || '', start);
return substr(s || '', start, count);
}
function is_space(ch) {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n';
}
function trim_s(s) {
s = (s == null) ? '' : (s + '');
while (s_len(s) > 0 && is_space(s_sub(s, 0, 1)))
s = s_sub(s, 1);
while (s_len(s) > 0 && is_space(s_sub(s, s_len(s) - 1, 1)))
s = s_sub(s, 0, s_len(s) - 1);
return s;
}
let default_interface = '';
let _default_if_pipe = popen("ip -4 route show default 2>/dev/null | awk '{print $5; exit}'");
if (_default_if_pipe) {
default_interface = trim_s(_default_if_pipe.read('all'));
_default_if_pipe.close();
}
function starts_with(s, prefix) {
return s_sub(s || '', 0, s_len(prefix)) == prefix;
}
function find_char(s, ch) {
for (let i = 0; i < s_len(s); i++)
if (s_sub(s, i, 1) == ch)
return i;
return -1;
}
function find_last_char(s, ch) {
for (let i = s_len(s) - 1; i >= 0; i--)
if (s_sub(s, i, 1) == ch)
return i;
return -1;
}
function split_uci_words(line) {
let out = [], cur = '', quote = '', esc = false;
for (let i = 0; i < s_len(line); i++) {
let ch = s_sub(line, i, 1);
if (esc) {
cur += ch;
esc = false;
continue;
}
if (ch == '\\') {
esc = true;
continue;
}
if (quote) {
if (ch == quote)
quote = '';
else
cur += ch;
continue;
}
if (ch == '"' || ch == "'") {
quote = ch;
continue;
}
if (is_space(ch)) {
if (s_len(cur) > 0) {
push(out, cur);
cur = '';
}
continue;
}
cur += ch;
}
if (s_len(cur) > 0)
push(out, cur);
return out;
}
function load_clashoo_uci() {
let uci_path = ARGV[9] || '/etc/config/clashoo';
let txt = readfile(uci_path) || '';
let sections = [], cur = null;
for (let line in split(txt, '\n')) {
line = trim_s(line);
if (!s_len(line) || starts_with(line, '#'))
continue;
let words = split_uci_words(line);
if (!length(words))
continue;
if (words[0] == 'config') {
cur = { type: words[1] || '', name: words[2] || '', options: {}, lists: {} };
push(sections, cur);
continue;
}
if (!cur || length(words) < 3)
continue;
if (words[0] == 'option')
cur.options[words[1]] = words[2];
else if (words[0] == 'list') {
if (cur.lists[words[1]] == null)
cur.lists[words[1]] = [];
push(cur.lists[words[1]], words[2]);
}
}
return sections;
}
let clashoo_uci = load_clashoo_uci();
function uci_config_section() {
for (let s in clashoo_uci)
if (s.type == 'clashoo' && (s.name == 'config' || s.name == ''))
return s;
return {};
}
let uci_cfg = uci_config_section();
function uci_opt(key, def) {
if (uci_cfg.options && uci_cfg.options[key] != null)
return uci_cfg.options[key];
return def;
}
function uci_list(key) {
if (uci_cfg.lists && uci_cfg.lists[key] != null)
return uci_cfg.lists[key];
if (uci_cfg.options && uci_cfg.options[key] != null)
return [ uci_cfg.options[key] ];
return [];
}
function uci_sections(type_name) {
let out = [];
for (let s in clashoo_uci)
if (s.type == type_name)
push(out, s);
return out;
}
function opt_bool(v, def) {
if (v == null || v == '')
return def;
return v === true || v == '1' || v == 'true' || v == 'yes' || v == 'on';
}
function normalize_dns_uri(address, protocol, port) {
address = trim_s(address || '');
protocol = trim_s(protocol || '');
port = trim_s(port || '');
if (!s_len(address))
return '';
if (find_char(address, ':') >= 0 && find_char(address, '/') >= 0)
return address;
if (protocol == 'none')
protocol = '';
if (protocol == 'dot')
protocol = 'tls://';
else if (protocol == 'doh')
protocol = 'https://';
else if (protocol == 'doq')
protocol = 'quic://';
if (s_len(protocol) && !starts_with(protocol, 'udp://') && !starts_with(protocol, 'tcp://') &&
!starts_with(protocol, 'tls://') && !starts_with(protocol, 'https://') && !starts_with(protocol, 'quic://'))
protocol += '://';
return protocol + address + (s_len(port) ? ':' + port : '');
}
function direct_outbound_tag() {
for (let ob in (cfg.outbounds || []))
if (ob && ob.type == 'direct' && s_len(ob.tag || ''))
return ob.tag;
return 'DIRECT';
}
function dns_server_obj(uri, tag, fallback_type) {
uri = normalize_dns_uri(uri || '', '', '');
if (!s_len(uri))
return null;
let scheme = fallback_type || 'udp';
let rest = uri;
let p = -1;
for (let i = 0; i < s_len(uri) - 2; i++) {
if (s_sub(uri, i, 3) == '://') {
p = i;
break;
}
}
if (p >= 0) {
scheme = s_sub(uri, 0, p);
rest = s_sub(uri, p + 3);
}
if (scheme == 'dot')
scheme = 'tls';
else if (scheme == 'doh')
scheme = 'https';
else if (scheme == 'doq')
scheme = 'quic';
let path = '';
let slash = find_char(rest, '/');
if (slash >= 0) {
path = s_sub(rest, slash);
rest = s_sub(rest, 0, slash);
}
let server = rest, server_port = null;
let colon = find_last_char(rest, ':');
if (colon > 0 && find_char(rest, ']') < 0) {
server = s_sub(rest, 0, colon);
let n = +(s_sub(rest, colon + 1));
if (n === n)
server_port = n;
}
let obj = { type: scheme, tag: tag, server: server };
if (server_port != null)
obj.server_port = server_port;
if (path && (scheme == 'https' || scheme == 'h3'))
obj.path = path;
if ((scheme == 'https' || scheme == 'tls' || scheme == 'quic') && tag != 'dns_resolver')
obj.domain_resolver = 'dns_resolver';
/* sing-box 1.12+ DNS servers detour to the proxy by default, so a server
* resolving its own domain via dns_resolver loops -> "deadline exceeded".
* Force direct/resolver servers to DIRECT; only dns_proxy may use the proxy. */
if (tag == 'dns_resolver' || tag == 'dns_direct' || tag == 'dns_foreign')
obj.detour = direct_outbound_tag();
return obj;
}
function dns_servers_by_role(role) {
let out = [];
for (let s in uci_sections('dnsservers')) {
if (!opt_bool(s.options.enabled, true))
continue;
if ((s.options.ser_type || 'nameserver') != role)
continue;
let uri = normalize_dns_uri(s.options.ser_address || '', s.options.protocol || '', s.options.ser_port || '');
if (s_len(uri))
push(out, uri);
}
return out;
}
function first_or(arr, fallback) {
return length(arr) ? arr[0] : fallback;
}
function local_rule_set_path(tag) {
if (!s_len(tag))
return '';
let path = '/usr/share/clashoo/ruleset/' + tag + '.srs';
if (access(path, 'r'))
return path;
if (tag == 'geolocation-cn' || tag == 'cn') {
path = '/usr/share/clashoo/ruleset/geosite-cn.srs';
if (access(path, 'r'))
return path;
}
return '';
}
function keep_remote_rule_set(rs) {
return false;
}
function normalize_rule_set_url(url) {
url = url || '';
url = replace(url, /^https:\/\/gh-proxy\.com\//, '');
let m = match(url, /^https:\/\/raw\.githubusercontent\.com\/([^\/]+)\/([^\/]+)\/([^\/]+)\/(.+)$/);
if (m)
return 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4];
m = match(url, /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/raw\/refs\/heads\/([^\/]+)\/(.+)$/);
if (m)
return 'https://cdn.jsdelivr.net/gh/' + m[1] + '/' + m[2] + '@' + m[3] + '/' + m[4];
return url;
}
function has_route_rule_set(tag) {
for (let rs in (cfg.route || {}).rule_set || [])
if (rs && rs.tag == tag)
return true;
return false;
}
function add_remote_rule_set(tag, url) {
cfg.route = cfg.route || {};
cfg.route.rule_set = cfg.route.rule_set || [];
if (has_route_rule_set(tag))
return;
push(cfg.route.rule_set, {
tag: tag,
type: 'remote',
format: 'binary',
url: url
});
}
function array_has_value(arr, val) {
if (type(arr) != 'array')
return false;
for (let item in arr)
if (item == val)
return true;
return false;
}
let dnsmasq_uid = null;
let _dnsmasq_uid_pipe = popen("id -u dnsmasq 2>/dev/null");
if (_dnsmasq_uid_pipe) {
let value = trim_s(_dnsmasq_uid_pipe.read('all'));
_dnsmasq_uid_pipe.close();
if (match(value, /^[0-9]+$/))
dnsmasq_uid = +value;
}
function ensure_tun_dnsmasq_exclude(ib) {
if (dnsmasq_uid == null)
return;
let excludes = ib.exclude_uid;
if (type(excludes) != 'array')
excludes = excludes != null ? [ excludes ] : [];
if (!array_has_value(excludes, dnsmasq_uid))
push(excludes, dnsmasq_uid);
ib.exclude_uid = excludes;
}
function ensure_tun_cn_exclude(ib) {
let excludes = ib.route_exclude_address_set;
if (type(excludes) != 'array')
excludes = excludes ? [ excludes ] : [];
if (!array_has_value(excludes, 'cn-ip'))
push(excludes, 'cn-ip');
ib.route_exclude_address_set = excludes;
}
function prune_tun_cn_exclude_if_missing() {
if (has_route_rule_set('cn-ip'))
return;
for (let ib in cfg.inbounds || []) {
if (!ib || ib.type != 'tun')
continue;
let excludes = [];
for (let item in ib.route_exclude_address_set || [])
if (item != 'cn-ip')
push(excludes, item);
if (length(excludes))
ib.route_exclude_address_set = excludes;
else
delete ib.route_exclude_address_set;
}
}
function matcher_rule(matcher, server_tag) {
let r = { server: server_tag };
if (starts_with(matcher, 'geosite:'))
r.rule_set = s_sub(matcher, 8);
else if (starts_with(matcher, 'rule_set:'))
r.rule_set = s_sub(matcher, 9);
else if (starts_with(matcher, 'domain:'))
r.domain = s_sub(matcher, 7);
else if (starts_with(matcher, 'domain-suffix:'))
r.domain_suffix = s_sub(matcher, 14);
else
r.domain_suffix = matcher;
return r;
}
function apply_dns_from_uci() {
cfg.dns = cfg.dns || {};
/* strip mihomo-style DNS fields (enable/ipv6/listen/fake-ip-filter/nameserver...)
* that leak in from YAML conversion; sing-box rejects them (Fatal). */
let _mihomo_dns_fields = ['enable', 'ipv6', 'listen', 'fake-ip-filter', 'fake-ip-range',
'enhanced-mode', 'nameserver', 'fallback', 'fallback-filter',
'use-hosts', 'default-nameserver', 'proxy-server-nameserver',
'direct-nameserver', 'nameserver-policy'];
for (let _i = 0; _i < length(_mihomo_dns_fields); _i++)
delete cfg.dns[_mihomo_dns_fields[_i]];
/* strip mihomo-style experimental fields */
let _mihomo_exp = ['sniff-tls-sni', 'sniff', 'sniffer'];
for (let _me = 0; _me < length(_mihomo_exp); _me++)
if (cfg.experimental && cfg.experimental[_mihomo_exp[_me]] != null)
delete cfg.experimental[_mihomo_exp[_me]];
/* strip non-sing-box root-level fields */
let _mihomo_root = ['clash-for-android', 'cfw-bypass', 'sniffer', 'profile',
'geodata-mode', 'geodata-loader', 'geox-url', 'geo-auto-update',
'geo-update-interval', 'tun', 'ipv6', 'interface-name',
'port', 'socks-port', 'mixed-port', 'redir-port', 'tproxy-port', 'mode', 'allow-lan', 'log-level', 'external-controller', 'secret', 'bind-address', 'routing-mark', 'find-process-mode', 'tcp-concurrent', 'unified-delay',
'keep-alive-interval', 'keep-alive-idle', 'disable-keep-alive'];
/* strip subconverter note fields and non-standard keys */
let _subconv_fields = ['_note', '_sub_url', 'hosts', 'script', 'enable', 'fake-ip-filter', 'fake-ip-range'];
for (let _sf = 0; _sf < length(_subconv_fields); _sf++)
if (cfg[_subconv_fields[_sf]] != null)
delete cfg[_subconv_fields[_sf]];
for (let _mr = 0; _mr < length(_mihomo_root); _mr++)
if (cfg[_mihomo_root[_mr]] != null)
delete cfg[_mihomo_root[_mr]];
let bootstrap = uci_list('default_nameserver');
if (!length(bootstrap))
bootstrap = uci_list('defaul_nameserver');
let resolver_uri = first_or(bootstrap, '223.5.5.5');
let direct_uri = first_or(dns_servers_by_role('direct-nameserver'), first_or(dns_servers_by_role('proxy-server-nameserver'), 'https://doh.pub/dns-query'));
let proxy_uri = first_or(dns_servers_by_role('nameserver'), first_or(dns_servers_by_role('fallback'), 'https://1.1.1.1/dns-query'));
let servers = [];
push(servers, dns_server_obj(resolver_uri, 'dns_resolver', 'udp'));
push(servers, dns_server_obj(direct_uri, 'dns_direct', 'udp'));
push(servers, dns_server_obj(proxy_uri, 'dns_proxy', 'tls'));
let enhanced = uci_opt('enhanced_mode', 'fake-ip');
if (enhanced == 'fake-ip') {
let fake_range = uci_opt('fake_ip_range', '198.18.0.1/16');
push(servers, {
type: 'fakeip',
tag: 'dns_fakeip',
inet4_range: fake_range || '198.18.0.1/16',
inet6_range: 'fc00::/18'
});
}
let rules = [];
push(servers, { type: 'udp', tag: 'dns_foreign', server: '1.1.1.1' });
if (enhanced == 'fake-ip') {
push(rules, {
rule_set: 'geolocation-!cn',
server: 'dns_fakeip'
});
}
let clean_servers = [];
for (let s in servers)
if (s)
push(clean_servers, s);
cfg.dns.servers = clean_servers;
cfg.dns.rules = rules;
/* Keep bootstrap direct; proxy unmatched queries in strict mode. */
if (opt_bool(uci_opt('dns_leak_protect', '0'), false)) {
cfg.dns.final = 'dns_proxy';
if (!opt_bool(uci_opt('ipv6_proxy', '0'), false))
unshift(cfg.dns.rules, { query_type: ['AAAA'], action: 'reject', method: 'drop' });
} else
cfg.dns.final = 'dns_direct';
let ecs = trim_s(uci_opt('dns_ecs', ''));
if (s_len(ecs))
cfg.dns.client_subnet = ecs;
else
delete cfg.dns.client_subnet;
if (opt_bool(uci_opt('singbox_independent_cache', '0'), false))
cfg.dns.independent_cache = true;
else
delete cfg.dns.independent_cache;
/* sing-box 1.14 requires this (else Fatal); dns_resolver is DIRECT so first-start node lookups use direct DNS */
cfg.route.default_domain_resolver = 'dns_resolver';
}
let inbounds = cfg.inbounds || [];
let normalized = [];
let has_redirect = false;
let has_tproxy = false;
let has_mixed = false;
let has_tun = false;
let has_dns_in = false;
let wants_tun = has_tun_device && (uci_opt('tcp_mode', '') == 'tun' || uci_opt('udp_mode', '') == 'tun');
let tun_stack = uci_opt('stack', 'mixed') || 'mixed';
if (wants_tun) {
add_remote_rule_set('cn-ip',
'https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/cn.srs');
}
for (let ib in inbounds) {
if (!ib)
continue;
if (ib.type == 'tun' || ib.tag == 'tun-in') {
/* Keep tun inbound only when the selected proxy mode actually uses TUN. */
if (wants_tun && !has_tun) {
ib.type = 'tun';
ib.tag = ib.tag || 'tun-in';
if (!ib.address)
ib.address = [ '172.19.0.1/30', 'fdfe:dcba:9876::1/126' ];
ib.auto_route = true;
ib.auto_redirect = true;
ib.auto_redirect_input_mark = '0x2023';
ib.auto_redirect_output_mark = '0x2024';
ib.strict_route = true;
ib.stack = tun_stack;
ensure_tun_dnsmasq_exclude(ib);
ensure_tun_cn_exclude(ib);
push(normalized, ib);
has_tun = true;
}
continue;
}
if (ib.tag == 'redirect-in' || ib.type == 'redirect') {
if (has_redirect)
continue;
ib.type = 'redirect';
ib.tag = 'redirect-in';
ib.listen = '0.0.0.0';
ib.listen_port = redir_port;
has_redirect = true;
push(normalized, ib);
continue;
}
if (ib.tag == 'tproxy-in' || ib.type == 'tproxy') {
if (has_tproxy)
continue;
ib.type = 'tproxy';
ib.tag = 'tproxy-in';
ib.listen = '0.0.0.0';
ib.listen_port = tproxy_port;
ib.network = 'udp';
has_tproxy = true;
push(normalized, ib);
continue;
}
if (ib.tag == 'mixed-in' || ib.type == 'mixed') {
if (has_mixed)
continue;
ib.type = 'mixed';
ib.tag = 'mixed-in';
ib.listen = '0.0.0.0';
ib.listen_port = mixed_port;
has_mixed = true;
push(normalized, ib);
continue;
}
if (ib.tag == 'dns-in') {
if (has_dns_in)
continue;
ib.type = 'direct';
ib.tag = 'dns-in';
ib.listen = '0.0.0.0';
ib.listen_port = dns_port;
has_dns_in = true;
push(normalized, ib);
continue;
}
push(normalized, ib);
}
if (!has_redirect) {
push(normalized, {
type: 'redirect',
tag: 'redirect-in',
listen: '0.0.0.0',
listen_port: redir_port
});
}
if (!has_mixed) {
push(normalized, {
type: 'mixed',
tag: 'mixed-in',
listen: '0.0.0.0',
listen_port: mixed_port
});
}
if (wants_tun && !has_tun) {
let tun_ib = {
type: 'tun',
tag: 'tun-in',
address: [ '172.19.0.1/30', 'fdfe:dcba:9876::1/126' ],
auto_route: true,
auto_redirect: true,
auto_redirect_input_mark: '0x2023',
auto_redirect_output_mark: '0x2024',
strict_route: true,
stack: tun_stack
};
ensure_tun_dnsmasq_exclude(tun_ib);
ensure_tun_cn_exclude(tun_ib);
push(normalized, tun_ib);
}
if (!has_tproxy) {
push(normalized, {
type: 'tproxy',
tag: 'tproxy-in',
listen: '0.0.0.0',
listen_port: tproxy_port,
network: 'udp'
});
}
if (!has_dns_in) {
push(normalized, {
type: 'direct',
tag: 'dns-in',
listen: '0.0.0.0',
listen_port: dns_port
});
}
cfg.inbounds = normalized;
for (let ob in (cfg.outbounds || [])) {
if (!ob || type(ob) != 'object')
continue;
let t = ob.type || '';
if (t == 'selector' || t == 'urltest' || t == 'fallback' || t == 'load_balance' || t == 'dns' || t == 'block')
continue;
/* Clash SS obfs is plugin=obfs + plugin-opts object; sing-box wants
* plugin=obfs-local + "obfs=http;obfs-host=..." string. */
if (ob.plugin == 'obfs')
ob.plugin = 'obfs-local';
if (ob.plugin_opts != null && type(ob.plugin_opts) == 'object') {
let _parts = [];
let _map = { mode: 'obfs', host: 'obfs-host', uri: 'obfs-uri' };
for (let _k in ob.plugin_opts) {
let _v = ob.plugin_opts[_k];
if (type(_v) == 'object' || type(_v) == 'array') continue;
push(_parts, (_map[_k] || _k) + '=' + _v);
}
ob.plugin_opts = join(';', _parts);
}
if (ob.plugin != null && ob.plugin != 'obfs-local' && ob.plugin != 'v2ray-plugin' && ob.plugin != 'shadow-tls') {
delete ob.plugin;
delete ob.plugin_opts;
}
/* Force ipv4_only on DIRECT outbound: most devices are IPv4-only egress,
* a CN domain resolving AAAA stalls on "network is unreachable". */
if (ob.type == 'direct' && ob.tag == 'DIRECT')
ob.domain_resolver = { server: 'dns_resolver', strategy: 'ipv4_only' };
if (wants_tun) {
delete ob.routing_mark;
if (ob.type == 'direct' && ob.tag == 'DIRECT' && default_interface)
ob.bind_interface = ob.bind_interface || default_interface;
} else if (ob.routing_mark == null)
ob.routing_mark = routing_mark;
}
/* Drop airline pseudo-nodes (Traffic:/Expire:/quota/官网/QQ/etc.) from selector/urltest lists.
* They're real SS/Vmess (kept so the UI reads traffic/expiry) but don't forward; in a
* selector/urltest they win and swallow foreign traffic ("CN ok, foreign out"). */
let _is_pseudo_tag = function(t) {
if (!t) return false;
return match(t, /^Traffic[:]/) || match(t, /^Expire[:]/) ||
match(t, /剩余流量|剩余[:]/) || match(t, /距离下次重置/) ||
match(t, /到期(时间|日期)?[:]/) ||
match(t, /官网[:]|网站[:]|套餐[:]?|客服[:]/) ||
match(t, /QQ[群]?[:]/) || match(t, /Telegram|TG群|官方群/) ||
match(t, /续费|订阅地址|流量重置/);
};
for (let ob in (cfg.outbounds || [])) {
if (!ob || type(ob) != 'object') continue;
let t = ob.type || '';
if (t != 'selector' && t != 'urltest' && t != 'fallback' && t != 'load_balance') continue;
if (type(ob.outbounds) != 'array') continue;
let cleaned = [];
for (let tag in ob.outbounds) if (!_is_pseudo_tag(tag)) push(cleaned, tag);
ob.outbounds = cleaned;
}
cfg.route = cfg.route || {};
cfg.route.rules = cfg.route.rules || [];
let has_dns_hijack = false;
for (let rule in cfg.route.rules) {
if (!rule || type(rule) != 'object')
continue;
if (rule.inbound == 'dns-in' && rule.action == 'hijack-dns') {
has_dns_hijack = true;
break;
}
}
if (!has_dns_hijack) {
unshift(cfg.route.rules, {
inbound: 'dns-in',
action: 'hijack-dns'
});
}
cfg.route.auto_detect_interface = true;
apply_dns_from_uci();
/* DNS leak protection: reject DoT/DoQ (853) to force DNS through the core; after hijack-dns so the dns inbound survives */
if (opt_bool(uci_opt('dns_leak_protect', '0'), false)) {
let _has_853 = false;
for (let _r in cfg.route.rules) {
if (_r && type(_r) == 'object' && _r.action == 'reject' && _r.port) {
if (type(_r.port) == 'array') {
for (let _p in _r.port) if (_p == 853) { _has_853 = true; break; }
} else if (_r.port == 853) {
_has_853 = true;
}
}
if (_has_853) break;
}
if (!_has_853) {
let _new_rules = [];
let _inserted = false;
for (let _ri = 0; _ri < length(cfg.route.rules); _ri++) {
push(_new_rules, cfg.route.rules[_ri]);
if (!_inserted && cfg.route.rules[_ri] && cfg.route.rules[_ri].action == 'hijack-dns') {
push(_new_rules, { port: [853], action: 'reject' });
_inserted = true;
}
}
if (!_inserted) unshift(_new_rules, { port: [853], action: 'reject' });
cfg.route.rules = _new_rules;
}
}
/* ===== Custom routing rules (UCI: config addtype) -> route.rules, mirrors mihomo iprules.sh =====
* User "domain/IP -> direct/proxy" rules from LuCI, inserted after the DNS funnel
* (hijack-dns / 853-reject) and before other rules, so overrides win without bypassing it. */
let _proxy_outbound_tag = function() {
for (let ob in (cfg.outbounds || []))
if (ob && ob.tag == '🚀 节点选择' && (ob.type == 'selector' || ob.type == 'urltest'))
return ob.tag;
for (let ob in (cfg.outbounds || []))
if (ob && ob.type == 'selector' && s_len(ob.tag || '') && !_is_pseudo_tag(ob.tag))
return ob.tag;
return direct_outbound_tag();
};
let _load_addtype_rules = function() {
let out = [];
let dtag = direct_outbound_tag();
let ptag = null;
for (let s in uci_sections('addtype')) {
let o = s.options || {};
let addr = trim_s(o.ipaaddr || '');
let rtype = trim_s(o.type || '');
let pg = trim_s(o.pgroup || '');
if (!s_len(addr) || !s_len(rtype) || !s_len(pg))
continue;
let r = {};
if (rtype == 'DOMAIN')
r.domain = addr;
else if (rtype == 'DOMAIN-SUFFIX')
r.domain_suffix = addr;
else if (rtype == 'DOMAIN-KEYWORD')
r.domain_keyword = addr;
else if (rtype == 'IP-CIDR' || rtype == 'IP-CIDR6')
r.ip_cidr = addr;
else
continue;
if (pg == 'DIRECT')
r.outbound = dtag;
else {
if (ptag == null)
ptag = _proxy_outbound_tag();
r.outbound = ptag;
}
push(out, r);
}
return out;
};
let _addtype_rules = _load_addtype_rules();
if (length(_addtype_rules)) {
let _nr = [];
let _ins = false;
for (let _i = 0; _i < length(cfg.route.rules); _i++) {
let _ru = cfg.route.rules[_i];
let _dns_front = _ru && type(_ru) == 'object' &&
(_ru.action == 'hijack-dns' || (_ru.action == 'reject' && _ru.port));
if (!_ins && !_dns_front) {
for (let _cr in _addtype_rules)
push(_nr, _cr);
_ins = true;
}
push(_nr, _ru);
}
if (!_ins)
for (let _cr in _addtype_rules)
push(_nr, _cr);
cfg.route.rules = _nr;
}
if (uci_opt('enhanced_mode', 'fake-ip') == 'fake-ip') {
add_remote_rule_set('geolocation-!cn',
'https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-!cn.srs');
}
/* Local .srs -> local; remaining remote rule_sets must keep/get a download_detour.
* CN first-start deadlock: no srs -> fetch rules -> via auto-select -> urltest dials
* the proxy -> resolves its domain -> pulled into an unloaded rule_set by DNS rules -> deadline.
* The srs URLs use a CN-reachable mirror, so download_detour
* must be DIRECT (real direct-outbound tag if any, else 'DIRECT'). */
let _pick_dl_detour = function() {
if (cfg.outbounds) {
for (let ob in cfg.outbounds) {
if (ob && ob.type == 'direct' && ob.tag) return ob.tag;
}
}
return 'DIRECT';
};
let _dl_detour = _pick_dl_detour();
for (let rs in (cfg.route || {}).rule_set || []) {
if (!rs) continue;
if (rs.type != 'remote') { delete rs.download_detour; continue; }
let path = local_rule_set_path(rs.tag || '');
if (rs.tag && access(path, 'r')) {
delete rs.url; delete rs.download_detour; rs.type = 'local'; rs.path = path;
continue;
}
if (keep_remote_rule_set(rs) && rs.url) {
rs.url = normalize_rule_set_url(rs.url);
rs.download_detour = _dl_detour;
continue;
}
/* No local cache -> skip this remote rule_set so a failed download doesn't block start.
* Rule-sets are fetched to /usr/share/clashoo/ruleset/ once proxied. */
continue;
}
/* filter out remote rule_sets with no local cache */
let _clean_rs = [];
for (let _rsi = 0; _rsi < length(cfg.route.rule_set); _rsi++) {
let _rs = cfg.route.rule_set[_rsi];
if (!_rs) continue;
if (_rs.type == 'remote' && _rs.url != null) {
let _local_path = local_rule_set_path(_rs.tag || '');
if (!_rs.tag || !access(_local_path, 'r'))
if (keep_remote_rule_set(_rs) && _rs.url) {
_rs.url = normalize_rule_set_url(_rs.url);
_rs.download_detour = _dl_detour;
push(_clean_rs, _rs);
continue;
} else {
continue; /* no local cache -> drop from final list */
}
/* has local cache -> convert to local */
delete _rs.url; delete _rs.download_detour; _rs.type = 'local'; _rs.path = _local_path;
}
push(_clean_rs, _rs);
}
cfg.route.rule_set = _clean_rs;
prune_tun_cn_exclude_if_missing();
/* drop DNS/route rules referencing a removed rule_set, else sing-box FATAL: rule-set not found */
let _existing_tags = {};
for (let _rs in cfg.route.rule_set || []) if (_rs && _rs.tag) _existing_tags[_rs.tag] = true;
let _rule_has_ref = function(_r) {
if (!_r || type(_r) != 'object') return false;
if (_r.rule_set) {
if (type(_r.rule_set) == 'array') { for (let _t in _r.rule_set) if (!_existing_tags[_t]) return false; }
else if (!_existing_tags[_r.rule_set]) return false;
}
return true;
};
/* clean dns.rules */
if (cfg.dns && type(cfg.dns.rules) == 'array') {
let _clean_dns_rules = [];
for (let _dr in cfg.dns.rules) if (_rule_has_ref(_dr)) push(_clean_dns_rules, _dr);
cfg.dns.rules = _clean_dns_rules;
}
/* clean route.rules */
if (cfg.route && type(cfg.route.rules) == 'array') {
let _clean_rt_rules = [];
for (let _rr in cfg.route.rules) if (_rule_has_ref(_rr)) push(_clean_rt_rules, _rr);
cfg.route.rules = _clean_rt_rules;
}
cfg.experimental = cfg.experimental || {};
cfg.experimental.clash_api = cfg.experimental.clash_api || {};
cfg.experimental.clash_api.external_controller = '0.0.0.0:' + dash_port;
cfg.experimental.clash_api.external_ui = '/etc/clashoo/dashboard';
cfg.experimental.clash_api.secret = dash_secret;
cfg.experimental.cache_file = {
enabled: true,
store_fakeip: true
};
/* OpenWrt already owns system time sync. Keep sing-box NTP disabled by
* default to avoid noisy IPv6 UDP/123 failures on IPv4-only routers. */
cfg.ntp = cfg.ntp || {};
cfg.ntp.enabled = false;
cfg.log = cfg.log || {};
cfg.log.output = '/var/log/clashoo/core.log';
if (!cfg.log.level)
cfg.log.level = 'info';
if (writefile(path, sprintf('%J', cfg)) === null) {
print("write failed\n");
exit(1);
}
print("normalized\n");
@@ -0,0 +1,138 @@
{
"log": {
"level": "info",
"timestamp": true
},
"dns": {
"servers": [
{ "type": "tcp", "tag": "dns_proxy", "server": "1.1.1.1", "domain_resolver": "dns_resolver" },
{ "type": "https", "tag": "dns_direct", "server": "dns.alidns.com", "domain_resolver": "dns_resolver" },
{ "type": "udp", "tag": "dns_resolver", "server": "223.5.5.5" },
{ "type": "fakeip", "tag": "dns_fakeip", "inet4_range": "198.18.0.0/15", "inet6_range": "fc00::/18" }
],
"rules": [
{ "rule_set": "geolocation-!cn", "query_type": ["A", "AAAA", "CNAME"], "server": "dns_fakeip" },
{ "query_type": ["A", "AAAA", "CNAME"], "invert": true, "action": "reject" }
],
"final": "dns_direct"
},
"ntp": {
"enabled": false,
"server": "time.apple.com",
"server_port": 123,
"interval": "30m"
},
"inbounds": [
{ "type": "mixed", "tag": "mixed-in", "listen": "0.0.0.0", "listen_port": 2080 },
{ "type": "tun", "tag": "tun-in", "address": ["172.19.0.1/30"], "auto_route": true, "auto_redirect": true, "strict_route": true, "stack": "mixed", "route_exclude_address_set": ["cn-ip"] }
],
"outbounds": [
{
"type": "selector", "tag": "🚀 节点选择",
"outbounds": ["🌍 全部节点","♻️ 自动选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","DIRECT"],
"default": "🌍 全部节点"
},
{ "type": "selector", "tag": "▶️ YouTube", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🔍 Google", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🔮 OpenAI", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🧠 Claude", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🤖 Gemini", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🐦 Twitter", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "📦 GitHub", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "Ⓜ️ Microsoft", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🎵 TikTok", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "📺 Netflix", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🎬 流媒体", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "💳 PayPal", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "📲 电报消息", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🍏 苹果服务", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
{ "type": "selector", "tag": "🎯 全球直连", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
{ "type": "selector", "tag": "🏠 私有网络", "outbounds": ["DIRECT","🚀 节点选择","♻️ 自动选择"] },
{ "type": "selector", "tag": "🌐 非中国", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🐟 漏网之鱼", "outbounds": ["🚀 节点选择","♻️ 香港自动","♻️ 日本自动","♻️ 美国自动","♻️ 新加坡自动","♻️ 集合自动","♻️ 自动选择","🇭🇰 香港节点","🇯🇵 日本节点","🇺🇲 美国节点","🇸🇬 新加坡节点","🇺🇳 集合节点","🌍 全部节点","DIRECT"] },
{ "type": "selector", "tag": "🇭🇰 香港节点", "outbounds": ["__NODES_HK__"] },
{ "type": "selector", "tag": "🇯🇵 日本节点", "outbounds": ["__NODES_JP__"] },
{ "type": "selector", "tag": "🇺🇲 美国节点", "outbounds": ["__NODES_US__"] },
{ "type": "selector", "tag": "🇸🇬 新加坡节点","outbounds": ["__NODES_SG__"] },
{ "type": "selector", "tag": "🇺🇳 集合节点", "outbounds": ["__NODES_OTHER__"] },
{ "type": "selector", "tag": "🌍 全部节点", "outbounds": ["__NODES__"] },
{ "type": "urltest", "tag": "♻️ 香港自动", "outbounds": ["__NODES_HK__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "urltest", "tag": "♻️ 日本自动", "outbounds": ["__NODES_JP__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "urltest", "tag": "♻️ 美国自动", "outbounds": ["__NODES_US__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "urltest", "tag": "♻️ 新加坡自动", "outbounds": ["__NODES_SG__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "urltest", "tag": "♻️ 集合自动", "outbounds": ["__NODES_OTHER__"], "url": "https://www.gstatic.com/generate_204", "interval": "5m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "urltest", "tag": "♻️ 自动选择", "outbounds": ["__NODES__"], "url": "https://www.gstatic.com/generate_204", "interval": "10m", "tolerance": 50, "interrupt_exist_connections": true },
{ "type": "direct", "tag": "DIRECT" }
],
"route": {
"default_domain_resolver": "dns_resolver",
"rule_set": [
{ "tag": "openai", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/openai.srs", "download_detour": "DIRECT" },
{ "tag": "anthropic", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/anthropic.srs", "download_detour": "DIRECT" },
{ "tag": "google-gemini", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/google-gemini.srs", "download_detour": "DIRECT" },
{ "tag": "youtube", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/youtube.srs", "download_detour": "DIRECT" },
{ "tag": "google", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/google.srs", "download_detour": "DIRECT" },
{ "tag": "twitter", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/twitter.srs", "download_detour": "DIRECT" },
{ "tag": "github", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/github.srs", "download_detour": "DIRECT" },
{ "tag": "gitlab", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/gitlab.srs", "download_detour": "DIRECT" },
{ "tag": "microsoft", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/microsoft.srs", "download_detour": "DIRECT" },
{ "tag": "tiktok", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/tiktok.srs", "download_detour": "DIRECT" },
{ "tag": "netflix", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/netflix.srs", "download_detour": "DIRECT" },
{ "tag": "hulu", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/hulu.srs", "download_detour": "DIRECT" },
{ "tag": "disney", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/disney.srs", "download_detour": "DIRECT" },
{ "tag": "hbo", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/hbo.srs", "download_detour": "DIRECT" },
{ "tag": "amazon", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/amazon.srs", "download_detour": "DIRECT" },
{ "tag": "bahamut", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/bahamut.srs", "download_detour": "DIRECT" },
{ "tag": "paypal", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/paypal.srs", "download_detour": "DIRECT" },
{ "tag": "apple", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/apple.srs", "download_detour": "DIRECT" },
{ "tag": "geolocation-cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-cn.srs", "download_detour": "DIRECT" },
{ "tag": "cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/cn.srs", "download_detour": "DIRECT" },
{ "tag": "geolocation-!cn", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geosite/geolocation-!cn.srs", "download_detour": "DIRECT" },
{ "tag": "google-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/google.srs", "download_detour": "DIRECT" },
{ "tag": "private-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/private.srs", "download_detour": "DIRECT" },
{ "tag": "cn-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/cn.srs", "download_detour": "DIRECT" },
{ "tag": "telegram-ip", "type": "remote", "format": "binary", "url": "https://github.com/MetaCubeX/meta-rules-dat/raw/refs/heads/sing/geo/geoip/telegram.srs", "download_detour": "DIRECT" }
],
"rules": [
{ "action": "sniff" },
{ "clash_mode": "direct", "outbound": "DIRECT" },
{ "clash_mode": "global", "outbound": "♻️ 自动选择" },
{ "protocol": "dns", "action": "hijack-dns" },
{ "rule_set": "openai", "outbound": "🔮 OpenAI" },
{ "rule_set": "anthropic", "outbound": "🧠 Claude" },
{ "rule_set": "google-gemini", "outbound": "🤖 Gemini" },
{ "rule_set": "youtube", "outbound": "▶️ YouTube" },
{ "rule_set": "google", "outbound": "🔍 Google" },
{ "rule_set": "twitter", "outbound": "🐦 Twitter" },
{ "rule_set": "tiktok", "outbound": "🎵 TikTok" },
{ "rule_set": "netflix", "outbound": "📺 Netflix" },
{ "rule_set": ["hulu", "disney", "hbo", "amazon", "bahamut"], "outbound": "🎬 流媒体" },
{ "rule_set": "paypal", "outbound": "💳 PayPal" },
{ "rule_set": "microsoft", "outbound": "Ⓜ️ Microsoft" },
{ "rule_set": "apple", "outbound": "🍏 苹果服务" },
{ "rule_set": ["github", "gitlab"], "outbound": "📦 GitHub" },
{ "rule_set": "telegram-ip", "outbound": "📲 电报消息" },
{ "rule_set": "private-ip", "outbound": "🏠 私有网络" },
{ "rule_set": ["geolocation-cn", "cn"], "outbound": "🎯 全球直连" },
{ "rule_set": "cn-ip", "outbound": "🎯 全球直连" },
{ "domain_suffix": ["cn"], "outbound": "🎯 全球直连" },
{ "rule_set": "geolocation-!cn", "outbound": "🌐 非中国" },
{ "rule_set": "google-ip", "outbound": "🔍 Google" }
],
"auto_detect_interface": true,
"final": "🐟 漏网之鱼"
},
"experimental": {
"cache_file": { "enabled": true, "store_fakeip": true },
"clash_api": {
"external_controller": "0.0.0.0:9090",
"external_ui": "dashboard",
"secret": "clashoo",
"external_ui_download_url": "https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip",
"external_ui_download_detour": "DIRECT"
}
}
}
@@ -0,0 +1,468 @@
#!/usr/bin/ucode
/* yaml2singbox.uc — convert mihomo/clash YAML proxies to sing-box outbounds
* Usage: ucode yaml2singbox.uc <yaml-in> [template-json] [json-out]
* Supports: ss, vmess, vless, trojan, hysteria2, tuic. Others are skipped.
* "__NODES__" in the template selector outbounds is expanded to all node tags. */
'use strict';
import { readfile, writefile, popen } from 'fs';
const TPL_DEFAULT = '/usr/share/clashoo/lib/templates/default.json';
const SUPPORTED = {
'ss': 'shadowsocks',
'shadowsocks':'shadowsocks',
'vmess': 'vmess',
'vless': 'vless',
'trojan': 'trojan',
'hysteria2': 'hysteria2',
'hy2': 'hysteria2',
'tuic': 'tuic'
};
function logerr(msg) {
warn(sprintf("[yaml2singbox] %s\n", msg));
}
function die(msg, code) {
logerr(msg);
exit(code || 1);
}
/* ---------- YAML read (via yq) ---------- */
function quote_sh(s) {
/* single-quote for shell; escape embedded quotes */
return "'" + replace(s, "'", "'\\''") + "'";
}
function read_yaml_as_json(path) {
const cmd = sprintf("yq -o=json eval . %s 2>/dev/null", quote_sh(path));
const h = popen(cmd, "r");
if (!h)
die(sprintf("popen failed: %s", cmd));
let buf = "", chunk;
while ((chunk = h.read(65536)))
buf += chunk;
h.close();
if (!length(buf))
die(sprintf("yq returned empty for %s (yaml loadable?)", path));
const data = json(buf);
if (data === null)
die("json parse of yq output failed");
return data;
}
/* ---------- helpers ---------- */
function pick(obj, ...keys) {
for (let k in keys)
if (obj[k] != null)
return obj[k];
return null;
}
function tobool(v) {
if (v === true || v === 'true' || v === 1 || v === '1') return true;
if (v === false || v === 'false' || v === 0 || v === '0') return false;
return null;
}
function toint(v) {
if (v == null) return null;
let n = +v;
return (n === n) ? n : null; /* NaN check */
}
function strip_null(obj) {
if (type(obj) !== 'object') return obj;
const out = {};
for (let k in obj) {
const v = obj[k];
if (v == null) continue;
if (type(v) === 'object') {
const sv = strip_null(v);
if (length(sv) > 0) out[k] = sv;
} else if (type(v) === 'array') {
const arr = map(v, (x) => (type(x) === 'object') ? strip_null(x) : x);
if (length(arr) > 0) out[k] = arr;
} else {
out[k] = v;
}
}
return out;
}
/* ---------- TLS block assembly ---------- */
function build_tls(p) {
const enabled = tobool(p.tls) || (p.sni && length(p.sni) > 0) || (p.servername && length(p.servername) > 0);
if (!enabled) return null;
const tls = {
enabled: true,
server_name: pick(p, 'sni', 'servername'),
insecure: tobool(p['skip-cert-verify']) === true ? true : null,
alpn: p.alpn
};
const reality = p['reality-opts'];
if (reality && type(reality) === 'object') {
tls.reality = {
enabled: true,
public_key: reality['public-key'],
short_id: reality['short-id']
};
}
const client_fp = p['client-fingerprint'];
if (client_fp)
tls.utls = { enabled: true, fingerprint: client_fp };
return strip_null(tls);
}
/* ---------- transport (ws / grpc / http) ---------- */
function build_transport(p) {
const net = p.network;
if (!net || net === 'tcp') return null;
if (net === 'ws') {
const opts = p['ws-opts'] || {};
return strip_null({
type: 'ws',
path: opts.path,
headers: opts.headers,
max_early_data: toint(opts['max-early-data']),
early_data_header_name: opts['early-data-header-name']
});
}
if (net === 'grpc') {
const opts = p['grpc-opts'] || {};
return { type: 'grpc', service_name: opts['grpc-service-name'] };
}
if (net === 'http' || net === 'h2') {
const opts = p['h2-opts'] || p['http-opts'] || {};
return strip_null({
type: 'http',
host: opts.host || opts.Host,
path: opts.path
});
}
if (net === 'httpupgrade') {
const opts = p['http-upgrade-opts'] || {};
return strip_null({ type: 'httpupgrade', path: opts.path, host: opts.host });
}
logerr(sprintf("unknown transport network '%s' for node '%s', ignored", net, p.name));
return null;
}
/* ---------- per-protocol conversion ---------- */
function convert_ss(p) {
const plugin = p.plugin, popts = p['plugin-opts'] || {};
let plugin_opts = null;
if (plugin && type(popts) === 'object') {
/* Field mapping: Clash simple-obfs -> sing-box obfs-local
* Clash: mode=http;host=xxx → sing-box: obfs=http;obfs-host=xxx */
const FIELD_MAP = {
'mode': 'obfs',
'host': 'obfs-host',
'uri': 'obfs-uri'
};
const parts = [];
for (let k in popts) {
const v = popts[k];
if (type(v) === 'object') continue;
push(parts, (FIELD_MAP[k] || k) + '=' + v);
}
plugin_opts = join(';', parts);
}
/* Clash "obfs" maps to sing-box plugin "obfs-local" */
let sb_plugin = null;
if (plugin === 'obfs') sb_plugin = 'obfs-local';
else if (plugin === 'v2ray-plugin') sb_plugin = 'v2ray-plugin';
else if (plugin === 'shadow-tls') sb_plugin = 'shadow-tls';
else if (plugin) sb_plugin = plugin;
return strip_null({
type: 'shadowsocks',
tag: p.name,
server: p.server,
server_port: toint(p.port),
method: p.cipher,
password: p.password,
plugin: sb_plugin,
plugin_opts: plugin_opts
});
}
function convert_vmess(p) {
return strip_null({
type: 'vmess',
tag: p.name,
server: p.server,
server_port: toint(p.port),
uuid: p.uuid,
alter_id: toint(p.alterId) || 0,
security: p.cipher || 'auto',
tls: build_tls(p),
transport: build_transport(p)
});
}
function convert_vless(p) {
return strip_null({
type: 'vless',
tag: p.name,
server: p.server,
server_port: toint(p.port),
uuid: p.uuid,
flow: p.flow,
tls: build_tls(p),
transport: build_transport(p)
});
}
function convert_trojan(p) {
return strip_null({
type: 'trojan',
tag: p.name,
server: p.server,
server_port: toint(p.port),
password: p.password,
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server },
transport: build_transport(p)
});
}
function convert_hysteria2(p) {
const obfs_pass = p['obfs-password'];
return strip_null({
type: 'hysteria2',
tag: p.name,
server: p.server,
server_port: toint(p.port),
password: pick(p, 'password', 'auth'),
up_mbps: toint(p.up),
down_mbps: toint(p.down),
obfs: (p.obfs && obfs_pass) ? { type: p.obfs, password: obfs_pass } : null,
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server }
});
}
function convert_tuic(p) {
return strip_null({
type: 'tuic',
tag: p.name,
server: p.server,
server_port: toint(p.port),
uuid: p.uuid,
password: p.password,
congestion_control: p['congestion-controller'] || p.congestion,
udp_relay_mode: p['udp-relay-mode'],
tls: build_tls(p) || { enabled: true, server_name: p.sni || p.server, alpn: p.alpn }
});
}
function convert_proxy(p) {
const sb_type = SUPPORTED[p.type];
if (!sb_type) {
if (p.type && p.type !== 'select')
logerr(sprintf("skip unsupported type '%s' for '%s'", p.type, p.name));
return null;
}
if (!p.server || !p.port || !p.name) {
logerr(sprintf("skip proxy missing server/port/name (type=%s)", p.type));
return null;
}
switch (p.type) {
case 'ss':
case 'shadowsocks': return convert_ss(p);
case 'vmess': return convert_vmess(p);
case 'vless': return convert_vless(p);
case 'trojan': return convert_trojan(p);
case 'hysteria2':
case 'hy2': return convert_hysteria2(p);
case 'tuic': return convert_tuic(p);
}
return null;
}
/* ---------- dedup (tag must be unique) ---------- */
function dedupe_tags(nodes) {
const seen = {};
for (let n in nodes) {
let base = n.tag, t = base, i = 2;
while (seen[t]) {
t = base + '_' + i;
i++;
}
n.tag = t;
seen[t] = true;
}
return nodes;
}
/* Drop airline pseudo-nodes (Traffic:/Expire:/quota/官网/QQ etc.) from selectors
* and urltests. They're real outbounds (so the UI can read traffic/expiry) but
* don't forward — in a selector/urltest they win (0ms) and swallow all traffic. */
function is_pseudo_node_tag(tag) {
if (!tag) return false;
const t = '' + tag;
const patterns = [
/^Traffic[:]/i,
/^Expire[:]/i,
/剩余流量|剩余[:]/,
/距离下次重置/,
/到期(时间|日期)?[:]/,
/官网[:]/,
/网站[:]/,
/套餐[:]?/,
/客服[:]/,
/QQ[群]?[:]/i,
/Telegram|TG群|官方群/i,
/续费|订阅地址|流量重置/,
];
for (let re in patterns) if (match(t, re)) return true;
return false;
}
/* ---------- detect region from tag -> 'HK'/'JP'/'US'/'SG'/'OTHER'/'' ---------- */
function region_of(tag) {
if (!tag) return '';
const t = '' + tag;
if (match(t, /港|🇭🇰|HK[^A-Za-z]|[^A-Za-z]HK|^HK$|[Hh]ong[Kk]/)) return 'HK';
if (match(t, /日|🇯🇵|JP[^A-Za-z]|[^A-Za-z]JP|^JP$|[Jj]apan/)) return 'JP';
if (match(t, /美|🇺🇸|US[^A-Za-z]|[^A-Za-z]US|^US$|[Uu]nited.?[Ss]tates|[Aa]merica/)) return 'US';
if (match(t, /新加坡|🇸🇬|SG[^A-Za-z]|[^A-Za-z]SG|^SG$|[Ss]ingapore/)) return 'SG';
if (match(t, /台湾|台|🇹🇼|TW[^A-Za-z]|[^A-Za-z]TW|^TW$|[Tt]aiwan|韩国|韩|🇰🇷|KR[^A-Za-z]|[^A-Za-z]KR|^KR$|[Kk]orea/)) return 'OTHER';
return '';
}
/* ---------- expand __NODES__/__NODES_XX__ placeholders to real tags (dropping pseudo-nodes) ---------- */
function expand_node_placeholder(outbounds, node_tags) {
const real_tags = [];
for (let t in node_tags) if (!is_pseudo_node_tag(t)) push(real_tags, t);
/* bucket by region */
let tags_hk = [], tags_jp = [], tags_us = [], tags_sg = [], tags_other = [];
for (let t in real_tags) {
const r = region_of(t);
if (r === 'HK') push(tags_hk, t);
else if (r === 'JP') push(tags_jp, t);
else if (r === 'US') push(tags_us, t);
else if (r === 'SG') push(tags_sg, t);
else if (r === 'OTHER') push(tags_other, t);
}
/* when a region has no nodes, fall back to all nodes (avoid empty outbounds) */
if (!length(tags_hk)) tags_hk = real_tags;
if (!length(tags_jp)) tags_jp = real_tags;
if (!length(tags_us)) tags_us = real_tags;
if (!length(tags_sg)) tags_sg = real_tags;
if (!length(tags_other)) tags_other = real_tags;
for (let ob in outbounds) {
if (ob.type !== 'selector' && ob.type !== 'urltest') continue;
if (!ob.outbounds || type(ob.outbounds) !== 'array') continue;
const expanded = [];
for (let item in ob.outbounds) {
let list = null;
if (item === '__NODES__') list = real_tags;
else if (item === '__NODES_HK__') list = tags_hk;
else if (item === '__NODES_JP__') list = tags_jp;
else if (item === '__NODES_US__') list = tags_us;
else if (item === '__NODES_SG__') list = tags_sg;
else if (item === '__NODES_OTHER__') list = tags_other;
if (list !== null) { for (let t in list) push(expanded, t); }
else { push(expanded, item); }
}
ob.outbounds = expanded;
}
return outbounds;
}
/* ---------- proxy-providers ---------- */
function resolve_providers(yaml) {
if (type(yaml['proxy-providers']) !== 'object')
return [];
const all = [];
for (let name in yaml['proxy-providers']) {
const p = yaml['proxy-providers'][name];
if (p.type !== 'http' || !p.url) {
logerr(sprintf("provider '%s': skip (not http or no url)", name));
continue;
}
const cmd = sprintf("wget -q -O- --timeout=20 %s 2>/dev/null | yq -o=json eval . 2>/dev/null", quote_sh(p.url));
const h = popen(cmd, "r");
if (!h) { logerr(sprintf("provider '%s': download failed", name)); continue; }
let buf = "", chunk;
while ((chunk = h.read(65536))) buf += chunk;
h.close();
if (!length(buf)) { logerr(sprintf("provider '%s': empty response", name)); continue; }
const data = json(buf);
if (!data || type(data.proxies) !== 'array') {
logerr(sprintf("provider '%s': no proxies array in response", name));
continue;
}
logerr(sprintf("provider '%s': %d proxies", name, length(data.proxies)));
for (let px in data.proxies) push(all, px);
}
return all;
}
/* ---------- main ---------- */
const yaml_path = ARGV[0];
const tpl_path = ARGV[1] || TPL_DEFAULT;
const out_path = ARGV[2];
if (!yaml_path)
die("usage: ucode yaml2singbox.uc <yaml-in> [template-json] [json-out]");
const yaml = read_yaml_as_json(yaml_path);
let proxies = [];
if (type(yaml.proxies) === 'array')
proxies = yaml.proxies;
else
logerr("no inline proxies array, checking proxy-providers...");
if (type(yaml['proxy-providers']) === 'object') {
const pproxies = resolve_providers(yaml);
for (let p in pproxies) push(proxies, p);
}
if (!length(proxies))
die(sprintf("no proxies found in %s (inline or via providers)", yaml_path));
const tpl_raw = readfile(tpl_path);
if (!tpl_raw)
die(sprintf("cannot read template %s", tpl_path));
const tpl = json(tpl_raw);
if (!tpl || type(tpl.outbounds) !== 'array')
die(sprintf("template %s has no outbounds[]", tpl_path));
/* convert proxies */
const nodes = [];
let skipped = 0;
for (let p in proxies) {
const o = convert_proxy(p);
if (o) push(nodes, o); else skipped++;
}
dedupe_tags(nodes);
if (!length(nodes))
die(sprintf("no usable nodes converted from %d proxies (skipped=%d)", length(proxies), skipped));
logerr(sprintf("converted=%d skipped=%d", length(nodes), skipped));
/* prepend converted nodes to template outbounds */
const final_outbounds = [];
for (let n in nodes) push(final_outbounds, n);
for (let ob in tpl.outbounds) push(final_outbounds, ob);
/* expand __NODES__ placeholders */
const node_tags = map(nodes, (n) => n.tag);
expand_node_placeholder(final_outbounds, node_tags);
tpl.outbounds = final_outbounds;
const out = sprintf("%.J\n", tpl);
if (out_path) {
if (!writefile(out_path, out))
die(sprintf("writefile failed: %s", out_path));
logerr(sprintf("wrote %s (%d bytes, %d nodes)", out_path, length(out), length(nodes)));
} else {
print(out);
}
+42
View File
@@ -0,0 +1,42 @@
#!/bin/sh
set -f
[ "$(uci -q get clashoo.config.fake_ip_filter_migrated 2>/dev/null)" = "1" ] && exit 0
filters="$(uci -q get clashoo.config.fake_ip_filter 2>/dev/null)"
has_legacy=0
for filter in $filters; do
[ "$filter" = "*.lan" ] && has_legacy=1
done
if [ "$has_legacy" = "1" ]; then
uci -q delete clashoo.config.fake_ip_filter
added_lan=0
added_local=0
for filter in $filters; do
case "$filter" in
'*.lan'|'+.lan')
if [ "$added_lan" = "0" ]; then
uci -q add_list clashoo.config.fake_ip_filter='+.lan'
added_lan=1
fi
;;
'+.local')
if [ "$added_local" = "0" ]; then
uci -q add_list clashoo.config.fake_ip_filter='+.local'
added_local=1
fi
;;
*)
uci -q add_list clashoo.config.fake_ip_filter="$filter"
;;
esac
done
if [ "$added_local" = "0" ]; then
uci -q add_list clashoo.config.fake_ip_filter='+.local'
fi
fi
uci -q set clashoo.config.fake_ip_filter_migrated='1'
uci -q commit clashoo
@@ -0,0 +1,38 @@
#!/bin/sh
[ "$(uci -q get clashoo.config.acl_migrated 2>/dev/null)" = "1" ] && exit 0
if uci -q show clashoo 2>/dev/null | grep -q '=lan_acl$'; then
uci -q set clashoo.config.acl_migrated='1'
uci -q commit clashoo
exit 0
fi
mode="$(uci -q get clashoo.config.access_control 2>/dev/null)"
add_group() {
local dns="$1" proxy="$2" list_key="${3:-}" section value
section="$(uci -q add clashoo lan_acl)" || exit 1
uci -q set clashoo."$section".enabled='1'
uci -q set clashoo."$section".dns="$dns"
uci -q set clashoo."$section".proxy="$proxy"
if [ -n "$list_key" ]; then
for value in $(uci -q get clashoo.config."$list_key" 2>/dev/null); do
uci -q add_list clashoo."$section".ip="$value"
done
fi
}
case "$mode" in
1)
add_group 1 1 proxy_lan_ips
add_group 0 0
;;
2)
add_group 0 0 reject_lan_ips
add_group 1 1
;;
esac
uci -q set clashoo.config.acl_migrated='1'
uci -q commit clashoo
+89
View File
@@ -0,0 +1,89 @@
#!/bin/sh
# clashoo access check
# 用法: access_check.sh <url> [mode]
# mode = direct | proxy(默认 proxy
# direct: 经 openwrt 主干直连,不走任何代理
# proxy: 走 clashoo 本地代理端口,验证核心出站
url="$1"
mode="${2:-proxy}"
[ -n "$url" ] || exit 1
attempts=1
ok=0
sum_ms=0
last_code=000
url_host() {
printf '%s' "$1" | sed -n 's#^[a-zA-Z][a-zA-Z0-9+.-]*://\([^/:?#]*\).*$#\1#p'
}
url_port() {
_u="$1"
_p="$(printf '%s' "$_u" | sed -n 's#^[a-zA-Z][a-zA-Z0-9+.-]*://[^/:?#]*:\([0-9][0-9]*\).*$#\1#p')"
[ -n "$_p" ] && { printf '%s' "$_p"; return; }
case "$_u" in
https://*) printf '443' ;;
http://*) printf '80' ;;
*) printf '443' ;;
esac
}
run_with_timeout() {
_secs="$1"
shift
if command -v timeout >/dev/null 2>&1; then
timeout "$_secs" "$@" 2>/dev/null
else
"$@" 2>/dev/null
fi
}
resolve_real_ip() {
_host="$1"
[ -n "$_host" ] || return 0
for _dns in 223.5.5.5 119.29.29.29 1.1.1.1; do
_ip="$(run_with_timeout 1 nslookup "$_host" "$_dns" | awk '/^Address: /{print $2} /^Address [0-9]+: /{print $3}' | grep -E '^[0-9]+(\.[0-9]+){3}$' | tail -n1)"
[ -n "$_ip" ] && { printf '%s' "$_ip"; return; }
done
}
set -- -4 -L --max-time 5 --connect-timeout 3 -s -o /dev/null -w '%{http_code} %{time_total}'
if [ "$mode" = "direct" ]; then
# 强制不走任何代理,避免误读 http_proxy 环境变量
set -- "$@" --noproxy '*'
host="$(url_host "$url")"
port="$(url_port "$url")"
real_ip="$(resolve_real_ip "$host")"
[ -n "$host" ] && [ -n "$port" ] && [ -n "$real_ip" ] && set -- "$@" --resolve "${host}:${port}:${real_ip}"
else
proxy_port="$(uci get clashoo.config.mixed_port 2>/dev/null)"
[ -z "$proxy_port" ] && proxy_port="$(uci get clashoo.config.http_port 2>/dev/null)"
[ -z "$proxy_port" ] && proxy_port=7890
set -- "$@" -x "http://127.0.0.1:${proxy_port}"
fi
set -- "$@" "$url"
i=1
while [ "$i" -le "$attempts" ]; do
out="$(curl "$@" 2>/dev/null || true)"
code="$(printf '%s' "$out" | awk '{print $1}')"
time_s="$(printf '%s' "$out" | awk '{print $2}')"
[ -n "$code" ] || code=000
[ -n "$time_s" ] || time_s=0
ms="$(awk -v t="$time_s" 'BEGIN{printf "%d", t*1000}')"
last_code="$code"
if [ "$code" = "200" ] || [ "$code" = "204" ] || [ "$code" = "301" ] || [ "$code" = "302" ]; then
ok=$((ok + 1))
sum_ms=$((sum_ms + ms))
fi
i=$((i + 1))
done
loss=$((attempts - ok))
avg_ms=0
if [ "$ok" -gt 0 ]; then
avg_ms=$((sum_ms / ok))
fi
echo "ok=$ok attempts=$attempts loss=$loss avg_ms=$avg_ms code=$last_code"
+195
View File
@@ -0,0 +1,195 @@
#!/bin/sh
set -eu
CACHE_DIR="/tmp/clashoo"
CACHE_FILE="/tmp/clashoo_check_cache"
LOCK_DIR="${CACHE_DIR}/access_check.lock"
LOCK_PID_FILE="${LOCK_DIR}/pid"
UPDATING_FLAG="${CACHE_DIR}/access_check_updating"
TMP_FILE="${CACHE_FILE}.tmp.$$"
DIAG_LOG="/tmp/clashoo_access_check.log"
take_lock() {
mkdir "$LOCK_DIR" 2>/dev/null || return 1
printf '%s\n' "$$" > "$LOCK_PID_FILE" 2>/dev/null
return 0
}
pid_is_cache_worker() {
[ -r "/proc/$1/cmdline" ] || return 1
tr '\0' ' ' <"/proc/$1/cmdline" 2>/dev/null | grep -q 'access_check_cache\.sh'
}
lock_is_dead() {
_pid="$(cat "$LOCK_PID_FILE" 2>/dev/null)"
case "$_pid" in
''|*[!0-9]*)
[ -n "$(find "$LOCK_DIR" -maxdepth 0 -mmin +3 2>/dev/null)" ]
return $?
;;
esac
[ ! -d "/proc/$_pid" ] && return 0
! pid_is_cache_worker "$_pid"
}
mkdir -p "$CACHE_DIR"
if ! take_lock; then
lock_is_dead || exit 0
rm -rf "$LOCK_DIR" >/dev/null 2>&1
take_lock || exit 0
fi
trap 'rm -rf "$LOCK_DIR" "$UPDATING_FLAG" "$TMP_FILE"' EXIT INT TERM
: > "$UPDATING_FLAG"
safe_int() {
case "${1:-}" in
''|*[!0-9]*)
printf '0'
;;
*)
printf '%s' "$1"
;;
esac
}
safe_code() {
case "${1:-}" in
''|*[!0-9A-Za-z]*)
printf '000'
;;
*)
printf '%s' "$1"
;;
esac
}
parse_field() {
_line="$1"
_key="$2"
printf '%s\n' "$_line" | sed -n "s/.*${_key}=\\([^ ]*\\).*/\\1/p"
}
probe_json() {
_line="$1"
_ok="$(safe_int "$(parse_field "$_line" "ok")")"
_attempts="$(safe_int "$(parse_field "$_line" "attempts")")"
_loss="$(safe_int "$(parse_field "$_line" "loss")")"
_avg_ms="$(safe_int "$(parse_field "$_line" "avg_ms")")"
_code="$(safe_code "$(parse_field "$_line" "code")")"
_state="down"
if [ "$_ok" -ge "$_attempts" ] && [ "$_attempts" -gt 0 ]; then
if [ "$_avg_ms" -ge 2500 ]; then
_state="high_latency"
else
_state="ok"
fi
elif [ "$_ok" -gt 0 ]; then
_state="loss"
fi
_ok_bool=false
[ "$_ok" -gt 0 ] && _ok_bool=true
printf '{"ok":%s,"state":"%s","code":"%s","ok_count":%s,"attempts":%s,"loss":%s,"avg_ms":%s}' \
"$_ok_bool" "$_state" "$_code" "$_ok" "$_attempts" "$_loss" "$_avg_ms"
}
log_diag() {
_line="$(date '+%Y-%m-%d %H:%M:%S') $1"
printf '%s\n' "$_line" >>"$DIAG_LOG" 2>/dev/null
_n="$(wc -l <"$DIAG_LOG" 2>/dev/null || echo 0)"
if [ "${_n:-0}" -gt 200 ]; then
tail -n 100 "$DIAG_LOG" >"${DIAG_LOG}.tmp" 2>/dev/null && mv "${DIAG_LOG}.tmp" "$DIAG_LOG" 2>/dev/null
fi
}
probe_run() {
_url="$1"
_mode="$2"
# nice + ionice:探测 IO/CPU 都低优先级,避免抢占 LuCI dispatcher
nice -n 19 /usr/share/clashoo/net/access_check.sh "$_url" "$_mode" 2>/dev/null || true
}
proxy_port="$(uci -q get clashoo.config.mixed_port)"
[ -z "$proxy_port" ] && proxy_port="$(uci -q get clashoo.config.http_port)"
[ -z "$proxy_port" ] && proxy_port="7890"
tcp_mode="$(uci -q get clashoo.config.tcp_mode)"
[ -z "$tcp_mode" ] && tcp_mode="redirect"
udp_mode="$(uci -q get clashoo.config.udp_mode)"
[ -z "$udp_mode" ] && udp_mode="$tcp_mode"
proxy_listening() {
# 检测 mixed-port 是否在 LISTENclashoo 未运行时立即标 proxy down,避免显示假绿)
if command -v ss >/dev/null 2>&1; then
ss -tln 2>/dev/null | awk -v p=":${proxy_port}" '$0 ~ "LISTEN" && index($4, p) {found=1; exit} END{exit !found}'
elif command -v netstat >/dev/null 2>&1; then
netstat -tln 2>/dev/null | awk -v p=":${proxy_port}" '$0 ~ "LISTEN" && index($4, p) {found=1; exit} END{exit !found}'
else
return 0
fi
}
# 并行探测,把 CPU 抢占窗口从串行 2s+ 压缩到最慢一路的耗时
f_db="${TMP_FILE}.db"
f_dy="${TMP_FILE}.dy"
f_pb="${TMP_FILE}.pb"
f_py="${TMP_FILE}.py"
probe_run "https://www.douyin.com/generate_204" "direct" >"$f_db" &
probe_run "https://www.youtube.com/generate_204" "direct" >"$f_dy" &
if proxy_listening; then
proxy_skipped=0
probe_run "https://www.douyin.com/generate_204" "proxy" >"$f_pb" &
probe_run "https://www.youtube.com/generate_204" "proxy" >"$f_py" &
else
# 代理端口未监听(clashoo 已停止),跳过探测,直接标 down
proxy_skipped=1
echo "ok=0 attempts=1 loss=1 avg_ms=0 code=skip" >"$f_pb"
echo "ok=0 attempts=1 loss=1 avg_ms=0 code=skip" >"$f_py"
fi
wait
direct_bytedance="$(cat "$f_db" 2>/dev/null)"
direct_youtube="$(cat "$f_dy" 2>/dev/null)"
proxy_bytedance="$(cat "$f_pb" 2>/dev/null)"
proxy_youtube="$(cat "$f_py" 2>/dev/null)"
rm -f "$f_db" "$f_dy" "$f_pb" "$f_py"
updated_at="$(date +%s)"
probe_all_down() {
for _p in "$direct_bytedance" "$direct_youtube" "$proxy_bytedance" "$proxy_youtube"; do
case "$_p" in
''|*"ok=0 "*) ;;
*) return 1 ;;
esac
done
return 0
}
if [ "$(uci -q get clashoo.config.access_check_debug)" = "1" ] || probe_all_down; then
log_diag "port=${proxy_port} proxy_skipped=${proxy_skipped} direct_bd=[${direct_bytedance}] direct_yt=[${direct_youtube}] proxy_bd=[${proxy_bytedance}] proxy_yt=[${proxy_youtube}]"
fi
cat >"$TMP_FILE" <<EOF
{
"proxy_port": "${proxy_port}",
"tcp_mode": "${tcp_mode}",
"udp_mode": "${udp_mode}",
"updated_at": ${updated_at},
"proxy_skipped": ${proxy_skipped},
"stale": false,
"updating": false,
"direct": {
"bytedance": $(probe_json "$direct_bytedance"),
"youtube": $(probe_json "$direct_youtube")
},
"proxy": {
"bytedance": $(probe_json "$proxy_bytedance"),
"youtube": $(probe_json "$proxy_youtube")
}
}
EOF
mv -f "$TMP_FILE" "$CACHE_FILE"
exit 0
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
RUNDIR="/tmp/clashoo"
PID_FILE="${RUNDIR}/access_check_daemon.pid"
LOCK_DIR="${RUNDIR}/access_check_daemon.lock"
# 周期:默认 30sACCESS_CHECK_INTERVAL 环境变量可固定覆盖
INTERVAL_FIXED="${ACCESS_CHECK_INTERVAL:-}"
mkdir -p "$RUNDIR"
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
old_pid="$(cat "$PID_FILE" 2>/dev/null)"
if [ -n "$old_pid" ] && [ -d "/proc/$old_pid" ] && \
tr '\0' ' ' <"/proc/$old_pid/cmdline" 2>/dev/null | grep -q 'access_check_daemon\.sh'; then
exit 0
fi
rm -rf "$LOCK_DIR" "$PID_FILE" >/dev/null 2>&1
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
fi
cleanup() {
rm -rf "$LOCK_DIR" "$PID_FILE"
}
trap cleanup EXIT
trap 'cleanup; exit 0' INT TERM
echo "$$" >"$PID_FILE"
while :; do
[ "$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ] || break
/usr/share/clashoo/net/access_check_cache.sh >/dev/null 2>&1 || true
if [ -n "$INTERVAL_FIXED" ]; then
sleep "$INTERVAL_FIXED"
else
sleep 30
fi
done
@@ -0,0 +1,240 @@
#!/bin/sh
APPLY=0
TIMEOUT="${CLASHOO_DNS_TEST_TIMEOUT:-2}"
START_TS="$(date +%s 2>/dev/null || echo 0)"
while [ $# -gt 0 ]; do
case "$1" in
--apply) APPLY=1 ;;
esac
shift
done
json_escape() {
printf '%s' "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
}
json_pair() {
printf '"%s":"%s"' "$1" "$(json_escape "$2")"
}
is_ipv4() {
echo "$1" | grep -Eq '^[0-9]+(\.[0-9]+){3}$'
}
append_unique() {
list="$1"
item="$2"
[ -n "$item" ] || { printf '%s' "$list"; return; }
case " $list " in
*" $item "*) printf '%s' "$list" ;;
*) printf '%s %s' "$list" "$item" ;;
esac
}
current_default_nameservers() {
uci -q get clashoo.config.default_nameserver 2>/dev/null || true
}
current_dns_servers() {
uci -q show clashoo 2>/dev/null | awk -F= '
function clean(v) {
gsub(/^'\''|'\''$/, "", v);
return v;
}
/^clashoo\.@dnsservers\[[0-9]+\]\./ {
line=$1;
val=clean($2);
sub(/^clashoo\.@dnsservers\[/, "", line);
idx=line;
sub(/\].*$/, "", idx);
opt=line;
sub(/^[0-9]+\]\./, "", opt);
data[idx, opt]=val;
seen[idx]=1;
}
END {
for (idx in seen) {
if (data[idx, "enabled"] == "0") continue;
role=data[idx, "ser_type"];
addr=data[idx, "ser_address"];
proto=data[idx, "protocol"];
port=data[idx, "ser_port"];
if (addr == "") continue;
if (proto != "" && proto != "none" && addr !~ /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//) {
addr=proto addr;
if (port != "" && addr !~ /:[0-9]+$/) addr=addr ":" port;
}
print role "|" addr;
}
}'
}
servers_for_role() {
role="$1"
current_dns_servers | awk -F'|' -v role="$role" '$1 == role { print $2 }'
}
candidate_host_port() {
uri="$1"
default_port="$2"
hostport="${uri#*://}"
hostport="${hostport%%/*}"
host="${hostport%%:*}"
port="${hostport##*:}"
[ "$port" != "$hostport" ] || port="$default_port"
printf '%s %s\n' "$host" "$port"
}
probe_tls() {
uri="$1"
set -- $(candidate_host_port "$uri" 853)
host="$1"
port="$2"
command -v nc >/dev/null 2>&1 || return 1
nc -w "$TIMEOUT" "$host" "$port" >/dev/null 2>&1
}
probe_ip_dns() {
ip="$1"
domain="$2"
command -v nslookup >/dev/null 2>&1 || return 1
nslookup "$domain" "$ip" >/dev/null 2>&1
}
probe_latency_ms() {
candidate="$1"
domain="$2"
if [ "${candidate#https://}" != "$candidate" ]; then
if printf '%s' "$candidate" | grep -q '?'; then
q="$candidate&name=$domain&type=A"
else
q="$candidate?name=$domain&type=A"
fi
command -v curl >/dev/null 2>&1 || return 1
t="$(curl -fsS --connect-timeout "$TIMEOUT" --max-time "$TIMEOUT" \
-H 'accept: application/dns-json' -o /dev/null -w '%{time_total}' "$q" 2>/dev/null)" || return 1
printf '%s' "$t" | awk '{ms=$1*1000; if (ms<1) ms=1; printf "%d", ms}'
elif [ "${candidate#tls://}" != "$candidate" ]; then
probe_tls "$candidate" || return 1
printf '900'
elif [ "${candidate#udp://}" != "$candidate" ]; then
probe_ip_dns "${candidate#udp://}" "$domain" || return 1
printf '950'
elif [ "${candidate#tcp://}" != "$candidate" ]; then
probe_ip_dns "${candidate#tcp://}" "$domain" || return 1
printf '950'
else
is_ipv4 "$candidate" && probe_ip_dns "$candidate" "$domain" || return 1
printf '950'
fi
}
select_best() {
list="$1"
domain="$2"
fallback="$3"
best=""
best_score=999999
order=0
for candidate in $list; do
order=$((order + 1))
ms="$(probe_latency_ms "$candidate" "$domain")" || { FAILED_COUNT=$((FAILED_COUNT + 1)); continue; }
score=$((ms * 100 + order))
if [ "$score" -lt "$best_score" ]; then
best="$candidate"
best_score="$score"
fi
done
[ -n "$best" ] || best="$fallback"
printf '%s' "$best"
}
add_dnsserver() {
role="$1"
addr="$2"
proto="$3"
port="$4"
sec="$(uci -q add clashoo dnsservers)" || return 1
[ -n "$sec" ] || return 1
uci -q set clashoo."$sec".enabled='1' || return 1
uci -q set clashoo."$sec".ser_type="$role" || return 1
uci -q set clashoo."$sec".ser_address="$addr" || return 1
uci -q set clashoo."$sec".protocol="$proto" || return 1
if [ -n "$port" ]; then
uci -q set clashoo."$sec".ser_port="$port" || return 1
fi
return 0
}
set_default_nameserver() {
uci -q delete clashoo.config.default_nameserver >/dev/null 2>&1 || true
uci -q add_list clashoo.config.default_nameserver="$1" || return 1
return 0
}
apply_result() {
uci -q set clashoo.config.enable_dns='1' || { uci -q revert clashoo; return 1; }
set_default_nameserver "$BOOTSTRAP" || { uci -q revert clashoo; return 1; }
while uci -q delete clashoo.@dnsservers[0] >/dev/null 2>&1; do :; done
add_dnsserver 'nameserver' "$NAMESERVER" 'none' '' || { uci -q revert clashoo; return 1; }
add_dnsserver 'direct-nameserver' "$BOOTSTRAP" 'udp://' '' || { uci -q revert clashoo; return 1; }
add_dnsserver 'proxy-server-nameserver' "$PROXY_NS" 'none' '' || { uci -q revert clashoo; return 1; }
add_dnsserver 'fallback' "$FALLBACK_NS" 'none' '' || { uci -q revert clashoo; return 1; }
uci -q commit clashoo || { uci -q revert clashoo; return 1; }
return 0
}
is_domestic_dns() {
printf '%s' "$1" | grep -qE 'alidns|doh\.pub|dnspod|223\.5\.5\.5|223\.6\.6\.6|119\.29\.29\.29|119\.28\.28\.28|114\.114|180\.184|onedns'
}
is_overseas_dns() {
printf '%s' "$1" | grep -qE 'cloudflare|1\.1\.1\.1|1\.0\.0\.1|one\.one\.one\.one|dns\.google|8\.8\.8\.8|8\.8\.4\.4|9\.9\.9\.9|quad9|opendns|208\.67\.'
}
FAILED_COUNT=0
BOOTSTRAP_CANDIDATES="223.5.5.5 119.29.29.29 180.184.1.1"
DOMESTIC_CANDIDATES="https://dns.alidns.com/dns-query https://doh.pub/dns-query udp://223.5.5.5 udp://119.29.29.29"
FALLBACK_CANDIDATES="https://cloudflare-dns.com/dns-query https://dns.google/dns-query tls://1.1.1.1:853"
for ns in $(current_default_nameservers); do
is_ipv4 "$ns" && BOOTSTRAP_CANDIDATES="$(append_unique "$BOOTSTRAP_CANDIDATES" "$ns")"
done
for ns in $(servers_for_role 'nameserver') $(servers_for_role 'direct-nameserver'); do
is_domestic_dns "$ns" && DOMESTIC_CANDIDATES="$(append_unique "$DOMESTIC_CANDIDATES" "$ns")"
done
for ns in $(servers_for_role 'fallback'); do
is_overseas_dns "$ns" && FALLBACK_CANDIDATES="$(append_unique "$FALLBACK_CANDIDATES" "$ns")"
done
BOOTSTRAP="$(select_best "$BOOTSTRAP_CANDIDATES" www.baidu.com 223.5.5.5)"
NAMESERVER="$(select_best "$DOMESTIC_CANDIDATES" www.baidu.com https://dns.alidns.com/dns-query)"
PROXY_NS="tls://1.1.1.1:853"
FALLBACK_NS="$(select_best "$FALLBACK_CANDIDATES" www.google.com https://cloudflare-dns.com/dns-query)"
APPLIED=false
if [ "$APPLY" = "1" ]; then
if apply_result; then
APPLIED=true
else
printf '{"success":false,"error":"apply_failed","message":"DNS 自动配置写入失败"}\n'
exit 1
fi
fi
END_TS="$(date +%s 2>/dev/null || echo "$START_TS")"
ELAPSED_MS=$(( (END_TS - START_TS) * 1000 ))
printf '{'
printf '"success":true,'
printf '"applied":%s,' "$APPLIED"
printf '"restarted":false,'
json_pair bootstrap "$BOOTSTRAP"; printf ','
json_pair nameserver "$NAMESERVER"; printf ','
json_pair direct_nameserver "$BOOTSTRAP"; printf ','
json_pair proxy_nameserver "$PROXY_NS"; printf ','
json_pair fallback "$FALLBACK_NS"; printf ','
printf '"failed_count":%s,' "$FAILED_COUNT"
printf '"elapsed_ms":%s' "$ELAPSED_MS"
printf '}\n'
+898
View File
@@ -0,0 +1,898 @@
#!/bin/sh
set -eu
NFT_DIR="/var/run/clash"
SETS_RULES="${NFT_DIR}/fw4_sets.nft"
DSTNAT_RULES="${NFT_DIR}/fw4_dstnat.nft"
MANGLE_RULES="${NFT_DIR}/fw4_mangle.nft"
OUTPUT_RULES="${NFT_DIR}/fw4_output.nft"
BUILTIN_NFT_DIR="/usr/share/clashoo/nftables"
GEOIP_CN_NFT="${BUILTIN_NFT_DIR}/geoip_cn.nft"
GEOIP6_CN_NFT="${BUILTIN_NFT_DIR}/geoip6_cn.nft"
LOCAL_OUTPUT_TABLE="clashoo_local"
QUIC_BLOCK_TABLE="clashoo_quic"
# PROXY_FWMARK: inbound TPROXY mark, ip rule -> table PROXY_ROUTE_TABLE -> lo.
# CORE_ROUTING_MARK: mihomo outbound SO_MARK (= routing-mark in config).
# Must differ: otherwise mihomo egress is pulled to lo -> unreachable.
# When changing CORE_ROUTING_MARK, also update yum_change.sh routing_mark_dec.
PROXY_FWMARK="0x162"
PROXY_ROUTE_TABLE="0x162"
CORE_ROUTING_MARK="0x1a0a" # = 6666
ACL_BYPASS_FWMARK="0x163"
SINGBOX_BYPASS_FWMARK="0x2024"
ACL_BYPASS_PREF="8998"
DNSMASQ_BYPASS_PREF="8999"
uci_get() {
uci -q get "$1" 2>/dev/null || true
}
bool_enabled() {
case "$1" in
1|true|TRUE|yes|on) return 0 ;;
*) return 1 ;;
esac
}
acl_bool() {
[ -n "$1" ] || return 0
bool_enabled "$1"
}
tun_available() {
ip tuntap add mode tun name cotuntest >/dev/null 2>&1 || return 1
ip link del cotuntest >/dev/null 2>&1 || true
return 0
}
config_redir_port() {
uci_get clashoo.config.redir_port
}
config_tproxy_port() {
local port
port="$(uci_get clashoo.config.tproxy_port)"
if [ -n "$port" ]; then
printf '%s\n' "$port"
else
config_redir_port
fi
}
config_tcp_mode() {
uci_get clashoo.config.tcp_mode
}
config_udp_mode() {
uci_get clashoo.config.udp_mode
}
config_access_control() {
[ "$(uci_get clashoo.config.acl_migrated)" = "1" ] && {
printf '0\n'
return
}
uci_get clashoo.config.access_control
}
acl_sections() {
uci -q show clashoo 2>/dev/null | sed -n 's/^clashoo\.\([^.=]*\)=lan_acl$/\1/p'
}
acl_list() {
uci_get "$1"
}
acl_sections_catchall_last() {
local section
for section in $(acl_sections); do
[ -z "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] || printf '%s\n' "$section"
done
for section in $(acl_sections); do
[ -n "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] || printf '%s\n' "$section"
done
}
grouped_acl_enabled() {
local section
[ "$(uci_get clashoo.config.acl_migrated)" = "1" ] || return 1
for section in $(acl_sections); do
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] && return 0
done
return 1
}
acl_has_catchall() {
local section
for section in $(acl_sections); do
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
[ -z "$(acl_list "clashoo.${section}.ip")$(acl_list "clashoo.${section}.ip6")$(acl_list "clashoo.${section}.mac")" ] && return 0
done
return 1
}
lan_acl_enabled() {
grouped_acl_enabled && return 0
case "$(config_access_control)" in
1|2) return 0 ;;
esac
return 1
}
config_enable_dns() {
uci_get clashoo.config.enable_dns
}
config_dns_port() {
local port=""
if [ "$(uci_get clashoo.config.core_type)" != "singbox" ]; then
port="$(sed -n 's/^[[:space:]]*listen:.*:[[:space:]]*['"'"'"]*\([0-9]\{1,\}\)['"'"'"]*[[:space:]]*$/\1/p' /etc/clashoo/config.yaml 2>/dev/null | head -n1)"
fi
[ -n "$port" ] || port="$(uci_get clashoo.config.listen_port)"
printf '%s' "$port" | grep -Eq '^[0-9]+$' || port=1053
printf '%s\n' "$port"
}
lan_dns_split_enabled() {
lan_acl_enabled || return 1
bool_enabled "$(config_enable_dns)" || return 1
bool_enabled "$(config_ipv4_dns_hijack)" || bool_enabled "$(config_ipv6_dns_hijack)"
}
tun_acl_enabled() {
lan_acl_enabled || return 1
[ "$(config_tcp_mode)" = "tun" ] || [ "$(config_udp_mode)" = "tun" ]
}
config_ipv4_dns_hijack() {
uci_get clashoo.config.ipv4_dns_hijack
}
config_ipv6_dns_hijack() {
uci_get clashoo.config.ipv6_dns_hijack
}
config_ipv4_proxy() {
uci_get clashoo.config.ipv4_proxy
}
config_ipv6_proxy() {
uci_get clashoo.config.ipv6_proxy
}
singbox_tun_active() {
[ "$(uci_get clashoo.config.core_type)" = "singbox" ] || return 1
[ "$(config_tcp_mode)" = "tun" ] || [ "$(config_udp_mode)" = "tun" ]
}
config_bypass_china() {
uci_get clashoo.config.bypass_china
}
config_bypass_china_ipv6() {
local value
value="$(uci_get clashoo.config.bypass_china_ipv6)"
if [ -n "$value" ]; then
printf '%s\n' "$value"
else
config_bypass_china
fi
}
config_block_quic() {
uci_get clashoo.config.block_quic
}
config_bypass_port_mode() {
uci_get clashoo.config.bypass_port_mode
}
config_bypass_port_custom() {
uci_get clashoo.config.bypass_port_custom
}
config_legacy_bypass_port() {
uci_list clashoo.config.bypass_port
}
config_proxy_tcp_dport() {
local mode custom legacy value
mode="$(config_bypass_port_mode)"
custom="$(config_bypass_port_custom)"
legacy="$(uci_get clashoo.config.proxy_tcp_dport)"
[ -z "$legacy" ] && legacy="$(config_legacy_bypass_port)"
case "$mode" in
all)
# 空值在 render_port_match 中表示该协议的全部端口
printf '%s\n' ''
;;
common)
printf '%s\n' '22,53,80,443,8080,8443'
;;
custom)
value="$custom"
[ -z "$value" ] && value="$legacy"
printf '%s\n' "$value"
;;
*)
printf '%s\n' "$legacy"
;;
esac
}
config_proxy_udp_dport() {
local mode custom legacy value
mode="$(config_bypass_port_mode)"
custom="$(config_bypass_port_custom)"
legacy="$(uci_get clashoo.config.proxy_udp_dport)"
[ -z "$legacy" ] && legacy="$(config_legacy_bypass_port)"
case "$mode" in
all)
printf '%s\n' ''
;;
common)
printf '%s\n' '22,53,80,443,8080,8443'
;;
custom)
value="$custom"
[ -z "$value" ] && value="$legacy"
printf '%s\n' "$value"
;;
*)
printf '%s\n' "$legacy"
;;
esac
}
config_bypass_dscp() {
uci_list clashoo.config.bypass_dscp
}
config_bypass_fwmark() {
uci_list clashoo.config.bypass_fwmark
}
config_fake_ip_range6() {
local value
value="$(uci_get clashoo.config.fake_ip_range6)"
[ -n "$value" ] || value="fc00::/18"
[ "$(uci_get clashoo.config.enhanced_mode)" = "redir-host" ] && return 0
bool_enabled "$(uci_get clashoo.config.enable_ipv6)" || return 0
printf '%s\n' "$value"
}
config_fake_ip_range() {
local value
value="$(uci_get clashoo.config.fake_ip_range)"
[ -n "$value" ] && {
printf '%s\n' "$value"
return
}
printf '198.18.0.1/16\n'
}
uci_list() {
local key="$1"
uci -q show "$key" 2>/dev/null | sed -n "s/^${key}=//p" | sed "s/'//g"
}
ensure_firewall_include() {
local name="$1"
local path="$2"
local chain="${3:-}"
local position="${4:-chain-pre}"
uci -q batch <<-EOF >/dev/null
set firewall.${name}=include
set firewall.${name}.type='nftables'
set firewall.${name}.path='${path}'
set firewall.${name}.position='${position}'
$( [ -n "$chain" ] && printf "set firewall.%s.chain='%s'\n" "$name" "$chain" )
commit firewall
EOF
}
remove_firewall_include() {
local name="$1"
uci -q delete firewall."${name}" >/dev/null 2>&1 || true
}
render_common_returns() {
local fake6
fake6="$(config_fake_ip_range6)"
printf '%s\n' 'meta nfproto ipv4 ip daddr { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 } return'
if [ -n "$fake6" ]; then
printf 'meta nfproto ipv6 ip6 daddr { ::1/128, fc00::/7, fe80::/10, ff00::/8 } ip6 daddr != %s return\n' "$fake6"
else
printf '%s\n' 'meta nfproto ipv6 ip6 daddr { ::1/128, fc00::/7, fe80::/10, ff00::/8 } return'
fi
}
render_ip_elements() {
local list="$1"
local first=1 entry
for entry in $list; do
if [ "$first" -eq 0 ]; then
printf ', '
fi
printf '%s' "$entry"
first=0
done
}
render_token_elements() {
printf '%s\n' "$1" | tr ',\t' ' ' | awk '
BEGIN { first = 1 }
{
for (i = 1; i <= NF; i++) {
if ($i == "")
continue
if (!first)
printf ", "
printf "%s", $i
first = 0
}
}'
}
detect_coexist_fwmarks() {
# Merge co-installed proxy plugin fwmarks into bypass to avoid intercepting
# their traffic. Only when init.d exists (passwall:0x1 passwall2:0xff
# nikki:tproxy 0x80/tun 0x81 mask 0xff).
local marks=""
[ -x /etc/init.d/passwall ] && marks="$marks 0x1"
[ -x /etc/init.d/passwall2 ] && marks="$marks 0xff"
if [ -x /etc/init.d/nikki ]; then
local nm
nm="$(uci -q get nikki.routing.tproxy_fw_mark) $(uci -q get nikki.routing.tun_fw_mark)"
[ -z "$(echo "$nm" | tr -d ' ')" ] && nm="0x80 0x81"
marks="$marks $nm"
fi
printf '%s\n' "$marks"
}
merge_fwmark_tokens() {
# Always bypass PROXY_FWMARK (inbound TPROXY) and CORE_ROUTING_MARK (core egress)
# so nft never traps them. Also merge co-installed plugin fwmarks for coexistence.
local coexist
coexist="$(detect_coexist_fwmarks)"
printf '%s %s %s %s\n' "$1" "$PROXY_FWMARK" "$CORE_ROUTING_MARK" "$coexist" | tr ',\t' ' ' | awk '
BEGIN { first = 1 }
{
for (i = 1; i <= NF; i++) {
if ($i == "" || seen[$i]++)
continue
if (!first)
printf ", "
printf "%s", $i
first = 0
}
}'
}
render_port_match() {
local proto="$1"
local ports="$2"
local port_elements
port_elements="$(render_token_elements "$ports")"
if [ -n "$port_elements" ]; then
printf 'meta l4proto %s %s dport { %s }' "$proto" "$proto" "$port_elements"
else
printf 'meta l4proto %s' "$proto"
fi
}
render_acl_dns_rules() {
local redirect_port="$1" section ipv4 ipv6 mac ipv4_elements ipv6_elements mac_elements action
for section in $(acl_sections_catchall_last); do
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
if acl_bool "$(uci_get "clashoo.${section}.dns")"; then
action="counter redirect to :${redirect_port}"
elif singbox_tun_active; then
action="meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
else
action="counter return"
fi
ipv4="$(acl_list "clashoo.${section}.ip")"
ipv6="$(acl_list "clashoo.${section}.ip6")"
mac="$(acl_list "clashoo.${section}.mac")"
ipv4_elements="$(render_ip_elements "$ipv4")"
ipv6_elements="$(render_ip_elements "$ipv6")"
mac_elements="$(render_ip_elements "$mac")"
if [ -z "$ipv4_elements$ipv6_elements$mac_elements" ]; then
bool_enabled "$(config_ipv4_dns_hijack)" && printf 'meta nfproto ipv4 meta l4proto { tcp, udp } th dport 53 %s\n' "$action"
bool_enabled "$(config_ipv6_dns_hijack)" && printf 'meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 %s\n' "$action"
continue
fi
if [ -n "$ipv4_elements" ] && bool_enabled "$(config_ipv4_dns_hijack)"; then
printf 'meta nfproto ipv4 ip saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$ipv4_elements" "$action"
fi
if [ -n "$ipv6_elements" ] && bool_enabled "$(config_ipv6_dns_hijack)"; then
printf 'meta nfproto ipv6 ip6 saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$ipv6_elements" "$action"
fi
if [ -n "$mac_elements" ]; then
bool_enabled "$(config_ipv4_dns_hijack)" && printf 'meta nfproto ipv4 ether saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$mac_elements" "$action"
bool_enabled "$(config_ipv6_dns_hijack)" && printf 'meta nfproto ipv6 ether saddr { %s } meta l4proto { tcp, udp } th dport 53 %s\n' "$mac_elements" "$action"
fi
done
}
render_acl_proxy_rules() {
local mode="$1" proto="$2" port_match="$3" target_port="$4"
local section ipv4 ipv6 mac ipv4_elements ipv6_elements mac_elements enabled action4 action6
for section in $(acl_sections_catchall_last); do
[ "$(uci_get "clashoo.${section}.enabled")" != "0" ] || continue
enabled="$(uci_get "clashoo.${section}.proxy")"
case "$mode" in
redirect)
if acl_bool "$enabled"; then
if singbox_tun_active; then
action4="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} counter redirect to :${target_port}"
else
action4="${port_match} counter redirect to :${target_port}"
fi
action6="$action4"
elif singbox_tun_active; then
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
action6="$action4"
else
action4="meta l4proto ${proto} counter return"
action6="$action4"
fi
;;
tproxy)
if acl_bool "$enabled"; then
if singbox_tun_active; then
action4="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} tproxy ip to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
action6="${port_match} ct mark set ${SINGBOX_BYPASS_FWMARK} tproxy ip6 to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
else
action4="${port_match} tproxy ip to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
action6="${port_match} tproxy ip6 to :${target_port} meta mark set ${PROXY_FWMARK} counter accept"
fi
elif singbox_tun_active; then
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter return"
action6="$action4"
else
action4="meta l4proto ${proto} counter return"
action6="$action4"
fi
;;
tun)
if acl_bool "$enabled"; then
action4="meta l4proto ${proto} counter return"
elif singbox_tun_active; then
action4="meta l4proto ${proto} meta mark set ${SINGBOX_BYPASS_FWMARK} ct mark set meta mark counter accept"
else
action4="meta l4proto ${proto} meta mark set ${ACL_BYPASS_FWMARK} counter accept"
fi
action6="$action4"
;;
esac
ipv4="$(acl_list "clashoo.${section}.ip")"
ipv6="$(acl_list "clashoo.${section}.ip6")"
mac="$(acl_list "clashoo.${section}.mac")"
ipv4_elements="$(render_ip_elements "$ipv4")"
ipv6_elements="$(render_ip_elements "$ipv6")"
mac_elements="$(render_ip_elements "$mac")"
if [ -z "$ipv4_elements$ipv6_elements$mac_elements" ]; then
bool_enabled "$(config_ipv4_proxy)" && printf 'meta nfproto ipv4 %s\n' "$action4"
bool_enabled "$(config_ipv6_proxy)" && printf 'meta nfproto ipv6 %s\n' "$action6"
continue
fi
if [ -n "$ipv4_elements" ] && bool_enabled "$(config_ipv4_proxy)"; then
printf 'meta nfproto ipv4 ip saddr { %s } %s\n' "$ipv4_elements" "$action4"
fi
if [ -n "$ipv6_elements" ] && bool_enabled "$(config_ipv6_proxy)"; then
printf 'meta nfproto ipv6 ip6 saddr { %s } %s\n' "$ipv6_elements" "$action6"
fi
if [ -n "$mac_elements" ]; then
bool_enabled "$(config_ipv4_proxy)" && printf 'meta nfproto ipv4 ether saddr { %s } %s\n' "$mac_elements" "$action4"
bool_enabled "$(config_ipv6_proxy)" && printf 'meta nfproto ipv6 ether saddr { %s } %s\n' "$mac_elements" "$action6"
fi
done
}
# Split proxied clients to the core DNS while ACL-bypassed clients use dnsmasq.
# Gated on UCI IPv4/IPv6 toggles.
# return 0: no output when both off, avoid set -e false-positive.
render_dns_hijack() {
local access_control="$1" redirect_port=53
if lan_dns_split_enabled; then
redirect_port="$(config_dns_port)"
fi
if grouped_acl_enabled; then
render_acl_dns_rules "$redirect_port"
acl_has_catchall && return 0
else
[ "$access_control" = "1" ] && printf '%s\n' 'ip saddr != @clash_proxy_lan return'
[ "$access_control" = "2" ] && printf '%s\n' 'ip saddr @clash_reject_lan return'
fi
bool_enabled "$(config_ipv4_dns_hijack)" && \
printf '%s\n' "meta nfproto ipv4 meta l4proto { tcp, udp } th dport 53 counter redirect to :${redirect_port}"
bool_enabled "$(config_ipv6_dns_hijack)" && \
printf '%s\n' "meta nfproto ipv6 meta l4proto { tcp, udp } th dport 53 counter redirect to :${redirect_port}"
return 0
}
apply_local_output_rule() {
local redir_port fake_ip_range tcp_mode bypass_fwmark bypass_china
local fwmark_elements fwmark_rule china_set china_rule dnsmasq_rule dnsmasq_uid
redir_port="$(config_redir_port)"
fake_ip_range="$(config_fake_ip_range)"
tcp_mode="$(config_tcp_mode)"
bypass_fwmark="$(config_bypass_fwmark)"
bypass_china="$(config_bypass_china)"
# Keep local-output redirect usable when tun mode is selected but tun
# device is unavailable on the system.
if [ "$tcp_mode" = "tun" ] && ! tun_available; then
tcp_mode="redirect"
fi
nft delete table ip ${LOCAL_OUTPUT_TABLE} >/dev/null 2>&1 || true
# Only apply local output redirect when tcp_mode is redirect
[ "$tcp_mode" != "redirect" ] && return 0
# bypass mihomo's own marks (prevent self-hijack -> dead loop)
fwmark_rule=""
fwmark_elements="$(merge_fwmark_tokens "$bypass_fwmark")"
[ -n "$fwmark_elements" ] && fwmark_rule="meta mark { ${fwmark_elements} } return"
# CN IP bypass (uses clashoo_china set from geoip_cn.nft)
china_set=""
china_rule=""
if bool_enabled "$bypass_china" && [ -s "$GEOIP_CN_NFT" ]; then
china_set="$(cat "$GEOIP_CN_NFT")"
china_rule="ip daddr @clashoo_china return"
fi
dnsmasq_rule=""
if lan_dns_split_enabled; then
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$' && dnsmasq_rule="meta skuid ${dnsmasq_uid} return"
fi
nft -f - <<EOF
table ip ${LOCAL_OUTPUT_TABLE} {
set clashoo_localnetwork {
type ipv4_addr
flags interval
auto-merge
elements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8,
169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16,
224.0.0.0/4, 240.0.0.0/4 }
}
${china_set}
chain output {
type nat hook output priority dstnat; policy accept;
${dnsmasq_rule}
${fwmark_rule}
ip daddr @clashoo_localnetwork return
${china_rule}
ip daddr ${fake_ip_range} tcp dport != 53 redirect to :${redir_port}
meta l4proto tcp redirect to :${redir_port}
}
}
EOF
}
remove_local_output_rule() {
nft delete table ip ${LOCAL_OUTPUT_TABLE} >/dev/null 2>&1 || true
}
remove_acl_bypass_rules() {
local dnsmasq_uid
ip rule del pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
ip -6 rule del pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
if printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$'; then
ip rule del pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
ip -6 rule del pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
fi
}
apply_acl_bypass_rules() {
local dnsmasq_uid
remove_acl_bypass_rules
tun_acl_enabled || return 0
ip rule add pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
ip -6 rule add pref "$ACL_BYPASS_PREF" fwmark "$ACL_BYPASS_FWMARK" lookup main >/dev/null 2>&1 || true
dnsmasq_uid="$(id -u dnsmasq 2>/dev/null)"
if printf '%s' "$dnsmasq_uid" | grep -Eq '^[0-9]+$'; then
ip rule add pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
ip -6 rule add pref "$DNSMASQ_BYPASS_PREF" uidrange "${dnsmasq_uid}-${dnsmasq_uid}" lookup main >/dev/null 2>&1 || true
fi
}
apply_block_quic_rule() {
local fake_ip_range china_set china_rule scope
nft delete table inet ${QUIC_BLOCK_TABLE} >/dev/null 2>&1 || true
bool_enabled "$(config_block_quic)" || return 0
fake_ip_range="$(config_fake_ip_range)"
china_set=""
scope="ip daddr ${fake_ip_range}"
if [ "$(uci_get clashoo.config.enhanced_mode)" != "fake-ip" ]; then
if bool_enabled "$(config_bypass_china)" && [ -s "$GEOIP_CN_NFT" ]; then
china_set="$(cat "$GEOIP_CN_NFT")"
china_rule="ip daddr @clashoo_china return"
fi
scope=""
fi
nft -f - <<EOF
table inet ${QUIC_BLOCK_TABLE} {
${china_set}
chain prerouting {
type filter hook prerouting priority mangle - 1; policy accept;
${china_rule:-}
${scope} udp dport 443 counter reject with icmpx type port-unreachable
}
chain forward {
type filter hook forward priority -10; policy accept;
${china_rule:-}
${scope} udp dport 443 counter reject with icmpx type port-unreachable
}
}
EOF
}
remove_block_quic_rule() {
nft delete table inet ${QUIC_BLOCK_TABLE} >/dev/null 2>&1 || true
}
write_empty_set() {
local set_name="$1"
local set_type="$2"
printf 'set %s {\n\ttype %s;\n\tflags interval;\n\tauto-merge;\n}\n\n' "$set_name" "$set_type"
}
append_set_from_file_or_empty() {
local file_path="$1"
local set_name="$2"
local set_type="$3"
if [ -s "$file_path" ]; then
cat "$file_path"
printf '\n'
else
write_empty_set "$set_name" "$set_type"
fi
}
generate_rules() {
local redir_port tproxy_port tcp_mode udp_mode access_control fake_ip_range proxy_lan_ips reject_lan_ips
local proxy_tcp_dport proxy_udp_dport bypass_dscp bypass_fwmark bypass_china bypass_china_ipv6
local acl_catchall=0
redir_port="$(config_redir_port)"
tproxy_port="$(config_tproxy_port)"
tcp_mode="$(config_tcp_mode)"
udp_mode="$(config_udp_mode)"
access_control="$(config_access_control)"
grouped_acl_enabled && acl_has_catchall && acl_catchall=1
bypass_china="$(config_bypass_china)"
bypass_china_ipv6="$(config_bypass_china_ipv6)"
proxy_tcp_dport="$(config_proxy_tcp_dport)"
proxy_udp_dport="$(config_proxy_udp_dport)"
bypass_dscp="$(config_bypass_dscp)"
bypass_fwmark="$(config_bypass_fwmark)"
fake_ip_range="$(config_fake_ip_range)"
proxy_lan_ips="$(uci_list clashoo.config.proxy_lan_ips)"
reject_lan_ips="$(uci_list clashoo.config.reject_lan_ips)"
# When tun device is unavailable, fall back to non-tun transparent modes
# so routing rules still take effect for sing-box redirect/tproxy inbounds.
if [ "$tcp_mode" = "tun" ] || [ "$udp_mode" = "tun" ]; then
if ! tun_available; then
[ "$tcp_mode" = "tun" ] && tcp_mode="redirect"
[ "$udp_mode" = "tun" ] && udp_mode="tproxy"
fi
fi
mkdir -p "$NFT_DIR"
# Build optional elements lines (nftables rejects empty elements = {})
local proxy_elements reject_elements dscp_elements fwmark_elements
proxy_elements="$(render_ip_elements "$proxy_lan_ips")"
reject_elements="$(render_ip_elements "$reject_lan_ips")"
dscp_elements="$(render_token_elements "$bypass_dscp")"
fwmark_elements="$(merge_fwmark_tokens "$bypass_fwmark")"
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
udp_match="$(render_port_match udp "$proxy_udp_dport")"
{
printf 'set clashoo_localnetwork {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
printf '\telements = { 0.0.0.0/8, 10.0.0.0/8, 100.64.0.0/10, 127.0.0.0/8, 169.254.0.0/16, 172.16.0.0/12, 192.168.0.0/16, 224.0.0.0/4, 240.0.0.0/4 }\n}\n\n'
append_set_from_file_or_empty "$GEOIP_CN_NFT" clashoo_china ipv4_addr
append_set_from_file_or_empty "$GEOIP6_CN_NFT" clashoo_china6 ipv6_addr
printf 'set clash_proxy_lan {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
[ -n "$proxy_elements" ] && printf '\telements = { %s }\n' "$proxy_elements"
printf '}\n\n'
printf 'set clash_reject_lan {\n\ttype ipv4_addr;\n\tflags interval;\n\tauto-merge;\n'
[ -n "$reject_elements" ] && printf '\telements = { %s }\n' "$reject_elements"
printf '}\n\n'
if grouped_acl_enabled && [ "$tcp_mode" = "tun" ]; then
printf 'chain clashoo_acl_tun_tcp {\n'
render_acl_proxy_rules tun tcp "$tcp_match" "$tproxy_port"
printf '}\n\n'
fi
if grouped_acl_enabled && [ "$udp_mode" = "tun" ]; then
printf 'chain clashoo_acl_tun_udp {\n'
render_acl_proxy_rules tun udp "$udp_match" "$tproxy_port"
printf '}\n'
fi
} > "$SETS_RULES"
: > "$OUTPUT_RULES"
# DNS hijack rules go atop DSTNAT_RULES: must run for all TCP modes
# (redirect/tproxy/tun) and BEFORE proxy redirect rules so port 53 is not stolen.
render_dns_hijack "$access_control" > "$DSTNAT_RULES"
# TCP rules: redirect mode appends proxy redirect; tproxy/tun skip this chain
case "$tcp_mode" in
redirect)
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
cat >> "$DSTNAT_RULES" <<EOF
$( render_common_returns )
$( bool_enabled "$bypass_china_ipv6" && printf '%s\n' 'ip6 daddr @clashoo_china6 return' )
$( bool_enabled "$bypass_china" && printf '%s\n' 'ip daddr @clashoo_china return' )
$( [ -n "$dscp_elements" ] && printf '%s\n' "ip dscp { ${dscp_elements} } return" )
$( [ -n "$dscp_elements" ] && printf '%s\n' "ip6 dscp { ${dscp_elements} } return" )
$( [ -n "$fwmark_elements" ] && printf '%s\n' "meta mark { ${fwmark_elements} } return" )
$( ! bool_enabled "$(config_ipv4_proxy)" && printf '%s\n' 'meta nfproto ipv4 return' )
$( ! bool_enabled "$(config_ipv6_proxy)" && printf '%s\n' 'meta nfproto ipv6 return' )
$( grouped_acl_enabled && render_acl_proxy_rules redirect tcp "$tcp_match" "$redir_port" )
$( [ "$access_control" = "1" ] && printf '%s\n' 'ip saddr != @clash_proxy_lan return' )
$( [ "$access_control" = "2" ] && printf '%s\n' 'ip saddr @clash_reject_lan return' )
$( [ "$acl_catchall" -eq 0 ] && { singbox_tun_active && printf '%s ct mark set %s redirect to :%s\n' "$tcp_match" "$SINGBOX_BYPASS_FWMARK" "$redir_port" || printf '%s redirect to :%s\n' "$tcp_match" "$redir_port"; } )
EOF
;;
esac
# UDP rules: tproxy via mangle. TUN uses mangle only for LAN ACL bypass.
# Also handle TCP tproxy mode here (both TCP+UDP in mangle).
local need_mangle=0 tun_acl=0
[ "$tcp_mode" = "tproxy" ] && need_mangle=1
[ "$udp_mode" = "tproxy" ] && need_mangle=1
if tun_acl_enabled "$access_control"; then
need_mangle=1
tun_acl=1
fi
if [ "$need_mangle" -eq 1 ]; then
tcp_match="$(render_port_match tcp "$proxy_tcp_dport")"
udp_match="$(render_port_match udp "$proxy_udp_dport")"
{
render_common_returns
if bool_enabled "$bypass_china_ipv6"; then
printf 'meta nfproto ipv6 ip6 daddr @clashoo_china6 return\n'
fi
if bool_enabled "$bypass_china"; then
printf 'ip daddr @clashoo_china return\n'
fi
if [ -n "$dscp_elements" ]; then
printf 'ip dscp { %s } return\n' "$dscp_elements"
printf 'ip6 dscp { %s } return\n' "$dscp_elements"
fi
if [ -n "$fwmark_elements" ]; then
printf 'meta mark { %s } return\n' "$fwmark_elements"
fi
bool_enabled "$(config_ipv4_proxy)" || printf 'meta nfproto ipv4 return\n'
bool_enabled "$(config_ipv6_proxy)" || printf 'meta nfproto ipv6 return\n'
if grouped_acl_enabled; then
[ "$tcp_mode" = "tproxy" ] && render_acl_proxy_rules tproxy tcp "$tcp_match" "$tproxy_port"
[ "$tcp_mode" = "tun" ] && printf 'meta l4proto tcp jump clashoo_acl_tun_tcp\n'
[ "$udp_mode" = "tproxy" ] && render_acl_proxy_rules tproxy udp "$udp_match" "$tproxy_port"
[ "$udp_mode" = "tun" ] && printf 'meta l4proto udp jump clashoo_acl_tun_udp\n'
elif [ "$tun_acl" -eq 1 ]; then
[ "$access_control" = "1" ] && printf 'ip saddr != @clash_proxy_lan meta mark set %s return\n' "$ACL_BYPASS_FWMARK"
[ "$access_control" = "2" ] && printf 'ip saddr @clash_reject_lan meta mark set %s return\n' "$ACL_BYPASS_FWMARK"
else
[ "$access_control" = "1" ] && printf 'ip saddr != @clash_proxy_lan return\n'
[ "$access_control" = "2" ] && printf 'ip saddr @clash_reject_lan return\n'
fi
if bool_enabled "$(config_block_quic)"; then
printf 'meta l4proto udp udp dport 443 return\n'
fi
if [ "$tcp_mode" = "tproxy" ] && [ "$acl_catchall" -eq 0 ]; then
if singbox_tun_active; then
printf '%s ct mark set %s tproxy to :%s meta mark set %s accept\n' "$tcp_match" "$SINGBOX_BYPASS_FWMARK" "$tproxy_port" "$PROXY_FWMARK"
else
printf '%s tproxy to :%s meta mark set %s accept\n' "$tcp_match" "$tproxy_port" "$PROXY_FWMARK"
fi
fi
if [ "$udp_mode" = "tproxy" ] && [ "$acl_catchall" -eq 0 ]; then
if singbox_tun_active; then
printf '%s ct mark set %s tproxy to :%s meta mark set %s accept\n' "$udp_match" "$SINGBOX_BYPASS_FWMARK" "$tproxy_port" "$PROXY_FWMARK"
else
printf '%s tproxy to :%s meta mark set %s accept\n' "$udp_match" "$tproxy_port" "$PROXY_FWMARK"
fi
fi
} > "$MANGLE_RULES"
else
: > "$MANGLE_RULES"
fi
}
apply_rules() {
generate_rules
ensure_firewall_include clash_fw4_sets "$SETS_RULES" '' table-pre
ensure_firewall_include clash_fw4_dstnat "$DSTNAT_RULES" dstnat
remove_firewall_include clash_fw4_output
if [ -s "$MANGLE_RULES" ]; then
_route_table_dec="$((PROXY_ROUTE_TABLE))"
ensure_firewall_include clash_fw4_mangle "$MANGLE_RULES" mangle_prerouting
ip rule show 2>/dev/null | grep -q "fwmark ${PROXY_FWMARK}.*lookup ${_route_table_dec}" ||
ip rule add fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip route show table "$PROXY_ROUTE_TABLE" 2>/dev/null | grep -q 'local 0.0.0.0/0 dev lo' ||
ip route add local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
if bool_enabled "$(config_ipv6_proxy)"; then
ip -6 rule show 2>/dev/null | grep -q "fwmark ${PROXY_FWMARK}.*lookup ${_route_table_dec}" ||
ip -6 rule add fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 route show table "$PROXY_ROUTE_TABLE" 2>/dev/null | grep -q 'local default dev lo' ||
ip -6 route add local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
else
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
fi
else
remove_firewall_include clash_fw4_mangle
ip rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip route del local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
fi
/etc/init.d/firewall restart >/dev/null 2>&1 || /etc/init.d/firewall reload >/dev/null 2>&1 || true
apply_acl_bypass_rules
apply_local_output_rule
apply_block_quic_rule
}
remove_rules() {
remove_acl_bypass_rules
remove_local_output_rule
remove_block_quic_rule
remove_firewall_include clash_fw4_sets
remove_firewall_include clash_fw4_dstnat
remove_firewall_include clash_fw4_output
remove_firewall_include clash_fw4_mangle
remove_firewall_include clash_fw4_forward
uci commit firewall >/dev/null 2>&1 || true
rm -f "$SETS_RULES" "$DSTNAT_RULES" "$OUTPUT_RULES" "$MANGLE_RULES"
ip rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip route del local 0.0.0.0/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 rule del fwmark "$PROXY_FWMARK" table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
ip -6 route del local ::/0 dev lo table "$PROXY_ROUTE_TABLE" >/dev/null 2>&1 || true
/etc/init.d/firewall restart >/dev/null 2>&1 || /etc/init.d/firewall reload >/dev/null 2>&1 || true
}
case "${1:-}" in
apply)
apply_rules
;;
remove)
remove_rules
;;
*)
echo "Usage: $0 {apply|remove}" >&2
exit 1
;;
esac
@@ -0,0 +1,43 @@
# clashoo 运行日志格式化
# 输出: MM-DD HH:MM:SS msg
# mihomo 原生行: time="YYYY-MM-DDTHH:MM:SS..." level=... msg="..."
/^time="[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]T[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ {
# extract YYYY-MM-DD and HH:MM:SS
ts_date = substr($0, 12, 5) # MM-DD
ts_time = substr($0, 18, 8) # HH:MM:SS
utc_h = substr(ts_time, 1, 2) + 0
cst_h = (utc_h + 8) % 24
ts = sprintf("%s %02d:%s", ts_date, cst_h, substr(ts_time, 4))
prefix = ""
if (match($0, /level=warning /)) prefix = " [warn]"
else if (match($0, /level=error /)) prefix = " [err]"
else if (match($0, /level=fatal /)) prefix = " [fatal]"
i = index($0, "msg=\"")
if (i > 0) {
rest = substr($0, i + 5)
sub(/"[[:space:]]*$/, "", rest)
print ts prefix " " rest
next
}
print ts " " $0
next
}
# log_msg 行: " YYYY-MM-DD HH:MM:SS - msg" → "MM-DD HH:MM:SS msg"
/^[[:space:]]+[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][[:space:]]+[0-9][0-9]:[0-9][0-9]:[0-9][0-9]/ {
gsub(/^[[:space:]]+/, "") # trim leading space
ts_date = substr($0, 6, 5) # MM-DD
ts_time = substr($0, 12, 8) # HH:MM:SS
# remove "YYYY-MM-DD HH:MM:SS - " prefix
sub(/^[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9][[:space:]]+[0-9][0-9]:[0-9][0-9]:[0-9][0-9][[:space:]]*-[[:space:]]*/, "")
print ts_date " " ts_time " " $0
next
}
# 空行丢弃
NF == 0 { next }
{ print }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,44 @@
#!/bin/sh
ACTION="$1"
LOG_FILE="/tmp/clash_update.txt"
log_line() {
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOG_FILE"
}
run_init_async() {
local action="$1"
mkdir -p /tmp/lock >/dev/null 2>&1
(flock /tmp/lock/clashoo_rpc_async.lock /etc/init.d/clashoo "$action" >/dev/null 2>&1 </dev/null &)
}
case "$ACTION" in
start)
run_init_async start
exit 0
;;
stop)
run_init_async stop
exit 0
;;
restart)
# Fire-and-forget restart used by LuCI RPC to avoid blocking UI apply flow.
run_init_async restart
exit 0
;;
update_china_ip)
log_line "[china-ip] task started"
nohup /usr/share/clashoo/update/update_china_ip.sh >>"$LOG_FILE" 2>&1 </dev/null &
exit 0
;;
update_geoip)
log_line "GeoIP 更新任务已触发"
(exec 1000>&-; nohup /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1 </dev/null &)
exit 0
;;
*)
echo "usage: $0 {start|stop|restart|update_china_ip|update_geoip}" >&2
exit 1
;;
esac
Binary file not shown.
Binary file not shown.
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
. /lib/functions.sh
CURL_GROUP_CACHE="/usr/share/clashbackup/clash_gorup.json"
CURL_NOW_CACHE="/usr/share/clashbackup/clash_now.json"
CURL_CACHE="/usr/share/clashbackup/clash_curl.json"
HISTORY_PATH="/usr/share/clashbackup/history"
SECRET=$(uci get clashoo.config.dash_pass 2>/dev/null)
LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null)
PORT=$(uci get clashoo.config.dash_port 2>/dev/null)
curl -m 5 --retry 2 -w %{http_code}"\n" -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X GET http://"$LAN_IP":"$PORT"/proxies > "$CURL_CACHE" 2>/dev/null
if [ "$(sed -n '$p' "$CURL_CACHE" 2>/dev/null)" = "200" ]; then
if [ ! -d /usr/share/clashbackup ];then
mkdir -p /usr/share/clashbackup 2>/dev/null
fi
cat "$CURL_CACHE" |jsonfilter -e '@["proxies"][@.type="Selector"]["name"]' > "$CURL_GROUP_CACHE" 2>/dev/null
cat "$CURL_CACHE" |jsonfilter -e '@["proxies"][@.type="Selector"]["now"]' > "$CURL_NOW_CACHE" 2>/dev/null
awk 'NR==FNR{a[i]=$0;i++}NR>FNR{print a[j]"#*#"$0;j++}' "$CURL_GROUP_CACHE" "$CURL_NOW_CACHE" > "$HISTORY_PATH" 2>/dev/null
fi
rm -rf /usr/share/clashbackup/clash_*.json 2>/dev/null
+347
View File
@@ -0,0 +1,347 @@
#!/bin/sh
REAL_LOG="/usr/share/clashoo/clashoo_real.txt"
UPDATE_LOG="/tmp/clash_update.txt"
LIST_FILE="/usr/share/clashbackup/confit_list.conf"
SUB_DIR="/usr/share/clashoo/config/sub"
TMP_PREFIX="/tmp/clash_sub_$$"
DEFAULT_SUB_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
subtype="$(uci -q get clashoo.config.subcri 2>/dev/null)"
config_name_raw="$(uci -q get clashoo.config.config_name 2>/dev/null)"
lang="$(uci -q get luci.main.lang 2>/dev/null)"
log_text() {
if [ "$lang" = "en" ]; then
echo "$1" >"$REAL_LOG"
else
echo "$2" >"$REAL_LOG"
fi
}
log_update() {
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
}
sanitize_name() {
local name
name="$1"
name="$(printf '%s' "$name" | tr 'A-Z' 'a-z')"
name="$(printf '%s' "$name" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
name="$(printf '%s' "$name" | tr ' /' '--')"
name="$(printf '%s' "$name" | sed -e 's/[^a-z0-9._-]/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
printf '%s' "$name"
}
sanitize_custom_name() {
local name
name="$1"
name="$(printf '%s' "$name" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
name="$(printf '%s' "$name" | tr ' /' '--')"
name="$(printf '%s' "$name" | sed -e 's/[\\]//g' -e 's/\.\.+/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
printf '%s' "$name"
}
url_decode() {
printf '%s' "$1" | sed 's/%/\\x/g' | xargs -0 printf '%b' 2>/dev/null || printf '%s' "$1"
}
url_to_name() {
local url host qname decoded
url="$1"
qname="$(printf '%s' "$url" | sed -n 's/.*[?&]filename=\([^&#]*\).*/\1/p')"
[ -n "$qname" ] || qname="$(printf '%s' "$url" | sed -n 's/.*[?&]name=\([^&#]*\).*/\1/p')"
if [ -n "$qname" ]; then
# URL decode then strip non-filename chars, keep CJK and alphanumeric
decoded="$(url_decode "$qname")"
decoded="$(printf '%s' "$decoded" | tr -d '\r\n' | sed -e 's/[[:space:]]/-/g' -e 's/[/\\:*?"<>|]//g' -e 's/\.yaml$//' -e 's/\.yml$//')"
[ -n "$decoded" ] && printf '%s' "$decoded" && return
fi
qname="$(sanitize_name "$qname")"
if [ -n "$qname" ]; then
printf '%s' "$qname"
return
fi
host="$(printf '%s' "$url" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's/:.*$//' -e 's#\..*$##')"
host="$(sanitize_name "$host")"
[ -n "$host" ] || host="sub"
printf '%s' "$host"
}
next_available_name() {
local base try idx
base="$1"
base="$(printf '%s' "$base" | sed -e 's/\.yaml$//' -e 's/\.yml$//')"
base="$(printf '%s' "$base" | tr ' /' '--')"
base="$(printf '%s' "$base" | sed -e 's/[\\]//g' -e 's/\.\.+/-/g' -e 's/--\+/-/g' -e 's/^[._-]*//' -e 's/[._-]*$//')"
[ -n "$base" ] || base="sub"
if [ ! -f "$SUB_DIR/${base}.yaml" ]; then
printf '%s' "$base"
return
fi
idx=2
while :; do
try="${base}-${idx}"
if [ ! -f "$SUB_DIR/${try}.yaml" ]; then
printf '%s' "$try"
return
fi
idx=$((idx + 1))
done
}
get_subscription_urls() {
uci -q show clashoo.config 2>/dev/null | awk -F"'" '
/^clashoo.config.subscribe_url=/ {
if (NF >= 3) {
for (i = 2; i <= NF; i += 2) {
if (length($i) > 0) print $i
}
} else {
sub(/^clashoo.config.subscribe_url=/, "", $0)
if (length($0) > 0) print $0
}
}
'
}
ensure_system_dns() {
local test_host
test_host="github.com"
if nslookup "$test_host" 127.0.0.1 >/dev/null 2>&1 || nslookup "$test_host" >/dev/null 2>&1; then
return 0
fi
uci delete dhcp.@dnsmasq[0].server >/dev/null 2>&1
uci set dhcp.@dnsmasq[0].noresolv='0' >/dev/null 2>&1
uci del_list dhcp.@dnsmasq[0].server='127.0.0.1#' >/dev/null 2>&1
uci del_list dhcp.@dnsmasq[0].server='127.0.0.1#5300' >/dev/null 2>&1
uci add_list dhcp.@dnsmasq[0].server='119.29.29.29' >/dev/null 2>&1
uci add_list dhcp.@dnsmasq[0].server='223.5.5.5' >/dev/null 2>&1
uci commit dhcp >/dev/null 2>&1
/etc/init.d/dnsmasq restart >/dev/null 2>&1
sleep 2
}
extract_host() {
printf '%s' "$1" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's#:.*$##' -e 's#.*@##'
}
resolve_via() {
local host dns
host="$1"
dns="$2"
nslookup "$host" "$dns" 2>/dev/null | awk '
/^Address/ {
ip = $NF
if (ip ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ &&
ip !~ /^127\./ &&
ip !~ /^0\./ &&
ip !~ /^198\.18\./ &&
ip != "8.8.8.8" && ip != "8.8.4.4" &&
ip != "1.1.1.1" && ip != "1.0.0.1" &&
ip != "223.5.5.5" && ip != "223.6.6.6" &&
ip != "119.29.29.29" && ip != "114.114.114.114") {
print ip
exit
}
}'
}
curl_subscription() {
local url tmp hdr err ua extra_resolve http_code rc
url="$1"
tmp="$2"
hdr="$3"
err="$4"
ua="$5"
extra_resolve="$6"
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
# shellcheck disable=SC2086
http_code="$(curl -sSL --connect-timeout 15 --max-time 60 \
--speed-time 30 --speed-limit 1 --retry 2 \
-H "User-Agent: ${ua}" -D "$hdr" -o "$tmp" \
$extra_resolve \
-w '%{http_code}' "$url" 2>"$err")"
rc=$?
printf '%s\n' "$http_code"
return "$rc"
}
download_subscription() {
local url target tmp hdr err rc http_code info_line ua err_msg
local host ip dns extra
url="$1"
target="$2"
tmp="${TMP_PREFIX}.yaml"
hdr="${TMP_PREFIX}.hdr"
err="${TMP_PREFIX}.err"
ua="$(uci -q get clashoo.config.sub_ua 2>/dev/null)"
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
if command -v curl >/dev/null 2>&1; then
http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "")"
rc=$?
if [ "$rc" -ne 0 ] || [ "$http_code" = "000" ]; then
host="$(extract_host "$url")"
if [ -n "$host" ]; then
for dns in 223.5.5.5 119.29.29.29 1.1.1.1 8.8.8.8; do
ip="$(resolve_via "$host" "$dns")"
[ -n "$ip" ] || continue
log_update "DNS 回退:${host} -> ${ip} (@${dns})"
extra="--resolve ${host}:443:${ip} --resolve ${host}:80:${ip}"
http_code="$(curl_subscription "$url" "$tmp" "$hdr" "$err" "$ua" "$extra")"
rc=$?
[ "$rc" -eq 0 ] && [ "$http_code" = "200" ] && break
done
fi
fi
else
wget -q --tries=4 --timeout=20 \
--user-agent="$ua" "$url" -O "$tmp" 2>"$err"
rc=$?
http_code=""
fi
if [ "$rc" -ne 0 ]; then
err_msg="$(grep -a 'curl:' "$err" 2>/dev/null | tail -1)"
[ -z "$err_msg" ] && err_msg="$(tail -1 "$err" 2>/dev/null)"
log_update "下载失败:$(basename "$target") rc=${rc} ${err_msg}"
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
return 1
fi
if [ -n "$http_code" ] && [ "$http_code" != "200" ]; then
log_update "下载失败:$(basename "$target") HTTP ${http_code}"
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
return 1
fi
if ! grep -Eq '^(proxies|proxy-providers):' "$tmp" 2>/dev/null; then
log_update "校验失败:$(basename "$target") 内容不含 proxies/proxy-providers"
rm -f "$tmp" "$hdr" "$err" >/dev/null 2>&1
return 1
fi
info_line="$(grep -i 'subscription-userinfo:' "$hdr" 2>/dev/null | head -1 | \
sed 's/^[Ss]ubscription-[Uu]serinfo:[[:space:]]*//' | tr -d '\r')"
[ -n "$info_line" ] && printf '%s\n' "$info_line" > "${target}.info" || \
rm -f "${target}.info" >/dev/null 2>&1
rm -f "$hdr" "$err" >/dev/null 2>&1
mv "$tmp" "$target" >/dev/null 2>&1 || {
rm -f "$tmp" >/dev/null 2>&1
return 1
}
printf '%s\n' "$ua" >"${target}.ua"
return 0
}
upsert_meta() {
local filename url typ tmpf
filename="$1"
url="$2"
typ="$3"
tmpf="${TMP_PREFIX}.list"
[ -f "$LIST_FILE" ] || touch "$LIST_FILE"
awk -F '#' -v n="$filename" '$1 != n { print $0 }' "$LIST_FILE" >"$tmpf"
printf '%s#%s#%s\n' "$filename" "$url" "$typ" >>"$tmpf"
mv "$tmpf" "$LIST_FILE"
}
cleanup_tmp() {
rm -f "${TMP_PREFIX}.yaml" "${TMP_PREFIX}.urls" "${TMP_PREFIX}.list" \
"${TMP_PREFIX}.hdr" "${TMP_PREFIX}.err" >/dev/null 2>&1
}
trap cleanup_tmp EXIT INT TERM
[ "$subtype" = "clash" ] || [ "$subtype" = "meta" ] || subtype="clash"
mkdir -p "$SUB_DIR" /usr/share/clashbackup >/dev/null 2>&1
[ -f "$LIST_FILE" ] || touch "$LIST_FILE"
URLS_FILE="${TMP_PREFIX}.urls"
get_subscription_urls | sed '/^[[:space:]]*$/d' | awk '!seen[$0]++' >"$URLS_FILE"
url_count="$(wc -l <"$URLS_FILE" 2>/dev/null | tr -d ' ')"
if [ -z "$url_count" ] || [ "$url_count" -eq 0 ]; then
log_update "未找到订阅链接"
log_text "No subscription URL found" "未找到订阅链接"
sleep 2
log_text "Clash for OpenWRT" "Clash for OpenWRT"
exit 1
fi
ensure_system_dns
log_update "开始下载订阅(共 ${url_count} 条)"
log_text "Downloading subscription..." "开始下载订阅..."
base_name="$(sanitize_custom_name "$config_name_raw")"
timestamp="$(date +%Y%m%d)"
success=0
failed=0
idx=0
first_file=""
while IFS= read -r url; do
[ -n "$url" ] || continue
idx=$((idx + 1))
if [ -n "$base_name" ]; then
if [ "$url_count" -gt 1 ]; then
name_candidate="$(sanitize_name "${base_name}-${idx}")"
file_base="$(next_available_name "$name_candidate")"
else
file_base="$base_name"
fi
else
name_candidate="$(url_to_name "$url")-${timestamp}"
if [ "$url_count" -gt 1 ]; then
name_candidate="${name_candidate}-${idx}"
fi
file_base="$(next_available_name "$name_candidate")"
fi
target_file="$SUB_DIR/${file_base}.yaml"
if download_subscription "$url" "$target_file"; then
upsert_meta "${file_base}.yaml" "$url" "$subtype"
log_update "订阅下载成功:${file_base}.yaml"
[ -n "$first_file" ] || first_file="$target_file"
success=$((success + 1))
else
log_update "订阅下载失败:${file_base}.yaml"
failed=$((failed + 1))
fi
done <"$URLS_FILE"
if [ "$success" -gt 0 ]; then
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
if [ -z "$use_config" ] || [ ! -f "$use_config" ]; then
uci set clashoo.config.use_config="$first_file"
uci set clashoo.config.config_type='1'
uci commit clashoo
fi
log_text "Subscription download completed: ${success} success, ${failed} failed" "订阅下载完成:成功 ${success} 个,失败 ${failed}"
log_update "订阅下载完成:成功 ${success} 个,失败 ${failed}"
ret=0
else
log_text "All subscription downloads failed" "订阅下载失败"
log_update "订阅下载失败:全部链接失败"
ret=1
fi
sleep 2
log_text "Clash for OpenWRT" "Clash for OpenWRT"
exit "$ret"
+451
View File
@@ -0,0 +1,451 @@
#!/bin/sh
# Shared DNS helpers for ClashOO runtime generators. Keep POSIX sh compatible.
dns_trim() {
printf '%s' "$1" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//'
}
dns_has_scheme() {
printf '%s' "$1" | grep -Eq '^[A-Za-z][A-Za-z0-9+.-]*://'
}
dns_norm_protocol() {
case "$(dns_trim "${1:-}")" in
''|'none') printf '' ;;
udp|'udp://') printf 'udp://' ;;
tcp|'tcp://') printf 'tcp://' ;;
dot|tls|'tls://') printf 'tls://' ;;
doh|https|'https://') printf 'https://' ;;
doq|quic|'quic://') printf 'quic://' ;;
*) printf '%s' "$1" ;;
esac
}
dns_normalize_server() {
local address protocol port prefix
address=$(dns_trim "${1:-}")
protocol=$(dns_trim "${2:-}")
port=$(dns_trim "${3:-}")
[ -n "$address" ] || return 0
if dns_has_scheme "$address"; then
printf '%s' "$address"
return 0
fi
prefix=$(dns_norm_protocol "$protocol")
if [ -n "$port" ]; then
printf '%s%s:%s' "$prefix" "$address" "$port"
else
printf '%s%s' "$prefix" "$address"
fi
}
dns_yaml_sq() {
printf "%s" "$1" | sed "s/'/''/g"
}
dns_yaml_list_item() {
local value
value=$(dns_yaml_sq "$1")
printf " - '%s'\n" "$value"
}
# Route DNS upstreams by rules and preserve the original setting.
dns_mihomo_apply_respect_rules() {
local cfg="$1" enabled="${2:-0}" tmp="${1}.$$"
[ -f "$cfg" ] || return 0
awk -v enabled="$enabled" '
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
function spaces(n, out,i) {
out = ""
for (i = 0; i < n; i++)
out = out " "
return out
}
function trim_value(s) {
sub(/^[[:space:]]*/, "", s)
sub(/[[:space:]]*$/, "", s)
return s
}
function flush_dns( i, sp) {
if (!in_dns)
return
sp = spaces(child_indent > 0 ? child_indent : dns_indent + 2)
print dns_lines[1]
if (enabled == "1") {
print sp "# >>> clashoo:dns_leak_protect_respect_rules"
print sp "# original-respect-rules: " (original_value != "" ? original_value : "absent")
print sp "respect-rules: true"
print sp "# <<< clashoo:dns_leak_protect_respect_rules"
} else if (original_value != "" && original_value != "absent") {
print sp "respect-rules: " original_value
}
for (i = 2; i <= dns_n; i++)
print dns_lines[i]
in_dns = 0
dns_n = 0
}
/^dns:[[:space:]]*$/ {
flush_dns()
in_dns = 1
dns_indent = indent_len($0)
child_indent = -1
dns_n = 1
dns_lines[dns_n] = $0
original_value = ""
in_marker = 0
next
}
in_dns {
cur_indent = indent_len($0)
if ($0 ~ /^[^[:space:]#][^:]*:/ && cur_indent <= dns_indent) {
flush_dns()
print
next
}
if ($0 ~ /^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_respect_rules[[:space:]]*$/) {
in_marker = 1
if (child_indent < 0) child_indent = cur_indent
next
}
if (in_marker) {
if ($0 ~ /^[[:space:]]*#[[:space:]]*original-respect-rules:[[:space:]]*/) {
value = $0
sub(/^[[:space:]]*#[[:space:]]*original-respect-rules:[[:space:]]*/, "", value)
original_value = trim_value(value)
}
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_respect_rules[[:space:]]*$/)
in_marker = 0
next
}
if ($0 ~ /^[[:space:]]*respect-rules:[[:space:]]*/) {
if (original_value == "") {
value = $0
sub(/^[[:space:]]*respect-rules:[[:space:]]*/, "", value)
original_value = trim_value(value)
}
if (child_indent < 0) child_indent = cur_indent
next
}
if ($0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && cur_indent > dns_indent && child_indent < 0)
child_indent = cur_indent
dns_lines[++dns_n] = $0
next
}
{ print }
END { flush_dns() }
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
rm -f "$tmp" 2>/dev/null
}
# Use fallback DNS for queries; keep bootstrap DNS unchanged.
dns_mihomo_apply_leak_nameservers() {
local cfg="$1" enabled="${2:-0}" state="${1}.dns-leak-nameserver"
local selected="${1}.dns-leak-selected" current="${1}.dns-leak-current" tmp="${1}.$$"
[ -f "$cfg" ] || return 0
command -v yq >/dev/null 2>&1 || return 0
if [ "$enabled" = "1" ]; then
yq e -r '.dns.fallback[]' "$cfg" > "$selected" 2>/dev/null || true
if [ ! -s "$selected" ]; then
printf '%s\n' 'https://1.1.1.1/dns-query' 'https://8.8.8.8/dns-query' > "$selected"
fi
yq e -r '.dns.nameserver[]' "$cfg" > "$current" 2>/dev/null || true
if [ ! -f "$state" ] ||
! grep -q 'clashoo:dns_leak_protect_respect_rules' "$cfg" ||
! cmp -s "$current" "$selected"; then
awk '
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
/^dns:[[:space:]]*$/ { in_dns = 1; dns_indent = indent_len($0); next }
in_dns && /^[^[:space:]#][^:]*:/ { exit }
in_dns && /^[[:space:]]*nameserver:/ {
print
rest = $0
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
sub(/[[:space:]]*(#.*)?$/, "", rest)
if (rest == "") { in_ns = 1; ns_indent = indent_len($0) }
next
}
in_ns {
cur_indent = indent_len($0)
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) {
print
next
}
exit
}
' "$cfg" > "$state"
[ -s "$state" ] || printf '__ABSENT__\n' > "$state"
fi
awk -v selected="$selected" '
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
function spaces(n, out,i) {
out = ""
for (i = 0; i < n; i++) out = out " "
return out
}
function print_selected(sp, line) {
print sp "nameserver:"
while ((getline line < selected) > 0)
if (line != "") print sp " - " line
close(selected)
inserted = 1
}
/^dns:[[:space:]]*$/ {
in_dns = 1
dns_indent = indent_len($0)
child_indent = -1
print
next
}
in_dns && /^[^[:space:]#][^:]*:/ {
if (!inserted) print_selected(spaces(child_indent > 0 ? child_indent : dns_indent + 2))
in_dns = 0
print
next
}
in_dns && /^[[:space:]]*[^#[:space:]][^:]*:/ && child_indent < 0 {
child_indent = indent_len($0)
}
in_dns && /^[[:space:]]*nameserver:/ {
ns_indent = indent_len($0)
rest = $0
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
sub(/[[:space:]]*(#.*)?$/, "", rest)
print_selected(spaces(ns_indent))
if (rest == "") skip_ns = 1
next
}
skip_ns {
cur_indent = indent_len($0)
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) next
skip_ns = 0
}
{ print }
END {
if (in_dns && !inserted)
print_selected(spaces(child_indent > 0 ? child_indent : dns_indent + 2))
}
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
elif [ -f "$state" ]; then
awk -v state="$state" '
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
function print_original( line) {
if ((getline line < state) > 0 && line != "__ABSENT__") {
print line
while ((getline line < state) > 0) print line
}
close(state)
restored = 1
}
/^dns:[[:space:]]*$/ { in_dns = 1; print; next }
in_dns && /^[^[:space:]#][^:]*:/ {
if (!restored) print_original()
in_dns = 0
print
next
}
in_dns && /^[[:space:]]*nameserver:/ {
ns_indent = indent_len($0)
rest = $0
sub(/^[[:space:]]*nameserver:[[:space:]]*/, "", rest)
sub(/[[:space:]]*(#.*)?$/, "", rest)
print_original()
if (rest == "") skip_ns = 1
next
}
skip_ns {
cur_indent = indent_len($0)
if ($0 ~ /^[[:space:]]*$/ || cur_indent > ns_indent) next
skip_ns = 0
}
{ print }
END { if (in_dns && !restored) print_original() }
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
rm -f "$state"
fi
rm -f "$selected" "$current" "$tmp" 2>/dev/null
}
# handle mihomo dns block:
# - strip previous clashoo:dns_leak_protect injection markers (incl. the old
# geosite:gfw block written by older versions)
# - rewrite the ipv6: line from ipv6_value (skip when empty)
# No longer injects fallback-filter.geosite:gfw — mihomo deprecated it and a
# GeoSite.dat missing the gfw category froze startup (issue #25). See flush_dns.
# (The fallback-filter parsing vars below are now vestigial; clean up separately.)
dns_mihomo_apply_leak_dns_block() {
local cfg="$1" enabled="${2:-0}" ipv6_value="${3:-}" tmp="${1}.$$"
[ -f "$cfg" ] || return 0
awk -v enabled="$enabled" -v ipv6_value="$ipv6_value" '
function indent_len(s) { match(s, /[^ ]/); return RSTART ? RSTART - 1 : length(s) }
function spaces(n, out,i) {
out = ""
for (i = 0; i < n; i++)
out = out " "
return out
}
function flush_dns( i, sp, ff_sp) {
if (!in_dns)
return
if (in_ff) ff_end = dns_n
sp = spaces(child_indent > 0 ? child_indent : dns_indent + 2)
ff_sp = (ff_child_indent > 0 ? spaces(ff_child_indent) : sp " ")
print dns_lines[1]
if (ipv6_value != "")
print sp "ipv6: " ipv6_value
# NOTE: clashoo used to inject `fallback-filter.geosite: [gfw]` here, but
# mihomo deprecated fallback-filter.geosite ("replace with nameserver-policy,
# will be removed") AND it hard-fails preflight + freezes startup (~90s) when
# the downloaded GeoSite.dat lacks the gfw category (issue #25). Stop
# injecting it. DNS-leak protection still works via respect-rules / leak
# nameservers / DST-PORT,853,REJECT handled elsewhere. The marker strip below
# still removes any gfw block injected by older versions, so disabling /
# upgrading cleans it up.
for (i = 2; i <= dns_n; i++)
print dns_lines[i]
in_dns = 0
dns_n = 0
}
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect[[:space:]]*$/ {
in_marker = 1
next
}
in_marker {
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect[[:space:]]*$/)
in_marker = 0
next
}
/^dns:[[:space:]]*$/ {
flush_dns()
in_dns = 1
dns_indent = indent_len($0)
child_indent = -1
dns_n = 1
dns_lines[dns_n] = $0
has_ff = 0
in_ff = 0
in_ff_geosite = 0
ff_indent = -1
ff_child_indent = -1
ff_geosite_indent = -1
ff_has_gfw = 0
ff_end = -1
next
}
in_dns {
cur_indent = indent_len($0)
if ($0 ~ /^[^[:space:]#][^:]*:/ && cur_indent <= dns_indent) {
flush_dns()
print
next
}
if ($0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && cur_indent > dns_indent && child_indent < 0)
child_indent = cur_indent
if (in_ff && cur_indent <= ff_indent) {
ff_end = dns_n
in_ff = 0
in_ff_geosite = 0
}
if (in_ff_geosite && cur_indent <= ff_geosite_indent)
in_ff_geosite = 0
if ($0 ~ /^[[:space:]]*fallback-filter:[[:space:]]*$/) {
has_ff = 1
in_ff = 1
ff_indent = cur_indent
ff_child_indent = -1
} else if (in_ff && $0 ~ /^[[:space:]]*[^#[:space:]][^:]*:/ && ff_child_indent < 0) {
ff_child_indent = cur_indent
}
if (in_ff && $0 ~ /^[[:space:]]*geosite:[[:space:]]*$/) {
in_ff_geosite = 1
ff_geosite_indent = cur_indent
} else if (in_ff_geosite && $0 ~ /^[[:space:]]*-[[:space:]]*gfw([[:space:]]*(#.*)?)?$/) {
ff_has_gfw = 1
}
if ($0 ~ /^[[:space:]]*ipv6:[[:space:]]*/)
next
dns_lines[++dns_n] = $0
next
}
{ print }
END { flush_dns() }
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
rm -f "$tmp" 2>/dev/null
}
# handle DST-PORT,853,REJECT injection into rules block
# only remove Clashoo marker block, never user original rules
dns_mihomo_apply_leak_rule() {
local cfg="$1" enabled="${2:-0}" tmp="${1}.$$" has_user_853
[ -f "$cfg" ] || return 0
has_user_853=$(awk '
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/ {
in_marker = 1
next
}
in_marker {
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/)
in_marker = 0
next
}
/^[[:space:]]*-[[:space:]]*DST-PORT,853,REJECT([[:space:]]*(#.*)?)?$/ { found = 1 }
END { print found ? 1 : 0 }
' "$cfg")
awk -v enabled="$enabled" -v has_user_853="$has_user_853" '
BEGIN { inserted = 0; saw_rules = 0 }
/^[[:space:]]*#[[:space:]]*>>>[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/ {
in_marker = 1
next
}
in_marker {
if ($0 ~ /^[[:space:]]*#[[:space:]]*<<<[[:space:]]*clashoo:dns_leak_protect_rule[[:space:]]*$/)
in_marker = 0
next
}
/^rules:[[:space:]]*$/ {
saw_rules = 1
print
if (enabled == "1" && has_user_853 != "1") {
print " # >>> clashoo:dns_leak_protect_rule"
print " - DST-PORT,853,REJECT"
print " # <<< clashoo:dns_leak_protect_rule"
inserted = 1
}
next
}
{ print }
END {
if (enabled == "1" && has_user_853 != "1" && !inserted && !saw_rules) {
print ""
print "rules:"
print " # >>> clashoo:dns_leak_protect_rule"
print " - DST-PORT,853,REJECT"
print " # <<< clashoo:dns_leak_protect_rule"
}
}
' "$cfg" > "$tmp" && mv "$tmp" "$cfg"
rm -f "$tmp" 2>/dev/null
}
dns_mihomo_apply_leak_protect() {
local cfg="$1" enabled="${2:-0}" ipv6_value="${3:-}"
[ -f "$cfg" ] || return 0
dns_mihomo_apply_leak_nameservers "$cfg" "$enabled"
dns_mihomo_apply_respect_rules "$cfg" "$enabled"
dns_mihomo_apply_leak_dns_block "$cfg" "$enabled" "$ipv6_value"
dns_mihomo_apply_leak_rule "$cfg" "$enabled"
}
@@ -0,0 +1,17 @@
. as $document |
strenv(CLASHOO_DNS_OLD_PORT) as $old_port |
strenv(CLASHOO_DNS_HOST_PATTERN) as $host_pattern |
(strenv(CLASHOO_DNS_REPLACEMENT) | split(" ") | map(select(length > 0))) as $replacement |
("(?i)^(?:[a-z][a-z0-9+.-]*://)?(?:" + $host_pattern + "):" + $old_port + "(?:$|[/#?])") as $old_loopback |
(
$document |
select((.dns."proxy-server-nameserver" | type) == "!!seq") |
([.dns."proxy-server-nameserver"[] | select(type == "!!str" and test($old_loopback))] | length) as $match_count |
select($match_count > 0) |
.dns."proxy-server-nameserver" =
([
.dns."proxy-server-nameserver"[] |
select((type == "!!str" and test($old_loopback)) | not) |
select(. as $server | ($replacement | contains([$server])) | not)
] + $replacement)
) // $document
+98
View File
@@ -0,0 +1,98 @@
#!/bin/sh
. /lib/functions.sh
TMP_PREFIX="/tmp/clashoo_iprules.$$"
CUSTOM_RULE_FILE="${TMP_PREFIX}.ipadd.conf"
RULE="${TMP_PREFIX}.rules_conf.yaml"
CLASH="${TMP_PREFIX}.conf.yaml"
CONFIG_YAML="/etc/clashoo/config.yaml"
CLASH_CONFIG="${TMP_PREFIX}.config.yaml"
trap 'rm -f "$CUSTOM_RULE_FILE" "$RULE" "$CLASH" "$CLASH_CONFIG"' EXIT
[ -f "$CONFIG_YAML" ] || exit 0
. /usr/share/clashoo/runtime/primary_group.sh
# __PROXY__ placeholder -> primary proxy group (cached by RPC);
# fallback to GLOBAL (built-in mihomo group)
CACHED_PRIMARY_GROUP="$(uci -q get clashoo.config.primary_proxy_group)"
PRIMARY_GROUP="$(clashoo_resolve_primary_group "$CONFIG_YAML" "$CACHED_PRIMARY_GROUP")"
[ -n "$PRIMARY_GROUP" ] || PRIMARY_GROUP="GLOBAL"
append=$(uci get clashoo.config.append_rules 2>/dev/null)
if [ "${append:-0}" -eq 1 ];then
if [ -f $CLASH_CONFIG ];then
rm -rf $CLASH_CONFIG 2>/dev/null
fi
cp $CONFIG_YAML $CLASH_CONFIG 2>/dev/null
if [ ! -z "$(grep "^Rule:" "$CLASH_CONFIG")" ]; then
sed -i "/^Rule:/i\#RULESTART#" $CLASH_CONFIG 2>/dev/null
elif [ ! -z "$(grep "^rules:" "$CLASH_CONFIG")" ]; then
sed -i "/^rules:/i\#RULESTART#" $CLASH_CONFIG 2>/dev/null
fi
sed -i -e "\$a#RULEEND#" $CLASH_CONFIG 2>/dev/null
awk '/#RULESTART#/,/#RULEEND#/{print}' "$CLASH_CONFIG" 2>/dev/null |sed "s/\'//g" 2>/dev/null |sed 's/\"//g' 2>/dev/null |sed 's/\t/ /g' 2>/dev/null |grep '^ \{0,\}- '|awk -F '- ' '{print "- "$2}' | sed 's/^ \{0,\}//' 2>/dev/null |sed 's/ \{0,\}$//' 2>/dev/null >$RULE 2>&1
sed -i '/#RULESTART#/,/#RULEEND#/d' "$CLASH_CONFIG" 2>/dev/null
sed -i -e "\$a " $CLASH_CONFIG 2>/dev/null
sed -i "1i\rules:" $RULE 2>/dev/null
cat $CLASH_CONFIG $RULE >$CLASH 2>/dev/null
mv $CLASH $CONFIG_YAML 2>/dev/null
rm -f $RULE $CLASH_CONFIG 2>/dev/null
fi
# ===== custom routing rules (UCI: config addtype), decoupled =====
# fields: type(DOMAIN|SUFFIX|KEYWORD|IP-CIDR), ipaaddr, pgroup, res(no-resolve)
# pgroup: DIRECT or __PROXY__ (resolved at injection)
rm -f "$CUSTOM_RULE_FILE" 2>/dev/null
ipadd()
{
local section="$1"
config_get "pgroup" "$section" "pgroup" ""
config_get "ipaaddr" "$section" "ipaaddr" ""
config_get "type" "$section" "type" ""
config_get "res" "$section" "res" ""
[ -z "$type" ] && return
[ -z "$ipaaddr" ] && return
[ -z "$pgroup" ] && return
[ "$pgroup" = "__PROXY__" ] && pgroup="$PRIMARY_GROUP"
# quote whole rule (group name may contain spaces/emoji)
# so mixed indent with subscription rules does not break YAML parsing
if [ "${res}" = "1" ];then
echo "${RULE_INDENT}- \"$type,$ipaaddr,$pgroup,no-resolve\"" >> "$CUSTOM_RULE_FILE"
else
echo "${RULE_INDENT}- \"$type,$ipaaddr,$pgroup\"" >> "$CUSTOM_RULE_FILE"
fi
}
# detect existing rules indent for alignment (default 2)
RULE_INDENT="$(awk '/^[[:space:]]*rules:/{f=1;next} f&&/^[[:space:]]*-/{match($0,/^[[:space:]]*/);print substr($0,1,RLENGTH);exit}' "$CONFIG_YAML" 2>/dev/null)"
[ -n "$RULE_INDENT" ] || RULE_INDENT=" "
config_load "clashoo"
config_foreach ipadd "addtype"
# clean previous injection block (idempotent)
sed -i '/#CUSTOMRULESTART#/,/#CUSTOMRULEEND#/d' "$CONFIG_YAML" 2>/dev/null
if [ -f "$CUSTOM_RULE_FILE" ];then
sed -i -e "\$a#CUSTOMRULEEND#" "$CUSTOM_RULE_FILE" 2>/dev/null
if [ ! -z "$(grep "^ \{0,\}rules:" "$CONFIG_YAML")" ]; then
# 插在 rules: 段最前面,优先于订阅自带规则命中(纠错)
sed -i '/^ \{0,\}rules:/a\#CUSTOMRULESTART#' "$CONFIG_YAML" 2>/dev/null
else
echo "rules:" >> "$CONFIG_YAML" 2>/dev/null
echo "#CUSTOMRULESTART#" >> "$CONFIG_YAML" 2>/dev/null
fi
sed -i "/#CUSTOMRULESTART#/r${CUSTOM_RULE_FILE}" "$CONFIG_YAML" 2>/dev/null
fi
@@ -0,0 +1,317 @@
#!/usr/bin/ucode
'use strict';
import { cursor } from 'uci';
const uci = cursor();
function b(v) {
if (v == null || v == '' || v == '0' || v == 'false') return false;
if (v == '1' || v == 'true') return true;
return v;
}
function i(v, d) { let n = v != null ? int(v) : null; return n != null ? n : d; }
function s(v, d) { let t = trim(v || ''); return length(t) ? t : d; }
function a(k) { return uci.get('clashoo', 'config', k); }
function ab(k) { return b(a(k)); }
const cfg = {};
/* ── 端口与基本设置 ─────────────────────────────────────── */
let v;
v = i(a('http_port')); if (v != null) cfg['port'] = v;
v = i(a('socks_port')); if (v != null) cfg['socks-port'] = v;
v = i(a('redir_port')); if (v != null) cfg['redir-port'] = v;
v = i(a('mixed_port')); if (v != null) cfg['mixed-port'] = v;
v = i(a('tproxy_port')); if (v != null) cfg['tproxy-port'] = v;
v = a('bind_addr');
if (!v || v == '*') v = null;
if (v) cfg['bind-address'] = v;
v = a('p_mode');
if (v == 'script') v = 'rule';
if (v) cfg['mode'] = v;
cfg['log-level'] = s(a('level'), 'info');
cfg['allow-lan'] = ab('allow_lan');
cfg['ipv6'] = ab('enable_ipv6');
cfg['routing-mark'] = 6666;
cfg['external-controller'] = '0.0.0.0:' + i(a('dash_port'), 9090);
cfg['external-ui'] = './dashboard';
cfg['secret'] = s(a('dash_pass'), '');
v = a('interf');
if (v && v != '0') cfg['interface-name'] = v;
/* ── TUN ───────────────────────────────────────────────── */
let tun_mode = i(a('tun_mode'), 0);
let tcp_mode = s(a('tcp_mode'), 'redirect');
let udp_mode = s(a('udp_mode'), 'tproxy');
let tun_enabled = (tun_mode == 1 || tcp_mode == 'tun' || udp_mode == 'tun');
let acl_enabled = false;
uci.foreach('clashoo', 'lan_acl', function(sec) {
if (b(sec.enabled) != false) acl_enabled = true;
});
if (!ab('acl_migrated')) {
let access_control = i(a('access_control'), 0);
acl_enabled = access_control == 1 || access_control == 2;
}
let tun_acl = tun_enabled && acl_enabled;
cfg['tun'] = {
enable: tun_enabled,
stack: s(a('stack'), 'gvisor'),
'auto-route': true,
'auto-redirect': !tun_acl,
'auto-detect-interface': true,
};
let tun_mtu = a('tun_mtu');
if (tun_mtu != null) cfg['tun']['mtu'] = int(tun_mtu);
if (ab('tun_gso')) {
cfg['tun']['gso'] = true;
cfg['tun']['gso-max-size'] = i(a('tun_gso_max_size'), 65536);
}
if (ab('tun_dns_hijack')) {
cfg['tun']['dns-hijack'] = ['any:53', 'tcp://any:53'];
}
if (!tun_enabled) {
cfg['tun']['enable'] = false;
}
/* ── DNS ───────────────────────────────────────────────── */
let dns_port = i(a('listen_port'), 1053);
cfg['dns'] = {
enable: b(a('enable_dns')) != false,
listen: '0.0.0.0:' + dns_port,
'enhanced-mode': s(a('enhanced_mode'), 'fake-ip'),
'fake-ip-range': s(a('fake_ip_range'), '198.18.0.1/16'),
'fake-ip-filter': [],
ipv6: ab('enable_ipv6'),
};
if (cfg['dns']['enhanced-mode'] == 'fake-ip' && ab('enable_ipv6'))
cfg['dns']['fake-ip-range6'] = s(a('fake_ip_range6'), 'fc00::/18');
/* fake-ip-filter-mode: blacklist (default) | whitelist | rule */
let filter_mode = s(a('fake_ip_filter_mode'), 'blacklist');
if (filter_mode != 'blacklist') cfg['dns']['fake-ip-filter-mode'] = filter_mode;
/* map geosite:cn to bundled cn.mrs rule-set so mihomo skips the 10MB geosite.dat */
let need_cn_rs = false;
let keep_fakeip = length(s(getenv('CLASHOO_KEEP_FAKEIP_FILTER'), ''));
if (keep_fakeip) {
delete cfg['dns']['fake-ip-filter'];
} else {
let push_filter = function(f) {
if (f == 'geosite:cn') { f = 'rule-set:cn_domain'; need_cn_rs = true; }
else if (f == 'rule-set:cn_domain') need_cn_rs = true;
push(cfg['dns']['fake-ip-filter'], f);
};
let filters = a('fake_ip_filter');
if (type(filters) == 'array') { for (let f in filters) push_filter(f); }
else if (filters != null) push_filter(filters);
}
/* fallback-filter(默认 geoip:false,防止冷启动依赖 MMDB */
cfg['dns']['fallback-filter'] = { geoip: ab('fallback_filter_geoip') };
let dns_present = {};
for (let f in split(s(getenv('CLASHOO_DNS_PRESENT'), ''), ','))
if (length(trim(f))) dns_present[trim(f)] = true;
let dns_force = {};
for (let f in split(s(getenv('CLASHOO_DNS_FORCE'), ''), ','))
if (length(trim(f))) dns_force[trim(f)] = true;
let dns_inherit_mode = s(getenv('CLASHOO_DNS_INHERIT_MODE'), '');
let inherited_nameserver = json(s(getenv('CLASHOO_DNS_INHERIT_NAMESERVER'), '[]'));
if (type(inherited_nameserver) != 'array') {
if (inherited_nameserver != null && inherited_nameserver != '')
inherited_nameserver = [inherited_nameserver];
else
inherited_nameserver = [];
}
let dns_role_fields = ['nameserver', 'proxy-server-nameserver', 'direct-nameserver',
'default-nameserver', 'nameserver-policy', 'respect-rules'];
let had_user_dns_roles = false;
for (let f in dns_role_fields)
if (dns_present[f]) had_user_dns_roles = true;
if (length(keys(dns_force)) || !had_user_dns_roles)
cfg['dns']['x-clashoo-managed-dns'] = true;
function dns_scheme(protocol) {
switch (trim(protocol || '')) {
case '':
case 'none': return '';
case 'udp': case 'udp://': return 'udp://';
case 'tcp': case 'tcp://': return 'tcp://';
case 'dot': case 'tls': case 'tls://': return 'tls://';
case 'doh': case 'https': case 'https://': return 'https://';
case 'doq': case 'quic': case 'quic://': return 'quic://';
default: return protocol;
}
}
function dns_server(address, protocol, port) {
let addr = trim(address || '');
if (!length(addr)) return null;
if (match(addr, /^[A-Za-z][A-Za-z0-9+.-]*:\/\//)) return addr;
let prefix = dns_scheme(protocol);
return length(trim(port || '')) ? prefix + addr + ':' + trim(port) : prefix + addr;
}
let dns_roles = {};
uci.foreach('clashoo', 'dnsservers', function(sec) {
if (b(sec.enabled) == false) return;
let role = s(sec.ser_type, 'nameserver');
if (role == 'fallback' && cfg['dns']['enhanced-mode'] == 'fake-ip') return;
let srv = dns_server(sec.ser_address, sec.protocol, sec.ser_port);
if (!srv) return;
if (!dns_roles[role]) dns_roles[role] = [];
push(dns_roles[role], srv);
});
for (let role in keys(dns_roles)) {
if (role == 'proxy-server-nameserver' && (dns_inherit_mode == 'inherit' || dns_inherit_mode == 'skip') &&
!dns_present[role] && !dns_force[role]) continue;
if (!dns_present[role] || dns_force[role]) cfg['dns'][role] = dns_roles[role];
}
if (dns_inherit_mode == 'inherit' && length(inherited_nameserver) &&
!dns_present['proxy-server-nameserver'] && !dns_force['proxy-server-nameserver'])
cfg['dns']['proxy-server-nameserver'] = inherited_nameserver;
let bootstrap = a('default_nameserver');
if (bootstrap == null) bootstrap = a('defaul_nameserver');
if (type(bootstrap) != 'array') bootstrap = bootstrap != null ? [bootstrap] : [];
if (length(bootstrap) && (!dns_present['default-nameserver'] || dns_force['default-nameserver']))
cfg['dns']['default-nameserver'] = bootstrap;
let policies = {};
uci.foreach('clashoo', 'dns_policy', function(sec) {
if (b(sec.enabled) == false) return;
if (s(sec.policy_type, 'nameserver-policy') != 'nameserver-policy') return;
let matcher = trim(sec.matcher || '');
let servers = sec.nameserver;
if (type(servers) != 'array') servers = servers != null ? [servers] : [];
if (!length(matcher) || !length(servers)) return;
if (matcher == 'geosite:cn') { matcher = 'rule-set:cn_domain'; need_cn_rs = true; }
policies[matcher] = servers;
});
if (length(keys(policies)) && (!dns_present['nameserver-policy'] || dns_force['nameserver-policy']))
cfg['dns']['nameserver-policy'] = policies;
let respect_rules = a('dns_respect_rules') == null ? true : ab('dns_respect_rules');
if (dns_force['respect-rules'])
cfg['dns']['respect-rules'] = respect_rules;
else if (respect_rules && length(cfg['dns']['proxy-server-nameserver'] || [])
&& !dns_present['respect-rules'] && !dns_present['prefer-h3'])
cfg['dns']['respect-rules'] = true;
/* profile */
let store_selected = ab('selection_cache');
/* fake-ip cache defaults ON to survive restarts; opt out with '0' */
let store_fake = a('fake_ip_cache') == null ? true : ab('fake_ip_cache');
if (store_selected || store_fake) {
cfg['profile'] = {};
if (store_selected) cfg['profile']['store-selected'] = true;
if (store_fake) cfg['profile']['store-fake-ip'] = true;
}
/* bundled cn.mrs rule-provider, referenced by fake-ip-filter rule-set:cn_domain */
if (need_cn_rs) {
if (!cfg['rule-providers']) cfg['rule-providers'] = {};
cfg['rule-providers']['cn_domain'] = {
type: 'file',
path: './ruleset/cn.mrs',
format: 'mrs',
behavior: 'domain',
};
}
/* ── authentication ────────────────────────────────── */
if (ab('authentication')) {
cfg['authentication'] = [];
uci.foreach('clashoo', 'authentication', function(sec) {
if (b(sec.enabled) == false) return;
let user = trim(sec.username || '');
let pass = trim(sec.password || '');
if (user) push(cfg['authentication'], user + ':' + pass);
});
}
/* ── hosts ────────────────────────────────────────── */
cfg['hosts'] = {};
uci.foreach('clashoo', 'hosts', function(sec) {
if (b(sec.enabled) == false) return;
let domain = trim(sec.adress || '');
let ip = trim(sec.ip || '');
if (domain && ip) cfg['hosts'][domain] = ip;
});
if (length(keys(cfg['hosts'])) == 0) delete cfg['hosts'];
/* ── sniffer ──────────────────────────────────────── */
if (ab('sniffer_streaming') && !length(s(getenv('CLASHOO_KEEP_SNIFFER'), ''))) {
cfg['sniffer'] = {
enable: true,
'force-dns-mapping': true,
'parse-pure-ip': true,
sniff: {
HTTP: { ports: [80, 8080], 'override-destination': true },
TLS: { ports: [443, 8443], 'override-destination': true },
QUIC: { ports: [443, 8443], 'override-destination': true },
},
'force-domain': [
'+.youtube.com',
'+.googlevideo.com',
'+.netflix.com',
'+.nflxvideo.net',
'+.disneyplus.com',
'+.hulu.com',
'+.hbomax.com',
],
'skip-domain': [
'+.lan',
'+.local',
],
};
}
/* ── Smart kernel injection ───────────────────────── */
if (ab('smart_auto_switch')) {
/* Smart 策略注入:通过 proxy-groups 覆盖实现 */
}
/* ── ECS ──────────────────────────────────────────── */
let ecs = s(a('dns_ecs'));
if (ecs) cfg['dns']['edns-client-subnet'] = ecs;
/* ── cache_file / experimental ──────────────────── */
cfg['experimental'] = {
'cache_file': { enabled: true },
'clash_api': {
'external-controller': '0.0.0.0:' + i(a('dash_port'), 9090),
'external-ui': './dashboard',
'secret': s(a('dash_pass'), ''),
},
};
/* ── 输出 JSON (不含 null/空对象) ─────────────────── */
function clean(obj) {
if (type(obj) == 'array') {
let r = [];
for (let e in obj) {
e = clean(e);
if (e != null) push(r, e);
}
return length(r) ? r : null;
}
if (type(obj) == 'object') {
let r = {};
for (let k in keys(obj)) {
let v = clean(obj[k]);
if (v != null) r[k] = v;
}
return length(keys(r)) ? r : null;
}
return obj;
}
print(clean(cfg));
+49
View File
@@ -0,0 +1,49 @@
#!/bin/sh
clashoo_resolve_primary_group() {
local config_yaml="$1"
local cached_group="$2"
local group_rows
local resolved_group
if [ "$cached_group" = "GLOBAL" ]; then
printf '%s\n' "GLOBAL"
return 0
fi
if [ ! -f "$config_yaml" ] || ! command -v yq >/dev/null 2>&1; then
printf '%s\n' "GLOBAL"
return 0
fi
group_rows="$(yq -M e -r '(."proxy-groups" // [])[] | [.name, .type] | @tsv' "$config_yaml" 2>/dev/null)" || {
printf '%s\n' "GLOBAL"
return 0
}
if [ -n "$cached_group" ] && printf '%s\n' "$group_rows" |
awk -F '\t' -v wanted="$cached_group" '$1 == wanted { found = 1; exit } END { exit !found }'
then
printf '%s\n' "$cached_group"
return 0
fi
resolved_group="$(printf '%s\n' "$group_rows" | awk -F '\t' '
tolower($2) == "select" &&
(index($1, "节点选择") || index($1, "代理") || index($1, "🚀") || tolower($1) ~ /proxy/) {
print $1
exit
}
')"
if [ -z "$resolved_group" ]; then
resolved_group="$(printf '%s\n' "$group_rows" | awk -F '\t' '
tolower($2) == "select" {
print $1
exit
}
')"
fi
printf '%s\n' "${resolved_group:-GLOBAL}"
}
@@ -0,0 +1,9 @@
# If every configured rule points to a proxy without UDP support, mihomo falls
# through to DIRECT. Reject only that otherwise-unmatched QUIC traffic so the
# client can retry over TCP; rules with a usable UDP target still win first.
with(
select(has("rules")) |
select((.rules | type) == "!!seq") |
select(([.rules[] | select(. == "AND,((NETWORK,UDP),(DST-PORT,443)),REJECT")] | length) == 0);
.rules += ["AND,((NETWORK,UDP),(DST-PORT,443)),REJECT"]
)
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
if [ -f /usr/share/clashbackup/history ];then
HISTORY_PATH="/usr/share/clashbackup/history"
SECRET=$(uci get clashoo.config.dash_pass 2>/dev/null)
LAN_IP=$(uci get network.lan.ipaddr 2>/dev/null |awk -F '/' '{print $1}' 2>/dev/null)
PORT=$(uci get clashoo.config.dash_port 2>/dev/null)
urlencode() {
local data
if [ "$#" != 1 ]; then
return 1
fi
data=$(curl -s -o /dev/null -w %{url_effective} --get --data-urlencode "$1" "")
if [ ! -z "$data" ]; then
echo "${data##/?}"
fi
return 0
}
cat $HISTORY_PATH |while read line
do
if [ -z "$(echo $line |grep "#*#")" ]; then
continue
else
GORUP_NAME=$(urlencode "$(echo $line |awk -F '#*#' '{print $1}')")
NOW_NAME=$(echo $line |awk -F '#*#' '{print $3}')
curl -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X PUT -d '{"name":"'"$NOW_NAME"'"}' http://"$LAN_IP":"$PORT"/proxies/"$GORUP_NAME" >/dev/null 2>&1
fi
done >/dev/null 2>&1
curl -m 5 --retry 2 -H "Authorization: Bearer ${SECRET}" -H "Content-Type:application/json" -X DELETE http://"$LAN_IP":"$PORT"/connections >/dev/null 2>&1
fi
@@ -0,0 +1,28 @@
# Preserve every explicit user choice while restoring missing per-protocol defaults.
with(
.sniffer |
select((has("enable") | not) or (.enable == true)) |
select((has("override-destination") | not) or (."override-destination" == true));
with(
select(has("force-dns-mapping") | not);
."force-dns-mapping" = true
) |
with(
select(has("parse-pure-ip") | not);
."parse-pure-ip" = true
) |
with(
.sniff | select(has("TLS"));
with(
.TLS | select(type == "!!map" and (has("override-destination") | not));
."override-destination" = true
)
) |
with(
.sniff | select(has("QUIC"));
with(
.QUIC | select(type == "!!map" and (has("override-destination") | not));
."override-destination" = true
)
)
)
+63
View File
@@ -0,0 +1,63 @@
time.windows.com
time.nist.gov
time.apple.com
time.asia.apple.com
cn.ntp.org.cn
edu.ntp.org.cn
hk.ntp.org.cn
tw.ntp.org.cn
us.ntp.org.cn
sgp.ntp.org.cn
kr.ntp.org.cn
jp.ntp.org.cn
de.ntp.org.cn
ina.ntp.org.cn
0.openwrt.pool.ntp.org
1.openwrt.pool.ntp.org
2.openwrt.pool.ntp.org
3.openwrt.pool.ntp.org
ntp.aliyun.com
ntp1.aliyun.com
ntp2.aliyun.com
ntp3.aliyun.com
ntp4.aliyun.com
ntp5.aliyun.com
ntp6.aliyun.com
ntp7.aliyun.com
time1.aliyun.com
time2.aliyun.com
time3.aliyun.com
time4.aliyun.com
time5.aliyun.com
time6.aliyun.com
time7.aliyun.com
s1c.time.edu.cn
s2m.time.edu.cn
s1b.time.edu.cn
s1e.time.edu.cn
s2a.time.edu.cn
s2b.time.edu.cn
time1.apple.com
time2.apple.com
time3.apple.com
time4.apple.com
time5.apple.com
time6.apple.com
time7.apple.com
time1.google.com
time2.google.com
time3.google.com
time4.google.com
.music.163.com
.music.126.net
man.netease.com
api.iplay.163.com
ac.dun.163yun.com
mr.da.netease.com
crash.163.com
imap.163.com
msftconnecttest.com
msftncsi.com
captive.qq.com
ssl.ptlogin2.qq.com
login.qq.com
+643
View File
@@ -0,0 +1,643 @@
#!/bin/sh
LOG_FILE="/tmp/clashoo_component_update.log"
RUN_FILE="/var/run/clashoo_component_update"
STATE_FILE="/tmp/clashoo_component_update_state"
TMP_DIR="/tmp/clashoo-component-update"
APK_UPGRADE_FLAG="/var/run/clashoo_package_upgrade"
FEED_BASE_URL="https://down.dllkids.xyz/openwrt-feed/clashoo"
GITHUB_API_URL="https://api.github.com/repos/kenzok8/openwrt-clashoo/releases/latest"
ENV_GITHUB_PROXY_PREFIX="${GITHUB_PROXY_PREFIX:-}"
UCI_GITHUB_PROXY_PREFIX="$(uci -q get clashoo.config.core_mirror_prefix 2>/dev/null)"
GITHUB_PROXY_PREFIX="${UCI_GITHUB_PROXY_PREFIX:-${ENV_GITHUB_PROXY_PREFIX:-https://ghfast.top/}}"
case "$GITHUB_PROXY_PREFIX" in
*/) ;;
*) GITHUB_PROXY_PREFIX="${GITHUB_PROXY_PREFIX}/" ;;
esac
CONNECT_TIMEOUT="${CONNECT_TIMEOUT:-8}"
REQUEST_TIMEOUT="${REQUEST_TIMEOUT:-20}"
LOW_SPEED_TIME="${LOW_SPEED_TIME:-30}"
LOW_SPEED_LIMIT="${LOW_SPEED_LIMIT:-1024}"
trap 'rm -f "$APK_UPGRADE_FLAG"' EXIT
log() {
mkdir -p "$(dirname "$LOG_FILE")" "$(dirname "$STATE_FILE")" >/dev/null 2>&1
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >>"$LOG_FILE"
printf 'component=%s\nstatus=%s\nmessage=%s\n' "${COMPONENT:-}" "${STATUS:-running}" "$*" >"$STATE_FILE"
}
finish() {
rc="$1"
msg="$2"
STATUS="success"
[ "$rc" -eq 0 ] || STATUS="failed"
log "$msg"
rm -f "$RUN_FILE" "$APK_UPGRADE_FLAG"
exit "$rc"
}
# In kernel-only mode there is no transparent proxy, so component downloads
# would go out direct and stall behind the GFW. Route them through the running
# core (shared logic in proxy_lib.sh). Normal mode returns empty -> TPROXY.
# Guard the source: on a stale install missing proxy_lib.sh, an unguarded `.`
# under `set -e` kills the whole update silently (empty log, nothing happens).
# Fall back to no proxy detection (direct / TPROXY) instead of dying.
if [ -f /usr/share/clashoo/update/proxy_lib.sh ]; then
. /usr/share/clashoo/update/proxy_lib.sh
else
clashoo_detect_proxy() { :; }
fi
detect_proxy() { clashoo_detect_proxy; }
fetch_text() {
url="$1"
proxy="$(detect_proxy)"
if command -v curl >/dev/null 2>&1; then
curl -fsSL --connect-timeout "$CONNECT_TIMEOUT" --max-time "$REQUEST_TIMEOUT" ${proxy:+--proxy "$proxy"} "$url"
return $?
fi
if command -v wget >/dev/null 2>&1; then
[ -n "$proxy" ] && { http_proxy="$proxy" https_proxy="$proxy" wget -qO- --timeout="$REQUEST_TIMEOUT" --tries=1 "$url"; return $?; }
wget -qO- --timeout="$REQUEST_TIMEOUT" --tries=1 "$url"
return $?
fi
return 127
}
download_file() {
url="$1"
out="$2"
proxy="$(detect_proxy)"
if command -v curl >/dev/null 2>&1; then
curl -fL --connect-timeout "$CONNECT_TIMEOUT" --speed-time "$LOW_SPEED_TIME" --speed-limit "$LOW_SPEED_LIMIT" ${proxy:+--proxy "$proxy"} "$url" -o "$out"
return $?
fi
if [ -n "$proxy" ]; then
http_proxy="$proxy" https_proxy="$proxy" wget -qO "$out" --timeout="$REQUEST_TIMEOUT" --tries=1 "$url"
return $?
fi
wget -qO "$out" --timeout="$REQUEST_TIMEOUT" --tries=1 "$url"
}
download_url() {
url="$1"
case "$url" in
https://github.com/*)
printf '%s%s\n' "$GITHUB_PROXY_PREFIX" "$url"
;;
*)
printf '%s\n' "$url"
;;
esac
}
root_free_kb() {
df -k / 2>/dev/null | awk 'NR==2 {print $4}'
}
kb_to_mib() {
awk "BEGIN {printf \"%.0f\", (${1:-0}) / 1024}"
}
ensure_root_space() {
need_kb="$1"
free_kb="$(root_free_kb)"
[ -n "$free_kb" ] || return 0
[ "$free_kb" -ge "$need_kb" ] && return 0
finish 1 "根分区空间不足:可用 $(kb_to_mib "$free_kb") MiB,至少需要 $(kb_to_mib "$need_kb") MiB"
}
log_install_error() {
file="$1"
[ -s "$file" ] || return 0
tail -8 "$file" 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && log "$line"
done
}
detect_manager() {
if command -v opkg >/dev/null 2>&1; then
echo opkg
return
fi
if command -v apk >/dev/null 2>&1; then
echo apk
return
fi
echo unsupported
}
installed_package_version() {
pkg="$1"
if command -v opkg >/dev/null 2>&1; then
opkg status "$pkg" 2>/dev/null | awk -F': ' '/^Version:/{print $2; exit}'
return
fi
if command -v apk >/dev/null 2>&1; then
apk list -I "$pkg" 2>/dev/null | awk '{print $1; exit}' | sed -n "s/^${pkg}-//p"
fi
}
package_installed() {
pkg="$1"
if command -v opkg >/dev/null 2>&1; then
opkg status "$pkg" 2>/dev/null | awk '
$1 == "Status:" && $2 == "install" && $NF == "installed" { found = 1 }
END { exit !found }
'
return
fi
apk info -e "$pkg" >/dev/null 2>&1
}
apk_install() {
proxy="$(detect_proxy)"
if [ -n "$proxy" ]; then
http_proxy="$proxy" https_proxy="$proxy" apk add --allow-untrusted --timeout 30 "$@"
else
apk add --allow-untrusted --timeout 30 "$@"
fi
}
apk_bundle_install() {
printf '%s\n' "$$" >"$APK_UPGRADE_FLAG"
apk_install "$@"
rc=$?
rm -f "$APK_UPGRADE_FLAG"
return "$rc"
}
verify_downloaded_package() {
file="$1"
[ -s "$file" ] || return 1
if [ "$PM" = "apk" ]; then
apk verify --allow-untrusted "$file" >/dev/null 2>&1
return
fi
gzip -t "$file" >/dev/null 2>&1
}
download_package_file() {
url="$1"
out="$2"
name="$3"
download_file "$(download_url "$url")" "$out" || finish 1 "下载 ${name} 失败"
verify_downloaded_package "$out" || finish 1 "${name} 文件校验失败"
}
detect_arch() {
pm="$1"
if [ "$pm" = "opkg" ]; then
opkg print-architecture 2>/dev/null | awk '/^arch / {print $2}' | tail -n 1
return
fi
# apk: DISTRIB_ARCH is authoritative (e.g. aarch64_cortex-a53); apk --print-arch
# only gives the generic "aarch64" which won't match the .apk package suffix.
if [ -r /etc/openwrt_release ]; then
a="$(sed -n "s/^DISTRIB_ARCH=['\"]\\([^'\"]*\\)['\"]$/\\1/p" /etc/openwrt_release | head -n 1)"
[ -n "$a" ] && { printf '%s\n' "$a"; return; }
fi
apk --print-arch 2>/dev/null
}
detect_sdk() {
[ -r /etc/openwrt_release ] || return 1
release="$(sed -n "s/^DISTRIB_RELEASE=['\"]\\([^'\"]*\\)['\"]$/\\1/p" /etc/openwrt_release | head -n 1)"
[ -n "$release" ] || return 1
printf '%s\n' "$release" | grep -Eo '[0-9]+\.[0-9]+' | head -n 1
}
append_unique_word() {
value="$1"
list="$2"
[ -n "$value" ] || {
printf '%s\n' "$list"
return
}
case " $list " in
*" $value "*) ;;
*) list="${list}${list:+ }${value}" ;;
esac
printf '%s\n' "$list"
}
build_sdk_candidates() {
pm="$1"
candidates=""
detected_sdk="$(detect_sdk || true)"
candidates="$(append_unique_word "$detected_sdk" "$candidates")"
if [ "$pm" = "opkg" ]; then
for sdk in 24.10 23.05 22.03 21.02; do
candidates="$(append_unique_word "$sdk" "$candidates")"
done
else
for sdk in 25.12 24.10; do
candidates="$(append_unique_word "$sdk" "$candidates")"
done
fi
printf '%s\n' "$candidates"
}
find_manifest_value() {
key="$1"
manifest_text="$2"
printf '%s\n' "$manifest_text" | sed -n "s/^${key}=//p" | head -n 1
}
load_manifest_urls() {
sdk="$1"
arch="$2"
manifest_url="${FEED_BASE_URL}/${sdk}/${arch}/manifest-clashoo.txt"
manifest_text="$(fetch_text "$manifest_url" || true)"
[ -n "$manifest_text" ] || return 1
core_file="$(find_manifest_value "core" "$manifest_text")"
luci_file="$(find_manifest_value "luci" "$manifest_text")"
i18n_file="$(find_manifest_value "i18n" "$manifest_text")"
[ -n "$core_file" ] || return 1
[ -n "$luci_file" ] || return 1
case "$core_file" in *."$EXT") ;; *) return 1 ;; esac
case "$luci_file" in *."$EXT") ;; *) return 1 ;; esac
case "$i18n_file" in ""|*."$EXT") ;; *) return 1 ;; esac
CORE_URL="${FEED_BASE_URL}/${sdk}/${arch}/${core_file}"
LUCI_URL="${FEED_BASE_URL}/${sdk}/${arch}/${luci_file}"
I18N_URL=""
[ -n "$i18n_file" ] && I18N_URL="${FEED_BASE_URL}/${sdk}/${arch}/${i18n_file}"
SOURCE_LABEL="R2 feed ${sdk}/${arch}"
return 0
}
load_github_urls() {
arch="$1"
ext="$2"
payload="$(fetch_text "$GITHUB_API_URL" || true)"
[ -n "$payload" ] || return 1
urls="$(printf '%s\n' "$payload" | sed -n 's/.*"browser_download_url":[[:space:]]*"\([^"]*\)".*/\1/p')"
[ -n "$urls" ] || return 1
if [ "$ext" = "apk" ]; then
CORE_URL="$(printf '%s\n' "$urls" | grep -E '/clashoo-[^-]+.*-r[0-9]+-'"$arch"'\.apk$' | head -n 1)"
LUCI_URL="$(printf '%s\n' "$urls" | grep -E '/luci-app-clashoo-[^-]+.*-r[0-9]+-('"$arch"'|all)\.apk$' | head -n 1)"
I18N_URL="$(printf '%s\n' "$urls" | grep -E '/luci-i18n-clashoo-zh-cn-[^-]+.*-r[0-9]+-('"$arch"'|all)\.apk$' | head -n 1)"
else
CORE_URL="$(printf '%s\n' "$urls" | grep -E '/clashoo_.*_'"$arch"'\.ipk$' | head -n 1)"
LUCI_URL="$(printf '%s\n' "$urls" | grep -E '/luci-app-clashoo_.*_all\.ipk$' | head -n 1)"
I18N_URL="$(printf '%s\n' "$urls" | grep -E '/luci-i18n-clashoo-zh-cn_.*_all\.ipk$' | head -n 1)"
fi
[ -n "$CORE_URL" ] || return 1
[ -n "$LUCI_URL" ] || return 1
SOURCE_LABEL="GitHub latest"
return 0
}
package_version_from_url() {
file="${1##*/}"
case "$file" in
clashoo_*_"$ARCH".ipk)
v="${file#clashoo_}"
v="${v%_${ARCH}.ipk}"
printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
;;
clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^clashoo_\(.*\)_[^_][^_]*\.ipk$/\1/p' | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/' ;;
luci-app-clashoo_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-app-clashoo_\(.*\)_all\.ipk$/\1/p' ;;
luci-i18n-clashoo-zh-cn_*.ipk) printf '%s\n' "$file" | sed -n 's/^luci-i18n-clashoo-zh-cn_\(.*\)_all\.ipk$/\1/p' ;;
clashoo-*.apk)
v="$(printf '%s\n' "$file" | sed -n -e 's/^clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1)"
printf '%s\n' "$v" | sed 's/^\([0-9][0-9][0-9][0-9]\.[0-9][0-9]*\.[0-9][0-9]*\)\./\1~/'
;;
luci-app-clashoo-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-app-clashoo-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
luci-i18n-clashoo-zh-cn-*.apk) printf '%s\n' "$file" | sed -n -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)-.*\.apk$/\1/p' -e 's/^luci-i18n-clashoo-zh-cn-\(.*-r[0-9][0-9]*\)\.apk$/\1/p' | head -n 1 ;;
*) printf '%s\n' "$file" ;;
esac
}
resolve_bundle_urls() {
PM="$(detect_manager)"
[ "$PM" != "unsupported" ] || return 1
ARCH="$(detect_arch "$PM")"
[ -n "$ARCH" ] || return 1
EXT="ipk"
[ "$PM" = "apk" ] && EXT="apk"
CORE_URL=""
LUCI_URL=""
I18N_URL=""
SOURCE_LABEL=""
SDK_CANDIDATES="$(build_sdk_candidates "$PM")"
for sdk in $SDK_CANDIDATES; do
if load_manifest_urls "$sdk" "$ARCH"; then
return 0
fi
done
load_github_urls "$ARCH" "$EXT"
}
backup_config() {
ts="$(date '+%Y%m%d-%H%M%S')"
BACKUP_DIR="/etc/clashoo/backup/component-upgrade-${ts}"
mkdir -p "$BACKUP_DIR/etc-config" "$BACKUP_DIR/etc-clashoo" >/dev/null 2>&1 || return 1
[ -r /etc/config/clashoo ] && cp -a /etc/config/clashoo "$BACKUP_DIR/etc-config/clashoo"
# 只备份用户配置;Model.bin 等可重新下载的大文件不入备份,避免每次更新
# 复制 ~5MB 的同一模型,把备份目录撑大、堆满本就紧张的根分区。
for path in \
/etc/clashoo/config.yaml \
/etc/clashoo/config.json \
/etc/clashoo/*.yaml \
/etc/clashoo/*.yml \
/etc/clashoo/*.json
do
[ -e "$path" ] || continue
cp -a "$path" "$BACKUP_DIR/etc-clashoo/" 2>/dev/null || true
done
log "已备份配置:${BACKUP_DIR}"
# 只保留最近 1 个备份,删除更早的,避免无限堆积占满磁盘。
ls -dt /etc/clashoo/backup/component-upgrade-* 2>/dev/null | tail -n +2 | while read -r _old; do
[ -n "$_old" ] && rm -rf "$_old" 2>/dev/null
done
}
restore_config_backup() {
[ -n "${BACKUP_DIR:-}" ] || return 0
[ -r "$BACKUP_DIR/etc-config/clashoo" ] && cp -a "$BACKUP_DIR/etc-config/clashoo" /etc/config/clashoo
if [ -d "$BACKUP_DIR/etc-clashoo" ]; then
mkdir -p /etc/clashoo
cp -a "$BACKUP_DIR/etc-clashoo/." /etc/clashoo/ 2>/dev/null || true
fi
uci commit clashoo >/dev/null 2>&1 || true
}
clashoo_was_running() {
/etc/init.d/clashoo status >/dev/null 2>&1 && return 0
/etc/init.d/sing-box status >/dev/null 2>&1 && return 0
return 1
}
restart_web_stack() {
# reload (not restart) rpcd so the LuCI login session survives a plugin
# update — rpcd restart drops every ubus session and forces a re-login
# (issue #12). reload still picks up the new ucode RPC backend. uhttpd needs
# nothing: updated static assets are served on the next request.
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
}
# which: clashoo(仅核心)/ luciluci-app + 语言包)。两者拆开,clashoo
# 核心更新频率高于 LuCI,分别更新便于定位失败。
run_pkg_update() {
which="$1"
log "正在检查组件包"
if ! resolve_bundle_urls; then
finish 1 "未找到适配当前设备的组件包"
fi
log "更新来源:${SOURCE_LABEL}"
core_ver="$(package_version_from_url "$CORE_URL")"
luci_ver="$(package_version_from_url "$LUCI_URL")"
i18n_ver=""
[ -z "$I18N_URL" ] || i18n_ver="$(package_version_from_url "$I18N_URL")"
core_ver_before="$(installed_package_version clashoo)"
apk_core_ready() {
package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ]
}
apk_luci_ready() {
package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
&& { [ -z "$I18N_URL" ] || { package_installed luci-i18n-clashoo-zh-cn \
&& [ "$(installed_package_version luci-i18n-clashoo-zh-cn)" = "$i18n_ver" ]; }; } \
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ]
}
apk_bundle_ready() {
apk_core_ready && apk_luci_ready
}
apk_update_ready() {
if [ "$which" = "clashoo" ]; then
apk_bundle_ready
else
package_installed clashoo && apk_luci_ready
fi
}
if [ "$which" = "clashoo" ]; then
log "目标版本:Clashoo 核心 ${core_ver}"
cur_ver="$(installed_package_version clashoo)"
if [ "$PM" = "apk" ] && apk_update_ready; then
finish 0 "Clashoo 核心已是最新版本"
elif [ "$PM" != "apk" ] && [ -n "$core_ver" ] && [ "$cur_ver" = "$core_ver" ]; then
finish 0 "Clashoo 核心已是最新版本"
fi
else
log "目标版本:客户端 ${luci_ver}"
cur_ver="$(installed_package_version luci-app-clashoo)"
if [ "$PM" = "apk" ] && apk_update_ready; then
finish 0 "客户端已是最新版本"
elif [ "$PM" != "apk" ] && [ -n "$luci_ver" ] && [ "$cur_ver" = "$luci_ver" ]; then
finish 0 "客户端已是最新版本"
fi
fi
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR" || finish 1 "创建临时目录失败"
was_running=0
clashoo_was_running && was_running=1
backup_config || finish 1 "备份配置失败"
apk_core_file=""
if [ "$PM" = "apk" ]; then
log "正在下载组件包"
if [ "$which" = "clashoo" ] || ! package_installed clashoo; then
ensure_root_space 98304
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
apk_core_file="$TMP_DIR/core.${EXT}"
fi
download_package_file "$LUCI_URL" "$TMP_DIR/luci.${EXT}" "luci-app-clashoo"
if [ -n "$I18N_URL" ]; then
download_package_file "$I18N_URL" "$TMP_DIR/i18n.${EXT}" "语言包"
fi
elif [ "$which" = "clashoo" ]; then
log "正在下载 Clashoo 核心"
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
else
log "正在下载客户端(LuCI + 语言包)"
download_package_file "$LUCI_URL" "$TMP_DIR/luci.${EXT}" "luci-app-clashoo"
if [ -n "$I18N_URL" ]; then
download_package_file "$I18N_URL" "$TMP_DIR/i18n.${EXT}" "语言包"
fi
fi
if [ "$PM" != "apk" ] && [ "$which" = "clashoo" ]; then
ensure_root_space 98304
fi
if [ "$which" = "clashoo" ]; then
log "正在安装 Clashoo 核心"
if [ "$PM" = "opkg" ]; then
opkg install --force-downgrade "$TMP_DIR/core.${EXT}" >"$TMP_DIR/install.log" 2>&1; rc=$?
else
apk_bundle_install ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
fi
else
log "正在安装客户端"
if [ "$PM" = "opkg" ]; then
opkg install --force-downgrade "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
else
apk_bundle_install ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >"$TMP_DIR/install.log" 2>&1; rc=$?
fi
fi
if [ "$rc" -ne 0 ]; then
if [ "$PM" = "apk" ] && apk_update_ready; then
log "apk 报告了其它软件包的错误,本次组件已升级到目标版本"
rc=0
else
log_install_error "$TMP_DIR/install.log"
log "安装失败,正在恢复配置备份"
restore_config_backup
finish "$rc" "组件更新失败,已停止操作,请查看组件更新日志"
fi
fi
validation_failed() {
log "安装校验失败,正在恢复配置备份"
restore_config_backup
finish 1 "组件安装校验失败,已停止操作,请查看组件更新日志"
}
if [ "$PM" = "apk" ]; then
if ! apk_update_ready; then
log "安装校验失败,正在安全重装组件包"
if [ -z "$apk_core_file" ] && ! package_installed clashoo; then
ensure_root_space 98304
download_package_file "$CORE_URL" "$TMP_DIR/core.${EXT}" "clashoo"
apk_core_file="$TMP_DIR/core.${EXT}"
fi
apk_bundle_install --force-reinstall ${apk_core_file:+"$apk_core_file"} "$TMP_DIR/luci.${EXT}" \
${I18N_URL:+"$TMP_DIR/i18n.${EXT}"} >>"$TMP_DIR/install.log" 2>&1; repair_rc=$?
if [ "$repair_rc" -ne 0 ]; then
log_install_error "$TMP_DIR/install.log"
validation_failed
fi
apk_update_ready || validation_failed
fi
elif [ "$which" = "clashoo" ]; then
package_installed clashoo && [ "$(installed_package_version clashoo)" = "$core_ver" ] \
&& [ -x /etc/init.d/clashoo ] && [ -x /usr/share/clashoo/update/component_update.sh ] \
|| validation_failed
else
package_installed luci-app-clashoo && [ "$(installed_package_version luci-app-clashoo)" = "$luci_ver" ] \
&& [ -r /usr/share/luci/menu.d/luci-app-clashoo.json ] \
&& [ -r /usr/share/rpcd/ucode/luci.clashoo ] \
|| validation_failed
[ -z "$I18N_URL" ] || package_installed luci-i18n-clashoo-zh-cn || validation_failed
fi
# APK component updates install the LuCI bundle in the same transaction.
if [ "$which" = "luci" ] || [ "$PM" = "apk" ]; then
log "正在刷新 LuCI 服务"
restart_web_stack
fi
core_ver_after="$(installed_package_version clashoo)"
restart_clashoo=0
[ "$which" = "clashoo" ] && restart_clashoo=1
[ "$PM" = "apk" ] && [ "$core_ver_before" != "$core_ver_after" ] && restart_clashoo=1
if [ "$restart_clashoo" -eq 1 ] && [ "$was_running" -eq 1 ] && [ "$(uci -q get clashoo.config.enable 2>/dev/null)" = "1" ]; then
log "Clashoo 原本运行中,正在重启服务"
sh /usr/share/clashoo/rpc/rpc_async.sh restart >/dev/null 2>&1 || /etc/init.d/clashoo restart >/dev/null 2>&1 || true
elif [ "$restart_clashoo" -eq 1 ]; then
log "Clashoo 原本未运行,保持停止状态"
fi
finish 0 "组件更新完成"
}
run_core_update() {
dcore="$1"
label="$2"
log "正在更新 ${label}"
# Pass the target as an arg so core_download refreshes only that binary
# without switching the active kernel. It restarts only if the target is the
# currently-running core (see core_download.sh finalize). Switching kernels
# stays a separate, explicit user action.
touch /var/run/core_update
sh /usr/share/clashoo/update/core_download.sh "$dcore" >>/tmp/clash_update.txt 2>&1
rc=$?
rm -f /var/run/core_update
[ "$rc" -eq 0 ] || finish "$rc" "${label} 更新失败"
finish 0 "${label} 更新完成"
}
run_lgbm_update() {
log "正在更新 Smart LightGBM 模型"
sh /usr/share/clashoo/update/lgbm_update.sh >/tmp/lgbm_update.log 2>&1
rc=$?
tail -5 /tmp/lgbm_update.log 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && log "$line"
done
[ "$rc" -eq 0 ] || finish "$rc" "LightGBM 模型更新失败"
finish 0 "LightGBM 模型更新完成"
}
run_china_update() {
log "正在更新大陆白名单"
sh /usr/share/clashoo/update/update_china_ip.sh >>/tmp/clash_update.txt 2>&1
rc=$?
tail -6 /tmp/clash_update.txt 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && printf '%s\n' "$line" | grep -q '白名单' && log "$line"
done
[ "$rc" -eq 0 ] || finish "$rc" "大陆白名单更新失败"
finish 0 "大陆白名单更新完成"
}
run_geoip_update() {
log "正在更新 GeoIP / GeoSite"
touch /var/run/geoip_update
sh /usr/share/clashoo/update/geoip.sh >/tmp/geoip_update.txt 2>&1
rc=$?
rm -f /var/run/geoip_update
tail -8 /tmp/geoip_update.txt 2>/dev/null | while IFS= read -r line; do
[ -n "$line" ] && log "$line"
done
[ "$rc" -eq 0 ] || finish "$rc" "GeoIP / GeoSite 更新失败"
finish 0 "GeoIP / GeoSite 更新完成"
}
COMPONENT="${1:-}"
VARIANT="${2:-}" # mihomo/singbox: stable | alpha
[ -n "$COMPONENT" ] || {
echo "usage: $0 <clashoo|luci|mihomo|singbox|smart|lgbm|china|geoip> [stable|alpha]"
exit 2
}
mkdir -p "$(dirname "$RUN_FILE")" >/dev/null 2>&1
printf '%s\n' "$COMPONENT" >"$RUN_FILE"
STATUS="running"
log "组件更新任务启动:${COMPONENT}${VARIANT:+ ($VARIANT)}"
case "$COMPONENT" in
clashoo) run_pkg_update clashoo ;;
luci) run_pkg_update luci ;;
mihomo)
if [ "$VARIANT" = "alpha" ]; then run_core_update 3 "mihomo Alpha 版"
else run_core_update 2 "mihomo 稳定版"; fi ;;
singbox)
if [ "$VARIANT" = "alpha" ]; then run_core_update 5 "sing-box Alpha 版"
else run_core_update 4 "sing-box 稳定版"; fi ;;
smart) run_core_update 1 "mihomo Smart 版" ;;
lgbm) run_lgbm_update ;;
china) run_china_update ;;
geoip) run_geoip_update ;;
*) finish 2 "未知组件:${COMPONENT}" ;;
esac
File diff suppressed because it is too large Load Diff
+267
View File
@@ -0,0 +1,267 @@
#!/bin/sh
LOG_FILE="/tmp/geoip_update.txt"
TMP_DIR="/tmp/clash_geoip_$$"
MMDB_MIN_SIZE=2000000
GEOSITE_MIN_SIZE=2000000
geoip_source="$(uci -q get clashoo.config.geoip_source 2>/dev/null)"
license_key="$(uci -q get clashoo.config.license_key 2>/dev/null)"
cfg_mmdb_url="$(uci -q get clashoo.config.geoip_mmdb_url 2>/dev/null)"
cfg_geosite_url="$(uci -q get clashoo.config.geosite_url 2>/dev/null)"
cfg_geoip_dat_url="$(uci -q get clashoo.config.geoip_dat_url 2>/dev/null)"
cfg_geoip_asn_url="$(uci -q get clashoo.config.geoip_asn_url 2>/dev/null)"
DEFAULT_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
DEFAULT_GEOSITE_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geosite.dat"
DEFAULT_GEOIP_DAT_URL="https://raw.githubusercontent.com/MetaCubeX/meta-rules-dat/release/geoip.dat"
DEFAULT_GEOIP_ASN_URL="https://github.com/xishang0128/geoip/releases/download/latest/GeoLite2-ASN.mmdb"
LOYALSOLDIER_MMDB_URL="https://raw.githubusercontent.com/Loyalsoldier/geoip/release/Country.mmdb"
LOYALSOLDIER_GEOSITE_URL="https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geosite.dat"
LOYALSOLDIER_GEOIP_DAT_URL="https://raw.githubusercontent.com/Loyalsoldier/v2ray-rules-dat/release/geoip.dat"
# v2fly ships no Country.mmdb — borrow Loyalsoldier's for the mmdb slot
V2FLY_GEOSITE_URL="https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat"
V2FLY_GEOIP_DAT_URL="https://github.com/v2fly/geoip/releases/latest/download/geoip.dat"
log() {
echo " $(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
cleanup() {
rm -rf "$TMP_DIR" >/dev/null 2>&1
rm -f /var/run/geoip_update >/dev/null 2>&1
}
config_needs_geosite() {
_cfg="/etc/clashoo/config.yaml"
[ -f "$_cfg" ] || return 1
grep -Eq "^[[:space:]]*-[[:space:]]*[\"']?GEOSITE,|^[[:space:]]*geosite:[[:space:]]*|^[[:space:]]*'geosite:|^[[:space:]]*-[[:space:]]*[\"']?geosite:" "$_cfg"
}
# Shared port detection (handles smart core + sing-box profile port); keep the
# local anonymity probe so we skip a coexisting plugin's auth-locked proxy.
. /usr/share/clashoo/update/proxy_lib.sh
detect_proxy() {
proxy="$(clashoo_detect_proxy)"
[ -n "$proxy" ] || return 0
# Only use it if it actually proxies anonymously. A coexisting plugin
# (e.g. OpenClash on the same 7890) may hold this port behind auth and
# answer HTTP 407 — in that case fall through to the mirrors instead.
if command -v curl >/dev/null 2>&1; then
curl -fsS --proxy "$proxy" --connect-timeout 3 --max-time 5 -o /dev/null \
http://www.gstatic.com/generate_204 >/dev/null 2>&1 || return 0
fi
echo "$proxy"
}
_fetch() {
url="$1"; target="$2"; proxy="$3"
if command -v curl >/dev/null 2>&1; then
if [ -n "$proxy" ]; then
curl --proxy "$proxy" -fsSL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$url" -o "$target"
else
curl -fsSL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$url" -o "$target"
fi
return $?
fi
wget -q --timeout=120 --no-check-certificate --user-agent="Clash/OpenWRT" "$url" -O "$target"
}
# 多源拉取:本机代理 → 国内镜像(gh-proxy / ghfast / jsdelivr)→ 原 URL 兜底
download_to() {
url="$1"; target="$2"
[ -z "$url" ] && return 1
proxy="$(detect_proxy)"
if [ -n "$proxy" ]; then
_fetch "$url" "$target" "$proxy" && return 0
fi
case "$url" in
https://raw.githubusercontent.com/*)
rest="${url#https://raw.githubusercontent.com/}"
owner="${rest%%/*}"; r1="${rest#*/}"
repo="${r1%%/*}"; r2="${r1#*/}"
branch="${r2%%/*}"; path="${r2#*/}"
for m in \
"https://gh-proxy.com/${url}" \
"https://ghfast.top/${url}" \
"https://cdn.jsdelivr.net/gh/${owner}/${repo}@${branch}/${path}"; do
_fetch "$m" "$target" "" && return 0
done
;;
https://github.com/*)
for m in \
"https://gh-proxy.com/${url}" \
"https://ghfast.top/${url}"; do
_fetch "$m" "$target" "" && return 0
done
;;
esac
_fetch "$url" "$target" ""
}
download_optional() {
url="$1"
target="$2"
name="$3"
tmp_target="${TMP_DIR}/$(basename "$target").tmp"
if [ -z "$url" ]; then
log "$name skip (url empty)"
return 0
fi
rm -f "$tmp_target" >/dev/null 2>&1
if download_to "$url" "$tmp_target"; then
mv -f "$tmp_target" "$target" >/dev/null 2>&1 || return 1
chmod 644 "$target" >/dev/null 2>&1
log "$name updated"
return 0
fi
rm -f "$tmp_target" >/dev/null 2>&1
log "$name update failed"
return 0
}
download_geosite() {
url="$1"
tmp_target="${TMP_DIR}/GeoSite.dat.tmp"
size=0
if [ -z "$url" ]; then
log "GeoSite.dat skip (url empty)"
return 0
fi
rm -f "$tmp_target" >/dev/null 2>&1
if ! download_to "$url" "$tmp_target"; then
rm -f "$tmp_target" >/dev/null 2>&1
log "GeoSite.dat update failed"
return 0
fi
size=$(wc -c <"$tmp_target" 2>/dev/null)
if [ -z "$size" ] || [ "$size" -lt "$GEOSITE_MIN_SIZE" ]; then
log "GeoSite.dat download invalid or incomplete (${size:-0} bytes)"
rm -f "$tmp_target" >/dev/null 2>&1
return 0
fi
mv -f "$tmp_target" /etc/clashoo/GeoSite.dat >/dev/null 2>&1 || return 1
cp -f /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1 || true
chmod 644 /etc/clashoo/GeoSite.dat /etc/clashoo/geosite.dat >/dev/null 2>&1
log "GeoSite.dat updated"
return 0
}
download_mmdb() {
url="$1"
tmp_target="${TMP_DIR}/Country.mmdb.tmp"
size=0
rm -f "$tmp_target" >/dev/null 2>&1
if ! download_to "$url" "$tmp_target"; then
rm -f "$tmp_target" >/dev/null 2>&1
return 1
fi
size=$(wc -c <"$tmp_target" 2>/dev/null)
if [ -z "$size" ] || [ "$size" -lt "$MMDB_MIN_SIZE" ]; then
log "Country.mmdb download invalid or incomplete (${size:-0} bytes)"
rm -f "$tmp_target" >/dev/null 2>&1
return 1
fi
mv -f "$tmp_target" /etc/clashoo/Country.mmdb >/dev/null 2>&1 || return 1
chmod 644 /etc/clashoo/Country.mmdb >/dev/null 2>&1
return 0
}
trap cleanup EXIT INT TERM
mkdir -p "$TMP_DIR" /etc/clashoo >/dev/null 2>&1
rm -f /var/run/geoip_down_complete >/dev/null 2>&1
: > /var/run/geoip_update
: > "$LOG_FILE"
log "GeoIP 更新任务启动"
mmdb_url=""
geosite_url=""
geoip_dat_url=""
geoip_asn_url=""
case "$geoip_source" in
1)
if [ -z "$license_key" ]; then
log "MaxMind source selected but license key is empty"
exit 1
fi
log "Updating Country.mmdb from MaxMind"
if ! download_to "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${license_key}&suffix=tar.gz" "$TMP_DIR/geoip.tar.gz"; then
log "MaxMind download failed"
exit 1
fi
if ! tar zxf "$TMP_DIR/geoip.tar.gz" -C "$TMP_DIR" >/dev/null 2>&1; then
log "MaxMind archive extract failed"
exit 1
fi
mmdb_file="$(ls "$TMP_DIR"/GeoLite2-Country_*/GeoLite2-Country.mmdb 2>/dev/null | head -n 1)"
if [ -z "$mmdb_file" ] || [ ! -f "$mmdb_file" ]; then
log "MaxMind Country.mmdb not found in archive"
exit 1
fi
cp -f "$mmdb_file" /etc/clashoo/Country.mmdb
chmod 644 /etc/clashoo/Country.mmdb >/dev/null 2>&1
log "Country.mmdb updated"
;;
3)
# Loyalsoldier (default)
mmdb_url="$LOYALSOLDIER_MMDB_URL"
geosite_url="$LOYALSOLDIER_GEOSITE_URL"
geoip_dat_url="$LOYALSOLDIER_GEOIP_DAT_URL"
geoip_asn_url="$DEFAULT_GEOIP_ASN_URL"
;;
5)
# v2fly (mmdb borrowed from Loyalsoldier)
mmdb_url="$LOYALSOLDIER_MMDB_URL"
geosite_url="$V2FLY_GEOSITE_URL"
geoip_dat_url="$V2FLY_GEOIP_DAT_URL"
geoip_asn_url="$DEFAULT_GEOIP_ASN_URL"
;;
4)
# custom — asn falls back to default when left blank
mmdb_url="$cfg_mmdb_url"
geosite_url="$cfg_geosite_url"
geoip_dat_url="$cfg_geoip_dat_url"
geoip_asn_url="${cfg_geoip_asn_url:-$DEFAULT_GEOIP_ASN_URL}"
;;
*)
mmdb_url="${cfg_mmdb_url:-$DEFAULT_MMDB_URL}"
geosite_url="${cfg_geosite_url:-$DEFAULT_GEOSITE_URL}"
geoip_dat_url="${cfg_geoip_dat_url:-$DEFAULT_GEOIP_DAT_URL}"
geoip_asn_url="${cfg_geoip_asn_url:-$DEFAULT_GEOIP_ASN_URL}"
;;
esac
if [ "$geoip_source" != "1" ]; then
log "Updating Country.mmdb"
if ! download_mmdb "$mmdb_url"; then
log "Country.mmdb download failed"
exit 1
fi
log "Country.mmdb updated"
if config_needs_geosite; then
download_geosite "$geosite_url"
else
log "GeoSite.dat skip (no geosite reference in config)"
fi
download_optional "$geoip_dat_url" /etc/clashoo/geoip.dat "geoip.dat"
download_optional "$geoip_asn_url" /etc/clashoo/GeoLite2-ASN.mmdb "GeoLite2-ASN.mmdb"
fi
touch /var/run/geoip_down_complete >/dev/null 2>&1
if pidof mihomo >/dev/null 2>&1 || pidof clash-meta >/dev/null 2>&1; then
log "GeoIP update completed, apply on next Clashoo restart"
fi
log "GeoIP update completed"
exit 0
+52
View File
@@ -0,0 +1,52 @@
#!/bin/sh
MODEL_PATH="/etc/clashoo/Model.bin"
TMP_PATH="/tmp/clash_Model.bin"
LOG_FILE="/tmp/lgbm_update.log"
log() {
echo " $(date '+%Y-%m-%d %H:%M:%S') - $1" >> "$LOG_FILE"
}
: > "$LOG_FILE"
DOWNLOAD_URL=$(uci get clashoo.config.smart_lgbm_url 2>/dev/null)
[ -z "$DOWNLOAD_URL" ] && DOWNLOAD_URL="https://github.com/vernesong/mihomo/releases/download/LightGBM-Model/Model.bin"
log "Start downloading LightGBM model from: $DOWNLOAD_URL"
mkdir -p /etc/clashoo
rm -f "$TMP_PATH" 2>/dev/null
if command -v curl >/dev/null 2>&1; then
curl -fL --connect-timeout 15 --max-time 120 -A "Clash/OpenWRT" "$DOWNLOAD_URL" -o "$TMP_PATH" 2>/dev/null
dl_rc=$?
elif command -v wget >/dev/null 2>&1; then
wget -q --timeout=30 --tries=2 --no-check-certificate -U "Clash/OpenWRT" "$DOWNLOAD_URL" -O "$TMP_PATH" 2>/dev/null
dl_rc=$?
else
log "No curl or wget found"
exit 1
fi
if [ "$dl_rc" -ne 0 ] || [ ! -s "$TMP_PATH" ]; then
log "Download failed (rc=$dl_rc)"
rm -f "$TMP_PATH"
exit 1
fi
if [ -f "$MODEL_PATH" ] && cmp -s "$TMP_PATH" "$MODEL_PATH"; then
log "Model unchanged, no update needed"
rm -f "$TMP_PATH"
exit 0
fi
mv "$TMP_PATH" "$MODEL_PATH" 2>/dev/null
if [ $? -ne 0 ]; then
log "Failed to install model to $MODEL_PATH"
rm -f "$TMP_PATH"
exit 1
fi
log "LightGBM model updated successfully"
exit 0
+36
View File
@@ -0,0 +1,36 @@
#!/bin/sh
STATE_DIR="${CLASHOO_STATE_DIR:-/usr/share/clashbackup}"
UPDATE_SCRIPT="${CLASHOO_UPDATE_SCRIPT:-/usr/share/clashoo/update/update_all.sh}"
LOG_FILE="${CLASHOO_LOG_FILE:-/usr/share/clashoo/clashoo.txt}"
LOCK_DIR="${CLASHOO_LOCK_DIR:-/tmp/clashoo_maintenance.lock}"
NOW="${CLASHOO_NOW:-$(date +%s)}"
stamp() {
tmp="$STATE_DIR/$1.tmp.$$"
printf 'last_run=%s\n' "$NOW" >"$tmp" && mv -f "$tmp" "$STATE_DIR/$1"
}
due() {
hours="$(uci -q get "clashoo.config.$2")"
echo "$hours" | grep -Eq '^[0-9]+$' && [ "$hours" -ge 1 ] || hours=12
last="$(sed -n 's/^last_run=//p' "$STATE_DIR/$1" 2>/dev/null | head -1)"
if ! echo "$last" | grep -Eq '^[0-9]+$' || [ "$last" -gt "$NOW" ]; then
stamp "$1"
return 1
fi
[ $((NOW - last)) -ge $((hours * 3600)) ]
}
mkdir -p "$STATE_DIR" || exit 0
mkdir "$LOCK_DIR" 2>/dev/null || exit 0
trap 'rmdir "$LOCK_DIR" >/dev/null 2>&1' EXIT INT TERM
if [ "$(uci -q get clashoo.config.auto_update)" = "1" ] && due rule_update.status auto_update_time; then
CLASHOO_STATE_DIR="$STATE_DIR" sh "$UPDATE_SCRIPT" >/dev/null 2>&1
fi
if [ "$(uci -q get clashoo.config.auto_clear_log)" = "1" ] && due log_cleanup.status clear_time; then
: >"$LOG_FILE"
stamp log_cleanup.status
fi
+176
View File
@@ -0,0 +1,176 @@
#!/bin/sh
REAL_LOG="/usr/share/clashoo/clashoo_real.txt"
panel="$1"
lang="$(uci -q get luci.main.lang 2>/dev/null)"
STATE_FILE="/tmp/clash_panel_download_state"
RUN_FILE="/var/run/panel_downloading"
DASHBOARD_LINK="/etc/clashoo/dashboard"
[ -n "$panel" ] || panel="$(uci -q get clashoo.config.dashboard_panel 2>/dev/null)"
[ -n "$panel" ] || panel="zashboard"
case "$panel" in
metacubexd)
URLS="https://github.com/MetaCubeX/metacubexd/archive/refs/heads/gh-pages.zip"
TARGET_DIR="/etc/clashoo/dashboard-metacubexd"
;;
yacd)
URLS="https://github.com/MetaCubeX/Yacd-meta/archive/refs/heads/gh-pages.zip https://github.com/haishanh/yacd/archive/refs/heads/gh-pages.zip"
TARGET_DIR="/etc/clashoo/dashboard-yacd"
;;
zashboard)
URLS="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
TARGET_DIR="/etc/clashoo/dashboard-zashboard"
;;
razord)
URLS="https://github.com/MetaCubeX/Razord-meta/archive/refs/heads/gh-pages.zip https://github.com/ayanamist/clash-dashboard/archive/refs/heads/gh-pages.zip"
TARGET_DIR="/etc/clashoo/dashboard-razord"
;;
*)
URLS="https://github.com/Zephyruso/zashboard/releases/latest/download/dist.zip https://github.com/Zephyruso/zashboard/releases/latest/download/dist-cdn-fonts.zip"
TARGET_DIR="/etc/clashoo/dashboard-zashboard"
panel="zashboard"
;;
esac
TMP_ROOT="/tmp/clash_panel_${panel}_$$"
ZIP_FILE="$TMP_ROOT/panel.zip"
UNPACK_DIR="$TMP_ROOT/unpack"
UPDATE_LOG="/tmp/clash_update.txt"
update_log() {
[ -n "$1" ] || return 0
printf '%s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
}
log_msg() {
if [ "$lang" = "en" ]; then
echo "$1" >"$REAL_LOG"
[ "$3" = "1" ] || update_log "$1"
else
echo "$2" >"$REAL_LOG"
[ "$3" = "1" ] || update_log "$2"
fi
}
set_state() {
state="$1"
msg="$2"
echo "${state}:${panel}:${msg}" >"$STATE_FILE"
}
cleanup() {
rm -rf "$TMP_ROOT" >/dev/null 2>&1
rm -f "$RUN_FILE" >/dev/null 2>&1
}
# Route the panel download through the running core in kernel-only mode
# (shared logic in proxy_lib.sh). Normal mode returns empty -> TPROXY.
. /usr/share/clashoo/update/proxy_lib.sh
detect_proxy() { clashoo_detect_proxy; }
download_zip() {
proxy="$(detect_proxy)"
for url in $URLS; do
if [ -n "$proxy" ] && command -v curl >/dev/null 2>&1 &&
curl -fsSL --connect-timeout 15 --max-time 300 --retry 1 \
--proxy "$proxy" -A "Clash/OpenWRT" "$url" -o "$ZIP_FILE"; then
return 0
fi
if wget -q --timeout=60 --no-check-certificate --user-agent="Clash/OpenWRT" "$url" -O "$ZIP_FILE"; then
return 0
fi
done
return 1
}
extract_zip() {
if command -v unzip >/dev/null 2>&1; then
unzip -oq "$ZIP_FILE" -d "$UNPACK_DIR" >/dev/null 2>&1
return $?
fi
if command -v busybox >/dev/null 2>&1 && busybox --list 2>/dev/null | grep -qx "unzip"; then
busybox unzip -oq "$ZIP_FILE" -d "$UNPACK_DIR" >/dev/null 2>&1
return $?
fi
if command -v bsdtar >/dev/null 2>&1; then
bsdtar -xf "$ZIP_FILE" -C "$UNPACK_DIR" >/dev/null 2>&1
return $?
fi
return 2
}
find_web_root() {
if [ -f "$UNPACK_DIR/index.html" ]; then
echo "$UNPACK_DIR"
return 0
fi
index_file="$(find "$UNPACK_DIR" -type f -name index.html 2>/dev/null | head -n 1)"
if [ -n "$index_file" ]; then
dirname "$index_file"
return 0
fi
return 1
}
activate_panel() {
rm -rf "$DASHBOARD_LINK" >/dev/null 2>&1
ln -s "$TARGET_DIR" "$DASHBOARD_LINK" >/dev/null 2>&1 || cp -a "$TARGET_DIR" "$DASHBOARD_LINK" >/dev/null 2>&1
rm -rf /www/luci-static/yacd >/dev/null 2>&1
if [ "$panel" = "yacd" ]; then
ln -s "$TARGET_DIR" /www/luci-static/yacd >/dev/null 2>&1
fi
}
trap cleanup EXIT INT TERM
mkdir -p "$UNPACK_DIR" "$TARGET_DIR" >/dev/null 2>&1
touch "$RUN_FILE"
set_state "downloading" "开始下载"
log_msg "Downloading dashboard panel..." "正在下载面板..."
if ! download_zip; then
set_state "error" "下载失败"
log_msg "Dashboard panel download failed" "面板下载失败"
exit 1
fi
extract_zip
extract_rc=$?
if [ "$extract_rc" -ne 0 ]; then
if [ "$extract_rc" -eq 2 ]; then
set_state "error" "缺少解压工具"
log_msg "Panel unzip tool not found" "缺少解压工具,无法安装面板"
else
set_state "error" "解压失败"
log_msg "Dashboard panel unzip failed" "面板解压失败"
fi
exit 1
fi
SRC_DIR="$(find_web_root)"
if [ -z "$SRC_DIR" ] || [ ! -f "$SRC_DIR/index.html" ]; then
set_state "error" "文件无效"
log_msg "Dashboard panel content invalid" "面板文件无效"
exit 1
fi
rm -rf "$TARGET_DIR"/* >/dev/null 2>&1
cp -a "$SRC_DIR"/. "$TARGET_DIR"/ >/dev/null 2>&1
activate_panel
uci set clashoo.config.dashboard_panel="$panel" >/dev/null 2>&1
uci commit clashoo >/dev/null 2>&1
set_state "success" "安装成功"
log_msg "Dashboard panel installed" "面板安装完成"
sleep 1
log_msg "Clashoo" "Clashoo" 1
exit 0
@@ -0,0 +1,65 @@
# shellcheck shell=sh
# Shared proxy detection for clashoo's own outbound requests.
#
# clashoo's self-initiated downloads (update checks, component/panel/core/geoip
# downloads, subscriptions) and health checks must reach GitHub through the
# running core, otherwise they leak out direct and stall behind the GFW. Route
# them through the core's mixed port whenever a core is running — in BOTH modes:
# - core-only mode deliberately skips TPROXY to coexist with other plugins;
# - normal mode used to rely on transparent redirect, but the router's OWN
# output redirect is fragile (e.g. PPPoE/GL.iNet stacks where Redirect to
# loopback never completes — issue #25), so don't depend on it. The explicit
# mixed port works on every network stack.
# When no core is running (e.g. bootstrapping the very first core download),
# returns empty so the caller falls back to direct / mirror sources.
#
# Port source depends on the running Clashoo kernel. The liveness gate uses the
# procd owner instead of a binary name, because other proxy plugins may run the
# same binaries:
# sing-box -> /etc/sing-box/config.json mixed inbound
# (an imported profile may carry its own port)
# mihomo / clash-meta / smart -> /etc/clashoo/config.yaml mixed-port
# (a custom config's port may differ from uci)
# uci mixed_port is the last-resort fallback for both.
clashoo_running_core_type() {
_cdp_type="$(uci -q get clashoo.config.core_type 2>/dev/null)"
case "$_cdp_type" in
singbox)
# Clashoo owns this service only after it has configured the
# packaged sing-box init script for its own runtime config.
[ "$(uci -q get sing-box.main.conffile 2>/dev/null)" = "/etc/sing-box/config.json" ] || return 1
_cdp_service="sing-box"
_cdp_filter='@["sing-box"].instances.*.pid'
;;
*)
_cdp_type="mihomo"
_cdp_service="clashoo"
_cdp_filter='@.clashoo.instances.*.pid'
;;
esac
_cdp_pid="$(ubus call service list "{\"name\":\"$_cdp_service\",\"verbose\":true}" 2>/dev/null \
| jsonfilter -e "$_cdp_filter" 2>/dev/null | head -n1)"
[ -n "$_cdp_pid" ] && [ -d "/proc/$_cdp_pid" ] || return 1
printf '%s' "$_cdp_type"
}
clashoo_detect_proxy() {
_cdp_type="$(clashoo_running_core_type)" || return 0
_cdp_port=""
if [ "$_cdp_type" = "singbox" ]; then
_cdp_port="$(jsonfilter -i /etc/sing-box/config.json \
-e '@.inbounds[@.type="mixed"].listen_port' 2>/dev/null | head -n 1)"
else
# prefer mixed-port (HTTP+SOCKS) over plain port; a single two-pattern
# sed would return whichever appears first by line order, so loop by key
for _cdp_key in mixed-port port socks-port; do
_cdp_port="$(sed -n "s/^[[:space:]]*${_cdp_key}:[[:space:]]*\([0-9][0-9]*\).*/\1/p" \
/etc/clashoo/config.yaml 2>/dev/null | head -n 1)"
[ -n "$_cdp_port" ] && break
done
fi
[ -n "$_cdp_port" ] || _cdp_port="$(uci -q get clashoo.config.mixed_port 2>/dev/null)"
[ -n "$_cdp_port" ] || return 0
printf 'http://127.0.0.1:%s' "$_cdp_port"
}
@@ -0,0 +1,386 @@
#!/bin/sh
SUB_DIR="${CLASHOO_SUB_DIR:-/usr/share/clashoo/config/sub}"
SINGBOX_DIR="${CLASHOO_SINGBOX_DIR:-/usr/share/clashoo/config/singbox}"
BACKUP_DIR="${CLASHOO_BACKUP_DIR:-/usr/share/clashbackup}"
TEMPLATE_DIR="${CLASHOO_TEMPLATE_DIR:-/usr/share/clashoo/config/custom}"
LIST_FILE="${CLASHOO_LIST_FILE:-$BACKUP_DIR/confit_list.conf}"
BIND_FILE="${CLASHOO_BIND_FILE:-$BACKUP_DIR/template_bindings.conf}"
STATUS_FILE="${CLASHOO_STATUS_FILE:-$BACKUP_DIR/subscription_update.status}"
LOCK_DIR="${CLASHOO_LOCK_DIR:-/tmp/clashoo_subscription_update.lock}"
UPDATE_LOG="${CLASHOO_UPDATE_LOG:-/tmp/clash_update.txt}"
SERVICE_CMD="${CLASHOO_SERVICE_CMD:-/etc/init.d/clashoo}"
TMP_DIR="${CLASHOO_TMP_DIR:-/tmp}"
DEFAULT_SUB_UA='Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36'
DOWNLOAD_ERROR=""
# Subscriptions are fetched direct first (many airports are domestic / geo-fence
# foreign exit IPs). Only used as a last-resort fallback, see download_to.
. /usr/share/clashoo/update/proxy_lib.sh
updated=0
unchanged=0
failed=0
skipped=0
restart_needed=0
started_at="$(date +%s)"
message=""
record_status=0
[ "$1" = "--all" ] && record_status=1
log_update() {
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
}
write_status() {
local tmp
mkdir -p "$(dirname "$STATUS_FILE")" >/dev/null 2>&1
tmp="${STATUS_FILE}.tmp.$$"
cat >"$tmp" <<EOF
running=$1
last_run=$started_at
finished_at=$2
updated=$updated
unchanged=$unchanged
failed=$failed
skipped=$skipped
message=$message
EOF
mv "$tmp" "$STATUS_FILE"
}
cleanup() {
rm -rf "$LOCK_DIR" >/dev/null 2>&1
}
if ! mkdir "$LOCK_DIR" >/dev/null 2>&1; then
exit 75
fi
trap cleanup EXIT INT TERM
[ "$record_status" = "1" ] && write_status 1 0
safe_name() {
case "$1" in
''|*/*|*..*) return 1 ;;
esac
return 0
}
extract_host() {
printf '%s' "$1" | sed -e 's#^[a-zA-Z0-9+.-]*://##' -e 's#/.*$##' -e 's#:.*$##' -e 's#.*@##'
}
resolve_via() {
local host dns
host="$1"
dns="$2"
nslookup "$host" "$dns" 2>/dev/null | awk '
/^Address/ {
ip = $NF
if (ip ~ /^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$/ &&
ip !~ /^127\./ && ip !~ /^0\./ && ip !~ /^198\.18\./ &&
ip != "8.8.8.8" && ip != "1.1.1.1" &&
ip != "223.5.5.5" && ip != "119.29.29.29") {
print ip
exit
}
}'
}
curl_download() {
local url out hdr ua host ip
url="$1"
out="$2"
hdr="$3"
ua="$4"
host="$5"
ip="$6"
if [ -n "$host" ] && [ -n "$ip" ]; then
curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
--speed-limit 1 --retry 2 -A "$ua" -D "$hdr" -o "$out" \
--resolve "$host:443:$ip" --resolve "$host:80:$ip" \
-w '%{http_code}' "$url" 2>/dev/null
else
curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
--speed-limit 1 --retry 2 -A "$ua" -D "$hdr" -o "$out" \
-w '%{http_code}' "$url" 2>/dev/null
fi
}
subscription_ua() {
local target ua
target="$1"
ua=""
[ -r "${target}.ua" ] && ua="$(sed -n '1p' "${target}.ua" 2>/dev/null)"
[ -n "$ua" ] || ua="$(uci -q get clashoo.config.sub_ua 2>/dev/null)"
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
printf '%s' "$ua"
}
save_subscription_ua() {
printf '%s\n' "$2" >"${1}.ua"
}
curl_error() {
case "$1" in
6) printf '%s' "DNS 解析失败" ;;
7) printf '%s' "连接失败" ;;
28) printf '%s' "连接超时" ;;
35) printf '%s' "TLS 握手失败" ;;
60) printf '%s' "证书校验失败" ;;
*) printf '网络错误(curl rc=%s' "${1:-unknown}" ;;
esac
}
download_to() {
local url out hdr ua code rc last_rc host dns ip proxy
url="$1"
out="$2"
hdr="$3"
ua="$4"
[ -n "$ua" ] || ua="$DEFAULT_SUB_UA"
DOWNLOAD_ERROR=""
rm -f "$out" "$hdr" >/dev/null 2>&1
if command -v curl >/dev/null 2>&1; then
code="$(curl_download "$url" "$out" "$hdr" "$ua" "" "")"
rc=$?
if [ "$rc" -eq 0 ] && [ "$code" = "200" ]; then
return 0
fi
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
DOWNLOAD_ERROR="HTTP ${code}"
return 1
fi
last_rc="$rc"
host="$(extract_host "$url")"
for dns in 223.5.5.5 119.29.29.29 1.1.1.1 8.8.8.8; do
ip="$(resolve_via "$host" "$dns")"
[ -n "$ip" ] || continue
log_update "DNS 回退:${host} -> ${ip} (@${dns})"
code="$(curl_download "$url" "$out" "$hdr" "$ua" "$host" "$ip")"
rc=$?
[ "$rc" -eq 0 ] && [ "$code" = "200" ] && return 0
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
DOWNLOAD_ERROR="HTTP ${code}"
return 1
fi
last_rc="$rc"
done
# Last resort: direct + DNS-override both failed, so the source may be
# GFW-blocked (e.g. a github-hosted sub). In kernel-only mode try once
# through the running core — nothing left to lose, and it won't disturb
# any airport that already answered direct.
proxy="$(clashoo_detect_proxy)"
if [ -n "$proxy" ]; then
log_update "代理兜底:通过本地核心重试订阅"
code="$(curl -sSL --connect-timeout 15 --max-time 60 --speed-time 30 \
--speed-limit 1 --retry 1 -A "$ua" -D "$hdr" -o "$out" \
--proxy "$proxy" -w '%{http_code}' "$url" 2>/dev/null)"
rc=$?
[ "$rc" -eq 0 ] && [ "$code" = "200" ] && return 0
if [ "$rc" -eq 0 ] && [ -n "$code" ] && [ "$code" != "000" ]; then
DOWNLOAD_ERROR="HTTP ${code}"
return 1
fi
last_rc="$rc"
fi
DOWNLOAD_ERROR="$(curl_error "$last_rc")"
return 1
fi
wget -q --tries=4 --timeout=20 --user-agent="$ua" "$url" -O "$out"
rc=$?
[ "$rc" -eq 0 ] && return 0
DOWNLOAD_ERROR="网络错误(wget rc=${rc}"
return 1
}
update_info() {
local hdr target info
hdr="$1"
target="$2"
info="$(grep -i 'subscription-userinfo:' "$hdr" 2>/dev/null | head -1 | \
sed 's/^[Ss]ubscription-[Uu]serinfo:[[:space:]]*//' | tr -d '\r')"
if [ -n "$info" ]; then
printf '%s\n' "$info" >"${target}.info"
else
rm -f "${target}.info" >/dev/null 2>&1
fi
}
service_running() {
[ -n "${CLASHOO_SERVICE_CMD:-}" ] && return 0
"$SERVICE_CMD" status >/dev/null 2>&1
}
template_output_name() {
local sub tpl
sub="$(printf '%s' "$1" | sed -e 's/\.[Yy][Aa][Mm][Ll]$//' -e 's/\.[Yy][Mm][Ll]$//' -e 's/[^A-Za-z0-9._-]/-/g')"
tpl="$(printf '%s' "$2" | sed -e 's/\.[Yy][Aa][Mm][Ll]$//' -e 's/\.[Yy][Mm][Ll]$//' -e 's/[^A-Za-z0-9._-]/-/g')"
printf '_merged_%s__%s.yaml' "${sub:-sub}" "${tpl:-template}"
}
apply_template() {
local name target template merged merged_path use_config
name="$1"
target="$2"
[ -r "$BIND_FILE" ] || return 0
[ -x /usr/share/clashoo/update/template_merge.sh ] || return 0
template="$(awk -F '#' -v n="$name" '$1==n && ($3=="1" || $3=="true") {print $2; exit}' "$BIND_FILE")"
[ -n "$template" ] && [ -r "$TEMPLATE_DIR/$template" ] || return 0
merged="$(template_output_name "$name" "$template")"
merged_path="$TEMPLATE_DIR/$merged"
if sh /usr/share/clashoo/update/template_merge.sh "$target" "$TEMPLATE_DIR/$template" "$merged_path" >/dev/null 2>&1; then
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
if [ "$use_config" = "$target" ] || [ "$use_config" = "$merged_path" ]; then
uci -q set clashoo.config.use_config="$merged_path"
uci -q set clashoo.config.config_type='3'
uci -q commit clashoo >/dev/null 2>&1
restart_needed=1
fi
else
log_update "模板生成失败:${name} <- ${template}"
fi
}
update_mihomo() {
local name url typ target tmp hdr ua use_config config_type
name="$1"
url="$2"
typ="$3"
safe_name "$name" || return 1
case "$typ" in clash|meta) ;; *) skipped=$((skipped + 1)); return 0 ;; esac
target="$SUB_DIR/$name"
[ -f "$target" ] || { skipped=$((skipped + 1)); return 0; }
tmp="$TMP_DIR/clashoo_sub_$$.yaml"
hdr="$TMP_DIR/clashoo_sub_$$.hdr"
ua="$(subscription_ua "$target")"
if ! download_to "$url" "$tmp" "$hdr" "$ua"; then
failed=$((failed + 1))
log_update "更新失败(${DOWNLOAD_ERROR:-下载失败}):$name"
rm -f "$tmp" "$hdr"
return 1
fi
if ! grep -Eq '^(proxies|proxy-providers):' "$tmp" 2>/dev/null; then
failed=$((failed + 1))
log_update "更新失败(无效 Mihomo 配置):$name"
rm -f "$tmp" "$hdr"
return 1
fi
if cmp -s "$tmp" "$target"; then
update_info "$hdr" "$target"
save_subscription_ua "$target" "$ua"
unchanged=$((unchanged + 1))
rm -f "$tmp" "$hdr"
return 0
fi
if ! mv "$tmp" "$target"; then
failed=$((failed + 1))
rm -f "$hdr"
return 1
fi
update_info "$hdr" "$target"
save_subscription_ua "$target" "$ua"
rm -f "$hdr"
updated=$((updated + 1))
use_config="$(uci -q get clashoo.config.use_config 2>/dev/null)"
config_type="$(uci -q get clashoo.config.config_type 2>/dev/null)"
[ "$config_type" = "1" ] && [ "$use_config" = "$target" ] && restart_needed=1
apply_template "$name" "$target"
log_update "更新完成:$name"
}
valid_singbox() {
ucode -e 'import { readfile } from "fs"; let c = json(readfile(ARGV[0])); exit(type(c) == "object" && type(c.outbounds) == "array" && length(c.outbounds) > 0 ? 0 : 1);' "$1" >/dev/null 2>&1
}
update_singbox() {
local name target url tmp hdr ua active
name="$1"
safe_name "$name" || return 1
target="$SINGBOX_DIR/$name"
[ -f "$target" ] && [ -r "$target.url" ] || { skipped=$((skipped + 1)); return 0; }
url="$(sed -n '1p' "$target.url")"
[ -n "$url" ] || { skipped=$((skipped + 1)); return 0; }
tmp="$TMP_DIR/clashoo_sb_$$.json"
hdr="$TMP_DIR/clashoo_sb_$$.hdr"
ua="$(subscription_ua "$target")"
if ! download_to "$url" "$tmp" "$hdr" "$ua"; then
failed=$((failed + 1))
log_update "更新失败(${DOWNLOAD_ERROR:-下载失败}):$name"
rm -f "$tmp" "$hdr"
return 1
fi
if ! valid_singbox "$tmp"; then
failed=$((failed + 1))
log_update "更新失败(无效 sing-box 配置):$name"
rm -f "$tmp" "$hdr"
return 1
fi
if cmp -s "$tmp" "$target"; then
update_info "$hdr" "$target"
save_subscription_ua "$target" "$ua"
unchanged=$((unchanged + 1))
rm -f "$tmp" "$hdr"
return 0
fi
if ! mv "$tmp" "$target"; then
failed=$((failed + 1))
rm -f "$hdr"
return 1
fi
update_info "$hdr" "$target"
save_subscription_ua "$target" "$ua"
rm -f "$hdr"
updated=$((updated + 1))
active="$(uci -q get clashoo.config.singbox_active 2>/dev/null)"
[ "$active" = "$name" ] && restart_needed=1
log_update "更新完成:$name"
}
update_all() {
local name url typ url_file
if [ -r "$LIST_FILE" ]; then
while IFS='#' read -r name url typ _rest; do
[ -n "$name" ] && [ -n "$url" ] || continue
update_mihomo "$name" "$url" "$typ" || true
done <"$LIST_FILE"
fi
for url_file in "$SINGBOX_DIR"/*.url; do
[ -f "$url_file" ] || continue
name="$(basename "$url_file" .url)"
update_singbox "$name" || true
done
}
case "$1" in
--all)
update_all
;;
--mihomo)
line="$(awk -F '#' -v n="$2" '$1==n {print; exit}' "$LIST_FILE" 2>/dev/null)"
[ -n "$line" ] || { failed=1; message="subscription not found"; }
if [ -n "$line" ]; then
name="$(printf '%s' "$line" | awk -F '#' '{print $1}')"
url="$(printf '%s' "$line" | awk -F '#' '{print $2}')"
typ="$(printf '%s' "$line" | awk -F '#' '{print $3}')"
update_mihomo "$name" "$url" "$typ" || true
fi
;;
--singbox)
update_singbox "$2" || true
;;
*)
failed=1
message="invalid arguments"
;;
esac
if [ "$restart_needed" = "1" ] && service_running; then
"$SERVICE_CMD" restart >/dev/null 2>&1
fi
finished_at="$(date +%s)"
[ -n "$message" ] || message="updated=$updated unchanged=$unchanged failed=$failed skipped=$skipped"
[ "$record_status" = "1" ] && write_status 0 "$finished_at"
[ "$failed" -eq 0 ]
@@ -0,0 +1,18 @@
#!/bin/sh
STATUS_FILE="${CLASHOO_STATUS_FILE:-/usr/share/clashbackup/subscription_update.status}"
UPDATER="${CLASHOO_SUBSCRIPTION_UPDATER:-/usr/share/clashoo/update/subscription_update.sh}"
SERVICE_CMD="${CLASHOO_SERVICE_CMD:-/etc/init.d/clashoo}"
interval="$(uci -q get clashoo.config.subscription_update_interval 2>/dev/null)"
now="$(date +%s)"
last=0
[ "$(uci -q get clashoo.config.auto_subscription_update 2>/dev/null)" = "1" ] || exit 0
"$SERVICE_CMD" status >/dev/null 2>&1 || exit 0
echo "$interval" | grep -Eq '^[0-9]+$' || interval=72
[ "$interval" -gt 0 ] || interval=72
[ -r "$STATUS_FILE" ] && last="$(sed -n 's/^last_run=//p' "$STATUS_FILE" | head -1)"
echo "$last" | grep -Eq '^[0-9]+$' || last=0
[ $((now - last)) -ge $((interval * 3600)) ] || exit 0
sh "$UPDATER" --all
+101
View File
@@ -0,0 +1,101 @@
#!/bin/sh
set -eu
SUB_FILE="${1:-}"
TEMPLATE_FILE="${2:-}"
OUT_FILE="${3:-}"
TMP_FILE="/tmp/clash_template_merge_$$.yaml"
TMP_FILE2="${TMP_FILE}.norm"
LOG_FILE="/tmp/clash_update.txt"
log() {
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$LOG_FILE"
}
cleanup() {
rm -f "$TMP_FILE" "$TMP_FILE2" >/dev/null 2>&1 || true
}
trap cleanup EXIT INT TERM
[ -n "$SUB_FILE" ] || { log "模板复写失败:缺少订阅文件参数"; exit 1; }
[ -n "$TEMPLATE_FILE" ] || { log "模板复写失败:缺少模板文件参数"; exit 1; }
[ -n "$OUT_FILE" ] || { log "模板复写失败:缺少输出文件参数"; exit 1; }
[ -f "$SUB_FILE" ] || { log "模板复写失败:订阅文件不存在"; exit 1; }
[ -f "$TEMPLATE_FILE" ] || { log "模板复写失败:模板文件不存在"; exit 1; }
if ! command -v yq >/dev/null 2>&1; then
log "模板复写失败:缺少 yq"
exit 1
fi
core_bin=""
for b in mihomo clash-meta clash; do
if command -v "$b" >/dev/null 2>&1; then
core_bin="$b"
break
fi
done
mkdir -p "$(dirname "$OUT_FILE")" >/dev/null 2>&1
log "开始模板复写:$(basename "$SUB_FILE") <- $(basename "$TEMPLATE_FILE")"
export SUB_FILE TEMPLATE_FILE
# template is base; load() forces single-doc; explode anchors first (yq 4.53+ breaks "<<")
yq -n '
(load(strenv(SUB_FILE)) | explode(.)) as $sub |
(load(strenv(TEMPLATE_FILE)) | explode(.)) as $tpl |
(
($tpl * {
"proxies": (((($tpl.proxies // []) + ($sub.proxies // [])) | unique_by(.name))),
"proxy-providers": (($tpl."proxy-providers" // {}) * ($sub."proxy-providers" // {}))
})
| .dns = ((.dns // {}) * {
"proxy-server-nameserver": (
.dns."proxy-server-nameserver"
// $sub.dns."proxy-server-nameserver"
// $sub.dns.nameserver
// ["223.5.5.5", "119.29.29.29"]
)
})
)
' >"$TMP_FILE" 2>/dev/null || {
log "模板复写失败:YAML 合并错误"
exit 1
}
# 兼容第三方模板中使用“直连”字样,统一转换为 Clash 内置 DIRECT。
yq e '
(.. | select(tag == "!!str")) |= sub("^直连$", "DIRECT")
| .rules = ((.rules // []) | map(sub(",直连,", ",DIRECT,") | sub(",直连$", ",DIRECT")))
' "$TMP_FILE" >"$TMP_FILE2" 2>/dev/null || {
log "模板复写失败:直连别名标准化失败"
exit 1
}
mv -f "$TMP_FILE2" "$TMP_FILE" >/dev/null 2>&1 || {
log "模板复写失败:中间文件写入失败"
exit 1
}
yq e '.' "$TMP_FILE" >/dev/null 2>&1 || {
log "模板复写失败:合并结果 YAML 无效"
exit 1
}
if [ -n "$core_bin" ]; then
"$core_bin" -t -f "$TMP_FILE" >/dev/null 2>&1 || {
log "模板复写失败:内核校验不通过"
exit 1
}
fi
mv -f "$TMP_FILE" "$OUT_FILE" >/dev/null 2>&1 || {
log "模板复写失败:写入输出文件失败"
exit 1
}
chmod 644 "$OUT_FILE" >/dev/null 2>&1 || true
log "模板复写完成:$(basename "$OUT_FILE")"
exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
config_name="${1:-$(uci -q get clashoo.config.config_update_name 2>/dev/null)}"
[ -n "$config_name" ] || exit 1
exec sh /usr/share/clashoo/update/subscription_update.sh --mihomo "$config_name"
+20
View File
@@ -0,0 +1,20 @@
#!/bin/sh
UPDATE_LOG="/tmp/clash_update.txt"
STATE_DIR="${CLASHOO_STATE_DIR:-/usr/share/clashbackup}"
log_update() {
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >>"$UPDATE_LOG"
}
log_update "更新大陆白名单"
sh /usr/share/clashoo/update/update_china_ip.sh >> "$UPDATE_LOG" 2>&1
log_update "更新 GeoIP / GeoSite"
sh /usr/share/clashoo/update/geoip.sh >/dev/null 2>&1
mkdir -p "$STATE_DIR"
tmp="$STATE_DIR/rule_update.status.tmp.$$"
printf 'last_run=%s\n' "$(date +%s)" >"$tmp" && mv -f "$tmp" "$STATE_DIR/rule_update.status"
exit 0
+190
View File
@@ -0,0 +1,190 @@
#!/bin/sh
set -eu
LOG_FILE="${LOG_FILE:-/tmp/clash_update.txt}"
NFT_DIR="${NFT_DIR:-/usr/share/clashoo/nftables}"
TARGET_V4="${NFT_DIR}/geoip_cn.nft"
TARGET_V6="${NFT_DIR}/geoip6_cn.nft"
FW4_SCRIPT="${FW4_SCRIPT:-/usr/share/clashoo/net/fw4.sh}"
RUNTIME_STATE_FILE="${RUNTIME_STATE_FILE:-/tmp/clashoo/runtime_state}"
PROC_ROOT="${PROC_ROOT:-/proc}"
TMP_V4="/tmp/china_ip.txt.$$"
TMP_V6="/tmp/china_ipv6.txt.$$"
OUT_V4="/tmp/geoip_cn.nft.$$"
OUT_V6="/tmp/geoip6_cn.nft.$$"
log() {
printf ' %s - %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$1" >> "$LOG_FILE"
}
bool_enabled() {
case "$1" in
1|true|TRUE|yes|on) return 0 ;;
*) return 1 ;;
esac
}
download_with_fallback() {
local url="$1"
local output="$2"
local ip
if curl -fsSL "$url" -o "$output"; then
return 0
fi
case "$url" in
https://ispip.clang.cn/*)
for ip in 182.247.248.127 103.220.64.183; do
if curl -fsSL --resolve "ispip.clang.cn:443:${ip}" "$url" -o "$output"; then
log "DNS 异常,已使用 --resolve(${ip}) 回源下载"
return 0
fi
done
;;
esac
return 1
}
cleanup() {
rm -f "$TMP_V4" "$TMP_V6" "$OUT_V4" "$OUT_V6"
}
trap cleanup EXIT INT TERM
render_nft_set() {
local source_file="$1"
local output_file="$2"
local set_name="$3"
local set_type="$4"
awk -v set_name="$set_name" -v set_type="$set_type" '
BEGIN {
print "set " set_name " {"
print "\ttype " set_type ";"
print "\tflags interval;"
print "\tauto-merge;"
print "\telements = {"
first = 1
}
!/^[[:space:]]*$/ && !/^[[:space:]]*#/ {
gsub(/^[[:space:]]+|[[:space:]]+$/, "", $0)
if ($0 == "")
next
if (!first)
print ","
printf "\t\t%s", $0
first = 0
}
END {
if (!first)
print ""
print "\t}"
print "}"
}
' "$source_file" > "$output_file"
}
url4="$(uci -q get clashoo.config.china_ip_url 2>/dev/null || true)"
url6="$(uci -q get clashoo.config.china_ipv6_url 2>/dev/null || true)"
bypass_china="$(uci -q get clashoo.config.bypass_china 2>/dev/null || true)"
bypass_china_ipv6="$(uci -q get clashoo.config.bypass_china_ipv6 2>/dev/null || true)"
[ -n "$bypass_china_ipv6" ] || bypass_china_ipv6="$bypass_china"
[ -n "$url4" ] || url4='https://ispip.clang.cn/all_cn.txt'
[ -n "$url6" ] || url6='https://ispip.clang.cn/all_cn_ipv6.txt'
mkdir -p "$NFT_DIR"
log '开始更新大陆白名单'
download_with_fallback "$url4" "$TMP_V4"
[ -s "$TMP_V4" ] || {
log '大陆 IPv4 白名单下载失败:返回为空'
exit 1
}
changed=0
render_nft_set "$TMP_V4" "$OUT_V4" clashoo_china ipv4_addr
if cmp -s "$OUT_V4" "$TARGET_V4"; then
log '大陆 IPv4 白名单内容无变化'
else
mv "$OUT_V4" "$TARGET_V4"
chmod 644 "$TARGET_V4" >/dev/null 2>&1 || true
changed=1
log '大陆 IPv4 白名单更新完成'
fi
if download_with_fallback "$url6" "$TMP_V6"; then
if [ -s "$TMP_V6" ]; then
render_nft_set "$TMP_V6" "$OUT_V6" clashoo_china6 ipv6_addr
if cmp -s "$OUT_V6" "$TARGET_V6"; then
log '大陆 IPv6 白名单内容无变化'
else
mv "$OUT_V6" "$TARGET_V6"
chmod 600 "$TARGET_V6" >/dev/null 2>&1 || true
changed=1
log '大陆 IPv6 白名单更新完成'
fi
else
log '大陆 IPv6 白名单下载为空,保留原文件'
fi
else
log '大陆 IPv6 白名单下载失败,保留原文件'
fi
instance_pid() {
ubus call service list "{\"name\":\"$1\",\"verbose\":true}" 2>/dev/null |
jsonfilter -e "@[\"$1\"].instances.*.pid" 2>/dev/null | head -n1
}
pid_running() {
[ -n "$1" ] && [ -d "$PROC_ROOT/$1" ]
}
clashoo_manages_singbox() {
[ -r "$RUNTIME_STATE_FILE" ] || return 1
grep -qx 'configured_core=singbox' "$RUNTIME_STATE_FILE" 2>/dev/null || return 1
case "$(sed -n 's/^health_detail=//p' "$RUNTIME_STATE_FILE" 2>/dev/null | head -n1)" in
service_stopped|service_disabled|boot_disabled|preflight:*|start:*) return 1 ;;
esac
return 0
}
selected_core_running() {
local name pid
if [ "$(uci -q get clashoo.config.core_type 2>/dev/null)" = "singbox" ]; then
bool_enabled "$(uci -q get sing-box.main.enabled 2>/dev/null)" || return 1
clashoo_manages_singbox || return 1
name="sing-box"
else
name="clashoo"
fi
pid="$(instance_pid "$name")"
pid_running "$pid"
}
firewall_managed() {
bool_enabled "$(uci -q get clashoo.config.enable 2>/dev/null)" || return 1
[ "$(uci -q get clashoo.config.core_only 2>/dev/null)" = "1" ] && return 1
selected_core_running
}
if [ "$changed" -eq 0 ]; then
log '白名单内容未变,跳过防火墙重载'
elif ! firewall_managed; then
log '未接管防火墙(服务未运行或仅内核模式),仅更新白名单文件'
elif bool_enabled "$bypass_china" || bool_enabled "$bypass_china_ipv6"; then
if "$FW4_SCRIPT" apply >/dev/null 2>&1; then
log "大陆白名单规则已重载(IPv4=${bypass_china:-0}IPv6=${bypass_china_ipv6:-0}"
else
log '大陆白名单规则重载失败'
exit 1
fi
else
log '大陆 IPv4/IPv6 绕过均未启用,仅更新白名单文件'
fi
log '大陆白名单更新流程完成'
@@ -0,0 +1,91 @@
--- a/dns/middleware.go
+++ b/dns/middleware.go
@@ -12,6 +12,7 @@ import (
icontext "github.com/metacubex/mihomo/context"
"github.com/metacubex/mihomo/log"
+ M "github.com/metacubex/sing/common/metadata"
D "github.com/miekg/dns"
)
@@ -152,6 +153,9 @@ func withFakeIP(skipper *fakeip.Skipper,
q := r.Question[0]
host := strings.TrimRight(q.Name, ".")
+ if !M.IsDomainName(host) {
+ return next(ctx, r)
+ }
if skipper.ShouldSkipped(host) {
return next(ctx, r)
}
--- /dev/null
+++ b/dns/middleware_test.go
@@ -0,0 +1,68 @@
+package dns
+
+import (
+ "context"
+ "net/netip"
+ "testing"
+
+ "github.com/metacubex/mihomo/component/fakeip"
+ icontext "github.com/metacubex/mihomo/context"
+
+ D "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+func TestWithFakeIPSkipsInvalidDomain(t *testing.T) {
+ pool, err := fakeip.New(fakeip.Options{
+ IPNet: netip.MustParsePrefix("198.18.0.1/16"),
+ Size: 10,
+ })
+ require.NoError(t, err)
+
+ downstreamCalled := false
+ next := func(_ *icontext.DNSContext, request *D.Msg) (*D.Msg, error) {
+ downstreamCalled = true
+ response := new(D.Msg)
+ response.SetReply(request)
+ return response, nil
+ }
+
+ request := new(D.Msg)
+ request.SetQuestion("n-relay-ipc-txc-nj-00.tplinkcloud.com.cn\\152.", D.TypeA)
+ response, err := withFakeIP(&fakeip.Skipper{}, pool, nil, 1)(next)(icontext.NewDNSContext(context.Background()), request)
+
+ require.NoError(t, err)
+ require.True(t, downstreamCalled)
+ require.Empty(t, response.Answer)
+ _, mapped := pool.LookBack(netip.MustParseAddr("198.18.0.4"))
+ require.False(t, mapped)
+}
+
+func TestWithFakeIPKeepsValidDomain(t *testing.T) {
+ pool, err := fakeip.New(fakeip.Options{
+ IPNet: netip.MustParsePrefix("198.18.0.1/16"),
+ Size: 10,
+ })
+ require.NoError(t, err)
+
+ downstreamCalled := false
+ next := func(_ *icontext.DNSContext, request *D.Msg) (*D.Msg, error) {
+ downstreamCalled = true
+ response := new(D.Msg)
+ response.SetReply(request)
+ return response, nil
+ }
+
+ request := new(D.Msg)
+ request.SetQuestion("n-relay-ipc-txc-nj-00.tplinkcloud.com.cn.", D.TypeA)
+ dnsCtx := icontext.NewDNSContext(context.Background())
+ response, err := withFakeIP(&fakeip.Skipper{}, pool, nil, 1)(next)(dnsCtx, request)
+
+ require.NoError(t, err)
+ require.False(t, downstreamCalled)
+ require.Len(t, response.Answer, 1)
+ require.Equal(t, icontext.DNSTypeFakeIP, dnsCtx.Type())
+ host, mapped := pool.LookBack(netip.MustParseAddr("198.18.0.4"))
+ require.True(t, mapped)
+ require.Equal(t, "n-relay-ipc-txc-nj-00.tplinkcloud.com.cn", host)
+}
+41 -29
View File
@@ -5,51 +5,60 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=dae
PKG_VERSION:=2026.08.08
PKG_VERSION:=2026.09.12
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=1d52465dd455d9702a75486bc4dadde684d6f782
PKG_SOURCE_URL:=https://github.com/olicesx/dae.git
PKG_MIRROR_HASH:=skip
PKG_SOURCE:=dae-src-2026.09.12-187058462a1f.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/dae-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=187058462a1fd9eeb9885f4971ccf4bc81358533e71730d8509bd7968624c1c7
PKG_LICENSE:=AGPL-3.0-only
PKG_LICENSE_FILE:=LICENSE
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
PKG_MAINTAINER:=kenzok8
DAE_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
PKG_BUILD_DIR:=$(DAE_BUILD_DIR)/core
PKG_BUILD_DEPENDS:=golang/host bpf-headers
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
# armv7 lacks a CO-RE vmlinux.h for the trace eBPF, so trace's bpf2go fails to
# build there. Apply sbwml/openwrt_helloworld's arm patches (add vmlinux-arm.h
# + drop the kprobe that doesn't work) to let dae build on arm targets.
ifeq ($(ARCH),arm)
PATCH_DIR:=$(CURDIR)/patches_arm
endif
GO_PKG:=github.com/daeuniverse/dae
GO_PKG_EXCLUDES:=control/kern/tests
GO_PKG_LDFLAGS:= \
-s -w -buildid= \
-linkmode external -extldflags '-static -Wl,-s'
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/cmd.Version=$(PKG_VERSION) \
$(GO_PKG)/common/consts.MaxMatchSetLen_=1024
GO_PKG_TAGS:=trace
PGO_PROFILE:=$(CURDIR)/pprof/default.pgo
GO_PKG_TAGS:=trace,timetzdata
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/bpf.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
GO_PKG_BUILD_VARS+= \
GOFLAGS="-trimpath -buildvcs=false -pgo=auto"
GO_PKG_BUILD_VARS+= GOFLAGS="-trimpath -buildvcs=false -pgo=auto"
GO_PKG_TARGET_VARS+= \
CGO_LDFLAGS="$(TARGET_LDFLAGS) -static -Wl,-s" \
GOEXPERIMENT=newinliner,simd
define Package/dae
define Package/dae/Default
SECTION:=net
CATEGORY:=Network
SUBMENU:=Web Servers/Proxies
URL:=https://github.com/daeuniverse/dae
endef
define Package/dae
$(call Package/dae/Default)
TITLE:=A lightweight and high-performance transparent proxy solution
DEPENDS:=$(GO_ARCH_DEPENDS) \
+ca-bundle +kmod-sched-core +kmod-sched-bpf \
@@ -73,8 +82,13 @@ define Package/dae/config
endchoice
endef
define Package/dae/description
dae, means goose, is a lightweight and high-performance transparent
proxy solution.
endef
define Package/dae/conffiles
/etc/dae/
/etc/dae/config.dae
/etc/config/dae
endef
@@ -83,17 +97,16 @@ DAE_CFLAGS:= \
-DMAX_MATCH_SET_LEN=1024 \
-I$(BPF_HEADERS_DIR)/tools/lib \
-I$(BPF_HEADERS_DIR)/arch/$(BPF_KARCH)/include/asm/mach-generic
define Build/Prepare
$(call Build/Prepare/Default)
[ -f "$(PGO_PROFILE)" ] && $(CP) "$(PGO_PROFILE)" "$(PKG_BUILD_DIR)/default.pgo" || true
mkdir -p $(DAE_BUILD_DIR)
$(TAR) --strip-components=1 -C $(DAE_BUILD_DIR) -xzf $(DL_DIR)/$(PKG_SOURCE)
$(if $(wildcard $(PATCH_DIR)/*.patch),$(call PatchDir,$(PKG_BUILD_DIR),$(PATCH_DIR),))
endef
define Build/Compile
( \
pushd $(PKG_BUILD_DIR) ; \
go get -u=patch ; \
go mod tidy ; \
export \
$(GO_GENERAL_BUILD_CONFIG_VARS) \
$(GO_PKG_BUILD_CONFIG_VARS) \
@@ -112,17 +125,16 @@ endef
define Package/dae/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/bin/
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/dae $(1)/usr/bin/dae
$(INSTALL_DIR) $(1)/etc/dae/
$(INSTALL_CONF) $(PKG_BUILD_DIR)/example.dae $(1)/etc/dae/
$(INSTALL_CONF) $(CURDIR)/files/config.dae $(1)/etc/dae/config.dae
$(INSTALL_DIR) $(1)/etc/dae/config.d/
$(INSTALL_CONF) $(CURDIR)/files/config.d/dns.dae $(1)/etc/dae/config.d/dns.dae
$(INSTALL_CONF) $(CURDIR)/files/config.d/node.dae $(1)/etc/dae/config.d/node.dae
$(INSTALL_CONF) $(CURDIR)/files/config.d/route.dae $(1)/etc/dae/config.d/route.dae
$(INSTALL_DIR) $(1)/etc/dae
$(INSTALL_CONF) $(PKG_BUILD_DIR)/example.dae $(1)/etc/dae/example.dae
$(SED) 's|^[[:space:]]*#lan_interface: docker0| lan_interface: br-lan|' $(1)/etc/dae/example.dae
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) $(CURDIR)/files/dae.config $(1)/etc/config/dae
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/dae.init $(1)/etc/init.d/dae
endef
-25
View File
@@ -1,25 +0,0 @@
# dns.dae
dns {
optimistic_cache_ttl: 86400
max_cache_size: 4096
upstream {
localdns: 'udp://127.0.0.1:53'
overseadns: 'tcp+udp://1.0.0.1:53'
nodedns: 'udp://111.222.333.444:53'
}
routing {
request {
sub(sub_name) -> overseadns
subnode(sub_name) && subnode(name_keyword: 114514) -> nodedns
qtype(https) -> reject
qname(geosite:gfw) -> overseadns
fallback: localdns
}
response {
upstream(overseadns) -> accept
qname(geosite:private) -> accept
ip(geoip:cn) -> accept
fallback: overseadns
}
}
}
-19
View File
@@ -1,19 +0,0 @@
# node.dae
node {
node1: 'xxx'
node2: 'xxx'
}
subscription {
my_sub: 'https://www.example.com/subscription/link'
}
group {
my_group {
filter: subtag(my_sub) && !name(keyword: 'ExpireAt:')
filter: subtag(my_sub2)
policy: min_moving_avg
}
local_group {
filter: name(node1, node2)
policy: fixed(0)
}
}
-22
View File
@@ -1,22 +0,0 @@
# route.dae
routing {
pname(dnsmasq, zerotier-one) -> must_direct
dip(224.0.0.0/3, 'ff00::/8', geoip:private) -> direct
domain(geosite:category-bank-cn, geosite:boc@!cn, geosite:synology) -> direct
domain(geosite:category-ai-!cn, geosite:google, geosite:category-entertainment, geosite:gfw, geosite:github, geosite:spotify) && l4proto(udp) && dport(443) -> block
domain(geosite:spotify) -> proxy
domain(geosite:category-entertainment) -> proxy
domain(geosite:category-ai-!cn, geosite:google, geosite:github) -> proxy
domain(geosite:gfw) -> proxy
dip(geoip:telegram) -> proxy
dip(geoip:cn) -> direct
l4proto(udp) && dport(443) -> block
fallback: proxy
}
-18
View File
@@ -1,18 +0,0 @@
# config.dae
# load all dae files placed in ./config.d/
include {
config.d/*.dae
}
global {
log_level:warn
check_interval:600s
check_tolerance:20ms
lan_interface:"br-lan"
wan_interface:auto
auto_config_kernel_parameter:true
sniffing_timeout:300ms
udp_check_dns: "dns.google:53,8.8.8.8,2001:4860:4860::8888"
tcp_check_url: "http://cp.cloudflare.com,1.1.1.1,2606:4700:4700::1111"
dial_mode: domain
allow_insecure: true
}
+1
View File
@@ -2,5 +2,6 @@
config dae 'config'
option enabled '0'
option config_file '/etc/dae/config.dae'
option lan_interface 'br-lan'
option log_maxbackups '1'
option log_maxsize '1'
Executable → Regular
+45 -117
View File
@@ -1,141 +1,69 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2023 Tianling Shen <cnsztl@immortalwrt.org>
USE_PROCD=1
START=96
STOP=10
START=99
extra_command "hot_reload" "Hot-reload service"
CONF="dae"
PROG="/usr/bin/dae"
LOG="/var/log/dae/dae.log"
DAE_HOST_IFACE="dae0"
DAE_PEER_IFACE="dae0peer"
DAE_NETNS_NAME="daens"
DAENETNS="/run/netns/$DAE_NETNS_NAME"
RESOLV_DAE="/tmp/resolv.conf.dae"
set_chmod_640() {
mkdir -p /etc/dae/config.d
mkdir -p /var/log/dae
chmod -R 640 /etc/dae/ 2>/dev/null || true
}
update_cron() {
local temp_cron="/tmp/root.cron"
if [ -f /etc/crontabs/root ]; then
grep -v '/etc/init.d/dae hot_reload' /etc/crontabs/root > "$temp_cron" 2>/dev/null
fi
: >> "$temp_cron"
local auto_update
auto_update=$(uci -q get dae.config.subscribe_auto_update)
if [ "$1" = "add" ] && [ "${auto_update:-0}" -eq 1 ]; then
local min=$((RANDOM % 60))
local hr=$(uci -q get dae.config.subscribe_update_day_time)
local wk=$(uci -q get dae.config.subscribe_update_week_time)
echo "$min ${hr:-*} * * ${wk:-*} /etc/init.d/dae hot_reload >/dev/null 2>&1" >> "$temp_cron"
fi
crontab "$temp_cron" 2>/dev/null
rm -f "$temp_cron"
}
cleanup_netns() {
tc qdisc del dev "$DAE_HOST_IFACE" clsact 2>/dev/null || true
ip link del "$DAE_HOST_IFACE" 2>/dev/null || true
ip link del "$DAE_PEER_IFACE" 2>/dev/null || true
ip netns del "$DAE_NETNS_NAME" 2>/dev/null || true
umount "$DAENETNS" 2>/dev/null || true
rm -f "$DAENETNS" 2>/dev/null || true
}
hijack_resolv_conf() {
grep -q ' /tmp/resolv\.conf ' /proc/mounts && return 0
grep -E '^(search|domain) ' /tmp/resolv.conf > "$RESOLV_DAE" 2>/dev/null
local dns_list LOGICAL_WAN
. /lib/functions/network.sh
network_find_wan LOGICAL_WAN 2>/dev/null
if [ -n "$LOGICAL_WAN" ]; then
dns_list=$(ubus call "network.interface.$LOGICAL_WAN" status 2>/dev/null | \
jsonfilter -e '@["dns-server"][*]' 2>/dev/null)
fi
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
[ -z "$dns_list" ] && dns_list="119.29.29.29 223.5.5.5"
for dns in $dns_list; do
echo "nameserver $dns" >> "$RESOLV_DAE"
done
mount --bind "$RESOLV_DAE" /tmp/resolv.conf
mount -o remount,ro,bind /tmp/resolv.conf
}
restore_resolv_conf() {
grep -q ' /tmp/resolv\.conf ' /proc/mounts || return 0
mount -o remount,rw,bind /tmp/resolv.conf 2>/dev/null
umount /tmp/resolv.conf 2>/dev/null
rm -f "$RESOLV_DAE"
}
LOG_DIR="/var/log/dae"
start_service() {
local enabled config_file log_maxbackups log_maxsize
config_load "$CONF"
uci commit "$CONF" 2>/dev/null
config_load "$CONF"
config_get_bool enabled "config" "enabled" "0"
[ "$enabled" -eq "1" ] || return 1
local enabled
config_get_bool enabled "config" "enabled" "0"
[ "$enabled" -eq "1" ] || return 1
config_get config_file "config" "config_file" "/etc/dae/config.dae"
local config_file
config_get config_file "config" "config_file" "/etc/dae/config.dae"
set_chmod_640
"$PROG" validate -c "$config_file" || return 1
update_cron "add"
hijack_resolv_conf
cleanup_netns
"$PROG" validate -c "$config_file" || return 1
config_get log_maxbackups "config" "log_maxbackups" "1"
config_get log_maxsize "config" "log_maxsize" "1"
local log_maxbackups log_maxsize
config_get log_maxbackups "config" "log_maxbackups" "1"
config_get log_maxsize "config" "log_maxsize" "1"
procd_open_instance "$CONF"
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray"
procd_set_param command "$PROG" run
procd_append_param command --config "$config_file"
procd_append_param command --disable-timestamp
procd_append_param command --logfile "$LOG"
procd_append_param command --logfile-maxbackups "$log_maxbackups"
procd_append_param command --logfile-maxsize "$log_maxsize"
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param respawn
procd_set_param stderr 1
procd_close_instance
procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param command "$PROG" run
procd_append_param command --config "$config_file"
procd_append_param command --logfile "$LOG_DIR/dae.log"
procd_append_param command --logfile-maxbackups "$log_maxbackups"
procd_append_param command --logfile-maxsize "$log_maxsize"
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param respawn
# procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
}
service_stopped() {
update_cron "remove"
restore_resolv_conf
cleanup_netns
}
reload_service() {
stop
start
stop_service() {
rm -rf "$LOG_DIR"
# kill lingering procs in daens: TERM first, then KILL for stubborn ones
for pid in $(ip netns pids daens 2>/dev/null); do kill "$pid" 2>/dev/null; done
if [ -n "$(ip netns pids daens 2>/dev/null)" ]; then
sleep 1
for pid in $(ip netns pids daens 2>/dev/null); do kill -9 "$pid" 2>/dev/null; done
fi
# cleanup daens netns; ip-full needed for netns, busybox fallback via umount
if ! ip netns del daens 2>/dev/null; then
umount /run/netns/daens 2>/dev/null
rm -f /run/netns/daens
fi
# clean orphaned dae0
ip link del dae0 2>/dev/null || true
}
service_triggers() {
procd_add_reload_trigger "$CONF"
procd_add_reload_trigger "$CONF"
}
hot_reload() {
set_chmod_640
/etc/init.d/dae running && "$PROG" reload
/etc/init.d/dae running && "$PROG" reload "$(cat /var/run/dae.pid)"
}
+216
View File
@@ -0,0 +1,216 @@
diff --git a/cmd/reload_manager.go b/cmd/reload_manager.go
index c78910c..bcc1a37 100644
--- a/cmd/reload_manager.go
+++ b/cmd/reload_manager.go
@@ -423,5 +423,8 @@ func dnsConfigFingerprint(dns config.Dns) string {
b.WriteString("max_cache_size=")
b.WriteString(strconv.Itoa(dns.MaxCacheSize))
b.WriteByte(';')
+ b.WriteString("response_ttl=")
+ b.WriteString(strconv.Itoa(dns.ResponseTtl))
+ b.WriteByte(';')
return b.String()
}
diff --git a/cmd/run_shutdown_test.go b/cmd/run_shutdown_test.go
index f8f731f..4dcdcff 100644
--- a/cmd/run_shutdown_test.go
+++ b/cmd/run_shutdown_test.go
@@ -609,6 +609,7 @@ func TestDNSConfigFingerprintCoversAllDnsFields(t *testing.T) {
"OptimisticCache": {},
"OptimisticCacheTtl": {},
"MaxCacheSize": {},
+ "ResponseTtl": {},
}
dnsType := reflect.TypeOf(config.Dns{})
diff --git a/config/config.go b/config/config.go
index e688e88..af28da5 100644
--- a/config/config.go
+++ b/config/config.go
@@ -160,6 +160,7 @@ type Dns struct {
OptimisticCache bool `mapstructure:"optimistic_cache" default:"true"`
OptimisticCacheTtl int `mapstructure:"optimistic_cache_ttl" default:"60"`
MaxCacheSize int `mapstructure:"max_cache_size" default:"0"`
+ ResponseTtl int `mapstructure:"response_ttl" default:"0"`
}
type Routing struct {
diff --git a/config/desc.go b/config/desc.go
index 513cf7e..9b3c1a2 100644
--- a/config/desc.go
+++ b/config/desc.go
@@ -66,6 +66,7 @@ var GlobalDesc = Desc{
var DnsDesc = Desc{
"ipversion_prefer": "For example, if ipversion_prefer is 4 and the domain name has both type A and type AAAA records, the dae will only respond to type A queries and response empty answer to type AAAA queries.",
"fixed_domain_ttl": "Give a fixed ttl for domains. Zero means that dae will request to upstream every time and not cache DNS results for these domains.",
+ "response_ttl": "Override the TTL returned to downstream DNS clients. Zero keeps dae's default behavior.",
"upstream": "Value can be scheme://host:port, where the scheme can be tcp/udp/tcp+udp.\nIf host is a domain and has both IPv4 and IPv6 record, dae will automatically choose IPv4 or IPv6 to use according to group policy (such as min latency policy).\nPlease make sure DNS traffic will go through and be forwarded by dae, which is REQUIRED for domain routing.\nIf dial_mode is \"ip\", the upstream DNS answer SHOULD NOT be polluted, so domestic public DNS is not recommended.",
"request": `DNS request routing for ordinary client traffic uses qname and qtype.
Built-in outbounds for ordinary DNS requests: asis, reject.
diff --git a/control/control_plane.go b/control/control_plane.go
index 5df98db..a543f3b 100644
--- a/control/control_plane.go
+++ b/control/control_plane.go
@@ -771,6 +771,7 @@ func newControlPlaneWithContextOptions(
}
plane.dnsRouting = dnsUpstream
plane.dnsFixedDomainTtl = fixedDomainTtl
+ plane.dnsResponseTtl = dnsConfig.ResponseTtl
dnsControllerOption := plane.dnsControllerOption()
dnsControllerOption.OptimisticCache = dnsConfig.OptimisticCache
dnsControllerOption.OptimisticCacheTtl = dnsConfig.OptimisticCacheTtl
@@ -1256,6 +1257,7 @@ func (c *ControlPlane) dnsControllerOption() *DnsControllerOption {
Log: c.log,
LifecycleContext: c.ctx,
ConcurrencyLimit: 0,
+ ResponseTtl: c.dnsResponseTtl,
CacheAccessCallback: func(cache *DnsCache) (err error) {
if err = c.core.BatchUpdateDomainRouting(cache); err != nil {
return fmt.Errorf("BatchUpdateDomainRouting: %w", err)
diff --git a/control/dns_control.go b/control/dns_control.go
index d5bf872..b52132d 100644
--- a/control/dns_control.go
+++ b/control/dns_control.go
@@ -82,6 +82,7 @@ type DnsControllerOption struct {
OptimisticCache bool
OptimisticCacheTtl int // 0 means never expire (rely on LRU eviction)
MaxCacheSize int // maximum number of cache entries (0 = unlimited)
+ ResponseTtl int
}
type dnsControllerRuntimeState struct {
@@ -94,6 +95,7 @@ type dnsControllerRuntimeState struct {
bestDialerChooser func(ctx context.Context, req *udpRequest, upstream *dns.Upstream) (*dialArgument, error)
timeoutExceedCallback func(dialArgument *dialArgument, err error)
fixedDomainTtl map[string]int
+ responseTtl int
}
type dnsControllerStore struct {
@@ -170,6 +172,9 @@ func normalizeDnsRuntimeBehavior(option *DnsControllerOption) (qtypePrefer uint1
if err != nil {
return 0, false, 0, 0, err
}
+ if option.ResponseTtl < 0 {
+ return 0, false, 0, 0, fmt.Errorf("response_ttl must be greater than or equal to 0")
+ }
optimisticCacheTtl = option.OptimisticCacheTtl
maxCacheSize = option.MaxCacheSize
if optimisticCacheTtl == 0 && maxCacheSize == 0 {
@@ -422,6 +427,7 @@ func (c *DnsController) updateRuntime(option *DnsControllerOption, routing *dns.
bestDialerChooser: option.BestDialerChooser,
timeoutExceedCallback: option.TimeoutExceedCallback,
fixedDomainTtl: option.FixedDomainTtl,
+ responseTtl: option.ResponseTtl,
})
return nil
}
@@ -1547,10 +1553,16 @@ func (c *DnsController) NormalizeAndCacheDnsResp_(msg *dnsmessage.Msg, responseC
ttl = 31536000
}
- // For A/AAAA records, we set TTL to 0 to prevent downstream caching while we manage it.
+ responseTtl := 0
+ if rt := c.runtime(); rt != nil {
+ responseTtl = rt.responseTtl
+ }
+
+ // For A/AAAA records, we set TTL to 0 by default to prevent downstream caching while we manage it.
+ // When response_ttl is set, use it as the downstream-visible TTL.
if q.Qtype == dnsmessage.TypeA || q.Qtype == dnsmessage.TypeAAAA {
for i := range msg.Answer {
- msg.Answer[i].Header().Ttl = 0
+ msg.Answer[i].Header().Ttl = uint32(responseTtl)
}
}
diff --git a/control/dns_response_ttl_test.go b/control/dns_response_ttl_test.go
new file mode 100644
index 0000000..0e5406e
--- /dev/null
+++ b/control/dns_response_ttl_test.go
@@ -0,0 +1,64 @@
+/*
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Copyright (c) 2022-2026, daeuniverse Organization <dae@v2raya.org>
+ */
+
+package control
+
+import (
+ "net"
+ "testing"
+ "time"
+
+ dnsmessage "github.com/miekg/dns"
+ "github.com/stretchr/testify/require"
+)
+
+func TestDnsController_ResponseTtlOverride(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ responseTtl int
+ wantTtl uint32
+ }{
+ {name: "default_zero", responseTtl: 0, wantTtl: 0},
+ {name: "override", responseTtl: 60, wantTtl: 60},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ ctrl := setTestDnsControllerRuntime(newTestDnsController(), func(rt *dnsControllerRuntimeState) {
+ rt.responseTtl = tt.responseTtl
+ rt.newCache = func(fqdn string, answers, ns, extra []dnsmessage.RR, deadline time.Time, originalDeadline time.Time) (*DnsCache, error) {
+ return &DnsCache{
+ Answer: answers,
+ NS: ns,
+ Extra: extra,
+ Deadline: deadline,
+ OriginalDeadline: originalDeadline,
+ }, nil
+ }
+ })
+
+ msg := new(dnsmessage.Msg)
+ msg.SetReply(&dnsmessage.Msg{
+ Question: []dnsmessage.Question{{
+ Name: "example.com.",
+ Qtype: dnsmessage.TypeA,
+ Qclass: dnsmessage.ClassINET,
+ }},
+ })
+ msg.Answer = []dnsmessage.RR{
+ &dnsmessage.A{
+ Hdr: dnsmessage.RR_Header{
+ Name: "example.com.",
+ Rrtype: dnsmessage.TypeA,
+ Class: dnsmessage.ClassINET,
+ Ttl: 300,
+ },
+ A: net.IPv4(93, 184, 216, 34),
+ },
+ }
+
+ require.NoError(t, ctrl.NormalizeAndCacheDnsResp_(msg, "example.com.:1"))
+ require.Equal(t, tt.wantTtl, msg.Answer[0].Header().Ttl)
+ })
+ }
+}
diff --git a/control/dns_runtime.go b/control/dns_runtime.go
index b871cc3..0c1d9c6 100644
--- a/control/dns_runtime.go
+++ b/control/dns_runtime.go
@@ -20,6 +20,7 @@ type controlPlaneDNSRuntime struct {
dnsController *DnsController
dnsRouting *dns.Dns
dnsFixedDomainTtl map[string]int
+ dnsResponseTtl int
dnsListener *DNSListener
dnsListenerStopRegistered bool
delayDNSListenerStart bool
@@ -262,6 +263,7 @@ func (r *controlPlaneDNSRuntime) releaseRetainedState() {
r.dnsController = nil
r.dnsRouting = nil
r.dnsFixedDomainTtl = nil
+ r.dnsResponseTtl = 0
r.dnsListener = nil
r.dnsListenerStopRegistered = false
r.delayDNSListenerStart = false
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
--- a/trace/kern/trace.c
+++ b/trace/kern/trace.c
@@ -228,7 +228,7 @@ KPROBE_SKB_AT(1)
KPROBE_SKB_AT(2)
KPROBE_SKB_AT(3)
KPROBE_SKB_AT(4)
-KPROBE_SKB_AT(5)
+//KPROBE_SKB_AT(5)
SEC("kprobe/skb_lifetime_termination")
int kprobe_skb_lifetime_termination(struct pt_regs *ctx)
--- a/trace/trace.go
+++ b/trace/trace.go
@@ -212,8 +212,8 @@ func attachBpfToTargets(objs *bpfObjects
kp, err = link.Kprobe(fn, objs.KprobeSkb3, nil)
case 4:
kp, err = link.Kprobe(fn, objs.KprobeSkb4, nil)
- case 5:
- kp, err = link.Kprobe(fn, objs.KprobeSkb5, nil)
+ //case 5:
+ // kp, err = link.Kprobe(fn, objs.KprobeSkb5, nil)
}
if err != nil {
logrus.Debugf("failed to attach kprobe to %s: %+v\n", fn, err)
Binary file not shown.
+27 -62
View File
@@ -5,23 +5,17 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=daed
PKG_VERSION:=2026.07.31
DAED_VERSION:=daed-671e65d
WING_VERSION:=wing-dc50308
CORE_VERSION:=core-caa6f5e
WING_HASH_SHORT:=$(shell echo $(WING_VERSION) | cut -d- -f2)
CORE_HASH_SHORT:=$(shell echo $(CORE_VERSION) | cut -d- -f2)
PKG_VERSION:=2026.09.12
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_PROTO:=git
PKG_SOURCE_VERSION:=671e65d2fdcd62fe6a3ec18ecda209c5addea898
PKG_SOURCE_URL:=https://github.com/daeuniverse/daed.git
PKG_MIRROR_HASH:=skip
PKG_SOURCE:=daed-src-2026.09.12-a0181f729855.tar.gz
PKG_SOURCE_URL:=https://github.com/kenzok8/openwrt-daede/releases/download/daed-src
PKG_SOURCE_SUBDIR:=$(PKG_NAME)-$(PKG_VERSION)
PKG_HASH:=a0181f7298552497ed193e683a54b1df77f2d5441524d61989f3e3e3cf6eac51
PKG_LICENSE:=AGPL-3.0-only MIT
PKG_LICENSE_FILES:=LICENSE wing/LICENSE
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
PKG_MAINTAINER:=kenzok8
DAED_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)-$(PKG_VERSION)
PKG_BUILD_DIR:=$(DAED_BUILD_DIR)/wing
@@ -30,6 +24,13 @@ PKG_BUILD_DEPENDS:=golang/host bpf-headers
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
# armv7 lacks a CO-RE vmlinux.h for the trace eBPF (under wing/dae-core), so
# trace's bpf2go fails there. Apply sbwml/openwrt_helloworld's arm patches
# (add vmlinux-arm.h + drop the kprobe that doesn't work) to build on arm.
ifeq ($(ARCH),arm)
PATCH_DIR:=$(CURDIR)/patches_arm
endif
GO_PKG:=github.com/daeuniverse/dae-wing
GO_PKG_LDFLAGS:= \
@@ -38,16 +39,14 @@ GO_PKG_LDFLAGS:= \
-X '$(GO_PKG)/db.AppDescription=$(PKG_NAME) is a integration solution of dae, API and UI.'
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/db.AppName=$(PKG_NAME) \
$(GO_PKG)/db.AppVersion=$(DAED_VERSION)_$(WING_VERSION)_$(CORE_VERSION)
GO_PKG_TAGS:=embedallowed,trace
$(GO_PKG)/db.AppVersion=$(PKG_VERSION)
GO_PKG_TAGS:=embedallowed,trace,timetzdata
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/bpf.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
GO_PKG_BUILD_VARS+= \
GOFLAGS="-trimpath -buildvcs=false -pgo=auto"
GO_PKG_BUILD_VARS+= GOFLAGS="-trimpath -buildvcs=false -pgo=auto"
GO_PKG_TARGET_VARS+= \
CGO_LDFLAGS="$(TARGET_LDFLAGS) -static -Wl,-s" \
GOEXPERIMENT=newinliner,simd
@@ -94,52 +93,11 @@ define Package/daed/conffiles
/etc/config/daed
endef
NODE_VERSION:=v24.12.0
NODE_DIST:=node-$(NODE_VERSION)-linux-x64
NODE_URL:=https://nodejs.org/dist/$(NODE_VERSION)/$(NODE_DIST).tar.xz
define Build/Prepare
( \
rm -rf $(DAED_BUILD_DIR) ; \
mkdir -p $(DAED_BUILD_DIR) ; \
$(TAR) --strip-components=1 -C $(DAED_BUILD_DIR) -xzf $(DL_DIR)/$(PKG_NAME)-$(PKG_VERSION).tar.gz ; \
git clone https://github.com/daeuniverse/dae-wing $(PKG_BUILD_DIR) && \
git -C $(PKG_BUILD_DIR) checkout $(WING_HASH_SHORT) ; \
rm -rf $(PKG_BUILD_DIR)/dae-core ; \
git clone https://github.com/daeuniverse/dae $(PKG_BUILD_DIR)/dae-core && \
git -C $(PKG_BUILD_DIR)/dae-core checkout $(CORE_HASH_SHORT) ; \
pushd $(PKG_BUILD_DIR)/dae-core ; \
git submodule update --init ; \
go get -u=patch ; \
go mod tidy ; \
popd ; \
pushd $(PKG_BUILD_DIR) ; \
go get -u=patch ; \
go mod tidy ; \
wget -qO default.pgo "https://github.com/QiuSimons/luci-app-dae/raw/refs/heads/kix/dae/pprof/default.pgo" ; \
popd ; \
mkdir -p $(DAED_BUILD_DIR)/.node_tmp/config ; \
wget -qO - "$(NODE_URL)" | tar -xJ -C $(DAED_BUILD_DIR)/.node_tmp --strip-components=1 ; \
export PATH="$(DAED_BUILD_DIR)/.node_tmp/bin:$$$$PATH" ; \
export XDG_CONFIG_HOME="$(DAED_BUILD_DIR)/.node_tmp/config" ; \
npm install -g pnpm ; \
pushd $(DAED_BUILD_DIR) ; \
pnpm install ; \
pnpm build --filter daed ; \
popd ; \
mkdir -p $(PKG_BUILD_DIR)/webrender/web ; \
cp -rf $(DAED_BUILD_DIR)/apps/web/dist/* $(PKG_BUILD_DIR)/webrender/web ; \
find $(PKG_BUILD_DIR)/webrender/web -name "*.map" -type f -delete ; \
find $(PKG_BUILD_DIR)/webrender/web -type f -size +4k ! -name "*.gz" ! -name "*.woff" ! -name "*.woff2" -exec sh -c '\
gzip -9 -k "{}"; \
if [ "$$$$(stat -c %s {})" -lt "$$$$(stat -c %s {}.gz)" ]; then \
rm {}.gz; \
else \
rm {}; \
fi' \
";" ; \
rm -rf $(DAED_BUILD_DIR)/.node_tmp ; \
)
mkdir -p $(DAED_BUILD_DIR)
$(TAR) --strip-components=1 -C $(DAED_BUILD_DIR) -xzf $(DL_DIR)/$(PKG_SOURCE)
$(if $(wildcard $(CURDIR)/patches/*.patch),$(call PatchDir,$(PKG_BUILD_DIR),$(CURDIR)/patches,))
$(if $(filter arm,$(ARCH)),$(if $(wildcard $(CURDIR)/patches_arm/*.patch),$(call PatchDir,$(PKG_BUILD_DIR),$(CURDIR)/patches_arm,)))
endef
DAE_CFLAGS:= \
@@ -174,12 +132,19 @@ define Package/daed/install
$(call GoPackage/Package/Install/Bin,$(PKG_INSTALL_DIR))
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_INSTALL_DIR)/usr/bin/dae-wing $(1)/usr/bin/daed
$(INSTALL_BIN) $(CURDIR)/files/daed-guard $(1)/usr/bin/daed-guard
$(INSTALL_DIR) $(1)/usr/share/daed
$(INSTALL_DATA) $(CURDIR)/files/daed-cleanup.sh $(1)/usr/share/daed/cleanup.sh
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) $(CURDIR)/files/daed.config $(1)/etc/config/daed
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/daed.init $(1)/etc/init.d/daed
$(INSTALL_DIR) $(1)/lib/upgrade/keep.d
$(INSTALL_DATA) $(CURDIR)/files/daed.keep $(1)/lib/upgrade/keep.d/daed
endef
$(eval $(call GoBinPackage,daed))
+106
View File
@@ -0,0 +1,106 @@
#!/bin/sh
# daed-cleanup.sh — reaper for stale daed kernel state.
#
# Removes:
# 1. Processes inside the `daens` netns (SIGTERM, then SIGKILL after 1s)
# 2. The `daens` network namespace itself (ip netns del, with
# umount+rm as fallback for zombie nsfs mounts)
# 3. The `dae0` veth pair in the host netns
#
# daed itself owns TC clsact detach. This opkg-side helper only
# reaps stale daens/dae0 state and never removes /sys/fs/bpf/daed.
# The pin directory is created during normal startup, so it is not
# a reliable leak indicator and must not block service lifecycle.
#
# The function stays sourceable by both init.d/daed and daed-guard.
daed_process_probe() {
if command -v pgrep >/dev/null 2>&1; then
pgrep -f '^/usr/bin/daed([[:space:]]|$)' >/dev/null 2>&1
case "$?" in
0) return 0 ;;
1) return 1 ;;
esac
fi
if command -v pidof >/dev/null 2>&1; then
pidof daed >/dev/null 2>&1
case "$?" in
0) return 0 ;;
1) return 1 ;;
esac
fi
return 2
}
daed_cleanup_runtime() {
local pid rc=0 probe_rc
# If daed userspace is currently running, do not touch the
# netns, the veth, or the eBPF dataplane. They are in active
# use; removing them would make every connection through dae
# hang.
daed_process_probe
probe_rc=$?
case "$probe_rc" in
0)
if [ "${DAED_GUARD_CLEANUP:-start}" = "start" ]; then
logger -t daed-init "cleanup: pre-start skipped because /usr/bin/daed is still running; refusing a second instance"
return 1
fi
logger -t daed-init "cleanup: post-exit skipped because another /usr/bin/daed instance is still running"
return 0
;;
1) ;;
*)
logger -t daed-init "cleanup: cannot determine whether daed is running; refusing to remove netns/veth"
return 1
;;
esac
# 1. Kill processes inside daens.
for pid in $(ip netns pids daens 2>/dev/null); do
kill "$pid" 2>/dev/null
done
if [ -n "$(ip netns pids daens 2>/dev/null)" ]; then
sleep 1
for pid in $(ip netns pids daens 2>/dev/null); do
kill -9 "$pid" 2>/dev/null
done
fi
# 2. Remove the daens netns. ip netns del can fail if a
# process still references it via /proc/<pid>/ns/net or
# because the umount has already happened. Try the
# umount/rm fallback.
if ! ip netns del daens 2>/dev/null; then
umount -l /run/netns/daens 2>/dev/null
if [ -e /run/netns/daens ] && ! rm -f /run/netns/daens 2>/dev/null; then
logger -t daed-init "cleanup: failed to remove /run/netns/daens (resource busy); a reboot may be required"
rc=1
fi
fi
# 3. Remove the dae0 veth pair.
if ip link show dae0 >/dev/null 2>&1; then
if ! ip link del dae0 2>/dev/null; then
logger -t daed-init "cleanup: failed to remove dae0 veth pair"
rc=1
fi
fi
# 4. The pin root is normal persistent state. Never remove it or
# make its existence change the cleanup result.
if [ -e /sys/fs/bpf/daed ]; then
logger -t daed-init "cleanup: /sys/fs/bpf/daed exists; leaving normal pin root unchanged"
fi
# Final verification covers only netns and veth state.
[ ! -e /run/netns/daens ] || return 1
! ip netns list 2>/dev/null | grep -Eq '^daens([[:space:]]|$)' || return 1
! ip link show dae0 >/dev/null 2>&1 || return 1
return $rc
}
+131
View File
@@ -0,0 +1,131 @@
#!/bin/sh
# Keep daed as a child so signals can be forwarded and stale netns/veth
# state can be cleaned before start and after exit. daed owns TC detach;
# /sys/fs/bpf/daed is normal persistent state and is never removed here.
. /usr/share/daed/cleanup.sh
# Pre-start cleanup refuses to remove runtime state while another
# daed instance is active, and fails closed if that probe is broken.
DAED_GUARD_CLEANUP=start
if ! daed_cleanup_runtime; then
echo "daed: stale /usr/bin/daed or netns state could not be verified or removed; refusing to start. Check process and netns state." >&2
logger -t daed-init "pre-start cleanup failed: refusing to start daed"
exit 1
fi
# Keep daed as a child so post-exit cleanup runs before procd can
# respawn it.
child_pid=
pending_signal=
shutdown_signal=
shutdown_elapsed=0
forced_kill=0
child_term_timeout=20
forward_signal() {
local sig="$1"
if [ -z "$child_pid" ]; then
pending_signal="$sig"
logger -t daed-init "signal $sig received before daed child started; launch cancelled"
return 0
fi
case "$sig" in
TERM|INT|QUIT)
if [ -z "$shutdown_signal" ]; then
shutdown_signal="$sig"
shutdown_elapsed=0
fi
;;
esac
kill -"$sig" "$child_pid" 2>/dev/null
}
exit_with_signal() {
local sig="$1"
trap - TERM INT HUP QUIT
kill -"$sig" "$$" 2>/dev/null
exit 1
}
child_is_running() {
local state
kill -0 "$child_pid" 2>/dev/null || return 1
state=$(awk '{ print $3 }' "/proc/$child_pid/stat" 2>/dev/null)
[ "$state" != "Z" ]
}
trap 'forward_signal TERM' TERM
trap 'forward_signal INT' INT
trap 'forward_signal HUP' HUP
trap 'forward_signal QUIT' QUIT
start_child() {
local sig
# Keep this check inside the function as well as at the call site:
# it closes the ordinary pre-start window, while the pending-signal
# path below handles a signal arriving during the background fork.
[ -z "$pending_signal" ] || return 125
/usr/bin/daed "$@" &
child_pid=$!
if [ -n "$pending_signal" ]; then
sig="$pending_signal"
pending_signal=
forward_signal "$sig"
fi
}
if [ -n "$pending_signal" ]; then
logger -t daed-init "refusing to start daed after pending signal $pending_signal"
exit_with_signal "$pending_signal"
fi
# Best-effort OOM preference; failure must not block startup.
if ! echo -16 > /proc/self/oom_score_adj 2>/dev/null; then
logger -t daed-init "warn: failed to set /proc/self/oom_score_adj; continuing without OOM preference"
fi
if ! start_child "$@"; then
logger -t daed-init "refusing to start daed after pending signal $pending_signal"
if [ -n "$pending_signal" ]; then
exit_with_signal "$pending_signal"
fi
exit 1
fi
status=0
reaped=0
while [ "$reaped" -eq 0 ]; do
if [ -n "$shutdown_signal" ] && child_is_running; then
if [ "$shutdown_elapsed" -ge "$child_term_timeout" ]; then
if [ "$forced_kill" -eq 0 ]; then
logger -t daed-init "daed did not exit within ${child_term_timeout}s after $shutdown_signal; sending KILL"
kill -KILL "$child_pid" 2>/dev/null
forced_kill=1
fi
else
sleep 1
shutdown_elapsed=$((shutdown_elapsed + 1))
continue
fi
sleep 1
continue
fi
wait "$child_pid" 2>/dev/null
status=$?
if ! child_is_running; then
reaped=1
fi
done
child_pid=
trap - TERM INT HUP QUIT
# Post-exit cleanup runs after the child is reaped.
DAED_GUARD_CLEANUP=post-exit
cleanup_status=0
daed_cleanup_runtime || cleanup_status=$?
if [ "$cleanup_status" -ne 0 ]; then
echo "daed: runtime cleanup after exit failed" >&2
logger -t daed-init "post-exit cleanup failed; check ip netns / ip link show"
fi
if [ "$status" -ne 0 ]; then
exit "$status"
fi
exit "$cleanup_status"
+3 -1
View File
@@ -4,4 +4,6 @@ config daed 'config'
option listen_addr '0.0.0.0:2023'
option log_maxbackups '1'
option log_maxsize '5'
option subscribe_auto_update '0'
option subscribe_update_cycle 'daily'
option subscribe_update_hour '4'
Executable → Regular
+40 -13
View File
@@ -1,36 +1,41 @@
#!/bin/sh /etc/rc.common
# Copyright (C) 2023 Tianling Shen <cnsztl@immortalwrt.org>
# daed-guard handles bounded child shutdown and post-exit netns/veth cleanup.
# Keep the log file and a pre-stop state snapshot for diagnostics.
USE_PROCD=1
START=99
CONF="daed"
PROG="/usr/bin/daed"
PROG="/usr/bin/daed-guard"
LOG="/var/log/daed/daed.log"
. /usr/share/daed/cleanup.sh
cleanup_netns() {
if [ -e "/run/netns/daens" ]; then
logger -t daed "Cleaning up residual netns 'daens'..."
ip netns del daens 2>/dev/null
rm -f /run/netns/daens 2>/dev/null
fi
log() {
logger -t daed-init "$@"
}
start_service() {
log "start: begin"
config_load "$CONF"
local enabled
config_get_bool enabled "config" "enabled" "0"
[ "$enabled" -eq "1" ] || return 1
cleanup_netns
if [ "$enabled" -ne "1" ]; then
log "start: config disabled, exit"
return 1
fi
local listen_addr log_maxbackups log_maxsize
config_get listen_addr "config" "listen_addr" "0.0.0.0:2023"
config_get log_maxbackups "config" "log_maxbackups" "1"
config_get log_maxsize "config" "log_maxsize" "5"
log "start: listen=$listen_addr log_maxbackups=$log_maxbackups log_maxsize=$log_maxsize"
procd_open_instance "$CONF"
procd_set_param env DAE_LOCATION_ASSET="/usr/share/v2ray" TZ="$(uci -q get system.@system[0].zonename)"
procd_set_param command "$PROG" run
procd_append_param command --config "/etc/daed/"
procd_append_param command --listen "$listen_addr"
@@ -40,15 +45,37 @@ start_service() {
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param respawn
# daed-guard escalates its child after 20 seconds. Leave time for
# reap and post-exit cleanup before procd kills the wrapper.
procd_set_param term_timeout 30
# procd_set_param respawn: arguments are (threshold, timeout, retry).
# threshold = runtime that resets the short-lived exit counter
# timeout = seconds to wait between retries
# retry = maximum short-lived exits before procd gives up
# Reset the counter after one hour of stable runtime; otherwise retry
# after 5 seconds and stop after 10 failures.
procd_set_param respawn 3600 5 10
# daed-guard sets oom_score_adj before forking; procd has no
# oom_adj/oom_score_adj parameter.
# procd_set_param stdout 1
procd_set_param stderr 1
procd_close_instance
log "start: procd_open_instance done"
}
stop_service() {
procd_kill "$CONF" ""
cleanup_netns
log "stop: begin"
# Cleanup runs in daed-guard after its child exits. Record only a
# pre-stop snapshot here; pin entries do not prove TC attachment.
local pinned="" ns_left=""
if [ -d /sys/fs/bpf/daed ]; then
pinned=$(ls /sys/fs/bpf/daed 2>/dev/null | tr '\n' ' ')
fi
if ip netns list 2>/dev/null | grep -q '^daens'; then
ns_left="daens"
fi
log "stop: pre-stop state — bpf_pin_entries=[${pinned:-none}] netns_left=[${ns_left:-none}]"
}
service_triggers() {
+1
View File
@@ -0,0 +1 @@
/etc/daed/wing.db-wal
@@ -0,0 +1,23 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-06-23
Subject: [PATCH] daed: detach BPF hooks when stopping from dashboard
Detach BPF hooks when the dashboard stops dae through EmptyConfig.
Reported: https://github.com/kenzok8/openwrt-daede/issues/13
---
--- a/dae/run.go
+++ b/dae/run.go
@@ -138,6 +138,13 @@
/* dae-wing start */
newConf := newReloadMsg.Config
/* dae-wing end */
+
+ // openwrt-daede: dashboard stop reloads to EmptyConfig; detach hooks.
+ if newConf == EmptyConfig {
+ if e := c.DetachBpfHooks(); e != nil {
+ log.Errorln("[Stop] DetachBpfHooks:", e)
+ }
+ }
// New logger.
oldLogOutput := log.Out
log = logrus.New()
@@ -0,0 +1,18 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-06-29
Subject: [PATCH] daed: use bounded probes for manual latency tests
Use the existing bounded probe for manual dashboard latency tests.
Reported: https://github.com/kenzok8/openwrt-daede/issues/20
---
--- a/graphql/service/node/latency_mutation_utils.go
+++ b/graphql/service/node/latency_mutation_utils.go
@@ -108,7 +108,7 @@ func testSingleNodeLatency(option *diale
}
defer d.Close()
- result, err := d.ProbeLatency()
+ result, err := d.ProbeLatencyFast()
if err != nil {
msg := err.Error()
resolver.MessageV = &msg
@@ -0,0 +1,19 @@
From: kenzok8 <kenzok8@noreply>
Date: 2026-06-30
Subject: [PATCH] daed: raise manual latency probe concurrency to 16
Raise concurrent probes 8->16 so manual latency tests finish faster
when many nodes (incl. dead/timeout ones) are present.
Reported: https://github.com/kenzok8/openwrt-daede/issues/20
---
--- a/graphql/service/node/latency_mutation_utils.go
+++ b/graphql/service/node/latency_mutation_utils.go
@@ -19,7 +19,7 @@
"github.com/sirupsen/logrus"
)
-const latencyProbeConcurrency = 8
+const latencyProbeConcurrency = 16
func TestLatencies(ctx context.Context, ids *[]graphql.ID) ([]*LatencyResolver, error) {
option, err := latencyProbeOption(ctx)
@@ -0,0 +1,63 @@
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index 7a3c33f..fd61554 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -9,11 +9,13 @@ import (
"context"
"errors"
"fmt"
+ "os"
"reflect"
"regexp"
"sort"
"strings"
"sync"
+ "time"
"github.com/daeuniverse/dae-wing/common"
"github.com/daeuniverse/dae-wing/dae"
@@ -258,6 +260,15 @@ func Rename(ctx context.Context, _id graphql.ID, name string) (n int32, err erro
var runLock sync.Mutex
+const reloadWaitTimeout = 75 * time.Second
+
+func restartAfterReloadTimeout() {
+ go func() {
+ time.Sleep(time.Second)
+ os.Exit(1)
+ }()
+}
+
func Run(d *gorm.DB, noLoad bool) (n int32, err error) {
if ok := runLock.TryLock(); !ok {
return 0, fmt.Errorf("the last request didn't complete; make a cup of tea and take a break")
@@ -455,13 +466,24 @@ func Run(d *gorm.DB, noLoad bool) (n int32, err error) {
/// Reload with current config.
chReloadCallback := make(chan error)
- dae.ChReloadConfigs <- &dae.ReloadMessage{
+ reloadMsg := &dae.ReloadMessage{
Config: c,
Callback: chReloadCallback,
}
- errReload := <-chReloadCallback
- if errReload != nil {
- return 0, fmt.Errorf("failed to load new config: %w; see more in log", errReload)
+ select {
+ case dae.ChReloadConfigs <- reloadMsg:
+ case <-time.After(reloadWaitTimeout):
+ restartAfterReloadTimeout()
+ return 0, fmt.Errorf("failed to submit reload within %s; restarting daed to recover runtime", reloadWaitTimeout)
+ }
+ select {
+ case errReload := <-chReloadCallback:
+ if errReload != nil {
+ return 0, fmt.Errorf("failed to load new config: %w; see more in log", errReload)
+ }
+ case <-time.After(reloadWaitTimeout):
+ restartAfterReloadTimeout()
+ return 0, fmt.Errorf("reload timed out after %s; restarting daed to recover runtime", reloadWaitTimeout)
}
// Save running status
@@ -0,0 +1,88 @@
diff --git a/dae/run.go b/dae/run.go
index d8f86c2..a0a665d 100644
--- a/dae/run.go
+++ b/dae/run.go
@@ -32,7 +32,8 @@ var ChReloadConfigs = make(chan *ReloadMessage)
var GracefullyExit = make(chan struct{})
var EmptyConfig *daeConfig.Config
var c *control.ControlPlane
-var onceWaitingNetwork sync.Once
+var waitingNetworkMu sync.Mutex
+var networkReady bool
func init() {
sections, err := config_parser.Parse(`global{} routing{}`)
@@ -52,6 +53,19 @@ func ControlPlane() (*control.ControlPlane, error) {
return c, nil
}
+func waitForNetwork(log *logrus.Logger) error {
+ waitingNetworkMu.Lock()
+ defer waitingNetworkMu.Unlock()
+ if networkReady {
+ return nil
+ }
+ if err := WaitForNetwork(log); err != nil {
+ return err
+ }
+ networkReady = true
+ return nil
+}
+
func Run(log *logrus.Logger, conf *daeConfig.Config, externGeoDataDirs []string, disableTimestamp bool, dry bool) (err error) {
defer close(GracefullyExit)
// Not really run dae.
@@ -221,6 +235,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
// Deep copy to prevent modification.
conf = deepcopy.Copy(conf).(*daeConfig.Config)
+ if bpf == nil {
+ control.PurgeStaleTCFilters(log)
+ }
// Init Direct Dialers.
direct.InitDirectDialers(conf.Global.FallbackResolver)
@@ -228,9 +245,9 @@ func newControlPlane(log *logrus.Logger, bpf interface{}, dnsCache map[string]*c
if !conf.Global.DisableWaitingNetwork && len(conf.Global.WanInterface) > 0 {
// Wait for network for WAN ready.
- onceWaitingNetwork.Do(func() {
- WaitForNetwork(log)
- })
+ if err = waitForNetwork(log); err != nil {
+ return nil, err
+ }
}
/// Get subscription -> nodeList mapping.
diff --git a/dae/utils.go b/dae/utils.go
index 65965a2..dc43cbc 100644
--- a/dae/utils.go
+++ b/dae/utils.go
@@ -94,8 +94,9 @@ func preprocessWanInterfaceAuto(params *daeConfig.Config) error {
return nil
}
-func WaitForNetwork(log *logrus.Logger) {
+func WaitForNetwork(log *logrus.Logger) error {
epo := 5 * time.Second
+ deadline := time.Now().Add(60 * time.Second)
client := http.Client{
Transport: &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (c net.Conn, err error) {
@@ -115,6 +116,9 @@ func WaitForNetwork(log *logrus.Logger) {
}
log.Infoln("Waiting for network...")
for i := 0; ; i++ {
+ if time.Now().After(deadline) {
+ return fmt.Errorf("network unavailable after 60 seconds")
+ }
resp, err := client.Get(cmd.CheckNetworkLinks[i%len(cmd.CheckNetworkLinks)])
if err != nil {
log.Debugln("CheckNetwork:", err)
@@ -134,4 +138,5 @@ func WaitForNetwork(log *logrus.Logger) {
time.Sleep(epo)
}
log.Infoln("Network online.")
+ return nil
}
@@ -0,0 +1,617 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: strictly reconcile subscription updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
订阅更新按节点链接精确对账,普通组里的失效节点删除,fixed 组固定节点转成独立节点保留。
数据库提交后按运行状态应用配置,补齐并发地址校验、导入失败回滚和 reload 超时恢复。
---
cmd/run.go | 6 +-
graphql/mutation.go | 9 +-
graphql/root_schema.go | 2 +-
graphql/service/config/mutation_utils.go | 59 ++++-
.../service/subscription/mutation_utils.go | 181 +++++++++++----
.../subscription/mutation_utils_test.go | 212 ++++++++++++++++++
6 files changed, 408 insertions(+), 61 deletions(-)
create mode 100644 graphql/service/subscription/mutation_utils_test.go
diff --git a/cmd/run.go b/cmd/run.go
index 7dce577..978f46c 100644
--- a/cmd/run.go
+++ b/cmd/run.go
@@ -161,11 +161,8 @@ func restoreRunningState() (err error) {
if !reload {
return nil
}
- tx := db.BeginTx(context.TODO())
// Reload.
- if _, err = config.Run(tx, false); err != nil {
- tx.Rollback()
-
+ if _, err = config.Run(context.TODO(), false); err != nil {
// Another tx.
// Set running = false.
tx2 := db.BeginTx(context.TODO())
@@ -183,7 +180,6 @@ func restoreRunningState() (err error) {
tx2.Commit()
return err
}
- tx.Commit()
return nil
}
diff --git a/graphql/mutation.go b/graphql/mutation.go
index fcbf440..adb747c 100644
--- a/graphql/mutation.go
+++ b/graphql/mutation.go
@@ -256,14 +256,7 @@ func (r *MutationResolver) SelectConfig(args *struct {
func (r *MutationResolver) Run(args *struct {
Dry bool
}) (int32, error) {
- tx := db.BeginTx(context.TODO())
- ret, err := config.Run(tx, args.Dry)
- if err != nil {
- tx.Rollback()
- return 0, err
- }
- tx.Commit()
- return ret, nil
+ return config.Run(context.TODO(), args.Dry)
}
func (r *MutationResolver) CreateDns(args *struct {
diff --git a/graphql/root_schema.go b/graphql/root_schema.go
index 8b82db6..a96cace 100644
--- a/graphql/root_schema.go
+++ b/graphql/root_schema.go
@@ -121,7 +121,7 @@ type Mutation {
# tagSubscription is to give the subscription a new tag.
tagSubscription(id: ID!, tag: String!): Int! @hasRole(role: ADMIN)
- # updateSubscription is to re-fetch subscription and resolve subscription into nodes. Old nodes that independently belong to any groups will not be removed.
+ # updateSubscription re-fetches and strictly reconciles subscription nodes. Stale nodes pinned by fixed groups become independent nodes.
updateSubscription(id: ID!): Subscription! @hasRole(role: ADMIN)
# updateSubscriptionLink is to update the subscription link without re-fetching nodes.
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index fd61554..feb94d3 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -21,6 +21,7 @@ import (
"github.com/daeuniverse/dae-wing/dae"
"github.com/daeuniverse/dae-wing/db"
"github.com/daeuniverse/dae-wing/graphql/service/config/global"
+ "github.com/daeuniverse/dae-wing/graphql/service/general"
daeConfig "github.com/daeuniverse/dae/config"
"github.com/daeuniverse/dae/pkg/config_parser"
"github.com/graph-gophers/graphql-go"
@@ -269,21 +270,69 @@ func restartAfterReloadTimeout() {
}()
}
-func Run(d *gorm.DB, noLoad bool) (n int32, err error) {
+func runTransaction(ctx context.Context, noLoad bool) (n int32, err error) {
+ tx := db.BeginTx(ctx)
+ if tx.Error != nil {
+ return 0, tx.Error
+ }
+ n, err = runLocked(tx, noLoad)
+ if err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ if err = tx.Commit().Error; err != nil {
+ return 0, err
+ }
+ return n, nil
+}
+
+func Run(ctx context.Context, noLoad bool) (n int32, err error) {
if ok := runLock.TryLock(); !ok {
return 0, fmt.Errorf("the last request didn't complete; make a cup of tea and take a break")
}
defer runLock.Unlock()
+ return runTransaction(ctx, noLoad)
+}
+
+func ApplyIfRunning(ctx context.Context) error {
+ runLock.Lock()
+ defer runLock.Unlock()
+
+ daeResolver := general.DaeResolver{Ctx: ctx}
+ running, err := daeResolver.Running()
+ if err != nil || !running {
+ return err
+ }
+ modified, err := daeResolver.Modified()
+ if err != nil || !modified {
+ return err
+ }
+ _, err = runTransaction(ctx, false)
+ return err
+}
+
+func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
//// Dry run.
if noLoad {
ch := make(chan error)
- dae.ChReloadConfigs <- &dae.ReloadMessage{
+ reloadMsg := &dae.ReloadMessage{
Config: dae.EmptyConfig,
Callback: ch,
}
- err = <-ch
- if err != nil {
- return 0, fmt.Errorf("failed to dryrun: %w; see more in log and report bugs", err)
+ select {
+ case dae.ChReloadConfigs <- reloadMsg:
+ case <-time.After(reloadWaitTimeout):
+ restartAfterReloadTimeout()
+ return 0, fmt.Errorf("failed to submit dryrun within %s; restarting daed to recover runtime", reloadWaitTimeout)
+ }
+ select {
+ case err = <-ch:
+ if err != nil {
+ return 0, fmt.Errorf("failed to dryrun: %w; see more in log and report bugs", err)
+ }
+ case <-time.After(reloadWaitTimeout):
+ restartAfterReloadTimeout()
+ return 0, fmt.Errorf("dryrun timed out after %s; restarting daed to recover runtime", reloadWaitTimeout)
}
// Running -> false
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index e55e65b..e01a342 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -18,6 +18,7 @@ import (
"github.com/daeuniverse/dae-wing/dae"
"github.com/daeuniverse/dae-wing/db"
"github.com/daeuniverse/dae-wing/graphql/internal"
+ "github.com/daeuniverse/dae-wing/graphql/service/config"
"github.com/daeuniverse/dae-wing/graphql/service/node"
"github.com/daeuniverse/dae/common/subscription"
"github.com/go-co-op/gocron"
@@ -171,8 +172,9 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) {
}
var (
- schedulerCache = make(map[uint]*gocron.Scheduler)
- schedulerMu sync.RWMutex
+ schedulerCache = make(map[uint]*gocron.Scheduler)
+ schedulerMu sync.RWMutex
+ subscriptionUpdateMu sync.Mutex
)
func UpdateAll(ctx context.Context) {
@@ -258,68 +260,161 @@ func Update(ctx context.Context, _id graphql.ID) (r *Resolver, err error) {
return &Resolver{Subscription: m}, nil
}
-func UpdateById(ctx context.Context, subId uint) (sub *db.Subscription, err error) {
- // Fetch node links.
- var m db.Subscription
- if err = db.DB(ctx).Where(&db.Subscription{ID: subId}).First(&m).Error; err != nil {
- return nil, err
+func reconcileSubscriptionNodes(tx *gorm.DB, subId uint, links []string) error {
+ var existing []db.Node
+ if err := tx.Where("subscription_id = ?", subId).Find(&existing).Error; err != nil {
+ return err
}
- links, err := fetchLinks(m.Link)
- if err != nil {
- return nil, err
+
+ incoming := make(map[string]struct{}, len(links))
+ for _, link := range links {
+ incoming[link] = struct{}{}
}
- tx := db.BeginTx(ctx)
- defer func() {
- if err == nil {
- tx.Commit()
+ current := make(map[string]struct{}, len(existing))
+ var staleIds []uint
+ for _, n := range existing {
+ if _, ok := incoming[n.Link]; ok {
+ current[n.Link] = struct{}{}
+ continue
+ }
+ staleIds = append(staleIds, n.ID)
+ }
+
+ fixed := make(map[uint]struct{})
+ if len(staleIds) > 0 {
+ var fixedIds []uint
+ if err := tx.Table("group_nodes").
+ Distinct("group_nodes.node_id").
+ Joins("INNER JOIN groups ON groups.id = group_nodes.group_id").
+ Where("groups.policy = ?", "fixed").
+ Where("group_nodes.node_id IN ?", staleIds).
+ Pluck("group_nodes.node_id", &fixedIds).Error; err != nil {
+ return err
+ }
+ for _, id := range fixedIds {
+ fixed[id] = struct{}{}
+ }
+ }
+
+ var detachIds, deleteIds []uint
+ for _, id := range staleIds {
+ if _, ok := fixed[id]; ok {
+ detachIds = append(detachIds, id)
} else {
- tx.Rollback()
+ deleteIds = append(deleteIds, id)
+ }
+ }
+
+ if len(staleIds) > 0 {
+ if err := node.AutoUpdateVersionByIds(tx, staleIds); err != nil {
+ return err
+ }
+ }
+ if len(detachIds) > 0 {
+ if err := tx.Model(&db.Node{}).
+ Where("id IN ?", detachIds).
+ Update("subscription_id", nil).Error; err != nil {
+ return err
+ }
+ }
+ if len(deleteIds) > 0 {
+ if err := tx.Exec("DELETE FROM group_nodes WHERE node_id IN ?", deleteIds).Error; err != nil {
+ return err
+ }
+ if err := tx.Where("id IN ?", deleteIds).Delete(&db.Node{}).Error; err != nil {
+ return err
}
- }()
- // Remove those nodes whose subscription are independent from any groups.
- subQuery := tx.Raw(`select nodes.id as id
- from nodes
- inner join group_nodes on group_nodes.node_id = nodes.id
- where subscription_id = ?`, subId)
-
- if err = tx.Where("subscription_id = ?", subId).
- Where("id not in (?)", subQuery).
- Select(clause.Associations).
- Delete(&db.Node{}).Error; err != nil {
- return nil, err
}
- // Import node links.
+
+ seen := make(map[string]struct{}, len(links))
var args []*internal.ImportArgument
for _, link := range links {
+ if _, ok := seen[link]; ok {
+ continue
+ }
+ seen[link] = struct{}{}
+ if _, ok := current[link]; ok {
+ continue
+ }
args = append(args, &internal.ImportArgument{Link: link})
}
- result, err := node.Import(tx, false, &subId, args)
+ result, err := node.Import(tx, true, &subId, args)
if err != nil {
- return nil, err
+ return errors.New("failed to import subscription node")
}
- hasAnyCandidate := false
for _, r := range result {
- if r.Error == nil {
- hasAnyCandidate = true
- break
+ if r.Error != nil {
+ return errors.New("failed to import subscription node")
}
}
- if !hasAnyCandidate {
- return nil, fmt.Errorf("interrupt to update subscription: no any valid node can be imported")
+
+ var count int64
+ if err := tx.Model(&db.Node{}).Where("subscription_id = ?", subId).Count(&count).Error; err != nil {
+ return err
}
- // Update updated_at and return the latest version.
- if err = tx.Model(&m).
+ if count == 0 {
+ return fmt.Errorf("interrupt to update subscription: no any valid node can be imported")
+ }
+ return nil
+}
+
+func updateSubscriptionTx(ctx context.Context, m *db.Subscription, subId uint, links []string) (err error) {
+ subscriptionUpdateMu.Lock()
+ defer subscriptionUpdateMu.Unlock()
+
+ tx := db.BeginTx(ctx)
+ if tx.Error != nil {
+ return tx.Error
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
+ var current db.Subscription
+ if err = tx.Where(&db.Subscription{ID: subId}).First(&current).Error; err != nil {
+ return err
+ }
+ if current.Link != m.Link {
+ return fmt.Errorf("subscription changed during update; retry")
+ }
+ *m = current
+
+ if err = reconcileSubscriptionNodes(tx, subId, links); err != nil {
+ return err
+ }
+ q := tx.Model(m).
Clauses(clause.Returning{}).
Where(&db.Subscription{ID: subId}).
- Update("updated_at", time.Now()).Error; err != nil {
- return nil, err
+ Update("updated_at", time.Now())
+ if q.Error != nil {
+ return q.Error
+ }
+ if q.RowsAffected == 0 {
+ return fmt.Errorf("no such subscription")
}
-
- // Update modified if subscription is referenced by running config.
if err = AutoUpdateVersionByIds(tx, []uint{subId}); err != nil {
+ return err
+ }
+ return tx.Commit().Error
+}
+
+func UpdateById(ctx context.Context, subId uint) (sub *db.Subscription, err error) {
+ var m db.Subscription
+ if err = db.DB(ctx).Where(&db.Subscription{ID: subId}).First(&m).Error; err != nil {
+ return nil, err
+ }
+ links, err := fetchLinks(m.Link)
+ if err != nil {
return nil, err
}
+ if err = updateSubscriptionTx(ctx, &m, subId, links); err != nil {
+ return nil, err
+ }
+ if err = config.ApplyIfRunning(ctx); err != nil {
+ return nil, fmt.Errorf("subscription updated but failed to apply runtime: %w", err)
+ }
return &m, nil
}
@@ -399,6 +494,8 @@ func UpdateLink(ctx context.Context, _id graphql.ID, link string) (r *Resolver,
if err != nil {
return nil, err
}
+ subscriptionUpdateMu.Lock()
+ defer subscriptionUpdateMu.Unlock()
tx := db.BeginTx(ctx)
defer func() {
diff --git a/graphql/service/subscription/mutation_utils_test.go b/graphql/service/subscription/mutation_utils_test.go
new file mode 100644
index 0000000..18ff9b9
--- /dev/null
+++ b/graphql/service/subscription/mutation_utils_test.go
@@ -0,0 +1,212 @@
+/*
+ * SPDX-License-Identifier: AGPL-3.0-only
+ * Copyright (c) 2023, daeuniverse Organization <team@v2raya.org>
+ */
+
+package subscription
+
+import (
+ "context"
+ "fmt"
+ "testing"
+ "time"
+
+ "github.com/daeuniverse/dae-wing/db"
+)
+
+func mustNoError(t *testing.T, err error) {
+ t.Helper()
+ if err != nil {
+ t.Fatal(err)
+ }
+}
+
+func assertError(t *testing.T, err error) {
+ t.Helper()
+ if err == nil {
+ t.Fatal("expected error")
+ }
+}
+
+func assertEqualValues(t *testing.T, want, got interface{}) {
+ t.Helper()
+ if fmt.Sprint(want) != fmt.Sprint(got) {
+ t.Fatalf("want %v, got %v", want, got)
+ }
+}
+
+func assertNotNil(t *testing.T, value *uint) {
+ t.Helper()
+ if value == nil {
+ t.Fatal("expected non-nil value")
+ }
+}
+
+func assertNil(t *testing.T, value *uint) {
+ t.Helper()
+ if value != nil {
+ t.Fatalf("expected nil, got %v", *value)
+ }
+}
+
+func assertZero(t *testing.T, value int64) {
+ t.Helper()
+ if value != 0 {
+ t.Fatalf("expected zero, got %d", value)
+ }
+}
+
+func initReconcileTestDB(t *testing.T) context.Context {
+ t.Helper()
+ mustNoError(t, db.InitDatabase(t.TempDir()))
+ return context.Background()
+}
+
+func createTestSubscription(t *testing.T, ctx context.Context, links ...string) (db.Subscription, []db.Node) {
+ t.Helper()
+ sub := db.Subscription{
+ UpdatedAt: time.Now(),
+ Link: "https://example.invalid/subscription",
+ Status: "",
+ Info: "",
+ }
+ mustNoError(t, db.DB(ctx).Create(&sub).Error)
+
+ nodes := make([]db.Node, 0, len(links))
+ for i, link := range links {
+ node := db.Node{
+ Link: link,
+ Name: "node",
+ Address: "192.0.2.1",
+ Protocol: "ss",
+ SubscriptionID: &sub.ID,
+ }
+ node.Name += string(rune('A' + i))
+ mustNoError(t, db.DB(ctx).Create(&node).Error)
+ nodes = append(nodes, node)
+ }
+ return sub, nodes
+}
+
+func createTestGroup(t *testing.T, ctx context.Context, policy string, systemID *uint, nodes ...db.Node) db.Group {
+ t.Helper()
+ group := db.Group{Name: policy + time.Now().Format("150405.000000000"), Policy: policy, SystemID: systemID}
+ mustNoError(t, db.DB(ctx).Create(&group).Error)
+ mustNoError(t, db.DB(ctx).Model(&group).Association("Node").Append(nodes))
+ return group
+}
+
+func countGroupNode(t *testing.T, ctx context.Context, groupID, nodeID uint) int64 {
+ t.Helper()
+ var count int64
+ mustNoError(t, db.DB(ctx).Table("group_nodes").
+ Where("group_id = ? AND node_id = ?", groupID, nodeID).
+ Count(&count).Error)
+ return count
+}
+
+func TestReconcileKeepsCurrentReferencedNode(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current")
+ group := createTestGroup(t, ctx, "min_moving_avg", nil, nodes[0])
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://current"}))
+
+ var got db.Node
+ mustNoError(t, db.DB(ctx).First(&got, nodes[0].ID).Error)
+ assertNotNil(t, got.SubscriptionID)
+ assertEqualValues(t, sub.ID, *got.SubscriptionID)
+ assertEqualValues(t, 1, countGroupNode(t, ctx, group.ID, got.ID))
+}
+
+func TestReconcileDeletesStaleUnreferencedNode(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current", "ss://stale")
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://current"}))
+
+ var count int64
+ mustNoError(t, db.DB(ctx).Model(&db.Node{}).Where("id = ?", nodes[1].ID).Count(&count).Error)
+ assertZero(t, count)
+}
+
+func TestReconcileDeletesStaleNormalGroupNode(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ system := db.System{Running: true}
+ mustNoError(t, db.DB(ctx).Create(&system).Error)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current", "ss://stale")
+ group := createTestGroup(t, ctx, "min_moving_avg", &system.ID, nodes...)
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://current"}))
+
+ var gotGroup db.Group
+ mustNoError(t, db.DB(ctx).First(&gotGroup, group.ID).Error)
+ assertEqualValues(t, 1, gotGroup.Version)
+ assertEqualValues(t, 0, countGroupNode(t, ctx, group.ID, nodes[1].ID))
+}
+
+func TestReconcileDetachesStaleFixedNode(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current", "ss://fixed-stale")
+ group := createTestGroup(t, ctx, "fixed", nil, nodes[1])
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://current"}))
+
+ var got db.Node
+ mustNoError(t, db.DB(ctx).First(&got, nodes[1].ID).Error)
+ assertNil(t, got.SubscriptionID)
+ assertEqualValues(t, 1, countGroupNode(t, ctx, group.ID, got.ID))
+}
+
+func TestReconcilePreservesNodeReferencedByFixedAndNormalGroups(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current", "ss://shared-stale")
+ fixed := createTestGroup(t, ctx, "fixed", nil, nodes[1])
+ normal := createTestGroup(t, ctx, "min", nil, nodes[1])
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://current"}))
+
+ var got db.Node
+ mustNoError(t, db.DB(ctx).First(&got, nodes[1].ID).Error)
+ assertNil(t, got.SubscriptionID)
+ assertEqualValues(t, 1, countGroupNode(t, ctx, fixed.ID, got.ID))
+ assertEqualValues(t, 1, countGroupNode(t, ctx, normal.ID, got.ID))
+}
+
+func TestReconcileAllLinksAlreadyExistSucceeds(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://one", "ss://two")
+
+ mustNoError(t, reconcileSubscriptionNodes(db.DB(ctx), sub.ID, []string{"ss://one", "ss://two", "ss://two"}))
+
+ var count int64
+ mustNoError(t, db.DB(ctx).Model(&db.Node{}).Where("subscription_id = ?", sub.ID).Count(&count).Error)
+ assertEqualValues(t, len(nodes), count)
+}
+
+func TestReconcileRollsBackWhenAnyIncomingNodeIsInvalid(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current", "ss://stale")
+ tx := db.BeginTx(ctx)
+
+ assertError(t, reconcileSubscriptionNodes(tx, sub.ID, []string{"ss://current", "not-a-node-link"}))
+ mustNoError(t, tx.Rollback().Error)
+
+ var count int64
+ mustNoError(t, db.DB(ctx).Model(&db.Node{}).Where("id IN ?", []uint{nodes[0].ID, nodes[1].ID}).Count(&count).Error)
+ assertEqualValues(t, 2, count)
+}
+
+func TestUpdateSubscriptionRejectsChangedLink(t *testing.T) {
+ ctx := initReconcileTestDB(t)
+ sub, nodes := createTestSubscription(t, ctx, "ss://current")
+ snapshot := sub
+ mustNoError(t, db.DB(ctx).Model(&sub).Update("link", "https://example.invalid/changed").Error)
+
+ assertError(t, updateSubscriptionTx(ctx, &snapshot, sub.ID, []string{"ss://replacement"}))
+
+ var got db.Node
+ mustNoError(t, db.DB(ctx).First(&got, nodes[0].ID).Error)
+ assertNotNil(t, got.SubscriptionID)
+ assertEqualValues(t, sub.ID, *got.SubscriptionID)
+}
@@ -0,0 +1,24 @@
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index e01a342..c6bc3e1 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -171,6 +171,8 @@ func AutoUpdateVersionByIds(d *gorm.DB, ids []uint) (err error) {
return nil
}
+const scheduledUpdateTimeout = 3 * time.Minute
+
var (
schedulerCache = make(map[uint]*gocron.Scheduler)
schedulerMu sync.RWMutex
@@ -224,7 +226,9 @@ func AddUpdateScheduler(ctx context.Context, id uint) {
}
logrus.Info("Subscription " + tag + " update task enabled, with exp " + sub.CronExp)
_, err := s.Cron(sub.CronExp).Do(func() {
- if _, err := UpdateById(context.Background(), sub.ID); err != nil {
+ ctx, cancel := context.WithTimeout(context.Background(), scheduledUpdateTimeout)
+ defer cancel()
+ if _, err := UpdateById(ctx, sub.ID); err != nil {
logrus.Error(err)
}
})
@@ -0,0 +1,73 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: open sqlite database in WAL mode
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
订阅自动更新写库时,WebUI 登录查询被锁 5 秒后报 database is locked。
开 WAL 并把 busy_timeout 提到 30 秒,读不再被写事务阻塞;WAL 附带的两个文件跟主库一样收紧到 0640。
---
db/db.go | 17 ++++++++++++-----
pkg/sqlite/sqlite_mipsarch.go | 4 +++-
pkg/sqlite/sqlite_others.go | 4 +++-
3 files changed, 18 insertions(+), 7 deletions(-)
diff --git a/db/db.go b/db/db.go
index a79022b..6145443 100644
--- a/db/db.go
+++ b/db/db.go
@@ -50,13 +50,20 @@ func InitDatabase(configDir string) (err error) {
); err != nil {
return err
}
- if fi, err := os.Stat(path); err != nil {
- return err
- } else if fi.Mode()&0037 > 0 {
- // Too open, chmod it to 0640.
- if err = os.Chmod(path, 0640); err != nil {
+ for _, p := range []string{path, path + "-wal", path + "-shm"} {
+ fi, err := os.Stat(p)
+ if os.IsNotExist(err) {
+ continue
+ }
+ if err != nil {
return err
}
+ if fi.Mode()&0037 > 0 {
+ // Too open, chmod it to 0640.
+ if err = os.Chmod(p, 0640); err != nil {
+ return err
+ }
+ }
}
return nil
diff --git a/pkg/sqlite/sqlite_mipsarch.go b/pkg/sqlite/sqlite_mipsarch.go
index 7b8c1b4..8c275e5 100644
--- a/pkg/sqlite/sqlite_mipsarch.go
+++ b/pkg/sqlite/sqlite_mipsarch.go
@@ -10,6 +10,8 @@ import (
"gorm.io/gorm"
)
+const pragmas = "?_journal_mode=WAL&_busy_timeout=30000&_synchronous=FULL"
+
func Open(dsn string) gorm.Dialector {
- return sqlite.Open(dsn)
+ return sqlite.Open(dsn + pragmas)
}
diff --git a/pkg/sqlite/sqlite_others.go b/pkg/sqlite/sqlite_others.go
index 4598651..83d8b80 100644
--- a/pkg/sqlite/sqlite_others.go
+++ b/pkg/sqlite/sqlite_others.go
@@ -7,6 +7,8 @@ import (
"gorm.io/gorm"
)
+const pragmas = "?_pragma=journal_mode(WAL)&_pragma=busy_timeout(30000)&_pragma=synchronous(FULL)"
+
func Open(dsn string) gorm.Dialector {
- return sqlite.Open(dsn)
+ return sqlite.Open(dsn + pragmas)
}
@@ -0,0 +1,166 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: reload dae outside the database transaction
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
配置生效前先提交读事务,reload 完再用短事务写运行状态。
原来整个 reload 都在事务里,最长可占着数据库锁一百多秒;写状态时按 ID 重查仍存在的分组,避免把 reload 期间被删的分组重新插回去。
---
graphql/service/config/mutation_utils.go | 77 ++++++++++++++++--------
1 file changed, 53 insertions(+), 24 deletions(-)
diff --git a/graphql/service/config/mutation_utils.go b/graphql/service/config/mutation_utils.go
index feb94d3..59f0ebd 100644
--- a/graphql/service/config/mutation_utils.go
+++ b/graphql/service/config/mutation_utils.go
@@ -270,28 +270,12 @@ func restartAfterReloadTimeout() {
}()
}
-func runTransaction(ctx context.Context, noLoad bool) (n int32, err error) {
- tx := db.BeginTx(ctx)
- if tx.Error != nil {
- return 0, tx.Error
- }
- n, err = runLocked(tx, noLoad)
- if err != nil {
- tx.Rollback()
- return 0, err
- }
- if err = tx.Commit().Error; err != nil {
- return 0, err
- }
- return n, nil
-}
-
func Run(ctx context.Context, noLoad bool) (n int32, err error) {
if ok := runLock.TryLock(); !ok {
return 0, fmt.Errorf("the last request didn't complete; make a cup of tea and take a break")
}
defer runLock.Unlock()
- return runTransaction(ctx, noLoad)
+ return runLocked(ctx, noLoad)
}
func ApplyIfRunning(ctx context.Context) error {
@@ -307,11 +291,11 @@ func ApplyIfRunning(ctx context.Context) error {
if err != nil || !modified {
return err
}
- _, err = runTransaction(ctx, false)
+ _, err = runLocked(ctx, false)
return err
}
-func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
+func runLocked(ctx context.Context, noLoad bool) (n int32, err error) {
//// Dry run.
if noLoad {
ch := make(chan error)
@@ -336,18 +320,38 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
}
// Running -> false
+ tx := db.BeginTx(ctx)
+ if tx.Error != nil {
+ return 0, tx.Error
+ }
var sys db.System
- if err = d.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil {
+ if err = tx.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil {
+ tx.Rollback()
return 0, err
}
- if err = d.Model(&sys).Updates(map[string]interface{}{
+ if err = tx.Model(&sys).Updates(map[string]interface{}{
"running": false,
}).Error; err != nil {
+ tx.Rollback()
+ return 0, err
+ }
+ if err = tx.Commit().Error; err != nil {
return 0, err
}
return 1, nil
}
+ d := db.BeginTx(ctx)
+ if d.Error != nil {
+ return 0, d.Error
+ }
+ readTx := d
+ defer func() {
+ if readTx != nil {
+ readTx.Rollback()
+ }
+ }()
+
//// Run selected global+dns+routing.
/// Get them from database and parse them to daeConfig.
var mConfig db.Config
@@ -513,6 +517,11 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
c.Node = append(c.Node, daeConfig.KeyableString(fmt.Sprintf("%v:%v", node.uniqueName, node.dbNode.Link)))
}
+ if err = readTx.Commit().Error; err != nil {
+ return 0, err
+ }
+ readTx = nil
+
/// Reload with current config.
chReloadCallback := make(chan error)
reloadMsg := &dae.ReloadMessage{
@@ -536,20 +545,37 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
}
// Save running status
+ tx := db.BeginTx(context.WithoutCancel(ctx))
+ if tx.Error != nil {
+ return 0, tx.Error
+ }
+ defer func() {
+ if err != nil {
+ tx.Rollback()
+ }
+ }()
var sys db.System
- if err = d.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil {
+ if err = tx.Model(&db.System{}).FirstOrCreate(&sys).Error; err != nil {
return 0, err
}
var gvs uint
var gids []string
+ groupIds := make([]uint, 0, len(groups))
for _, g := range groups {
gvs += g.Version
gids = append(gids, fmt.Sprintf("%x", g.ID))
+ groupIds = append(groupIds, g.ID)
+ }
+ liveGroups := make([]db.Group, 0, len(groupIds))
+ if len(groupIds) > 0 {
+ if err = tx.Where("id IN ?", groupIds).Find(&liveGroups).Error; err != nil {
+ return 0, err
+ }
}
sort.Slice(gids, func(i, j int) bool {
return gids[i] < gids[j]
})
- if err = d.Model(&sys).Updates(map[string]interface{}{
+ if err = tx.Model(&sys).Updates(map[string]interface{}{
"running": true,
"running_config_id": mConfig.ID,
"running_config_version": mConfig.Version,
@@ -562,7 +588,10 @@ func runLocked(d *gorm.DB, noLoad bool) (n int32, err error) {
}).Error; err != nil {
return 0, err
}
- if err = d.Model(&sys).Association("RunningGroups").Replace(groups); err != nil {
+ if err = tx.Model(&sys).Association("RunningGroups").Replace(liveGroups); err != nil {
+ return 0, err
+ }
+ if err = tx.Commit().Error; err != nil {
return 0, err
}
@@ -0,0 +1,68 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: serialize scheduled subscription updates
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
订阅更新的入口读也纳入同一把锁,整轮更新串行,排到才开始计时。
多个订阅定时任务撞在同一分钟时会互相锁死。
---
.../service/subscription/mutation_utils.go | 29 +++++++++++++------
1 file changed, 20 insertions(+), 9 deletions(-)
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index c6bc3e1..e2aec32 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -225,12 +225,8 @@ func AddUpdateScheduler(ctx context.Context, id uint) {
tag = *sub.Tag
}
logrus.Info("Subscription " + tag + " update task enabled, with exp " + sub.CronExp)
- _, err := s.Cron(sub.CronExp).Do(func() {
- ctx, cancel := context.WithTimeout(context.Background(), scheduledUpdateTimeout)
- defer cancel()
- if _, err := UpdateById(ctx, sub.ID); err != nil {
- logrus.Error(err)
- }
+ _, err := s.Cron(sub.CronExp).SingletonMode().Do(func() {
+ runScheduledUpdate(sub.ID)
})
if err != nil {
logrus.Errorf("Failed to schedule subscription %d update: invalid cron expression '%s': %v", sub.ID, sub.CronExp, err)
@@ -364,9 +360,6 @@ func reconcileSubscriptionNodes(tx *gorm.DB, subId uint, links []string) error {
}
func updateSubscriptionTx(ctx context.Context, m *db.Subscription, subId uint, links []string) (err error) {
- subscriptionUpdateMu.Lock()
- defer subscriptionUpdateMu.Unlock()
-
tx := db.BeginTx(ctx)
if tx.Error != nil {
return tx.Error
@@ -404,7 +397,25 @@ func updateSubscriptionTx(ctx context.Context, m *db.Subscription, subId uint, l
return tx.Commit().Error
}
+func runScheduledUpdate(subId uint) {
+ subscriptionUpdateMu.Lock()
+ defer subscriptionUpdateMu.Unlock()
+
+ ctx, cancel := context.WithTimeout(context.Background(), scheduledUpdateTimeout)
+ defer cancel()
+ if _, err := updateByIdLocked(ctx, subId); err != nil {
+ logrus.Error(err)
+ }
+}
+
func UpdateById(ctx context.Context, subId uint) (sub *db.Subscription, err error) {
+ subscriptionUpdateMu.Lock()
+ defer subscriptionUpdateMu.Unlock()
+
+ return updateByIdLocked(ctx, subId)
+}
+
+func updateByIdLocked(ctx context.Context, subId uint) (sub *db.Subscription, err error) {
var m db.Subscription
if err = db.DB(ctx).Where(&db.Subscription{ID: subId}).First(&m).Error; err != nil {
return nil, err
@@ -0,0 +1,44 @@
From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001
Subject: [PATCH] daed: apply subscription cron changes without restart
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
改 cron 后先提交事务再重建调度器。
原来在事务里就去重建,调度器另开连接读到的还是旧表达式,必须重启 daed 才生效。
---
graphql/service/subscription/mutation_utils.go | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/graphql/service/subscription/mutation_utils.go b/graphql/service/subscription/mutation_utils.go
index e2aec32..4f6bcfc 100644
--- a/graphql/service/subscription/mutation_utils.go
+++ b/graphql/service/subscription/mutation_utils.go
@@ -561,10 +561,12 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable
}
tx := db.BeginTx(ctx)
+ if tx.Error != nil {
+ return nil, tx.Error
+ }
+ committed := false
defer func() {
- if err == nil {
- tx.Commit()
- } else {
+ if !committed {
tx.Rollback()
}
}()
@@ -583,6 +585,10 @@ func UpdateCron(ctx context.Context, _id graphql.ID, cronExp string, cronEnable
}).Error; err != nil {
return nil, err
}
+ if err = tx.Commit().Error; err != nil {
+ return nil, err
+ }
+ committed = true
// Update scheduler
RemoveUpdateScheduler(id)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,24 @@
--- a/dae-core/trace/kern/trace.c
+++ b/dae-core/trace/kern/trace.c
@@ -228,7 +228,7 @@ KPROBE_SKB_AT(1)
KPROBE_SKB_AT(2)
KPROBE_SKB_AT(3)
KPROBE_SKB_AT(4)
-KPROBE_SKB_AT(5)
+//KPROBE_SKB_AT(5)
SEC("kprobe/skb_lifetime_termination")
int kprobe_skb_lifetime_termination(struct pt_regs *ctx)
--- a/dae-core/trace/trace.go
+++ b/dae-core/trace/trace.go
@@ -206,8 +206,8 @@ func attachBpfToTargets(objs *bpfObjects
kp, err = link.Kprobe(fn, objs.KprobeSkb3, nil)
case 4:
kp, err = link.Kprobe(fn, objs.KprobeSkb4, nil)
- case 5:
- kp, err = link.Kprobe(fn, objs.KprobeSkb5, nil)
+ //case 5:
+ // kp, err = link.Kprobe(fn, objs.KprobeSkb5, nil)
}
if err != nil {
logrus.Debugf("failed to attach kprobe to %s: %+v\n", fn, err)
+13 -11
View File
@@ -1,21 +1,19 @@
# SPDX-License-Identifier: GPL-3.0-only
#
# Copyright (C) 2021-2023 sirpdboy <herboy2008@gmail.com>
#
# This is free software, licensed under the Apache License, Version 2.0 .
# Copyright (C) 2021-2026 sirpdboy <herboy2008@gmail.com>
#
include $(TOPDIR)/rules.mk
PKG_NAME:=ddns-go
PKG_VERSION:=6.17.6
PKG_VERSION:=6.17.7
PKG_RELEASE:=1
PKG_VERSION:=6.17.7
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/jeessy2/ddns-go/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=5fd986644132678b6e80be6dfa5d57253b1640d661053e4820daa57320b42720
PKG_HASH:=f7001004e092d9641aad5a94158e0b4cae4a53a7f5c7d96d5c6af3d246c56fcc
PKG_LICENSE:=MIT
PKG_HASH:=f7001004e092d9641aad5a94158e0b4cae4a53a7f5c7d96d5c6af3d246c56fcc
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=Tianling Shen <cnsztl@immortalwrt.org>
@@ -44,14 +42,18 @@ define Package/ddns-go/description
support Alidns Dnspod Cloudflare Hicloud Callback Baiducloud porkbun GoDaddy Google Domains.
endef
define Package/ddns-go/conffiles
/etc/config/ddns-go
/etc/ddns-go/ddns-go-config.yaml
endef
define Package/ddns-go/install
$(call GoPackage/Package/Install/Bin,$(1))
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/file/ddns-go.init $(1)/etc/init.d/ddns-go
$(INSTALL_DIR) $(1)/etc/uci-defaults
$(INSTALL_BIN) $(CURDIR)/file/luci-ddns-go.uci-default $(1)/etc/uci-defaults/luci-ddns-go
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_BIN) $(CURDIR)/files/ddns-go.init $(1)/etc/init.d/ddns-go
$(INSTALL_CONF) $(CURDIR)/files/ddns-go.conf $(1)/etc/config/ddns-go
endef
$(eval $(call GoBinPackage,ddns-go))
-46
View File
@@ -1,46 +0,0 @@
#!/bin/sh /etc/rc.common
#
# Copyright (C) 2021-2023 sirpdboy <herboy2008@gmail.com> https://github.com/sirpdboy/luci-app-ddns-go
#
# This file is part of ddns-go .
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
START=99
USE_PROCD=1
PROG=/usr/bin/ddns-go
CONFDIR=/etc/ddns-go
CONF=$CONFDIR/ddns-go-config.yaml
get_config() {
config_get_bool enabled $1 enabled 1
config_get_bool logger $1 logger 1
config_get port $1 port 9876
config_get time $1 time 300
}
init_yaml(){
[ -d $CONFDIR ] || mkdir -p $CONFDIR 2>/dev/null
cat /usr/share/ddns-go/ddns-go-default.yaml > $CONF
}
start_service() {
config_load ddns-go
config_foreach get_config basic
[ x$enabled == x1 ] || return 1
[ -s ${CONF} ] || init_yaml
logger -t ddns-go -p warn "ddns-go is start."
echo "ddns-go is start."
procd_open_instance
procd_set_param command $PROG -l :$port -f $time -c "$CONF"
[ "x$logger" == x1 ] && procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "ddns-go"
}
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
[ -s "/etc/ddns-go/localtime" ] && mv -f /etc/ddns-go/localtime /etc/localtime
/etc/init.d/ddns-go enable
/etc/init.d/ddns-go start
rm -f /tmp/luci*
exit 0
+9
View File
@@ -0,0 +1,9 @@
config basic 'config'
option enabled '0'
option logger '1'
option port '9876'
option time '300'
option ctimes '5'
option skipverify '0'
option delay '0'
option dns '223.5.5.5'
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh /etc/rc.common
#
# Copyright (C) 2021-2026 sirpdboy <herboy2008@gmail.com>
#
# This file is part of ddns-go .
#
# This is free software, licensed under the Apache License, Version 2.0 .
#
START=99
USE_PROCD=1
NAME=ddns-go
PROG=/usr/bin/ddns-go
CONFDIR=/etc/ddns-go
CONF=$CONFDIR/ddns-go-config.yaml
init_yaml() {
[ -d "$CONFDIR" ] || mkdir -p "$CONFDIR"
chown -R ddns-go:ddns-go "$CONFDIR"
chmod 755 "$CONFDIR"
[ -f "$CONF" ] && chmod 644 "$CONF"
}
build_args() {
local cfg="$1"
local args="-c $CONF"
config_get port "$cfg" port '9876'
args="$args -l :$port"
config_get time "$cfg" time '300'
[ -n "$time" ] && args="$args -f $time"
config_get ctimes "$cfg" ctimes '5'
[ -n "$ctimes" ] && args="$args -cacheTimes $ctimes"
config_get dns "$cfg" dns '223.5.5.5'
[ -n "$dns" ] && args="$args -dns $dns"
config_get_bool noweb "$cfg" noweb 0
[ "$noweb" -eq 1 ] && args="$args -noweb"
config_get_bool skipverify "$cfg" skipverify 0
[ "$skipverify" -eq 1 ] && args="$args -skipVerify"
echo "$args"
}
start_instance() {
local cfg="$1"
local logger
config_get_bool enabled "$cfg" enabled 0
[ "$enabled" -eq 0 ] && return 0
config_get delay "$cfg" delay 0
if [ "$delay" -gt 0 ]; then
local uptime=$(awk -F. '{print $1}' /proc/uptime)
[ "$uptime" -lt 120 ] && sleep "$delay"
fi
init_yaml
local args=$(build_args "$cfg")
procd_open_instance
procd_set_param command $PROG $args
config_get_bool logger "$cfg" logger 1
procd_set_param stdout "$logger"
procd_set_param stderr "$logger"
procd_set_param user ddns-go
procd_set_param respawn
procd_close_instance
}
start_service() {
config_load "$NAME"
config_foreach start_instance 'basic'
}
service_triggers() {
procd_add_reload_trigger "$NAME"
}
+2 -2
View File
@@ -1,12 +1,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=docker
PKG_VERSION:=29.7.2
PKG_VERSION:=29.8.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/docker/cli/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=225b7ab2a15f5230b482df8461069cd4bce38891266fb9898d4188d0a3cbf54a
PKG_HASH:=c5fadbc00c02dbecb1b7c9936e188baf9c80421a9107e7e9ad36a0923a0fc764
PKG_BUILD_DIR:=$(BUILD_DIR)/cli-$(PKG_VERSION)
PKG_GIT_SHORT_COMMIT:=$(shell $(CURDIR)/git-short-commit.sh 'github.com/docker/cli' 'v$(PKG_VERSION)' '$(TMP_DIR)/git-short-commit/$(PKG_NAME)-$(PKG_VERSION)')
+2 -2
View File
@@ -1,7 +1,7 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=dockerd
PKG_VERSION:=29.7.2
PKG_VERSION:=29.8.0
PKG_RELEASE:=1
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
@@ -10,7 +10,7 @@ PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_GIT_URL:=github.com/moby/moby
PKG_GIT_REF:=docker-v$(PKG_VERSION)
PKG_SOURCE_URL:=https://codeload.$(PKG_GIT_URL)/tar.gz/$(PKG_GIT_REF)?
PKG_HASH:=3a93a88bff41ffa6f4dca9f4ed9fc05e7fdb08e0f9014cf1d8177f85ecbc0683
PKG_HASH:=e75ffb5d2ddc1fd98138fdb5e29f707b59f415ec8697e73b8bbdf8bbbb4be8eb
PKG_GIT_SHORT_COMMIT:=$(shell $(CURDIR)/git-short-commit.sh '$(PKG_GIT_URL)' '$(PKG_GIT_REF)' '$(TMP_DIR)/git-short-commit/$(PKG_NAME)-$(PKG_VERSION)')
PKG_MAINTAINER:=Gerard Ryan <G.M0N3Y.2503@gmail.com>
+78
View File
@@ -0,0 +1,78 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=filebrowser-q
PKG_VERSION:=1.5.6-stable
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/gtsteffaniak/filebrowser/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=skip
PKG_BUILD_DIR:=$(BUILD_DIR)/filebrowser-$(PKG_VERSION)
PKG_LICENSE:=Apache-2.0
PKG_LICENSE_FILES:=LICENSE
PKG_MAINTAINER:=kiddin9
PKG_BUILD_DEPENDS:=golang/host node/host
PKG_BUILD_PARALLEL:=1
PKG_BUILD_FLAGS:=no-mips16
GO_PKG:=github.com/gtsteffaniak/filebrowser/backend
GO_PKG_BUILD_DIR:=$(PKG_BUILD_DIR)/backend
GO_PKG_LDFLAGS_X:= \
$(GO_PKG)/internal/version.Version=v$(PKG_VERSION) \
$(GO_PKG)/internal/version.CommitSHA=$(PKG_VERSION)
include $(INCLUDE_DIR)/package.mk
include $(TOPDIR)/feeds/packages/lang/golang/golang-package.mk
define Package/filebrowser-q
SECTION:=utils
CATEGORY:=Utilities
SUBMENU:=Filesystem
TITLE:=FileBrowser Quantum - Modern Web File Manager
URL:=https://github.com/gtsteffaniak/filebrowser
DEPENDS:=$(GO_ARCH_DEPENDS)
endef
define Package/filebrowser-q/description
FileBrowser Quantum provides a modern, responsive web-based file management
interface with multi-source, real-time search, and enhanced preview features.
endef
define Package/filebrowser-q/conffiles
/etc/filebrowser-q/
/etc/config/filebrowser-q
endef
define Build/Prepare
$(call Build/Prepare/Default)
endef
define Build/Compile
( \
pushd $(PKG_BUILD_DIR)/frontend && \
npm install && \
npm run build ; \
)
( \
cd $(PKG_BUILD_DIR)/backend && \
$(GO_PKG_VARS) \
go build \
-trimpath \
-ldflags="-w -s" \
-o $(PKG_BUILD_DIR)/filebrowser . ; \
)
endef
define Package/filebrowser-q/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/filebrowser $(1)/usr/bin/filebrowser-q
$(INSTALL_DIR) $(1)/etc/config
$(INSTALL_CONF) $(CURDIR)/files/filebrowser.config $(1)/etc/config/filebrowser-q
$(INSTALL_DIR) $(1)/etc/init.d
$(INSTALL_BIN) $(CURDIR)/files/filebrowser.init $(1)/etc/init.d/filebrowser-q
endef
$(eval $(call BuildPackage,filebrowser-q))
+5
View File
@@ -0,0 +1,5 @@
config filebrowser 'config'
option enabled '0'
option listen_port '8787'
option root_path '/'
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh /etc/rc.common
USE_PROCD=1
START=99
CONF="filebrowser-q"
PROG="/usr/bin/filebrowser-q"
CONF_PATH="/etc/filebrowser-q/config.yaml"
DB_PATH="/etc/filebrowser-q/database.db"
start_service() {
config_load "$CONF"
local enabled
config_get_bool enabled "config" "enabled" "0"
[ "$enabled" -eq "1" ] || return 1
local listen_port root_path root_name
config_get listen_port "config" "listen_port" "8787"
config_get root_path "config" "root_path" "/"
root_name=""
[ "$root_path" = "/" ] && root_name="root"
if [ ! -f "$CONF_PATH" ]; then
mkdir -p "$(dirname "$CONF_PATH")"
cat <<EOF > "$CONF_PATH"
server:
port: $listen_port
database: "$DB_PATH"
sources:
- path: "$root_path"
name: "$root_name"
config:
defaultEnabled: true
auth:
adminUsername: "admin"
adminPassword: "admin"
userDefaults:
ui:
locale: "zhCN"
EOF
else
grep -q " name:" "$CONF_PATH" || sed -i "s,.*- path:.*,&\n name: \"\"," "$CONF_PATH"
sed -e "s/ port:.*/ port: $listen_port/" \
-e "s, - path:.*, - path: \"$root_path\"," \
-e "s/ name: \".*\"/ name: \"$root_name\"/" \
-i "$CONF_PATH"
fi
procd_open_instance
procd_set_param command "$PROG"
procd_append_param command -c "$CONF_PATH"
procd_set_param limits core="unlimited"
procd_set_param limits nofile="1000000 1000000"
procd_set_param stdout 1
procd_set_param stderr 1
procd_set_param respawn
procd_close_instance
}
service_triggers() {
procd_add_reload_trigger "$CONF"
}
+39
View File
@@ -0,0 +1,39 @@
#
# Copyright (C) 2015-2016 OpenWrt.org
#
# This is free software, licensed under the GNU General Public License v3.
#
include $(TOPDIR)/rules.mk
PKG_NAME:=geo2txt
PKG_VERSION:=1.0.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/sbwml/geo2txt/tar.gz/$(PKG_VERSION)?
PKG_HASH:=b777d9c67b36cd8ce68a0555200fae85751435c888364c921a361062ffee4409
PKG_LICENSE:=GPL-3.0
PKG_LICENSE_FILE:=LICENSE
PKG_MAINTAINER:=sbwml <admin@cooluc.com>
include $(INCLUDE_DIR)/package.mk
define Package/geo2txt
SECTION:=utils
CATEGORY:=Utilities
TITLE:=V2ray geosite & geoip unpacking CLI tool
URL:=https://github.com/sbwml/geo2txt
endef
define Package/quickfile/description
A lightweight pure C utility to unpack v2ray geosite.dat and geoip.dat.
endef
define Package/geo2txt/install
$(INSTALL_DIR) $(1)/usr/bin
$(INSTALL_BIN) $(PKG_BUILD_DIR)/geo2txt $(1)/usr/bin/geo2txt
endef
$(eval $(call BuildPackage,geo2txt))
+3 -2
View File
@@ -8,12 +8,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=go-ethereum
PKG_VERSION:=1.10.20
PKG_VERSION:=1.17.5
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/ethereum/go-ethereum/tar.gz/v${PKG_VERSION}?
PKG_HASH:=15ff54f0a4444eb9faa7c1f6219d3a1db5d547178b4eef6679bb601abc681f9d
PKG_HASH:=8428049b30e76efcd19507225aa67c67d5d98c10a0f3a4ea339dfbba285bac7d
PKG_MAINTAINER:=Mislav Novakovic <mislav.novakovic@sartura.hr>
PKG_LICENSE:=GPL-3.0-or-later LGPL-3.0-or-later
@@ -26,6 +26,7 @@ PKG_CONFIG_DEPENDS:=CONFIG_BUILD_NLS
GO_PKG:=github.com/ethereum/go-ethereum
GO_PKG_BUILD_PKG:=github.com/ethereum/go-ethereum/cmd/geth
GO_PKG_TAGS:=untested_go_version
include $(INCLUDE_DIR)/package.mk
include $(INCLUDE_DIR)/nls.mk
+1 -1
View File
@@ -9,7 +9,7 @@ PROG=/usr/bin/geth
start_service() {
procd_open_instance
procd_set_param command ${PROG}
procd_append_param command --syncmode "light" --cache 1024
procd_append_param command --syncmode "snap" --cache 1024
procd_set_param respawn
procd_close_instance
}
+3 -3
View File
@@ -9,9 +9,9 @@ PKG_RELEASE:=1
PKG_SOURCE_PROTO:=git
PKG_SOURCE_URL:=https://gn.googlesource.com/gn.git
PKG_SOURCE_DATE:=2026-08-19
PKG_SOURCE_VERSION:=58933a7cdbc90f70f2381f0c72e76d29be1d43a9
PKG_MIRROR_HASH:=b4fd70abf2fe8e3017ac3c4e025fd25cd4364dbe2470035b3daf58a129f9f54f
PKG_SOURCE_DATE:=2026-09-11
PKG_SOURCE_VERSION:=cfcd774b98f3433e18b722f9a7ff06119825b8eb
PKG_MIRROR_HASH:=d8bea9ac89f9e87f36874601588f0d8d32221bf1ad8488f2fd9775abcddf4860
PKG_LICENSE:=BSD 3-Clause
PKG_LICENSE_FILES:=LICENSE
+2 -2
View File
@@ -3,7 +3,7 @@
#ifndef OUT_LAST_COMMIT_POSITION_H_
#define OUT_LAST_COMMIT_POSITION_H_
#define LAST_COMMIT_POSITION_NUM 2528
#define LAST_COMMIT_POSITION "2528 (58933a7cdbc9)"
#define LAST_COMMIT_POSITION_NUM 2563
#define LAST_COMMIT_POSITION "2563 (cfcd774b98f3)"
#endif // OUT_LAST_COMMIT_POSITION_H_
+2 -2
View File
@@ -5,12 +5,12 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=gost
PKG_VERSION:=3.2.6
PKG_VERSION:=3.3.0
PKG_RELEASE:=1
PKG_SOURCE:=$(PKG_NAME)-$(PKG_VERSION).tar.gz
PKG_SOURCE_URL:=https://codeload.github.com/go-gost/gost/tar.gz/v$(PKG_VERSION)?
PKG_HASH:=79874354530b899576dd4866d3b1400651d0b17c1e7a90ad30c44686a0642600
PKG_HASH:=2a65e2da14fef6b6da8d4e32a8bc62e39970dbb141db42bc6f5821f90ac1e9a3
PKG_LICENSE:=MIT
PKG_LICENSE_FILES:=LICENSE

Some files were not shown because too many files have changed in this diff Show More