🎉 Sync 2026-09-29 23:46:13

This commit is contained in:
github-actions[bot] committed 2026-09-29 23:46:13 +08:00
1 parent bb21104c70
commit d720f47888
76 files changed
+37517

No files matched your search

+111
View File
@@ -0,0 +1,111 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=clashoo
PKG_SHORT_SHA:=63bd52e
PKG_COMMIT_DATE:=2026.09.27
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:=16a867c31778167b5297025cafd444ea8463b108
PKG_HASH:=skip
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))
+156
View File
@@ -0,0 +1,156 @@
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'
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 ''
+2507
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 是否在 LISTEN(clashoo 未运行时立即标 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"
# 周期:默认 30s,ACCESS_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
+642
View File
@@ -0,0 +1,642 @@
#!/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
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(仅核心)/ luci(luci-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,80 @@
# shellcheck shell=sh
# Clashoo's sing-box is private; HomeProxy owns the public system binary.
CLASHOO_SINGBOX_BIN_DIR="/usr/share/clashoo/bin"
CLASHOO_SINGBOX_RUNTIME_CONFIG="/var/run/clashoo/sing-box/config.json"
# 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 -> private runtime config 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)
_cdp_service="clashoo"
_cdp_filter='@.clashoo.instances.singbox.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
if [ "$_cdp_type" = "singbox" ]; then
local _cdp_exe _cdp_cmdline
_cdp_exe="$(readlink -f "/proc/$_cdp_pid/exe" 2>/dev/null)"
case "$_cdp_exe" in
"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha") ;;
*)
_cdp_cmdline="$(tr '\000' ' ' < "/proc/$_cdp_pid/cmdline" 2>/dev/null)"
case "$_cdp_cmdline" in
"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-stable "*|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha"|"$CLASHOO_SINGBOX_BIN_DIR/sing-box-alpha "*) ;;
*) return 1 ;;
esac
;;
esac
fi
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 "$CLASHOO_SINGBOX_RUNTIME_CONFIG" \
-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
+204
View File
@@ -0,0 +1,204 @@
#!/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}"
PROC_ROOT="${PROC_ROOT:-/proc}"
SINGBOX_BIN_DIR="${SINGBOX_BIN_DIR:-/usr/share/clashoo/bin}"
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() {
local service="$1"
local instance="${2:-}"
local filter
if [ -n "$instance" ]; then
filter="@.${service}.instances.${instance}.pid"
else
filter="@[\"${service}\"].instances.*.pid"
fi
ubus call service list "{\"name\":\"$service\",\"verbose\":true}" 2>/dev/null |
jsonfilter -e "$filter" 2>/dev/null | head -n1
}
pid_running() {
[ -n "$1" ] && [ -d "$PROC_ROOT/$1" ]
}
singbox_pid_owns_private_binary() {
local pid="$1" exe cmdline
pid_running "$pid" || return 1
exe="$(readlink -f "$PROC_ROOT/$pid/exe" 2>/dev/null)"
case "$exe" in
"$SINGBOX_BIN_DIR/sing-box-stable"|"$SINGBOX_BIN_DIR/sing-box-alpha") return 0 ;;
esac
cmdline="$(tr '\000' ' ' < "$PROC_ROOT/$pid/cmdline" 2>/dev/null)"
case "$cmdline" in
"$SINGBOX_BIN_DIR/sing-box-stable"|"$SINGBOX_BIN_DIR/sing-box-stable "*|"$SINGBOX_BIN_DIR/sing-box-alpha"|"$SINGBOX_BIN_DIR/sing-box-alpha "*) return 0 ;;
esac
return 1
}
selected_core_running() {
local name pid
if [ "$(uci -q get clashoo.config.core_type 2>/dev/null)" = "singbox" ]; then
name="clashoo"
pid="$(instance_pid "$name" singbox)"
singbox_pid_owns_private_binary "$pid"
return $?
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)
+}
+100
View File
@@ -0,0 +1,100 @@
include $(TOPDIR)/rules.mk
PKG_NAME:=luci-app-clashoo
PKG_VERSION:=1.30.0
PKG_RELEASE:=1
PKG_MAINTAINER:=kenzok8
PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_NAME)
include $(INCLUDE_DIR)/package.mk
define Package/luci-app-clashoo
SECTION:=luci
CATEGORY:=LuCI
SUBMENU:=3. Applications
TITLE:=LuCI app for Clashoo
DEPENDS:=+luci-base +clashoo
PKGARCH:=all
MAINTAINER:=kenzok8
endef
define Package/luci-app-clashoo/description
LuCI web interface for Clashoo
endef
define Build/Configure
endef
define Build/Prepare
mkdir -p $(PKG_BUILD_DIR)
po2lmo ./po/zh-cn/clashoo.po $(PKG_BUILD_DIR)/clashoo.zh-cn.lmo
endef
define Build/Compile
endef
define Package/luci-app-clashoo/install
$(INSTALL_DIR) $(1)/usr/share/rpcd/ucode
$(INSTALL_DIR) $(1)/usr/share/rpcd/acl.d
$(INSTALL_DIR) $(1)/usr/share/clashoo/rpc
$(INSTALL_DIR) $(1)/usr/share/luci/menu.d
$(INSTALL_DIR) $(1)/www/luci-static/resources/tools
$(INSTALL_DIR) $(1)/www/luci-static/resources/view/clashoo
$(INSTALL_DIR) $(1)/www/luci-static/resources/cm/mode
$(INSTALL_DIR) $(1)/www/luci-static/resources/cm/addon
$(INSTALL_DIR) $(1)/www/luci-static/clashoo
$(INSTALL_BIN) ./root/usr/share/rpcd/ucode/luci.clashoo $(1)/usr/share/rpcd/ucode/luci.clashoo
$(INSTALL_BIN) ./root/usr/share/clashoo/rpc/backup_archive.sh $(1)/usr/share/clashoo/rpc/backup_archive.sh
$(INSTALL_DATA) ./root/usr/share/rpcd/acl.d/luci-app-clashoo.json $(1)/usr/share/rpcd/acl.d/luci-app-clashoo.json
$(INSTALL_DATA) ./root/usr/share/luci/menu.d/luci-app-clashoo.json $(1)/usr/share/luci/menu.d/luci-app-clashoo.json
$(INSTALL_DATA) ./htdocs/luci-static/resources/tools/clashoo.js $(1)/www/luci-static/resources/tools/clashoo.js
$(INSTALL_DATA) ./htdocs/luci-static/resources/view/clashoo/* $(1)/www/luci-static/resources/view/clashoo/
$(INSTALL_DATA) ./htdocs/luci-static/resources/cm/codemirror.js $(1)/www/luci-static/resources/cm/codemirror.js
$(INSTALL_DATA) ./htdocs/luci-static/resources/cm/codemirror.css $(1)/www/luci-static/resources/cm/codemirror.css
$(INSTALL_DATA) ./htdocs/luci-static/resources/cm/mode/* $(1)/www/luci-static/resources/cm/mode/
$(INSTALL_DATA) ./htdocs/luci-static/resources/cm/addon/* $(1)/www/luci-static/resources/cm/addon/
$(INSTALL_DATA) ./htdocs/luci-static/clashoo/logo.png $(1)/www/luci-static/clashoo/logo.png
endef
define Package/luci-app-clashoo/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
rm -rf /tmp/luci-indexcache /tmp/luci-modulecache
# reload (not restart) rpcd so the LuCI login session survives the upgrade —
# rpcd restart drops every ubus session and forces a re-login (issue #12).
# reload still picks up the new ucode backend + ACL. uhttpd needs nothing:
# updated static assets are served on the next request.
/etc/init.d/rpcd reload >/dev/null 2>&1 || true
fi
exit 0
endef
define Package/luci-i18n-clashoo-zh-cn
SECTION:=luci
CATEGORY:=LuCI
TITLE:=luci-app-clashoo - Simplified Chinese (zh-cn)
DEPENDS:=luci-app-clashoo
DEFAULT:=LUCI_LANG_zh_Hans||(ALL&&m)
HIDDEN:=1
PKGARCH:=all
endef
define Package/luci-i18n-clashoo-zh-cn/install
$(INSTALL_DIR) $(1)/usr/lib/lua/luci/i18n
$(INSTALL_DATA) $(PKG_BUILD_DIR)/clashoo.zh-cn.lmo $(1)/usr/lib/lua/luci/i18n/clashoo.zh-cn.lmo
endef
define Package/luci-i18n-clashoo-zh-cn/postinst
#!/bin/sh
if [ -z "$${IPKG_INSTROOT}" ]; then
# just clear the LuCI cache so the new translations load on next request;
# no service restart needed (and none that would drop the login session).
rm -rf /tmp/luci*
fi
exit 0
endef
$(eval $(call BuildPackage,luci-app-clashoo))
$(eval $(call BuildPackage,luci-i18n-clashoo-zh-cn))
Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

@@ -0,0 +1 @@
!function(t){"object"==typeof exports&&"object"==typeof module?t(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],t):t(CodeMirror)}(function(r){var u=/MSIE \d/.test(navigator.userAgent)&&(null==document.documentMode||document.documentMode<8),k=r.Pos,p={"(":")>",")":"(<","[":"]>","]":"[<","{":"}>","}":"{<","<":">>",">":"<<"};function y(t){return t&&t.bracketRegex||/[(){}[\]]/}function f(t,e,n){var r=t.getLineHandle(e.line),i=e.ch-1,c=n&&n.afterCursor,a=(null==c&&(c=/(^| )cm-fat-cursor($| )/.test(t.getWrapperElement().className)),y(n)),c=!c&&0<=i&&a.test(r.text.charAt(i))&&p[r.text.charAt(i)]||a.test(r.text.charAt(i+1))&&p[r.text.charAt(++i)];if(!c)return null;a=">"==c.charAt(1)?1:-1;if(n&&n.strict&&0<a!=(i==e.ch))return null;r=t.getTokenTypeAt(k(e.line,i+1)),t=o(t,k(e.line,i+(0<a?1:0)),a,r,n);return null==t?null:{from:k(e.line,i),to:t&&t.pos,match:t&&t.ch==c.charAt(0),forward:0<a}}function o(t,e,n,r,i){for(var c=i&&i.maxScanLineLength||1e4,a=i&&i.maxScanLines||1e3,o=[],h=y(i),l=0<n?Math.min(e.line+a,t.lastLine()+1):Math.max(t.firstLine()-1,e.line-a),s=e.line;s!=l;s+=n){var u=t.getLine(s);if(u){var f=0<n?0:u.length-1,m=0<n?u.length:-1;if(!(u.length>c))for(s==e.line&&(f=e.ch-(n<0?1:0));f!=m;f+=n){var g=u.charAt(f);if(h.test(g)&&(void 0===r||(t.getTokenTypeAt(k(s,f+1))||"")==(r||""))){var d=p[g];if(d&&">"==d.charAt(1)==0<n)o.push(g);else{if(!o.length)return{pos:k(s,f),ch:g};o.pop()}}}}}return s-n!=(0<n?t.lastLine():t.firstLine())&&null}function e(t,e,n){for(var r=t.state.matchBrackets.maxHighlightLineLength||1e3,i=n&&n.highlightNonMatching,c=[],a=t.listSelections(),o=0;o<a.length;o++){var h,l=a[o].empty()&&f(t,a[o].head,n);l&&(l.match||!1!==i)&&t.getLine(l.from.line).length<=r&&(h=l.match?"CodeMirror-matchingbracket":"CodeMirror-nonmatchingbracket",c.push(t.markText(l.from,k(l.from.line,l.from.ch+1),{className:h})),l.to&&t.getLine(l.to.line).length<=r&&c.push(t.markText(l.to,k(l.to.line,l.to.ch+1),{className:h})))}if(c.length){u&&t.state.focused&&t.focus();function s(){t.operation(function(){for(var t=0;t<c.length;t++)c[t].clear()})}if(!e)return s;setTimeout(s,800)}}function i(t){t.operation(function(){t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null),t.state.matchBrackets.currentlyHighlighted=e(t,!1,t.state.matchBrackets)})}function c(t){t.state.matchBrackets&&t.state.matchBrackets.currentlyHighlighted&&(t.state.matchBrackets.currentlyHighlighted(),t.state.matchBrackets.currentlyHighlighted=null)}r.defineOption("matchBrackets",!1,function(t,e,n){n&&n!=r.Init&&(t.off("cursorActivity",i),t.off("focus",i),t.off("blur",c),c(t)),e&&(t.state.matchBrackets="object"==typeof e?e:{},t.on("cursorActivity",i),t.on("focus",i),t.on("blur",c))}),r.defineExtension("matchBrackets",function(){e(this,!0)}),r.defineExtension("findMatchingBracket",function(t,e,n){return f(this,t,e=!n&&"boolean"!=typeof e?e:n?(n.strict=e,n):e?{strict:!0}:null)}),r.defineExtension("scanForBracket",function(t,e,n,r){return o(this,t,e,n,r)})});
@@ -0,0 +1 @@
.CodeMirror{font-family:monospace;height:300px;color:#000;direction:ltr}.CodeMirror-lines{padding:4px 0}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{padding:0 4px}.CodeMirror-gutter-filler,.CodeMirror-scrollbar-filler{background-color:#fff}.CodeMirror-gutters{border-right:1px solid #ddd;background-color:#f7f7f7;white-space:nowrap}.CodeMirror-linenumber{padding:0 3px 0 5px;min-width:20px;text-align:right;color:#999;white-space:nowrap}.CodeMirror-guttermarker{color:#000}.CodeMirror-guttermarker-subtle{color:#999}.CodeMirror-cursor{border-left:1px solid #000;border-right:none;width:0}.CodeMirror div.CodeMirror-secondarycursor{border-left:1px solid silver}.cm-fat-cursor .CodeMirror-cursor{width:auto;border:0!important;background:#7e7}.cm-fat-cursor div.CodeMirror-cursors{z-index:1}.cm-fat-cursor .CodeMirror-line::selection,.cm-fat-cursor .CodeMirror-line>span::selection,.cm-fat-cursor .CodeMirror-line>span>span::selection{background:0 0}.cm-fat-cursor .CodeMirror-line::-moz-selection,.cm-fat-cursor .CodeMirror-line>span::-moz-selection,.cm-fat-cursor .CodeMirror-line>span>span::-moz-selection{background:0 0}.cm-fat-cursor{caret-color:transparent}@-moz-keyframes blink{50%{background-color:transparent}}@-webkit-keyframes blink{50%{background-color:transparent}}@keyframes blink{50%{background-color:transparent}}.cm-tab{display:inline-block;text-decoration:inherit}.CodeMirror-rulers{position:absolute;left:0;right:0;top:-50px;bottom:0;overflow:hidden}.CodeMirror-ruler{border-left:1px solid #ccc;top:0;bottom:0;position:absolute}.cm-s-default .cm-header{color:#00f}.cm-s-default .cm-quote{color:#090}.cm-negative{color:#d44}.cm-positive{color:#292}.cm-header,.cm-strong{font-weight:700}.cm-em{font-style:italic}.cm-link{text-decoration:underline}.cm-strikethrough{text-decoration:line-through}.cm-s-default .cm-keyword{color:#708}.cm-s-default .cm-atom{color:#219}.cm-s-default .cm-number{color:#164}.cm-s-default .cm-def{color:#00f}.cm-s-default .cm-variable-2{color:#05a}.cm-s-default .cm-type,.cm-s-default .cm-variable-3{color:#085}.cm-s-default .cm-comment{color:#a50}.cm-s-default .cm-string{color:#a11}.cm-s-default .cm-string-2{color:#f50}.cm-s-default .cm-meta{color:#555}.cm-s-default .cm-qualifier{color:#555}.cm-s-default .cm-builtin{color:#30a}.cm-s-default .cm-bracket{color:#997}.cm-s-default .cm-tag{color:#170}.cm-s-default .cm-attribute{color:#00c}.cm-s-default .cm-hr{color:#999}.cm-s-default .cm-link{color:#00c}.cm-s-default .cm-error{color:red}.cm-invalidchar{color:red}.CodeMirror-composing{border-bottom:2px solid}div.CodeMirror span.CodeMirror-matchingbracket{color:#0b0}div.CodeMirror span.CodeMirror-nonmatchingbracket{color:#a22}.CodeMirror-matchingtag{background:rgba(255,150,0,.3)}.CodeMirror-activeline-background{background:#e8f2ff}.CodeMirror{position:relative;overflow:hidden;background:#fff}.CodeMirror-scroll{overflow:scroll!important;margin-bottom:-50px;margin-right:-50px;padding-bottom:50px;height:100%;outline:0;position:relative;z-index:0}.CodeMirror-sizer{position:relative;border-right:50px solid transparent}.CodeMirror-gutter-filler,.CodeMirror-hscrollbar,.CodeMirror-scrollbar-filler,.CodeMirror-vscrollbar{position:absolute;z-index:6;display:none;outline:0}.CodeMirror-vscrollbar{right:0;top:0;overflow-x:hidden;overflow-y:scroll}.CodeMirror-hscrollbar{bottom:0;left:0;overflow-y:hidden;overflow-x:scroll}.CodeMirror-scrollbar-filler{right:0;bottom:0}.CodeMirror-gutter-filler{left:0;bottom:0}.CodeMirror-gutters{position:absolute;left:0;top:0;min-height:100%;z-index:3}.CodeMirror-gutter{white-space:normal;height:100%;display:inline-block;vertical-align:top;margin-bottom:-50px}.CodeMirror-gutter-wrapper{position:absolute;z-index:4;background:0 0!important;border:none!important}.CodeMirror-gutter-background{position:absolute;top:0;bottom:0;z-index:4}.CodeMirror-gutter-elt{position:absolute;cursor:default;z-index:4}.CodeMirror-gutter-wrapper ::selection{background-color:transparent}.CodeMirror-gutter-wrapper ::-moz-selection{background-color:transparent}.CodeMirror-lines{cursor:text;min-height:1px}.CodeMirror pre.CodeMirror-line,.CodeMirror pre.CodeMirror-line-like{-moz-border-radius:0;-webkit-border-radius:0;border-radius:0;border-width:0;background:0 0;font-family:inherit;font-size:inherit;margin:0;white-space:pre;word-wrap:normal;line-height:inherit;color:inherit;z-index:2;position:relative;overflow:visible;-webkit-tap-highlight-color:transparent;-webkit-font-variant-ligatures:contextual;font-variant-ligatures:contextual}.CodeMirror-wrap pre.CodeMirror-line,.CodeMirror-wrap pre.CodeMirror-line-like{word-wrap:break-word;white-space:pre-wrap;word-break:normal}.CodeMirror-linebackground{position:absolute;left:0;right:0;top:0;bottom:0;z-index:0}.CodeMirror-linewidget{position:relative;z-index:2;padding:.1px}.CodeMirror-rtl pre{direction:rtl}.CodeMirror-code{outline:0}.CodeMirror-gutter,.CodeMirror-gutters,.CodeMirror-linenumber,.CodeMirror-scroll,.CodeMirror-sizer{-moz-box-sizing:contentLine truncated
@@ -0,0 +1 @@
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e=e||self).CodeMirror=t()}(this,function(){"use strict";var e=navigator.userAgent,l=navigator.platform,d=/gecko\/\d/i.test(e),s=/MSIE \d/.test(e),a=/Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(e),u=/Edge\/(\d+)/.exec(e),w=s||a||u,v=w&&(s?document.documentMode||6:+(u||a)[1]),x=!u&&/WebKit\//.test(e),s=x&&/Qt\/\d+\.\d+/.test(e),m=!u&&/Chrome\/(\d+)/.exec(e),V=m&&+m[1],K=/Opera\//.test(e),j=/Apple Computer/.test(navigator.vendor),c=/Mac OS X 1\d\D([8-9]|\d\d)\D/.test(e),X=/PhantomJS/.test(e),Y=j&&(/Mobile\/\w+/.test(e)||2<navigator.maxTouchPoints),$=/Android/.test(e),_=Y||$||/webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(e),C=Y||/Mac/.test(l),q=/\bCrOS\b/.test(e),a=/win/i.test(l),u=K&&e.match(/Version\/(\d*\.\d*)/),Z=((u=u&&Number(u[1]))&&15<=u&&(x=!(K=!1)),C&&(s||K&&(null==u||u<12.11))),Q=d||w&&9<=v;function J(e){return new RegExp("(^|\\s)"+e+"(?:$|\\s)\\s*")}var ee=function(e,t){var n,r=e.className,t=J(t).exec(r);t&&(n=r.slice(t.index+t[0].length),e.className=r.slice(0,t.index)+(n?t[1]+n:""))};function te(e){for(var t=e.childNodes.length;0<t;--t)e.removeChild(e.firstChild);return e}function y(e,t){return te(e).appendChild(t)}function M(e,t,n,r){var i=document.createElement(e);if(n&&(i.className=n),r&&(i.style.cssText=r),"string"==typeof t)i.appendChild(document.createTextNode(t));else if(t)for(var o=0;o<t.length;++o)i.appendChild(t[o]);return i}function ne(e,t,n,r){e=M(e,t,n,r);return e.setAttribute("role","presentation"),e}function re(e,t){if(3==t.nodeType&&(t=t.parentNode),e.contains)return e.contains(t);do{if((t=11==t.nodeType?t.host:t)==e)return!0}while(t=t.parentNode)}function N(e){var t,n=e.ownerDocument||e;try{t=e.activeElement}catch(e){t=n.body||null}for(;t&&t.shadowRoot&&t.shadowRoot.activeElement;)t=t.shadowRoot.activeElement;return t}function ie(e,t){var n=e.className;J(t).test(n)||(e.className+=(n?" ":"")+t)}function oe(e,t){for(var n=e.split(" "),r=0;r<n.length;r++)n[r]&&!J(n[r]).test(t)&&(t+=" "+n[r]);return t}var le=document.createRange?function(e,t,n,r){var i=document.createRange();return i.setEnd(r||e,n),i.setStart(e,t),i}:function(e,t,n){var r=document.body.createTextRange();try{r.moveToElementText(e.parentNode)}catch(e){return r}return r.collapse(!0),r.moveEnd("character",n),r.moveStart("character",t),r},se=function(e){e.select()};function ae(e){return e.display.wrapper.ownerDocument}function ue(e){return ce(e.display.wrapper)}function ce(e){return e.getRootNode?e.getRootNode():e.ownerDocument}function he(e){return ae(e).defaultView}function de(e){var t=Array.prototype.slice.call(arguments,1);return function(){return e.apply(null,t)}}function fe(e,t,n){for(var r in t=t||{},e)!e.hasOwnProperty(r)||!1===n&&t.hasOwnProperty(r)||(t[r]=e[r]);return t}function S(e,t,n,r,i){null==t&&-1==(t=e.search(/[^\s\u00a0]/))&&(t=e.length);for(var o=r||0,l=i||0;;){var s=e.indexOf("\t",o);if(s<0||t<=s)return l+(t-o);l=(l+=s-o)+(n-l%n),o=s+1}}Y?se=function(e){e.selectionStart=0,e.selectionEnd=e.value.length}:w&&(se=function(e){try{e.select()}catch(e){}});function pe(){this.id=null,this.f=null,this.time=0,this.handler=de(this.onTimeout,this)}function L(e,t){for(var n=0;n<e.length;++n)if(e[n]==t)return n;return-1}pe.prototype.onTimeout=function(e){e.id=0,e.time<=+new Date?e.f():setTimeout(e.handler,e.time-+new Date)};var ge=50,me={toString:function(){return"CodeMirror.Pass"}},ve={scroll:!(pe.prototype.set=function(e,t){this.f=t;t=+new Date+e;(!this.id||t<this.time)&&(clearTimeout(this.id),this.id=setTimeout(this.handler,e),this.time=t)})},ye={origin:"*mouse"},be={origin:"+move"};function we(e,t,n){for(var r=0,i=0;;){var o=e.indexOf("\t",r),l=(o=-1==o?e.length:o)-r;if(o==e.length||t<=i+l)return r+Math.min(l,t-i);if(i+=o-r,r=o+1,t<=(i+=n-i%n))return r}}var xe=[""];function Ce(e){for(;xe.length<=e;)xe.push(z(xe)+" ");return xe[e]}function z(e){return e[e.length-1]}function Se(e,t){for(var n=[],r=0;r<e.length;r++)n[r]=t(e[r],r);return n}function Le(){}function ke(e,t){e=Object.create?Object.create(e):(Le.prototype=e,new Le);return t&&fe(t,e),e}var Te=/[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/;function Me(e){return/\w/.test(e)||"€"<e&&(e.toUpperCase()!=e.toLowerCase()||Te.test(e))}function Ne(e,t){return t?!!(-1<t.source.indexOf("\\w")&&Me(e))||t.test(e):Me(e)}function Oe(e){for(var t in e)if(e.hasOwnProperty(t)&&e[t])return;return 1}var Ae=/[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0acLine truncated
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(rt){"use strict";rt.defineMode("javascript",function(e,l){var t,r,O,P,f=e.indentUnit,N=l.statementIndent,U=l.jsonld,o=l.json||U,W=!1!==l.trackScope,u=l.typescript,B=l.wordCharacters||/[\w$\xa1-\uffff]/,F=(e=n("keyword a"),t=n("keyword b"),r=n("keyword c"),O=n("keyword d"),P=n("operator"),{if:n("if"),while:e,with:e,else:t,do:t,try:t,finally:t,return:O,break:O,continue:O,new:n("new"),delete:r,void:r,throw:r,debugger:n("debugger"),var:n("var"),const:n("var"),let:n("var"),function:n("function"),catch:n("catch"),for:n("for"),switch:n("switch"),case:n("case"),default:n("default"),in:P,typeof:P,instanceof:P,true:e={type:"atom",style:"atom"},false:e,null:e,undefined:e,NaN:e,Infinity:e,this:n("this"),class:n("class"),super:n("atom"),yield:r,export:n("export"),import:n("import"),extends:r,await:r});function n(e){return{type:e,style:"keyword"}}var H,D,G=/[+\-*&%=<>!?|~^@]/,J=/^@(context|id|value|language|type|container|list|set|reverse|index|base|vocab|graph)"/;function i(e,t,r){return H=e,D=r,t}function d(e,t){var a,r=e.next();if('"'==r||"'"==r)return t.tokenize=(a=r,function(e,t){var r,n=!1;if(U&&"@"==e.peek()&&e.match(J))return t.tokenize=d,i("jsonld-keyword","meta");for(;null!=(r=e.next())&&(r!=a||n);)n=!n&&"\\"==r;return n||(t.tokenize=d),i("string","string")}),t.tokenize(e,t);if("."==r&&e.match(/^\d[\d_]*(?:[eE][+\-]?[\d_]+)?/))return i("number","number");if("."==r&&e.match(".."))return i("spread","meta");if(/[\[\]{}\(\),;\:\.]/.test(r))return i(r);if("="==r&&e.eat(">"))return i("=>","operator");if("0"==r&&e.match(/^(?:x[\dA-Fa-f_]+|o[0-7_]+|b[01_]+)n?/))return i("number","number");if(/\d/.test(r))return e.match(/^[\d_]*(?:n|(?:\.[\d_]*)?(?:[eE][+\-]?[\d_]+)?)?/),i("number","number");if("/"==r)return e.eat("*")?(t.tokenize=K)(e,t):e.eat("/")?(e.skipToEnd(),i("comment","comment")):tt(e,t,1)?(function(e){for(var t,r=!1,n=!1;null!=(t=e.next());){if(!r){if("/"==t&&!n)return;"["==t?n=!0:n&&"]"==t&&(n=!1)}r=!r&&"\\"==t}}(e),e.match(/^\b(([gimyus])(?![gimyus]*\2))+\b/),i("regexp","string-2")):(e.eat("="),i("operator","operator",e.current()));if("`"==r)return(t.tokenize=L)(e,t);if("#"==r&&"!"==e.peek())return e.skipToEnd(),i("meta","meta");if("#"==r&&e.eatWhile(B))return i("variable","property");if("<"==r&&e.match("!--")||"-"==r&&e.match("->")&&!/\S/.test(e.string.slice(0,e.start)))return e.skipToEnd(),i("comment","comment");if(G.test(r))return">"==r&&t.lexical&&">"==t.lexical.type||(e.eat("=")?"!"!=r&&"="!=r||e.eat("="):/[<>*+\-|&?]/.test(r)&&(e.eat(r),">"==r&&e.eat(r))),"?"==r&&e.eat(".")?i("."):i("operator","operator",e.current());if(B.test(r)){e.eatWhile(B);r=e.current();if("."!=t.lastType){if(F.propertyIsEnumerable(r))return i((t=F[r]).type,t.style,r);if("async"==r&&e.match(/^(\s|\/\*([^*]|\*(?!\/))*?\*\/)*[\[\(\w]/,!1))return i("async","keyword",r)}return i("variable","variable",r)}}function K(e,t){for(var r,n=!1;r=e.next();){if("/"==r&&n){t.tokenize=d;break}n="*"==r}return i("comment","comment")}function L(e,t){for(var r,n=!1;null!=(r=e.next());){if(!n&&("`"==r||"$"==r&&e.eat("{"))){t.tokenize=d;break}n=!n&&"\\"==r}return i("quasi","string-2",e.current())}function Q(e,t){t.fatArrowAt&&(t.fatArrowAt=null);var r=e.string.indexOf("=>",e.start);if(!(r<0)){!u||(n=/:\s*(?:\w+(?:<[^>]*>|\[\])?|\{[^}]*\})\s*$/.exec(e.string.slice(e.start,r)))&&(r=n.index);for(var n,a=0,i=!1,o=r-1;0<=o;--o){var c=e.string.charAt(o),s="([{}])".indexOf(c);if(0<=s&&s<3){if(!a){++o;break}if(0==--a){"("==c&&(i=!0);break}}else if(3<=s&&s<6)++a;else if(B.test(c))i=!0;else if(/["'\/`]/.test(c))for(;;--o){if(0==o)return;if(e.string.charAt(o-1)==c&&"\\"!=e.string.charAt(o-2)){o--;break}}else if(i&&!a){++o;break}}i&&!a&&(t.fatArrowAt=o)}}var R={atom:!0,number:!0,variable:!0,string:!0,regexp:!0,this:!0,import:!0,"jsonld-keyword":!0};function X(e,t,r,n,a,i){this.indented=e,this.column=t,this.type=r,this.prev=a,this.info=i,null!=n&&(this.align=n)}function Y(e,t,r,n,a){var i=e.cc;for(c.state=e,c.stream=a,c.marked=null,c.cc=i,c.style=t,e.lexical.hasOwnProperty("align")||(e.lexical.align=!0);;)if((i.length?i.pop():o?x:b)(r,n)){for(;i.length&&i[i.length-1].lex;)i.pop()();return c.marked?c.marked:"variable"==r&&function(e,t){if(W){for(var r=e.localVars;r;r=r.next)if(r.name==t)return 1;for(var n=e.context;n;n=n.prev)for(r=n.vars;r;r=r.next)if(r.name==t)return 1}}(e,n)?"variable-2":t}}var c={state:null,column:null,marked:null,cc:null};function s(){for(var e=arguments.length-1;0<=e;e--)c.cc.push(arguments[e])}function p(){return s.apply(null,arguments),!0}function Z(e,t){for(var r=t;r;r=r.next)if(r.name==e)return 1}function a(e){var t=c.state;if(c.marked="def",W){if(t.context)if("var"==t.lexical.info&&t.context&&t.context.block){var r=function e(t,r){{var n;return r?r.block?(n=e(t,r.prev))?n==r.prev?r:new te(n,r.vars,!0):null:Z(t,r.vars)?r:new te(r.prev,new re(t,r.varLine truncated
@@ -0,0 +1 @@
!function(e){"object"==typeof exports&&"object"==typeof module?e(require("../../lib/codemirror")):"function"==typeof define&&define.amd?define(["../../lib/codemirror"],e):e(CodeMirror)}(function(e){"use strict";e.defineMode("yaml",function(){var n=new RegExp("\\b(("+["true","false","on","off","yes","no"].join(")|(")+"))$","i");return{token:function(e,i){var t=e.peek(),r=i.escaped;if(i.escaped=!1,"#"==t&&(0==e.pos||/\s/.test(e.string.charAt(e.pos-1))))return e.skipToEnd(),"comment";if(e.match(/^('([^']|\\.)*'?|"([^"]|\\.)*"?)/))return"string";if(i.literal&&e.indentation()>i.keyCol)return e.skipToEnd(),"string";if(i.literal&&(i.literal=!1),e.sol()){if(i.keyCol=0,i.pair=!1,i.pairStart=!1,e.match("---"))return"def";if(e.match("..."))return"def";if(e.match(/\s*-\s+/))return"meta"}if(e.match(/^(\{|\}|\[|\])/))return"{"==t?i.inlinePairs++:"}"==t?i.inlinePairs--:"["==t?i.inlineList++:i.inlineList--,"meta";if(0<i.inlineList&&!r&&","==t)return e.next(),"meta";if(0<i.inlinePairs&&!r&&","==t)return i.keyCol=0,i.pair=!1,i.pairStart=!1,e.next(),"meta";if(i.pairStart){if(e.match(/^\s*(\||\>)\s*/))return i.literal=!0,"meta";if(e.match(/^\s*(\&|\*)[a-z0-9\._-]+\b/i))return"variable-2";if(0==i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?$/))return"number";if(0<i.inlinePairs&&e.match(/^\s*-?[0-9\.\,]+\s?(?=(,|}))/))return"number";if(e.match(n))return"keyword"}return!i.pair&&e.match(/^\s*(?:[,\[\]{}&*!|>'"%@`][^\s'":]|[^\s,\[\]{}#&*!|>'"%@`])[^#:]*(?=:($|\s))/)?(i.pair=!0,i.keyCol=e.indentation(),"atom"):i.pair&&e.match(/^:\s*/)?(i.pairStart=!0,"meta"):(i.pairStart=!1,i.escaped="\\"==t,e.next(),null)},startState:function(){return{pair:!1,pairStart:!1,keyCol:0,inlinePairs:0,inlineList:0,literal:!1,escaped:!1}},lineComment:"#",fold:"indent"}}),e.defineMIME("text/x-yaml","yaml"),e.defineMIME("text/yaml","yaml")});
@@ -0,0 +1,264 @@
'use strict';
'require baseclass';
'require rpc';
'require ui';
(function () {
if (document.getElementById('clashoo-toast-style')) return;
var css = [
'#clashoo-toast-stack{position:fixed;top:20px;right:20px;z-index:99999;display:flex;flex-direction:column;gap:8px;max-width:380px;pointer-events:none;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}',
'.clashoo-toast{background:rgba(33,37,41,.92);color:#fff;font-size:13px;line-height:1.5;padding:10px 14px;border-radius:8px;box-shadow:0 4px 14px rgba(0,0,0,.18);opacity:0;transform:translateX(24px);transition:opacity .25s ease,transform .25s ease;pointer-events:auto;cursor:pointer;word-break:break-word}',
'.clashoo-toast.show{opacity:1;transform:translateX(0)}',
'.clashoo-toast.err{background:rgba(198,40,40,.94)}',
'.clashoo-toast.ok{background:rgba(46,125,50,.94)}',
'.clashoo-toast a{color:#8ec5ff;text-decoration:underline}',
'.clashoo-toast a:hover{color:#b3d7ff}'
].join('');
var s = document.createElement('style');
s.id = 'clashoo-toast-style';
s.textContent = css;
document.head.appendChild(s);
})();
function _clashooToastStack() {
var el = document.getElementById('clashoo-toast-stack');
if (!el) {
el = document.createElement('div');
el.id = 'clashoo-toast-stack';
document.body.appendChild(el);
}
return el;
}
function _clashooLinkify(safeText) {
var logUrl = L.url('admin/services/clashoo/system');
return safeText.replace(/(进度|日志)/g,
'<a href="' + logUrl + '">$1</a>');
}
function _clashooToast(msg, opts) {
opts = opts || {};
var stack = _clashooToastStack();
var el = document.createElement('div');
el.className = 'clashoo-toast' + (opts.kind ? ' ' + opts.kind : '');
var text;
if (msg && msg.nodeType === 1) text = msg.textContent || '';
else if (typeof msg === 'string') text = msg;
else text = String(msg || '');
var safe = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
el.innerHTML = _clashooLinkify(safe);
stack.appendChild(el);
requestAnimationFrame(function () { el.classList.add('show'); });
var dur = opts.duration || 3500;
var timer = null, remain = dur, start = 0;
function close() {
if (timer) { clearTimeout(timer); timer = null; }
el.classList.remove('show');
setTimeout(function () { if (el.parentNode) el.parentNode.removeChild(el); }, 280);
}
function arm() { start = Date.now(); timer = setTimeout(close, remain); }
el.addEventListener('mouseenter', function () {
if (timer) { clearTimeout(timer); timer = null; remain = Math.max(800, remain - (Date.now() - start)); }
});
el.addEventListener('mouseleave', arm);
el.addEventListener('click', function (ev) {
if (ev.target && ev.target.tagName === 'A') return;
close();
});
arm();
return { close: close };
}
(function () {
if (!ui || ui._clashooPatched) return;
ui._clashooPatched = true;
var orig = ui.addNotification.bind(ui);
ui.addNotification = function (title, children, classes) {
try {
var text = '';
var collect = function (c) {
if (c == null) return;
if (Array.isArray(c)) { c.forEach(collect); return; }
if (c.nodeType === 1) { text += (c.textContent || '') + ' '; return; }
text += String(c) + ' ';
};
if (title) text += title + ' ';
collect(children);
text = text.trim();
var kind = '';
var cls = classes ? String(classes) : '';
if (/error|danger/i.test(cls) || /失败|错误/.test(text)) kind = 'err';
else if (/success/i.test(cls) || /成功/.test(text)) kind = 'ok';
_clashooToast(text, { kind: kind });
return null;
} catch (e) {
return orig(title, children, classes);
}
};
})();
const callStatus = rpc.declare({ object: 'luci.clashoo', method: 'status', expect: {} });
const callStart = rpc.declare({ object: 'luci.clashoo', method: 'start', expect: {} });
const callStop = rpc.declare({ object: 'luci.clashoo', method: 'stop', expect: {} });
const callReload = rpc.declare({ object: 'luci.clashoo', method: 'reload', expect: {} });
const callRestart = rpc.declare({ object: 'luci.clashoo', method: 'restart', expect: {} });
const callVersion = rpc.declare({ object: 'luci.clashoo', method: 'version', expect: {} });
const callListProf = rpc.declare({ object: 'luci.clashoo', method: 'list_profiles', expect: {} });
const callListConfigs = rpc.declare({ object: 'luci.clashoo', method: 'list_configs', expect: {} });
const callSetConfig = rpc.declare({ object: 'luci.clashoo', method: 'set_config', params: ['name'], expect: {} });
const callSetMode = rpc.declare({ object: 'luci.clashoo', method: 'set_mode', params: ['mode'], expect: {} });
const callSetProxyMode = rpc.declare({ object: 'luci.clashoo', method: 'set_proxy_mode', params: ['mode'], expect: {} });
const callSetCore = rpc.declare({ object: 'luci.clashoo', method: 'set_core', params: ['core', 'dcore', 'action'], expect: {} });
const callSetPanel = rpc.declare({ object: 'luci.clashoo', method: 'set_panel', params: ['name'], expect: {} });
const callUpdatePanel = rpc.declare({ object: 'luci.clashoo', method: 'update_panel', params: ['name'], expect: {} });
const callPanelStatus = rpc.declare({ object: 'luci.clashoo', method: 'panel_status', expect: {} });
const callReadLog = rpc.declare({ object: 'luci.clashoo', method: 'read_log', expect: {} });
const callReadRealLog = rpc.declare({ object: 'luci.clashoo', method: 'read_real_log', expect: {} });
const callClearLog = rpc.declare({ object: 'luci.clashoo', method: 'clear_log', expect: {} });
const callReadUpdateLog = rpc.declare({ object: 'luci.clashoo', method: 'read_update_log', expect: {} });
const callClearUpdateLog= rpc.declare({ object: 'luci.clashoo', method: 'clear_update_log', expect: {} });
const callReadGeoipLog = rpc.declare({ object: 'luci.clashoo', method: 'read_geoip_log', expect: {} });
const callClearGeoipLog = rpc.declare({ object: 'luci.clashoo', method: 'clear_geoip_log', expect: {} });
const callReadCoreLog = rpc.declare({ object: 'luci.clashoo', method: 'read_core_log', expect: {} });
const callClearCoreLog = rpc.declare({ object: 'luci.clashoo', method: 'clear_core_log', expect: {} });
const callReadUpdateMergedLog = rpc.declare({ object: 'luci.clashoo', method: 'read_update_merged_log', expect: {} });
const callClearUpdateMergedLog = rpc.declare({ object: 'luci.clashoo', method: 'clear_update_merged_log', expect: {} });
const callGetCpuArch = rpc.declare({ object: 'luci.clashoo', method: 'get_cpu_arch', expect: {} });
const callDownloadCore = rpc.declare({ object: 'luci.clashoo', method: 'download_core', expect: {} });
const callUpdateGeoip = rpc.declare({ object: 'luci.clashoo', method: 'update_geoip', expect: {} });
const callGetGeoipVersion = rpc.declare({ object: 'luci.clashoo', method: 'get_geoip_version', expect: {} });
const callComponentStatus = rpc.declare({ object: 'luci.clashoo', method: 'component_status', expect: {} });
const callComponentUpdate = rpc.declare({ object: 'luci.clashoo', method: 'component_update', params: ['component', 'variant'], expect: {} });
const callComponentUpdateLog = rpc.declare({ object: 'luci.clashoo', method: 'component_update_log', expect: {} });
const callComponentCheckUpdates = rpc.declare({ object: 'luci.clashoo', method: 'component_check_updates', expect: {} });
const callUpdateChinaIp = rpc.declare({ object: 'luci.clashoo', method: 'update_china_ip', expect: {} });
const callGetLogStatus = rpc.declare({ object: 'luci.clashoo', method: 'get_log_status', expect: {} });
const callAccessCheck = rpc.declare({ object: 'luci.clashoo', method: 'access_check', expect: {} });
const callAccessCheckRefresh= rpc.declare({ object: 'luci.clashoo', method: 'access_check_refresh',expect: {} });
const callOverviewStats = rpc.declare({ object: 'luci.clashoo', method: 'overview_stats', expect: {} });
const callOverview = rpc.declare({ object: 'luci.clashoo', method: 'overview', expect: {} });
const callSmartFlushCache = rpc.declare({ object: 'luci.clashoo', method: 'smart_flush_cache', expect: {} });
const callListSingboxProfiles = rpc.declare({ object: 'luci.clashoo', method: 'list_singbox_profiles', expect: {} });
const callGetSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'get_singbox_profile', params: ['name'], expect: {} });
const callSaveSingboxProfileChunk = rpc.declare({ object: 'luci.clashoo', method: 'save_singbox_profile_chunk', params: ['name', 'content', 'index', 'total'], expect: {} });
const callSetSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'set_singbox_profile', params: ['name'], expect: {} });
const callDeleteSingboxProfile = rpc.declare({ object: 'luci.clashoo', method: 'delete_singbox_profile', params: ['name'], expect: {} });
const callCreateSingboxConfig = rpc.declare({ object: 'luci.clashoo', method: 'create_singbox_config', params: ['sub_url', 'name'], expect: {} });
const callCommitConfig = rpc.declare({ object: 'luci.clashoo', method: 'commit_config', expect: {} });
const callDnsAutoSetup = rpc.declare({ object: 'luci.clashoo', method: 'dns_auto_setup', expect: {} });
const callFetchSingboxNative = rpc.declare({ object: 'luci.clashoo', method: 'fetch_singbox_native', params: ['url', 'name'], expect: {} });
const callUpdateSingboxNative = rpc.declare({ object: 'luci.clashoo', method: 'update_singbox_native', params: ['name'], expect: {} });
// 把脚本输出的英文 log 行翻成中文(去掉前导时间戳)
function localizeLogLine(line) {
var msg = String(line || '').replace(/^\s*\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2}\s*-?\s*/, '');
var rules = [
// LightGBM 模型更新
[/^Start downloading LightGBM model from:?\s*(.*)$/i, _("Start downloading LightGBM model: $1")],
[/^Model unchanged, no update needed$/i, _("Model is already up to date")],
[/^LightGBM model updated successfully$/i, _("LightGBM model updated successfully")],
[/^Download failed \(rc=(\d+)\)$/i, _("Download failed (error code $1)")],
[/^Failed to install model to (.*)$/i, _("Model install failed: $1")],
[/^No curl or wget found$/i, _("curl or wget not found")],
// GeoIP / GeoSite 更新
[/^Updating (.+)$/i, _("Updating $1")],
[/^(.+) updated$/i, _("$1 updated")],
[/^GeoIP update completed, apply on next Clashoo restart$/i, _("GeoIP update completed, applies after Clashoo restarts")],
[/^GeoIP update completed$/i, _("GeoIP update completed")],
[/^GeoIP update failed$/i, _("GeoIP update failed")],
// 通用
[/^Download succeeded$/i, _("Download succeeded")],
[/^Already up to date$/i, _("Already up to date")]
];
for (var i = 0; i < rules.length; i++) {
if (rules[i][0].test(msg)) return msg.replace(rules[i][0], rules[i][1]);
}
return msg;
}
return baseclass.extend({
localizeLogLine: localizeLogLine,
status: function () { return L.resolveDefault(callStatus(), {}); },
start: function () { return L.resolveDefault(callStart(), {}); },
stop: function () { return L.resolveDefault(callStop(), {}); },
reload: function () { return L.resolveDefault(callReload(), {}); },
restart: function () { return L.resolveDefault(callRestart(), {}); },
version: function () { return L.resolveDefault(callVersion(), {}); },
listProfiles: function () { return L.resolveDefault(callListProf(), { profiles: [] }).then(r => r.profiles || []); },
listConfigs: function () { return L.resolveDefault(callListConfigs(), { configs: [], current: '' }); },
setConfig: function (name) { return L.resolveDefault(callSetConfig(name), {}); },
setMode: function (mode) { return L.resolveDefault(callSetMode(mode), {}); },
setProxyMode: function (mode) { return L.resolveDefault(callSetProxyMode(mode), {}); },
setCore: function (core, dcore, action) { return L.resolveDefault(callSetCore(core, dcore, action), {}); },
setPanel: function (name) { return L.resolveDefault(callSetPanel(name), {}); },
updatePanel: function (name) { return L.resolveDefault(callUpdatePanel(name || 'zashboard'), {}); },
panelStatus: function () { return L.resolveDefault(callPanelStatus(), {}); },
readLog: function () { return L.resolveDefault(callReadLog(), { content: '' }).then(r => r.content || ''); },
readRealLog: function () { return L.resolveDefault(callReadRealLog(), { content: '' }).then(r => r.content || ''); },
clearLog: function () { return L.resolveDefault(callClearLog(), {}); },
readUpdateLog: function () { return L.resolveDefault(callReadUpdateLog(), { content: '' }).then(r => r.content || ''); },
clearUpdateLog: function () { return L.resolveDefault(callClearUpdateLog(), {}); },
readGeoipLog: function () { return L.resolveDefault(callReadGeoipLog(), { content: '' }).then(r => r.content || ''); },
clearGeoipLog: function () { return L.resolveDefault(callClearGeoipLog(), {}); },
readCoreLog: function () { return L.resolveDefault(callReadCoreLog(), { content: '' }).then(r => r.content || ''); },
clearCoreLog: function () { return L.resolveDefault(callClearCoreLog(), {}); },
readUpdateMergedLog: function () { return L.resolveDefault(callReadUpdateMergedLog(), { content: '' }).then(r => r.content || ''); },
clearUpdateMergedLog: function () { return L.resolveDefault(callClearUpdateMergedLog(), {}); },
getCpuArch: function () { return L.resolveDefault(callGetCpuArch(), { arch: '' }).then(r => r.arch || ''); },
downloadCore: function (dcore, arch) { return L.resolveDefault(callDownloadCore({ dcore: dcore, arch: arch }), {}); },
updateGeoip: function () { return L.resolveDefault(callUpdateGeoip(), {}); },
getGeoipVersion: function () { return L.resolveDefault(callGetGeoipVersion(), { version: '' }); },
componentStatus: function () { return L.resolveDefault(callComponentStatus(), { running: false, components: [] }); },
componentUpdate: function (component, variant) { return L.resolveDefault(callComponentUpdate(component, variant || ''), { success: false }); },
componentUpdateLog: function () { return L.resolveDefault(callComponentUpdateLog(), { running: false, content: '' }); },
componentCheckUpdates: function () { return L.resolveDefault(callComponentCheckUpdates(), { ok: false, latest: {} }); },
updateChinaIp: function () { return L.resolveDefault(callUpdateChinaIp(), {}); },
getLogStatus: function () { return L.resolveDefault(callGetLogStatus(), {}); },
accessCheck: function () { return L.resolveDefault(callAccessCheck(), {}); },
accessCheckRefresh: function () { return L.resolveDefault(callAccessCheckRefresh(), { success: false }); },
overviewStats: function () { return L.resolveDefault(callOverviewStats(), {}); },
overview: function () {
return L.resolveDefault(callOverview(), {
status: {},
stats: {},
configs: { configs: [], current: '', core_type: '' },
access: {}
});
},
smartFlushCache: function () { return L.resolveDefault(callSmartFlushCache(), { success: false }); },
listSingboxProfiles: function () { return L.resolveDefault(callListSingboxProfiles(), { profiles: [], active: '' }); },
getSingboxProfile: function (name) { return L.resolveDefault(callGetSingboxProfile(name), {}); },
saveSingboxProfile: function (name, content) {
var chunkSize = 24576;
var total = Math.max(1, Math.ceil((content || '').length / chunkSize));
var index = 0;
function sendNext() {
var chunk = (content || '').slice(index * chunkSize, (index + 1) * chunkSize);
return L.resolveDefault(callSaveSingboxProfileChunk(name, chunk, String(index), String(total)), {}).then(function (r) {
if (!r || !r.success)
return { success: false, error: (r && r.error) || 'upload_failed', message: (r && (r.message || r.error)) || _('Upload failed') };
index++;
return index < total ? sendNext() : r;
});
}
return sendNext();
},
setSingboxProfile: function (name) { return L.resolveDefault(callSetSingboxProfile(name), {}); },
deleteSingboxProfile: function (name) { return L.resolveDefault(callDeleteSingboxProfile(name), {}); },
createSingboxConfig: function (url, name) { return L.resolveDefault(callCreateSingboxConfig(url, name), {}); },
commitConfig: function () { return L.resolveDefault(callCommitConfig(), { success: false }); },
dnsAutoSetup: function () { return L.resolveDefault(callDnsAutoSetup(), { success: false }); },
fetchSingboxNative: function (url, name) { return L.resolveDefault(callFetchSingboxNative(url, name), {}); },
updateSingboxNative: function (name) { return L.resolveDefault(callUpdateSingboxNative(name), {}); },
toast: _clashooToast
});
@@ -0,0 +1,12 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Bytedance">
<defs>
<linearGradient id="bdg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#3B82F6"/>
<stop offset="100%" stop-color="#2563EB"/>
</linearGradient>
</defs>
<rect x="12" y="14" width="7" height="36" rx="3.5" fill="url(#bdg)"/>
<rect x="23" y="20" width="7" height="30" rx="3.5" fill="url(#bdg)" opacity="0.92"/>
<rect x="34" y="10" width="7" height="40" rx="3.5" fill="url(#bdg)" opacity="0.96"/>
<path d="M46 25c3.3 0 6 2.7 6 6s-2.7 6-6 6h-2v-12h2z" fill="#1D4ED8"/>
</svg>

After

Width:  |  Height:  |  Size: 624 B

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
'use strict';
'require view.clashoo.overview as overview';
return overview;
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="YouTube">
<rect x="10" y="18" width="44" height="28" rx="9" fill="#FF0000"/>
<path d="M28 24l14 8-14 8z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 216 B

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
zh-cn
@@ -0,0 +1,421 @@
#!/bin/sh
set -eu
TMP_ROOT='/tmp/clashoo-backup'
RESTORE_LOCK_DIR="$TMP_ROOT/restore.lock"
IMPORT_LOCK_DIR="$TMP_ROOT/import.lock"
IMPORT_OWNER_FILE="$IMPORT_LOCK_DIR/path"
EXPORT_GUARD_DIR="$TMP_ROOT/export.lock"
MAX_ARCHIVE_BYTES=33554432
MAX_EXTRACTED_BYTES=67108864
MAX_TAR_BYTES=71303168
MAX_EXTRACTED_KB=65536
MAX_ENTRIES=2048
WORK_DIR=''
EXTRACT_DIR=''
RESTORE_LOCKED=0
EXPORT_GUARD_LOCKED=0
die() {
printf '%s\n' "$*" >&2
exit 1
}
cleanup() {
case "$WORK_DIR" in
"$TMP_ROOT"/*) rm -rf "$WORK_DIR" ;;
esac
if [ "$RESTORE_LOCKED" -eq 1 ]; then
rmdir "$RESTORE_LOCK_DIR" >/dev/null 2>&1 || true
fi
if [ "$EXPORT_GUARD_LOCKED" -eq 1 ]; then
rmdir "$EXPORT_GUARD_DIR" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
is_safe_name() {
local name="$1"
[ -n "$name" ] || return 1
case "$name" in
.*|*..*|*/*|*\\*|*' '*|*'#'*|*'?'*|*'*'*|*':'*) return 1 ;;
esac
! printf '%s' "$name" | LC_ALL=C grep -q '[[:cntrl:]]'
}
is_allowed_entry() {
local entry="$1" base
case "$entry" in
manifest.json|uci|meta/confit_list.conf|meta/template_bindings.conf)
return 0
;;
meta|config|config/sub|config/upload|config/custom|config/singbox|templates)
return 0
;;
config/sub/*)
base="${entry#config/sub/}"
is_safe_name "$base"
;;
config/upload/*)
base="${entry#config/upload/}"
is_safe_name "$base"
;;
config/custom/*)
base="${entry#config/custom/}"
is_safe_name "$base"
;;
config/singbox/*)
base="${entry#config/singbox/}"
is_safe_name "$base"
;;
templates/*)
base="${entry#templates/}"
is_safe_name "$base"
;;
*)
return 1
;;
esac
}
normalise_entry() {
local entry="$1"
while [ "${entry#./}" != "$entry" ]; do
entry="${entry#./}"
done
entry="${entry%/}"
printf '%s\n' "$entry"
}
require_archive_path() {
case "$1" in
"$TMP_ROOT"/*) ;;
*) die '备份临时路径无效' ;;
esac
}
ensure_tmp_root() {
local owner
[ ! -L "$TMP_ROOT" ] || die '备份临时目录不能是符号链接'
if [ -e "$TMP_ROOT" ]; then
[ -d "$TMP_ROOT" ] || die '备份临时路径不是目录'
owner="$(LC_ALL=C ls -nd "$TMP_ROOT" 2>/dev/null | awk '{ print $3 }')"
[ "$owner" = '0' ] || die '备份临时目录所有者异常'
else
mkdir -m 700 "$TMP_ROOT" || die '无法创建备份临时目录'
fi
chmod 700 "$TMP_ROOT" || die '无法保护备份临时目录'
}
cleanup_stale_upload() {
local upload=''
if [ -d "$IMPORT_LOCK_DIR" ]; then
if [ -f "$IMPORT_OWNER_FILE" ] &&
find "$IMPORT_OWNER_FILE" -mmin +15 | grep -q .; then
upload="$(sed -n '1p' "$IMPORT_OWNER_FILE" 2>/dev/null || true)"
case "$upload" in
"$TMP_ROOT"/import.??????) rm -f "$upload" "$upload.result" ;;
esac
rm -rf "$IMPORT_LOCK_DIR"
elif [ ! -f "$IMPORT_OWNER_FILE" ] &&
find "$IMPORT_LOCK_DIR" -mmin +15 | grep -q .; then
rm -rf "$IMPORT_LOCK_DIR"
fi
fi
find "$TMP_ROOT" -maxdepth 1 -type f -name 'import.*' -mmin +15 \
-exec rm -f {} \; 2>/dev/null || true
}
new_upload() {
local upload
cleanup_stale_upload
mkdir "$IMPORT_LOCK_DIR" 2>/dev/null || die '已有备份正在上传,请稍后重试'
if ! upload="$(mktemp "$TMP_ROOT/import.XXXXXX")"; then
rmdir "$IMPORT_LOCK_DIR" >/dev/null 2>&1 || true
die '无法创建上传临时文件'
fi
if ! chmod 600 "$upload" ||
! printf '%s\n' "$upload" > "$IMPORT_OWNER_FILE" ||
! chmod 600 "$IMPORT_OWNER_FILE"; then
rm -f "$upload" "$IMPORT_OWNER_FILE"
rmdir "$IMPORT_LOCK_DIR" >/dev/null 2>&1 || true
die '无法保护上传临时文件'
fi
printf '%s\n' "$upload"
}
new_export() {
local count file
if [ -d "$EXPORT_GUARD_DIR" ] &&
find "$EXPORT_GUARD_DIR" -mmin +1 | grep -q .; then
rmdir "$EXPORT_GUARD_DIR" >/dev/null 2>&1 || true
fi
if ! mkdir "$EXPORT_GUARD_DIR" 2>/dev/null; then
sleep 1
mkdir "$EXPORT_GUARD_DIR" 2>/dev/null || die '正在准备其他备份,请稍后重试'
fi
EXPORT_GUARD_LOCKED=1
find "$TMP_ROOT" -maxdepth 1 -type f -name 'export.*' -mmin +15 \
-exec rm -f {} \; 2>/dev/null || true
count="$(find "$TMP_ROOT" -maxdepth 1 -type f -name 'export.??????' | wc -l | tr -d ' ')"
[ "$count" -lt 2 ] || die '已有多个备份等待下载,请稍后重试'
file="$(mktemp "$TMP_ROOT/export.XXXXXX")" || die '无法创建导出临时文件'
chmod 600 "$file" || die '无法保护导出临时文件'
rmdir "$EXPORT_GUARD_DIR" || die '无法释放导出准备锁'
EXPORT_GUARD_LOCKED=0
printf '%s\n' "$file"
}
lock_restore() {
mkdir "$RESTORE_LOCK_DIR" 2>/dev/null || die '已有备份正在还原,请稍后重试'
}
unlock_restore() {
rmdir "$RESTORE_LOCK_DIR" >/dev/null 2>&1 || true
}
copy_regular() {
local root="$1" source="$2" relative="$3" parent
[ -f "$source" ] || return 0
[ ! -L "$source" ] || die "拒绝备份符号链接: $source"
parent="${relative%/*}"
[ "$parent" = "$relative" ] || mkdir -p "$root/$parent"
cp "$source" "$root/$relative"
}
copy_directory() {
local root="$1" source_dir="$2" prefix="$3" source base
[ -d "$source_dir" ] || return 0
mkdir -p "$root/$prefix"
for source in "$source_dir"/*; do
[ -f "$source" ] || continue
[ ! -L "$source" ] || continue
base="${source##*/}"
is_safe_name "$base" || continue
cp "$source" "$root/$prefix/$base"
done
}
copy_current_tree() {
local root="$1"
mkdir -p "$root"
copy_regular "$root" /etc/config/clashoo uci
copy_regular "$root" /usr/share/clashbackup/confit_list.conf meta/confit_list.conf
copy_regular "$root" /usr/share/clashbackup/template_bindings.conf meta/template_bindings.conf
copy_directory "$root" /usr/share/clashoo/config/sub config/sub
copy_directory "$root" /usr/share/clashoo/config/upload config/upload
copy_directory "$root" /usr/share/clashoo/config/custom config/custom
copy_directory "$root" /usr/share/clashoo/config/singbox config/singbox
copy_directory "$root" /etc/clashoo/templates templates
}
write_manifest() {
local root="$1" host version created file
host="$(uname -n 2>/dev/null || true)"
version="$(sed -n '1p' /usr/share/clashoo/luci_version 2>/dev/null || true)"
created="$(date +%s)"
# jshn uses optional globals internally and is not nounset-safe.
set +u
. /usr/share/libubox/jshn.sh
json_init
json_add_string marker 'clashoo-backup-v2'
json_add_int created_at "$created"
json_add_string host "$host"
json_add_string app_version "$version"
json_add_array files
for file in $(cd "$root" && find . -type f | sed 's#^\./##' | sort); do
json_add_string '' "$file"
done
json_close_array
json_dump > "$root/manifest.json"
set -u
}
validate_archive() {
local archive="$1" size validation_dir fifo tar_file gzip_pid head_rc gzip_rc tar_size list verbose raw entry mode type manifest_seen=0 entry_count=0 extracted_bytes extracted_kb marker
require_archive_path "$archive"
[ -f "$archive" ] || die '备份文件不存在'
size="$(wc -c < "$archive" | tr -d ' ')"
[ "$size" -gt 0 ] || die '备份文件为空'
[ "$size" -le "$MAX_ARCHIVE_BYTES" ] || die '备份文件超过 32MB'
if [ -z "$WORK_DIR" ]; then
WORK_DIR="$(mktemp -d "$TMP_ROOT/validate.XXXXXX")"
fi
validation_dir="$WORK_DIR/validation"
mkdir -p "$validation_dir"
fifo="$validation_dir/archive.pipe"
tar_file="$validation_dir/archive.tar"
mkfifo "$fifo" || die '无法创建备份校验管道'
gzip -dc "$archive" > "$fifo" 2>/dev/null &
gzip_pid=$!
set +e
head -c "$((MAX_TAR_BYTES + 1))" "$fifo" > "$tar_file" 2>/dev/null
head_rc=$?
wait "$gzip_pid"
gzip_rc=$?
set -e
rm -f "$fifo"
tar_size="$(wc -c < "$tar_file" | tr -d ' ')"
[ "$tar_size" -le "$MAX_TAR_BYTES" ] || die '备份解压后超过 64MB'
[ "$head_rc" -eq 0 ] && [ "$gzip_rc" -eq 0 ] || die '备份压缩包损坏'
list="$validation_dir/list"
verbose="$validation_dir/verbose"
tar -tf "$tar_file" > "$list" 2>/dev/null || die '无法读取备份文件清单'
tar -tvf "$tar_file" > "$verbose" 2>/dev/null || die '无法读取备份文件类型'
while IFS= read -r raw; do
entry_count=$((entry_count + 1))
[ "$entry_count" -le "$MAX_ENTRIES" ] || die '备份文件条目过多'
entry="$(normalise_entry "$raw")"
[ -n "$entry" ] || continue
case "$entry" in
/*|*\\*) die '备份文件包含非法路径' ;;
esac
case "/$entry/" in
*/../*|*/./*) die '备份文件包含目录穿越路径' ;;
esac
is_allowed_entry "$entry" || die "备份文件包含未知路径: $entry"
[ "$entry" = 'manifest.json' ] && manifest_seen=1
done < "$list"
[ "$manifest_seen" -eq 1 ] || die '备份文件缺少 manifest.json'
while IFS= read -r raw; do
mode="${raw%% *}"
type="$(printf '%s' "$mode" | cut -c 1)"
case "$type" in
-|d) ;;
*) die '备份文件包含链接或特殊文件' ;;
esac
done < "$verbose"
extracted_bytes="$(awk '$1 ~ /^-/ { total += $3 } END { printf "%.0f\n", total }' "$verbose")"
[ "$extracted_bytes" -le "$MAX_EXTRACTED_BYTES" ] || die '备份解压后超过 64MB'
EXTRACT_DIR="$validation_dir/extract"
mkdir -p "$EXTRACT_DIR"
tar -xf "$tar_file" -C "$EXTRACT_DIR" >/dev/null 2>&1 || die '备份文件解压失败'
if find "$EXTRACT_DIR" -type l | grep -q .; then
die '备份文件包含符号链接'
fi
extracted_kb="$(du -sk "$EXTRACT_DIR" | cut -f 1)"
[ "$extracted_kb" -le "$MAX_EXTRACTED_KB" ] || die '备份解压后超过 64MB'
[ -f "$EXTRACT_DIR/manifest.json" ] || die '备份清单不是普通文件'
[ -f "$EXTRACT_DIR/uci" ] || die '备份文件缺少 UCI 配置'
marker="$(jsonfilter -i "$EXTRACT_DIR/manifest.json" -e '@.marker' 2>/dev/null || true)"
[ "$marker" = 'clashoo-backup-v2' ] || die '备份格式标记无效'
}
clear_target_directories() {
local directory
for directory in \
/usr/share/clashoo/config/sub \
/usr/share/clashoo/config/upload \
/usr/share/clashoo/config/custom \
/usr/share/clashoo/config/singbox \
/etc/clashoo/templates; do
mkdir -p "$directory" || return 1
rm -rf "$directory"/* "$directory"/.[!.]* "$directory"/..?* || return 1
done
}
copy_tree_to_system() {
local root="$1" source base
clear_target_directories || return 1
[ -f "$root/uci" ] && cp "$root/uci" /etc/config/clashoo || return 1
rm -f /usr/share/clashbackup/confit_list.conf \
/usr/share/clashbackup/template_bindings.conf || return 1
if [ -f "$root/meta/confit_list.conf" ]; then
mkdir -p /usr/share/clashbackup || return 1
cp "$root/meta/confit_list.conf" /usr/share/clashbackup/confit_list.conf || return 1
fi
if [ -f "$root/meta/template_bindings.conf" ]; then
mkdir -p /usr/share/clashbackup || return 1
cp "$root/meta/template_bindings.conf" /usr/share/clashbackup/template_bindings.conf || return 1
fi
for source in "$root/config/sub"/*; do
[ -f "$source" ] || continue
base="${source##*/}"
cp "$source" "/usr/share/clashoo/config/sub/$base" || return 1
done
for source in "$root/config/upload"/*; do
[ -f "$source" ] || continue
base="${source##*/}"
cp "$source" "/usr/share/clashoo/config/upload/$base" || return 1
done
for source in "$root/config/custom"/*; do
[ -f "$source" ] || continue
base="${source##*/}"
cp "$source" "/usr/share/clashoo/config/custom/$base" || return 1
done
for source in "$root/config/singbox"/*; do
[ -f "$source" ] || continue
base="${source##*/}"
cp "$source" "/usr/share/clashoo/config/singbox/$base" || return 1
done
for source in "$root/templates"/*; do
[ -f "$source" ] || continue
base="${source##*/}"
cp "$source" "/etc/clashoo/templates/$base" || return 1
done
}
export_archive() {
local archive="$1" stage
require_archive_path "$archive"
WORK_DIR="$(mktemp -d "$TMP_ROOT/export.XXXXXX")"
stage="$WORK_DIR/stage"
copy_current_tree "$stage"
write_manifest "$stage"
tar -C "$stage" -czf "$archive" .
chmod 600 "$archive"
if ! sh "$0" validate "$archive"; then
rm -f "$archive"
die '生成的备份不符合还原限制'
fi
printf 'files=%s\n' "$(find "$stage" -type f | wc -l | tr -d ' ')"
}
restore_archive() {
local upload="$1" lock_mode="${2:-acquire}" archive rollback restored
require_archive_path "$upload"
if [ "$lock_mode" = 'acquire' ]; then
lock_restore
RESTORE_LOCKED=1
else
[ -d "$RESTORE_LOCK_DIR" ] || die '备份还原锁不存在'
fi
WORK_DIR="$(mktemp -d "$TMP_ROOT/restore.XXXXXX")"
archive="$WORK_DIR/import.tar.gz"
mv "$upload" "$archive" || die '无法接管已上传的备份文件'
validate_archive "$archive"
rollback="$WORK_DIR/rollback"
copy_current_tree "$rollback"
if ! copy_tree_to_system "$EXTRACT_DIR"; then
if copy_tree_to_system "$rollback" >/dev/null 2>&1; then
die '恢复写入失败,已回滚原配置'
fi
die '恢复写入失败,自动回滚也失败,请检查存储空间'
fi
restored="$(find "$EXTRACT_DIR" -type f ! -name manifest.json | wc -l | tr -d ' ')"
printf 'restored=%s\n' "$restored"
}
ensure_tmp_root
command="${1:-}"
archive="${2:-}"
case "$command" in
prepare) cleanup_stale_upload ;;
new-upload) new_upload ;;
new-export) new_export ;;
lock) lock_restore ;;
unlock) unlock_restore ;;
export) export_archive "$archive" ;;
validate) validate_archive "$archive" ;;
restore) restore_archive "$archive" ;;
restore-held) restore_archive "$archive" held ;;
*) die '不支持的备份操作' ;;
esac
@@ -0,0 +1,45 @@
{
"admin/services/clashoo": {
"title": "Clashoo",
"order": 90,
"action": {
"type": "firstchild"
},
"depends": {
"acl": ["luci-app-clashoo"]
}
},
"admin/services/clashoo/overview": {
"title": "Overview",
"order": 10,
"action": {
"type": "view",
"path": "clashoo/overview"
},
"depends": {
"acl": ["luci-app-clashoo"]
}
},
"admin/services/clashoo/config": {
"title": "Configuration",
"order": 20,
"action": {
"type": "view",
"path": "clashoo/config"
},
"depends": {
"acl": ["luci-app-clashoo"]
}
},
"admin/services/clashoo/system": {
"title": "System",
"order": 30,
"action": {
"type": "view",
"path": "clashoo/system"
},
"depends": {
"acl": ["luci-app-clashoo"]
}
}
}
@@ -0,0 +1,21 @@
{
"luci-app-clashoo": {
"description": "Grant access for luci-app-clashoo",
"read": {
"cgi-io": [ "download" ],
"file": {
"/tmp/clashoo-backup/export.*": [ "read" ]
},
"uci": [ "clashoo" ],
"ubus": {
"luci.clashoo": [ "*" ]
}
},
"write": {
"uci": [ "clashoo" ],
"ubus": {
"luci.clashoo": [ "*" ]
}
}
}
}
File diff suppressed because it is too large. Load diff
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
OUT_DIR="${ROOT_DIR}/clashoo/files/usr/share/clashoo/nftables"
TMP_DIR="$(mktemp -d)"
V2DAT_BIN="${V2DAT_BIN:-}"
GEOIP_DAT_URL="${GEOIP_DAT_URL:-https://testingcf.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@release/geoip.dat}"
V2DAT_PKG="${V2DAT_PKG:-github.com/kenzok8/v2dat}"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT INT TERM
ensure_v2dat() {
if [ -n "$V2DAT_BIN" ] && [ -x "$V2DAT_BIN" ]; then
return 0
fi
if command -v v2dat >/dev/null 2>&1; then
V2DAT_BIN="$(command -v v2dat)"
return 0
fi
export GOBIN="${TMP_DIR}/bin"
mkdir -p "$GOBIN"
go install "${V2DAT_PKG}@latest"
V2DAT_BIN="${GOBIN}/v2dat"
}
render_nft_set() {
local family="$1"
local input_file="$2"
local output_file="$3"
local set_name="$4"
awk -v set_name="$set_name" -v set_type="$family" '
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 "}"
}
' "$input_file" > "$output_file"
}
mkdir -p "$OUT_DIR" "${TMP_DIR}/out"
ensure_v2dat
curl -fsSL "$GEOIP_DAT_URL" -o "${TMP_DIR}/geoip.dat"
"$V2DAT_BIN" unpack geoip -o "${TMP_DIR}/out" -f cn "${TMP_DIR}/geoip.dat"
CN_TXT="$(find "${TMP_DIR}/out" -type f -name '*_cn.txt' | head -1)"
[ -n "${CN_TXT}" ] && [ -f "${CN_TXT}" ] || {
echo "failed to unpack CN CIDRs from geoip.dat" >&2
exit 1
}
awk 'index($0, ":") == 0 { print }' "${CN_TXT}" > "${TMP_DIR}/cn_ipv4.txt"
awk 'index($0, ":") != 0 { print }' "${CN_TXT}" > "${TMP_DIR}/cn_ipv6.txt"
render_nft_set ipv4_addr "${TMP_DIR}/cn_ipv4.txt" "${OUT_DIR}/geoip_cn.nft" clashoo_china
render_nft_set ipv6_addr "${TMP_DIR}/cn_ipv6.txt" "${OUT_DIR}/geoip6_cn.nft" clashoo_china6
echo "generated:"
echo " ${OUT_DIR}/geoip_cn.nft"
echo " ${OUT_DIR}/geoip6_cn.nft"
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
set -eu
ROOT_DIR="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
RULESET_DIR="${ROOT_DIR}/clashoo/files/usr/share/clashoo/ruleset"
TMP_DIR="$(mktemp -d)"
GEOSITE_BASE="${GEOSITE_BASE:-https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geosite}"
GEOIP_BASE="${GEOIP_BASE:-https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@sing/geo/geoip}"
# mihomo .mrs lives on the meta branch (binary, not .srs)
META_GEOSITE_BASE="${META_GEOSITE_BASE:-https://cdn.jsdelivr.net/gh/MetaCubeX/meta-rules-dat@meta/geo/geosite}"
cleanup() {
rm -rf "$TMP_DIR"
}
trap cleanup EXIT INT TERM
fetch_ruleset() {
tag="$1"
base="$2"
remote_name="${3:-$tag}"
url="${base}/${remote_name}.srs"
tmp_file="${TMP_DIR}/${tag}.srs"
out_file="${RULESET_DIR}/${tag}.srs"
echo "fetch ${tag}.srs"
curl -fsSL --retry 3 --retry-delay 2 "$url" -o "$tmp_file"
[ -s "$tmp_file" ] || {
echo "empty ruleset: $url" >&2
exit 1
}
mv "$tmp_file" "$out_file"
}
mkdir -p "$RULESET_DIR"
rm -f "${RULESET_DIR}"/*.srs
# Minimal built-in set:
# - geolocation-!cn: required by fake-ip DNS and broad non-CN routing.
# - geosite-cn: local alias for subscription tags geolocation-cn/cn.
# - cn-ip/private-ip: keep China/private IP direct without remote downloads.
fetch_ruleset "geolocation-!cn" "$GEOSITE_BASE"
fetch_ruleset "geosite-cn" "$GEOSITE_BASE" geolocation-cn
fetch_ruleset "private-ip" "$GEOIP_BASE" private
fetch_ruleset "cn-ip" "$GEOIP_BASE" cn
# mihomo cn.mrs replaces geosite:cn in fake-ip-filter so the core skips geosite.dat
echo "fetch cn.mrs"
curl -fsSL --retry 3 --retry-delay 2 "${META_GEOSITE_BASE}/cn.mrs" -o "${TMP_DIR}/cn.mrs"
[ -s "${TMP_DIR}/cn.mrs" ] || {
echo "empty ruleset: ${META_GEOSITE_BASE}/cn.mrs" >&2
exit 1
}
mv "${TMP_DIR}/cn.mrs" "${RULESET_DIR}/cn.mrs"
echo "generated sing-box rule sets in ${RULESET_DIR}"
+281
View File
@@ -0,0 +1,281 @@
#!/bin/sh
set -eu
FEED_BASE_URL="https://down.dllkids.xyz/openwrt-feed/clashoo"
GITHUB_API_URL="https://api.github.com/repos/kenzok8/openwrt-clashoo/releases/latest"
GITHUB_PROXY_PREFIX="${GITHUB_PROXY_PREFIX:-https://ghfast.top/}"
TMP_DIR="/tmp/clashoo-install"
fetch_text() {
url="$1"
if command -v curl >/dev/null 2>&1; then
curl -fsSL "$url"
return $?
fi
if command -v wget >/dev/null 2>&1; then
wget -qO- "$url"
return $?
fi
return 127
}
download_file() {
url="$1"
out="$2"
if command -v curl >/dev/null 2>&1; then
curl -fL "$url" -o "$out"
return $?
fi
wget -qO "$out" "$url"
}
download_url() {
url="$1"
case "$url" in
https://github.com/*)
printf '%s%s\n' "$GITHUB_PROXY_PREFIX" "$url"
;;
*)
printf '%s\n' "$url"
;;
esac
}
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"
}
detect_arch() {
pm="$1"
if [ "$pm" = "opkg" ]; then
opkg print-architecture | awk '/^arch / {print $2}' | tail -n 1
return
fi
# apk --print-arch 只回 CPU family(如 aarch64),丢掉 subtarget 后缀;
# feed/release 产物用完整 target arch(aarch64_cortex-a53),优先读 DISTRIB_ARCH
distrib_arch="$(sed -n "s/^DISTRIB_ARCH=['\"]\([^'\"]*\)['\"].*/\1/p" /etc/openwrt_release 2>/dev/null | head -n 1)"
if [ -n "$distrib_arch" ]; then
printf '%s\n' "$distrib_arch"
else
apk --print-arch
fi
}
detect_sdk() {
if [ ! -r /etc/openwrt_release ]; then
return 1
fi
release="$(sed -n "s/^DISTRIB_RELEASE=['\"]\\([^'\"]*\\)['\"]$/\\1/p" /etc/openwrt_release | head -n 1)"
[ -n "$release" ] || return 1
sdk="$(printf '%s\n' "$release" | grep -Eo '[0-9]+\.[0-9]+' | head -n 1)"
[ -n "$sdk" ] || return 1
printf '%s\n' "$sdk"
}
find_feed_filename() {
package_name="$1"
packages_text="$2"
printf '%s\n' "$packages_text" | awk -v pkg="$package_name" '
$0 == "Package: " pkg {
in_pkg = 1
next
}
in_pkg && /^Filename: / {
sub(/^Filename: /, "")
print
exit
}
in_pkg && /^$/ {
in_pkg = 0
}
'
}
find_manifest_value() {
key="$1"
manifest_text="$2"
printf '%s\n' "$manifest_text" | sed -n "s/^${key}=//p" | 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"
}
# Feed 结构:<base>/<sdk>/<arch>/<files> —— 与 dllkids setup.sh 对齐,不嵌 feedname 子目录
feed_base_for() {
printf '%s/%s/%s' "$FEED_BASE_URL" "$1" "$2"
}
load_manifest_urls() {
sdk="$1"
arch="$2"
base="$(feed_base_for "$sdk" "$arch")"
manifest_url="${base}/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
CORE_URL="${base}/${core_file}"
LUCI_URL="${base}/${luci_file}"
I18N_URL=""
[ -n "$i18n_file" ] && I18N_URL="${base}/${i18n_file}"
return 0
}
load_opkg_feed_urls() {
sdk="$1"
arch="$2"
base="$(feed_base_for "$sdk" "$arch")"
packages_url="${base}/Packages.gz"
packages_text="$(fetch_text "$packages_url" || true)"
if ! printf '%s' "$packages_text" | grep -q '^Package: '; then
return 1
fi
core_file="$(find_feed_filename "clashoo" "$packages_text")"
luci_file="$(find_feed_filename "luci-app-clashoo" "$packages_text")"
i18n_file="$(find_feed_filename "luci-i18n-clashoo-zh-cn" "$packages_text")"
[ -n "$core_file" ] || return 1
[ -n "$luci_file" ] || return 1
CORE_URL="${base}/${core_file}"
LUCI_URL="${base}/${luci_file}"
I18N_URL=""
[ -n "$i18n_file" ] && I18N_URL="${base}/${i18n_file}"
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
return 0
}
PM="$(detect_manager)"
if [ "$PM" = "unsupported" ]; then
echo "No supported package manager found (opkg/apk)."
exit 1
fi
ARCH="$(detect_arch "$PM")"
[ -n "$ARCH" ] || { echo "Cannot detect architecture"; exit 1; }
SDK_CANDIDATES="$(build_sdk_candidates "$PM")"
EXT="ipk"
[ "$PM" = "apk" ] && EXT="apk"
CORE_URL=""
LUCI_URL=""
I18N_URL=""
if [ -n "$SDK_CANDIDATES" ]; then
for SDK in $SDK_CANDIDATES; do
if load_manifest_urls "$SDK" "$ARCH"; then
echo "Using R2 feed manifest: ${SDK}/${ARCH}"
break
fi
if [ "$PM" = "opkg" ] && load_opkg_feed_urls "$SDK" "$ARCH"; then
echo "Using R2 feed: ${SDK}/${ARCH}"
break
fi
done
fi
if [ -z "$CORE_URL" ] || [ -z "$LUCI_URL" ]; then
if load_github_urls "$ARCH" "$EXT"; then
echo "Using GitHub latest release"
else
echo "Cannot find required packages for arch: $ARCH"
echo "Detected package manager: $PM"
echo "Tried SDKs: $SDK_CANDIDATES"
exit 1
fi
fi
rm -rf "$TMP_DIR"
mkdir -p "$TMP_DIR"
echo "Downloading packages..."
download_file "$(download_url "$CORE_URL")" "$TMP_DIR/core.${EXT}"
download_file "$(download_url "$LUCI_URL")" "$TMP_DIR/luci.${EXT}"
if [ -n "$I18N_URL" ]; then
download_file "$(download_url "$I18N_URL")" "$TMP_DIR/i18n.${EXT}"
fi
echo "Installing packages with $PM..."
if [ "$PM" = "opkg" ]; then
# Refresh package lists so deps (yq, coreutils-nohup, kmod-*) resolve on fresh systems
opkg update || echo "Warning: opkg update failed, dependency resolution may break"
opkg install "$TMP_DIR/core.${EXT}" "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"}
else
apk update || echo "Warning: apk update failed, dependency resolution may break"
apk add --allow-untrusted "$TMP_DIR/core.${EXT}" "$TMP_DIR/luci.${EXT}" ${I18N_URL:+"$TMP_DIR/i18n.${EXT}"}
fi
echo "Install complete."
+57
View File
@@ -0,0 +1,57 @@
#!/bin/sh
set -eu
echo "Stopping clashoo service..."
/etc/init.d/clashoo stop >/dev/null 2>&1 || true
/etc/init.d/clashoo disable >/dev/null 2>&1 || true
core_still_installed() {
if command -v apk >/dev/null 2>&1; then
apk list -I 2>/dev/null | grep -q "^clashoo-[0-9]"
elif command -v opkg >/dev/null 2>&1; then
opkg list-installed 2>/dev/null | grep -q "^clashoo "
else
return 1
fi
}
if command -v opkg >/dev/null 2>&1; then
echo "Removing packages with opkg..."
opkg remove luci-i18n-clashoo-zh-cn luci-app-clashoo clashoo >/dev/null 2>&1 || true
elif command -v apk >/dev/null 2>&1; then
echo "Removing packages with apk..."
apk del luci-i18n-clashoo-zh-cn luci-app-clashoo clashoo >/dev/null 2>&1 || true
else
echo "No supported package manager found (opkg/apk), skip package removal."
fi
# Always safe: purely runtime/temp files, never owned by the package manager.
echo "Cleaning runtime temp files..."
rm -rf /tmp/clashoo /tmp/clashoo-install /tmp/clashoo_* /tmp/luci* 2>/dev/null || true
# CRITICAL: only delete package-owned files (config / init.d / dirs) when the
# core package was actually removed. If it's still installed - e.g. nikki, which
# depends on the clashoo-provided `mihomo`, blocks `apk del clashoo` - deleting
# these behind the package manager's back corrupts apk's state: the db keeps
# owning files that no longer exist, so a later same-version reinstall only
# writes `.apk-new` and never restores them, leaving the UI broken
# ("kernel not installed" / start stuck). Leave them in place instead.
if core_still_installed; then
echo "WARNING: 'clashoo' is still installed because other packages depend on it"
echo " (e.g. nikki depends on the clashoo-provided 'mihomo')."
echo " Kept /etc/config/clashoo, /etc/init.d/clashoo and /etc/clashoo intact"
echo " to avoid corrupting package state. Remove nikki first for a full"
echo " uninstall, or just reinstall clashoo to refresh it."
else
echo "Cleaning package files..."
rm -rf /etc/clashoo /usr/share/clashoo /usr/share/clashbackup 2>/dev/null || true
rm -f /etc/init.d/clashoo /etc/config/clashoo 2>/dev/null || true
fi
/etc/init.d/rpcd restart >/dev/null 2>&1 || true
/etc/init.d/uhttpd restart >/dev/null 2>&1 || true
/etc/init.d/dnsmasq restart >/dev/null 2>&1 || true
/etc/init.d/firewall restart >/dev/null 2>&1 || true
echo "Uninstall & reset complete."