mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-10 18:34:18 +08:00
🔥 Sync 2026-09-07 20:49:02
This commit is contained in:
@@ -385,9 +385,11 @@ return view.extend({
|
||||
so.validate = validateServerValue;
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'upstream', _('Upstream DNS'));
|
||||
so = ss.option(form.DynamicList, 'upstream', _('Upstream DNS'),
|
||||
_('Allows the use of a specified upstream for specific domain. <a href="%s" target="_blank">Syntax reference</a>.')
|
||||
.format('https://github.com/AdguardTeam/dnsproxy#specifying-upstreams-for-domains'));
|
||||
so.rmempty = false;
|
||||
so.validate = validateServerValue;
|
||||
//so.validate = validateServerValue; // See #11
|
||||
so.modalonly = true;
|
||||
|
||||
so = ss.option(form.DynamicList, 'fallback', _('Fallback DNS'));
|
||||
|
||||
@@ -11,6 +11,12 @@ msgstr ""
|
||||
msgid "Add DNS profile"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/view/dnsproxy.js:389
|
||||
msgid ""
|
||||
"Allows the use of a specified upstream for specific domain. <a href=\"%s\" "
|
||||
"target=\"_blank\">Syntax reference</a>."
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/view/dnsproxy.js:285
|
||||
msgid "Bogus-NXDOMAIN"
|
||||
msgstr ""
|
||||
@@ -122,7 +128,7 @@ msgstr ""
|
||||
msgid "Failed to apply DNS profile: %s"
|
||||
msgstr ""
|
||||
|
||||
#: htdocs/luci-static/resources/view/dnsproxy.js:393
|
||||
#: htdocs/luci-static/resources/view/dnsproxy.js:395
|
||||
msgid "Fallback DNS"
|
||||
msgstr ""
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.DS_Store
|
||||
*.log
|
||||
|
||||
.claude/
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
graphify-out/
|
||||
/scripts/sync-openwrt.sh
|
||||
/scripts/po2lmo.py
|
||||
ts-src/package-lock.json
|
||||
ts-src/node_modules/
|
||||
@@ -0,0 +1,118 @@
|
||||
include $(TOPDIR)/rules.mk
|
||||
|
||||
PKG_NAME:=luci-app-hijpass
|
||||
PKG_VERSION:=26.9.6
|
||||
PKG_RELEASE:=1
|
||||
LUCI_TITLE:=LuCI support for HiJpass
|
||||
LUCI_PKGARCH:=all
|
||||
|
||||
PKG_CONFIG_DEPENDS:= \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Libev_Client \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Libev_Server \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Server \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Shadow_TLS \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_Trojan_Plus \
|
||||
CONFIG_PACKAGE_$(PKG_NAME)_INCLUDE_tuic_client
|
||||
|
||||
LUCI_DEPENDS:= +ca-bundle +curl +file +firewall4 +ip-full +luci-lua-runtime \
|
||||
+sing-box +unzip +xray-core \
|
||||
+kmod-nft-socket +kmod-nft-tproxy +kmod-nft-nat \
|
||||
+v2ray-geosite +v2ray-geoip +geoview +chinadns-ng +jq
|
||||
|
||||
define Package/$(PKG_NAME)/config
|
||||
menu "Configuration"
|
||||
depends on PACKAGE_$(PKG_NAME)
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Hysteria
|
||||
bool "Include Hysteria"
|
||||
select PACKAGE_hysteria
|
||||
default y if aarch64||x86_64
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_NaiveProxy
|
||||
bool "Include NaiveProxy"
|
||||
depends on !(arc||armeb||loongarch64||mips||mips64||powerpc||TARGET_gemini)
|
||||
select PACKAGE_naiveproxy
|
||||
default n
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Libev_Client
|
||||
bool "Include Shadowsocks Libev Client"
|
||||
select PACKAGE_shadowsocks-libev-ss-local
|
||||
select PACKAGE_shadowsocks-libev-ss-redir
|
||||
default y
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Libev_Server
|
||||
bool "Include Shadowsocks Libev Server"
|
||||
select PACKAGE_shadowsocks-libev-ss-server
|
||||
default y
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Client
|
||||
bool "Include Shadowsocks Rust Client"
|
||||
depends on !i386
|
||||
select PACKAGE_shadowsocks-rust-sslocal
|
||||
default n
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadowsocks_Rust_Server
|
||||
bool "Include Shadowsocks Rust Server"
|
||||
depends on !i386
|
||||
select PACKAGE_shadowsocks-rust-ssserver
|
||||
default n
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Shadow_TLS
|
||||
bool "Include Shadow-TLS"
|
||||
select PACKAGE_shadow-tls
|
||||
depends on aarch64||arm||x86_64
|
||||
default n
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_Trojan_Plus
|
||||
bool "Include Trojan-Plus"
|
||||
select PACKAGE_trojan-plus
|
||||
default y
|
||||
|
||||
config PACKAGE_$(PKG_NAME)_INCLUDE_tuic_client
|
||||
bool "Include tuic-client"
|
||||
depends on aarch64||arm||i386||x86_64
|
||||
select PACKAGE_tuic-client
|
||||
default n
|
||||
|
||||
endmenu
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/conffiles
|
||||
/etc/hijpass/fw4-template.nft
|
||||
/etc/hijpass/hook/dns-hook.sh
|
||||
/etc/hijpass/hook/nft-hook.sh
|
||||
/etc/hijpass/rules/chn-list.txt
|
||||
/etc/hijpass/rules/chn-route.txt
|
||||
/etc/hijpass/rules/chn-route6.txt
|
||||
/etc/hijpass/rules/domain-direct.txt
|
||||
/etc/hijpass/rules/domain-proxy.txt
|
||||
/etc/hijpass/rules/gfw-list.txt
|
||||
/etc/hijpass/rules/ip-direct.txt
|
||||
/etc/hijpass/rules/ip-proxy.txt
|
||||
/etc/hijpass/shunt.json
|
||||
/etc/config/hijpass
|
||||
/etc/config/hijserver
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/prerm
|
||||
#!/bin/sh
|
||||
/etc/init.d/hijpass stop
|
||||
/etc/init.d/hijserver stop
|
||||
exit 0
|
||||
endef
|
||||
|
||||
define Package/$(PKG_NAME)/postrm
|
||||
#!/bin/sh
|
||||
uci -q batch <<-EOF >/dev/null
|
||||
delete firewall.hijpass
|
||||
commit firewall
|
||||
EOF
|
||||
exit 0
|
||||
endef
|
||||
|
||||
include $(TOPDIR)/feeds/luci/luci.mk
|
||||
|
||||
# call BuildPackage - OpenWrt buildroot signature
|
||||
@@ -0,0 +1,31 @@
|
||||
# 编译
|
||||
|
||||
## OpenWrt 软件包
|
||||
|
||||
在已添加本包、安装所需 feeds 并完成配置的 OpenWrt 源码树或 SDK 根目录执行(首次编译需要联网):
|
||||
|
||||
```sh
|
||||
make package/luci-app-hijpass/clean
|
||||
make package/luci-app-hijpass/compile V=s
|
||||
```
|
||||
|
||||
## OpenWrt 固件
|
||||
|
||||
在 OpenWrt 源码树根目录执行,在菜单中选中本包后编译:
|
||||
|
||||
```sh
|
||||
make menuconfig
|
||||
make -j"$(nproc)"
|
||||
```
|
||||
|
||||
## 本地 JS
|
||||
|
||||
仓库中默认js是已经编译好的最新版本。如想自行编译,安装 Node.js 18 或更高版本,在本仓库根目录执行:
|
||||
|
||||
```sh
|
||||
cd ts-src
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
输出目录:`htdocs/luci-static/resources/view/hijpass/`(相对于仓库根目录)。
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Symlink
+1
@@ -0,0 +1 @@
|
||||
zh_Hans
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
|
||||
config hijpass
|
||||
option enabled '0'
|
||||
option startup_delay '1'
|
||||
option delay_time '10'
|
||||
option ip_direct '/etc/hijpass/rules/ip-direct.txt'
|
||||
option ip_proxy '/etc/hijpass/rules/ip-proxy.txt'
|
||||
option domain_direct '/etc/hijpass/rules/domain-direct.txt'
|
||||
option domain_proxy '/etc/hijpass/rules/domain-proxy.txt'
|
||||
option gfw_list '/etc/hijpass/rules/gfw-list.txt'
|
||||
option chn_route '/etc/hijpass/rules/chn-route.txt'
|
||||
option chn_route6 '/etc/hijpass/rules/chn-route6.txt'
|
||||
option chn_list '/etc/hijpass/rules/chn-list.txt'
|
||||
option nft '/etc/hijpass/fw4-template.nft'
|
||||
option nft_hook '/etc/hijpass/hook/nft-hook.sh'
|
||||
option dns_hook '/etc/hijpass/hook/dns-hook.sh'
|
||||
option shunt_conf '/etc/hijpass/shunt.json'
|
||||
option client_dir '/etc/hijpass/client'
|
||||
option log_level 'INFO'
|
||||
option log_path '/tmp/hijpass/log/hijpass.log'
|
||||
option log_dir '/tmp/hijpass/log'
|
||||
|
||||
config firewall
|
||||
option tproxy_proto 'tcp,udp'
|
||||
option shunt_port '18081'
|
||||
option dns_forward 'none'
|
||||
option proxy_local '1'
|
||||
option use_chnroute '0'
|
||||
|
||||
config shunt
|
||||
option type 'sing-box'
|
||||
option shunt_listen_port '18081'
|
||||
option dns_listen_port '10153'
|
||||
option config_type 'tmpl'
|
||||
option log_path '/tmp/hijpass/log/shunt.log'
|
||||
option log_level 'warn'
|
||||
option ruleset_convert '1'
|
||||
option use_cache '1'
|
||||
option shunt_hash '2927059463'
|
||||
option default_proxy_node 'hijpass-direct'
|
||||
option ruleset_out_node 'hijpass-direct'
|
||||
option default_dns_node 'Ali'
|
||||
option ruleset_dns_node 'Ali'
|
||||
option default_strategy '64'
|
||||
|
||||
config dns
|
||||
option dns_service 'chinadns-ng'
|
||||
option direct_dns 'dnsmasq'
|
||||
option proxy_dns '127.0.0.1#10153'
|
||||
option cdg_port '53653'
|
||||
option domain_proxy_hash ''
|
||||
option domain_direct_hash ''
|
||||
|
||||
config rule
|
||||
list chn_domain_list 'https://fastly.jsdelivr.net/gh/felixonmars/dnsmasq-china-list/accelerated-domains.china.conf'
|
||||
option gfw_list 'https://fastly.jsdelivr.net/gh/Loyalsoldier/v2ray-rules-dat@release/gfw.txt'
|
||||
option geosite 'https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat'
|
||||
option geoip 'https://github.com/Loyalsoldier/geoip/releases/latest/download/geoip.dat'
|
||||
option update_schedule '0 5 * * 1'
|
||||
option download_timeout '3'
|
||||
list chn_v4_route 'https://fastly.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china.txt'
|
||||
list chn_v4_route 'https://ispip.clang.cn/all_cn.txt'
|
||||
list chn_v6_route 'https://fastly.jsdelivr.net/gh/gaoyifan/china-operator-ip@ip-lists/china6.txt'
|
||||
list chn_v6_route 'https://ispip.clang.cn/all_cn_ipv6.txt'
|
||||
|
||||
config shunt_dns_node
|
||||
option enabled '1'
|
||||
option type 'https'
|
||||
option tag 'Ali'
|
||||
option server '223.5.5.5'
|
||||
|
||||
config shunt_dns_node
|
||||
option type 'https'
|
||||
option tag 'Google'
|
||||
option server '8.8.8.8'
|
||||
option enabled '1'
|
||||
|
||||
config shunt_dns_node
|
||||
option enabled '1'
|
||||
option type 'https'
|
||||
option tag 'Cloudflare'
|
||||
option server '1.1.1.1'
|
||||
|
||||
config shunt_dns_node
|
||||
option enabled '1'
|
||||
option type 'https'
|
||||
option tag 'Quad9'
|
||||
option server '9.9.9.9'
|
||||
@@ -0,0 +1,4 @@
|
||||
config hijserver
|
||||
option enabled '0'
|
||||
option log_dir '/tmp/hijserver/log'
|
||||
option server_dir '/etc/hijpass/server'
|
||||
@@ -0,0 +1,279 @@
|
||||
table inet hijpass {
|
||||
# 防火墙配置不建议轻度用户修改,否则容易引发网络故障
|
||||
chain pre-filter {
|
||||
type filter hook prerouting priority filter - 50; policy accept;
|
||||
|
||||
# 过滤来源非本地内网的请求
|
||||
# fib saddr type != local iif != br-lan counter return
|
||||
|
||||
# 过滤访问本地的请求
|
||||
fib daddr type local counter return
|
||||
|
||||
# WAN 入站流量不参与透明代理
|
||||
iifname $WAN_DEV counter return
|
||||
|
||||
iif != @proxy_interface_list ether saddr != @proxy_mac_list counter return
|
||||
iif @proxy_interface_exclude_list counter return
|
||||
ether saddr @proxy_mac_exclude_list counter return
|
||||
|
||||
# 过滤回包
|
||||
ct direction reply counter return
|
||||
|
||||
# 不满足代理协议,返回原链
|
||||
meta l4proto != $TPROXY_PROTO counter return
|
||||
|
||||
# 新连接设置代理、分流标记
|
||||
ct state new,related counter jump tproxy-shunt
|
||||
|
||||
# 未标记连接,返回原链
|
||||
ct mark != { $TPROXY_MARK, $TPROXY_SHUNT_MARK } counter return
|
||||
|
||||
# 仅代理常用端口
|
||||
counter jump proxy-port-filter
|
||||
|
||||
# 代理标记,通过代理节点进行透明代理
|
||||
meta l4proto $TPROXY_PROTO ct mark $TPROXY_MARK tproxy to :$TPROXY_PORT meta mark set $TPROXY_MARK counter accept
|
||||
|
||||
# 分流、代理标记,通过分流节点进行透明代理
|
||||
meta l4proto $TPROXY_PROTO ct mark { $TPROXY_MARK, $TPROXY_SHUNT_MARK } tproxy to :$TPROXY_SHUNT_PORT meta mark set $TPROXY_MARK counter
|
||||
}
|
||||
|
||||
chain out-route {
|
||||
type route hook output priority filter - 50; policy accept;
|
||||
|
||||
fib daddr type local counter return
|
||||
ct direction reply counter return
|
||||
meta l4proto != $TPROXY_PROTO counter return
|
||||
meta skgid $HIJPASS_USER counter return
|
||||
|
||||
ct state new,related counter jump tproxy-shunt
|
||||
|
||||
counter jump proxy-port-filter
|
||||
counter jump tproxy-mark
|
||||
}
|
||||
|
||||
chain tproxy-shunt {
|
||||
ct mark { $TPROXY_MARK, $TPROXY_SHUNT_MARK } return
|
||||
|
||||
ip daddr @proxy4list ct mark set $TPROXY_MARK counter return
|
||||
ip6 daddr @proxy6list ct mark set $TPROXY_MARK counter return
|
||||
ip daddr @dnsproxy4list ct mark set $TPROXY_MARK counter return
|
||||
ip6 daddr @dnsproxy6list ct mark set $TPROXY_MARK counter return
|
||||
|
||||
ip daddr @bypass4list counter return
|
||||
ip6 daddr @bypass6list counter return
|
||||
ip daddr @dnsbypass4list counter return
|
||||
ip6 daddr @dnsbypass6list counter return
|
||||
|
||||
ip daddr @gfw4list counter ct mark set $TPROXY_MARK counter return
|
||||
ip6 daddr @gfw6list counter ct mark set $TPROXY_MARK counter return
|
||||
|
||||
ip daddr @chn4list counter return
|
||||
ip6 daddr @chn6list counter return
|
||||
|
||||
ip daddr @chnroute4list counter return
|
||||
ip6 daddr @chnroute6list counter return
|
||||
|
||||
ct mark set $TPROXY_SHUNT_MARK counter comment "shunt mark"
|
||||
}
|
||||
|
||||
chain tproxy-mark {
|
||||
# 根据 ct mark 设置 meta mark
|
||||
ct mark { $TPROXY_MARK, $TPROXY_SHUNT_MARK } meta mark set $TPROXY_MARK
|
||||
}
|
||||
|
||||
chain proxy-port-filter {
|
||||
# 禁止quic代理
|
||||
meta l4proto udp udp dport 443 counter reject
|
||||
|
||||
# 不代理非DNS的UDP请求
|
||||
meta l4proto udp udp dport != 53 ct mark set 0 counter return
|
||||
|
||||
# 非指定代理请求,仅代理常用TCP端口
|
||||
meta l4proto tcp ct mark != $TPROXY_MARK tcp dport != { 22, 25, 53, 143, 465, 587, 853, 993, 995, 80, 443} ct mark set 0 counter return
|
||||
}
|
||||
|
||||
chain dns-forward {
|
||||
type nat hook prerouting priority dstnat - 1; policy accept;
|
||||
|
||||
# WAN 入站流量不参与 DNS 转发
|
||||
iifname $WAN_DEV counter return
|
||||
|
||||
# 仅转发透明代理 ACL 选中的客户端 DNS 查询
|
||||
iif != @proxy_interface_list ether saddr != @proxy_mac_list counter return
|
||||
iif @proxy_interface_exclude_list counter return
|
||||
ether saddr @proxy_mac_exclude_list counter return
|
||||
meta l4proto { tcp, udp } th dport 53 counter redirect to :$DNS_FORWARD_PORT
|
||||
}
|
||||
|
||||
chain dns-forward-output {
|
||||
type nat hook output priority dstnat - 1; policy accept;
|
||||
|
||||
# DNS 服务和代理核心的上游查询不参与转发,避免递归
|
||||
meta skgid $HIJPASS_USER counter return
|
||||
|
||||
# 仅转发路由器发往本机 53 端口的系统 DNS 查询
|
||||
fib daddr type local meta l4proto { tcp, udp } th dport 53 counter redirect to :$DNS_FORWARD_PORT
|
||||
}
|
||||
|
||||
######################
|
||||
# 以下配置请勿随意修改 #
|
||||
######################
|
||||
|
||||
# chnroute
|
||||
set chnroute4list {
|
||||
type ipv4_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
elements = {
|
||||
$IP4_CHNROUTE_LIST
|
||||
}
|
||||
}
|
||||
|
||||
set chnroute6list {
|
||||
type ipv6_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
elements = {
|
||||
$IP6_CHNROUTE_LIST
|
||||
}
|
||||
}
|
||||
|
||||
# gfwlist
|
||||
set gfw4list {
|
||||
type ipv4_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
set gfw6list {
|
||||
type ipv6_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
# chnlist
|
||||
set chn4list {
|
||||
type ipv4_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
set chn6list {
|
||||
type ipv6_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
# user custom proxy domain list
|
||||
set dnsproxy4list {
|
||||
type ipv4_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
set dnsproxy6list {
|
||||
type ipv6_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
# user custom direct domain list
|
||||
set dnsbypass4list {
|
||||
type ipv4_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
set dnsbypass6list {
|
||||
type ipv6_addr
|
||||
flags interval,timeout
|
||||
auto-merge
|
||||
timeout 3d
|
||||
gc-interval 3d
|
||||
}
|
||||
|
||||
# user custom proxy ip list
|
||||
set bypass4list {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
$IP4_DIRECT_NFT_LIST
|
||||
}
|
||||
}
|
||||
|
||||
set proxy4list {
|
||||
type ipv4_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
$IP4_PROXY_NFT_LIST
|
||||
}
|
||||
}
|
||||
|
||||
# user custom direct ip list
|
||||
set bypass6list {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
$IP6_DIRECT_NFT_LIST
|
||||
}
|
||||
}
|
||||
|
||||
set proxy6list {
|
||||
type ipv6_addr
|
||||
flags interval
|
||||
auto-merge
|
||||
elements = {
|
||||
$IP6_PROXY_NFT_LIST
|
||||
}
|
||||
}
|
||||
|
||||
set proxy_mac_list {
|
||||
type ether_addr
|
||||
elements = {
|
||||
$PROXY_MAC
|
||||
}
|
||||
}
|
||||
|
||||
set proxy_mac_exclude_list {
|
||||
type ether_addr
|
||||
elements = {
|
||||
$PROXY_MAC_EXCLUDE
|
||||
}
|
||||
}
|
||||
|
||||
set proxy_interface_list {
|
||||
type iface_index
|
||||
elements = {
|
||||
$PROXY_INTERFACE
|
||||
}
|
||||
}
|
||||
|
||||
set proxy_interface_exclude_list {
|
||||
type iface_index
|
||||
elements = {
|
||||
$PROXY_INTERFACE_EXCLUDE
|
||||
}
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/sh
|
||||
# DNS 服务 Hook 脚本
|
||||
# 启动时以 start 参数调用,停止时以 stop 参数调用
|
||||
# 用法: dns-hook.sh [start|stop]
|
||||
|
||||
case "$1" in
|
||||
start)
|
||||
# 在此添加 DNS 服务启动后需要执行的操作
|
||||
;;
|
||||
stop)
|
||||
# 在此添加 DNS 服务停止前需要执行的操作
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
#!/bin/sh
|
||||
|
||||
exit 0
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,25 @@
|
||||
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/3
|
||||
|
||||
::/127
|
||||
::ffff:0.0.0.0/96
|
||||
::ffff:0:0:0/96
|
||||
64:ff9b::/96
|
||||
100::/64
|
||||
2001::/32
|
||||
2001:20::/28
|
||||
2001:db8::/32
|
||||
2002::/16
|
||||
fc00::/7
|
||||
fe80::/10
|
||||
ff00::/8
|
||||
|
||||
223.5.5.5
|
||||
114.114.114.114
|
||||
119.29.29.29
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"log": {
|
||||
"disabled": false,
|
||||
"level": "warn",
|
||||
"timestamp": true
|
||||
},
|
||||
"dns": {},
|
||||
"inbounds": [],
|
||||
"outbounds": [],
|
||||
"route": {}
|
||||
}
|
||||
Executable
+243
@@ -0,0 +1,243 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=15
|
||||
|
||||
NAME=hijpass
|
||||
|
||||
LIB_PATH="/usr/lib/$NAME"
|
||||
NFT_SCRIPT="$LIB_PATH/nft.sh"
|
||||
ROUTE_SCRIPT="$LIB_PATH/route.sh"
|
||||
RULE_UPDATE_SCRIPT="$LIB_PATH/rules.sh"
|
||||
CHINANDS_NG_SCRIPT="$LIB_PATH/chinadns-ng.sh"
|
||||
GEOVIEW_SCRIPT="$LIB_PATH/geoview.sh"
|
||||
|
||||
CONF_PATH="/etc/$NAME"
|
||||
SHUNT_CONF="$CONF_PATH/shunt.json"
|
||||
|
||||
# Global
|
||||
ENABLED=$(uci -q get hijpass.@hijpass[0].enabled || :)
|
||||
DELAY_TIME=$(uci -q get hijpass.@hijpass[0].delay_time || :)
|
||||
CLIENT_DIR=$(uci -q get hijpass.@hijpass[0].client_dir || :)
|
||||
|
||||
# RuleDTO
|
||||
CRON_CONF=$(uci -q get hijpass.@rule[0].update_schedule || :)
|
||||
|
||||
# Log
|
||||
REDIRECT_LOG=
|
||||
|
||||
# DNS
|
||||
DNS_SERVICE=$(uci -q get hijpass.@dns[0].dns_service || :)
|
||||
DNS_HOOK=$(uci -q get hijpass.@hijpass[0].dns_hook || :)
|
||||
CHINADNS_NG_CONF="$CONF_PATH/chinadns-ng.conf"
|
||||
|
||||
start_service() {
|
||||
[ "$ENABLED" != "1" ] && return
|
||||
# 启动进程
|
||||
config_load $NAME
|
||||
log_info "===================== Starting service ======================="
|
||||
log_info "Running proxy nodes..."
|
||||
config_foreach run_regular_proxy proxy_node
|
||||
sleep 2
|
||||
log_info "Running load balancing proxy nodes..."
|
||||
config_foreach run_load_balance_proxy proxy_node
|
||||
sleep 1
|
||||
log_info "Running shunt..."
|
||||
config_foreach run_shunt shunt
|
||||
sleep 1
|
||||
# 配置防火墙
|
||||
log_info "Configuring firewall rules..."
|
||||
reset_fw_tproxy
|
||||
# 启动定时任务
|
||||
log_info "Resetting geo cron jobs..."
|
||||
reset_geo_cron
|
||||
# 启动DNS劫持
|
||||
log_info "DNS hijacking..."
|
||||
start_dns_service
|
||||
log_info "================ Service started successfully ================"
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
log_info "===================== Stopping service ======================="
|
||||
reset_geo_cron
|
||||
remove_fw_tproxy
|
||||
stop_dns_service
|
||||
log_info "================ Service stopped successfully ================"
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "$NAME"
|
||||
procd_add_interface_trigger "interface.*.up" "*" /etc/init.d/$NAME restart
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
/etc/init.d/$NAME restart
|
||||
}
|
||||
|
||||
boot() {
|
||||
[ "$DELAY_TIME" -gt 0 ] && sleep "$DELAY_TIME"
|
||||
restart
|
||||
}
|
||||
|
||||
run_regular_proxy() {
|
||||
local type
|
||||
config_get type "$1" type
|
||||
[ "$type" = "load_balance" ] && return
|
||||
run_proxy "$1"
|
||||
}
|
||||
|
||||
run_load_balance_proxy() {
|
||||
local type
|
||||
config_get type "$1" type
|
||||
[ "$type" != "load_balance" ] && return
|
||||
run_proxy "$1"
|
||||
}
|
||||
|
||||
run_proxy() {
|
||||
local command log_path name enabled nest_proxy core
|
||||
local enable_std_log="1"
|
||||
config_get enabled "$1" enabled
|
||||
[ "$enabled" != "1" ] && return
|
||||
config_get command "$1" command
|
||||
config_get log_path "$1" log_path
|
||||
config_get name "$1" name
|
||||
config_get nest_proxy "$1" nest_proxy
|
||||
config_get core "$1" core
|
||||
|
||||
local conf_path="$CLIENT_DIR/$name-$1.json"
|
||||
|
||||
case "$core" in
|
||||
xray) command="xray -c $conf_path" ;;
|
||||
sing-box) command="sing-box -c $conf_path run" ;;
|
||||
custom) command=$(echo "$command" | sed "s|{conf_path}|$conf_path|g") ;;
|
||||
*) log_warn "Node '$name' has unknown core='$core', skipping"; return ;;
|
||||
esac
|
||||
|
||||
if [ -n "$log_path" ]; then
|
||||
: > "$log_path"
|
||||
command="$command >> $log_path 2>&1"
|
||||
enable_std_log="0"
|
||||
REDIRECT_LOG="1"
|
||||
else
|
||||
command="$command > /dev/null 2>&1"
|
||||
fi
|
||||
open_instance "$1" "$name" "$command" "$enable_std_log" "$nest_proxy"
|
||||
}
|
||||
|
||||
run_shunt() {
|
||||
local type log_path enabled ruleset_convert
|
||||
config_get enabled "$1" enabled
|
||||
[ "$enabled" = "0" ] && log_info "Shunt disabled, skipping" && return
|
||||
config_get type "$1" type
|
||||
[ -z "$type" ] && log_error "Shunt type not set" && return
|
||||
config_get log_path "$1" log_path
|
||||
config_get ruleset_convert "$1" ruleset_convert
|
||||
|
||||
local shunt_command="$type -c $SHUNT_CONF"
|
||||
[ "$type" = "sing-box" ] && shunt_command="$type run -c $SHUNT_CONF"
|
||||
[ "$type" = "sing-box" ] && [ "$ruleset_convert" = "1" ] && $GEOVIEW_SCRIPT -t
|
||||
|
||||
local enable_std_log="0"
|
||||
if [ -n "$log_path" ]; then
|
||||
: > "$log_path"
|
||||
shunt_command="$shunt_command >> $log_path 2>&1"
|
||||
REDIRECT_LOG="1"
|
||||
else
|
||||
shunt_command="$shunt_command > /dev/null 2>&1"
|
||||
fi
|
||||
open_instance "$1" "shunt" "$shunt_command" "$enable_std_log"
|
||||
}
|
||||
|
||||
open_instance() {
|
||||
log_debug "id: $1, name: $2, cmd: $3"
|
||||
local instance_name="$1-$2"
|
||||
local cmd="$3"
|
||||
local enable_std_log="$4"
|
||||
local nest_proxy="$5"
|
||||
procd_open_instance "$instance_name"
|
||||
procd_set_param command /bin/sh -c "$cmd"
|
||||
if [ "$2" = "shunt" ]; then
|
||||
procd_set_param limits core="unlimited"
|
||||
procd_set_param limits nofile="1000000 1000000"
|
||||
procd_set_param env "XRAY_LOCATION_ASSET=/usr/share/v2ray"
|
||||
fi
|
||||
config_list_foreach "$1" procd_env procd_set_env "$instance_name"
|
||||
if [ "$enable_std_log" = "1" ]; then
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
fi
|
||||
procd_set_param pidfile "/var/run/$instance_name.pid"
|
||||
if [ "$nest_proxy" = "1" ]; then
|
||||
procd_set_param user "root"
|
||||
else
|
||||
procd_set_param user "hijpass"
|
||||
fi
|
||||
procd_set_param respawn 10 5 2
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
procd_set_env() {
|
||||
[ -z "$1" ] && return
|
||||
log_debug "Setting $2 env: $1"
|
||||
procd_set_param env "$1"
|
||||
}
|
||||
|
||||
reset_fw_tproxy() {
|
||||
if ! $NFT_SCRIPT reset > /dev/null 2>&1; then
|
||||
stop
|
||||
exit 1
|
||||
fi
|
||||
$ROUTE_SCRIPT reset_hijpass_route
|
||||
}
|
||||
|
||||
remove_fw_tproxy() {
|
||||
$ROUTE_SCRIPT remove_hijpass_route
|
||||
$NFT_SCRIPT remove > /dev/null 2>&1
|
||||
}
|
||||
|
||||
start_dns_service() {
|
||||
if [ "$DNS_SERVICE" = "chinadns-ng" ]; then
|
||||
log_info "Starting chinadns-ng service"
|
||||
$CHINANDS_NG_SCRIPT
|
||||
local cid
|
||||
cid="$(uci show hijpass.@dns[0] | grep 'dns$' | awk -F '[.=]' '{print $2}')"
|
||||
open_instance "$cid" "chinadns-ng" "$DNS_SERVICE -C $CHINADNS_NG_CONF"
|
||||
fi
|
||||
if [ -n "$DNS_HOOK" ]; then
|
||||
[ ! -f "$DNS_HOOK" ] && log_warn "Custom DNS hook script $DNS_HOOK not found" && return 1
|
||||
log_info "Executing custom DNS hook script (start)"
|
||||
$DNS_HOOK start || log_error "Custom DNS hook script execute failed"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_dns_service() {
|
||||
if [ "$DNS_SERVICE" = "chinadns-ng" ]; then
|
||||
rm -f "$CHINADNS_NG_CONF" && log_info "Deleted chinadns-ng conf"
|
||||
fi
|
||||
if [ -n "$DNS_HOOK" ]; then
|
||||
[ ! -f "$DNS_HOOK" ] && log_warn "Custom DNS hook script $DNS_HOOK not found" && return 1
|
||||
log_info "Executing custom DNS hook script (stop)"
|
||||
$DNS_HOOK stop || log_warn "Custom DNS hook script execute failed"
|
||||
fi
|
||||
}
|
||||
|
||||
reset_geo_cron() {
|
||||
sed -i '/hijpass\/rules\.sh/d' /etc/crontabs/root 2>/dev/null
|
||||
sed -i '/hijpass\/clean-service-log\.sh hijpass/d' /etc/crontabs/root 2>/dev/null
|
||||
if [ "$REDIRECT_LOG" = "1" ]; then
|
||||
echo "*/10 * * * * /usr/lib/hijpass/clean-service-log.sh hijpass" >> /etc/crontabs/root
|
||||
log_info "Added log split service cron job: */10 * * * * /usr/lib/hijpass/clean-service-log.sh hijpass"
|
||||
fi
|
||||
if [ -z "$CRON_CONF" ] || [ "$ENABLED" != "1" ]; then
|
||||
/etc/init.d/cron restart > /dev/null 2>&1
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "$CRON_CONF $RULE_UPDATE_SCRIPT update" >> /etc/crontabs/root
|
||||
log_info "Added rule update cron job: $CRON_CONF $RULE_UPDATE_SCRIPT update"
|
||||
|
||||
/etc/init.d/cron restart > /dev/null 2>&1
|
||||
}
|
||||
Executable
+149
@@ -0,0 +1,149 @@
|
||||
#!/bin/sh /etc/rc.common
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
USE_PROCD=1
|
||||
START=99
|
||||
STOP=15
|
||||
|
||||
NAME=hijserver
|
||||
|
||||
DELAY_TIME=10
|
||||
NFT_TABLE_POST=/usr/share/nftables.d/table-post/hijserver.nft
|
||||
NFT_CHAIN_PRE=/usr/share/nftables.d/chain-pre/input_wan/hijserver.nft
|
||||
ENABLED=$(uci -q get hijserver.@hijserver[0].enabled || :)
|
||||
LOG_DIR=$(uci -q get hijserver.@hijserver[0].log_dir || :)
|
||||
|
||||
SERVER_PORTS=
|
||||
REDIRECT_LOG=
|
||||
|
||||
start_service() {
|
||||
[ "$ENABLED" != "1" ] && return
|
||||
[ -d "$LOG_DIR" ] || mkdir -p "$LOG_DIR"
|
||||
# 启动进程
|
||||
config_load $NAME
|
||||
log_info "Starting hijserver..."
|
||||
config_foreach run_server server_node
|
||||
sleep 2
|
||||
# 配置防火墙
|
||||
log_info "Configuring server firewall rules..."
|
||||
reset_fw_tproxy
|
||||
reset_geo_cron
|
||||
log_info "Hijserver started successfully"
|
||||
}
|
||||
|
||||
stop_service() {
|
||||
log_info "Stopping server..."
|
||||
remove_fw_tproxy
|
||||
reset_geo_cron
|
||||
log_info "Server stopped successfully"
|
||||
}
|
||||
|
||||
service_triggers() {
|
||||
procd_add_reload_trigger "$NAME"
|
||||
procd_add_interface_trigger "interface.*.up" "*" /etc/init.d/$NAME restart
|
||||
}
|
||||
|
||||
reload_service() {
|
||||
/etc/init.d/$NAME restart
|
||||
}
|
||||
|
||||
boot() {
|
||||
[ "$DELAY_TIME" -gt 0 ] && sleep "$DELAY_TIME"
|
||||
restart
|
||||
}
|
||||
|
||||
run_server() {
|
||||
local command log_path name enabled listen_port allow_firewall
|
||||
local enable_std_log="1"
|
||||
config_get enabled "$1" enabled
|
||||
[ "$enabled" != "1" ] && return
|
||||
config_get command "$1" command
|
||||
config_get log_path "$1" log_path
|
||||
config_get name "$1" name
|
||||
config_get listen_port "$1" listen_port
|
||||
config_get allow_firewall "$1" allow_firewall
|
||||
|
||||
local server_dir
|
||||
server_dir=$(uci -q get hijserver.@hijserver[0].server_dir || echo '/etc/hijpass/server')
|
||||
local conf_path="$server_dir/$name-$1.json"
|
||||
command=$(echo "$command" | sed "s|{conf_path}|$conf_path|g")
|
||||
|
||||
if [ -n "$log_path" ]; then
|
||||
: > "$log_path"
|
||||
command="$command >> $log_path 2>&1"
|
||||
enable_std_log="0"
|
||||
REDIRECT_LOG=1
|
||||
else
|
||||
command="$command > /dev/null 2>&1"
|
||||
fi
|
||||
|
||||
if open_instance "$1" "$name" "$command" "$enable_std_log"; then
|
||||
log_info "Server $name started successfully"
|
||||
[ "$allow_firewall" = "1" ] && SERVER_PORTS="$SERVER_PORTS,$listen_port"
|
||||
else
|
||||
log_warn "Server $name start failed"
|
||||
fi
|
||||
}
|
||||
|
||||
open_instance() {
|
||||
log_debug "id: $1, name: $2, cmd: $3"
|
||||
local instance_name="$1-$2"
|
||||
local cmd="$3"
|
||||
local enable_std_log="$4"
|
||||
procd_open_instance "$instance_name"
|
||||
procd_set_param command /bin/sh -c "$cmd"
|
||||
config_list_foreach "$1" procd_env procd_set_env "$instance_name"
|
||||
if [ "$enable_std_log" = "1" ]; then
|
||||
procd_set_param stdout 1
|
||||
procd_set_param stderr 1
|
||||
fi
|
||||
procd_set_param pidfile "/var/run/$instance_name.pid"
|
||||
procd_close_instance
|
||||
}
|
||||
|
||||
procd_set_env() {
|
||||
[ -z "$1" ] && return
|
||||
log_debug "Setting $2 env: $1"
|
||||
procd_set_param env "$1"
|
||||
}
|
||||
|
||||
reset_fw_tproxy() {
|
||||
SERVER_PORTS=$(echo "$SERVER_PORTS" | sed 's/^,//;s/,$//')
|
||||
[ -z "$SERVER_PORTS" ] && return 0
|
||||
log_info "Configuring firewall allow port $SERVER_PORTS"
|
||||
cat <<- EOF > $NFT_TABLE_POST
|
||||
chain $NAME {
|
||||
tcp dport { $SERVER_PORTS } counter accept
|
||||
udp dport { $SERVER_PORTS } counter accept
|
||||
return
|
||||
}
|
||||
EOF
|
||||
cat <<- EOF > $NFT_CHAIN_PRE
|
||||
jump $NAME comment "hijserver rule"
|
||||
EOF
|
||||
if ! fw4 reload > /dev/null 2>&1; then
|
||||
log_error "Failed to reload hijserver firewall rules"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
remove_fw_tproxy() {
|
||||
: > $NFT_TABLE_POST
|
||||
: > $NFT_CHAIN_PRE
|
||||
if ! fw4 reload > /dev/null 2>&1; then
|
||||
log_error "Failed to reload hijserver firewall rules"
|
||||
exit 1
|
||||
fi
|
||||
nft delete chain inet fw4 $NAME 2> /dev/null
|
||||
}
|
||||
|
||||
reset_geo_cron() {
|
||||
sed -i '/hijpass\/clean-service-log\.sh hijserver/d' /etc/crontabs/root 2>/dev/null
|
||||
if [ "$REDIRECT_LOG" = "1" ]; then
|
||||
echo "*/10 * * * * /usr/lib/hijpass/clean-service-log.sh hijserver" >> /etc/crontabs/root
|
||||
log_info "Added log split service cron job: */10 * * * * /usr/lib/hijpass/clean-service-log.sh hijserver"
|
||||
fi
|
||||
|
||||
/etc/init.d/cron restart > /dev/null 2>&1
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
#!/bin/sh
|
||||
|
||||
if ! grep -q "^hijpass:" /etc/group; then
|
||||
echo "hijpass:x:4396:hijpass" >>/etc/group
|
||||
fi
|
||||
# UID 0 用于保留代理核心所需权限;独立 GID 供 nftables 识别代理流量。
|
||||
if ! grep -q "^hijpass:" /etc/passwd; then
|
||||
echo "hijpass:x:0:4396:hijpass:/var/run/hijpass:/bin/false" >>/etc/passwd
|
||||
fi
|
||||
if ! grep -q "^hijpass:" /etc/shadow; then
|
||||
echo "hijpass:x:0:0:99999:7:::" >>/etc/shadow
|
||||
fi
|
||||
|
||||
uci -q batch <<-EOF >/dev/null
|
||||
delete firewall.hijpass
|
||||
set firewall.hijpass=include
|
||||
set firewall.hijpass.type=script
|
||||
set firewall.hijpass.path=/usr/lib/hijpass/fw4.sh
|
||||
commit firewall
|
||||
EOF
|
||||
|
||||
shunt_type="$(uci -q get hijpass.@shunt[0].type || :)"
|
||||
[ -z "$shunt_type" ] && uci -q set hijpass.@shunt[0].type='sing-box'
|
||||
[ "$shunt_type" = "singbox" ] && uci -q set hijpass.@shunt[0].type='sing-box'
|
||||
direct_dns="$(uci -q get hijpass.@dns[0].direct_dns || :)"
|
||||
[ "$direct_dns" = "system" ] && uci -q set hijpass.@dns[0].direct_dns='dnsmasq'
|
||||
proxy_dns="$(uci -q get hijpass.@dns[0].proxy_dns || :)"
|
||||
[ "$proxy_dns" = "system" ] && uci -q set hijpass.@dns[0].proxy_dns='dnsmasq'
|
||||
proxy_local="$(uci -q get hijpass.@firewall[0].proxy_local || :)"
|
||||
[ -z "$proxy_local" ] && uci -q set hijpass.@firewall[0].proxy_local='0'
|
||||
uci -q show hijpass | sed -n "s/^\(hijpass\.[^.]*\)\.core='singbox'$/\1/p" | while read -r section; do
|
||||
uci -q set "$section.core=sing-box"
|
||||
done
|
||||
|
||||
port_in_use() {
|
||||
{
|
||||
uci -q show hijpass
|
||||
uci -q show hijserver
|
||||
} | grep -Eq "\.(listen_port|socks_port|shunt_listen_port|dns_listen_port|cdg_port)='$1'$"
|
||||
}
|
||||
|
||||
find_available_port() {
|
||||
local port="$1"
|
||||
while port_in_use "$port"; do
|
||||
port=$((port + 1))
|
||||
done
|
||||
echo "$port"
|
||||
}
|
||||
|
||||
migrate_shunt_load_balance() {
|
||||
local members core node_name suffix section listen_port socks_port option value route_section field
|
||||
members="$(uci -q get hijpass.@shunt[0].lb_node_list || :)"
|
||||
[ -z "$members" ] && return
|
||||
|
||||
core="$(uci -q get hijpass.@shunt[0].type || :)"
|
||||
[ "$core" = "singbox" ] && core='sing-box'
|
||||
[ "$core" != "xray" ] && [ "$core" != "sing-box" ] && core='sing-box'
|
||||
|
||||
node_name='load-balance'
|
||||
suffix=2
|
||||
while uci -q show hijpass | grep -Fq ".name='$node_name'"; do
|
||||
node_name="load-balance-$suffix"
|
||||
suffix=$((suffix + 1))
|
||||
done
|
||||
|
||||
listen_port="$(find_available_port 7890)"
|
||||
socks_port="$(find_available_port $((listen_port + 1)))"
|
||||
section="$(uci -q add hijpass proxy_node)"
|
||||
uci -q set "hijpass.$section.name=$node_name"
|
||||
uci -q set "hijpass.$section.type=load_balance"
|
||||
uci -q set "hijpass.$section.core=$core"
|
||||
uci -q set "hijpass.$section.enabled=1"
|
||||
uci -q set "hijpass.$section.listen_port=$listen_port"
|
||||
uci -q set "hijpass.$section.socks_port=$socks_port"
|
||||
uci -q set "hijpass.$section.log_level=info"
|
||||
uci -q set "hijpass.$section.log_path=/tmp/hijpass/log/$node_name-$section.log"
|
||||
for value in $members; do
|
||||
uci -q add_list "hijpass.$section.member_node=$value"
|
||||
done
|
||||
|
||||
for option in url interval tolerance idle_timeout interrupt_exist_connections \
|
||||
strategy strategy_expected strategy_max_rtt strategy_tolerance fallback_tag \
|
||||
probe_url probe_connectivity probe_interval probe_timeout probe_sampling probe_http_method; do
|
||||
value="$(uci -q get "hijpass.@shunt[0].$option" || :)"
|
||||
[ -n "$value" ] && uci -q set "hijpass.$section.$option=$value"
|
||||
done
|
||||
|
||||
for field in default_proxy_node ruleset_out_node; do
|
||||
value="$(uci -q get "hijpass.@shunt[0].$field" || :)"
|
||||
[ "$value" = 'hijpass-slb' ] && uci -q set "hijpass.@shunt[0].$field=$node_name"
|
||||
done
|
||||
|
||||
uci -q show hijpass | sed -n "s/^\(hijpass\.[^.]*\)=shunt_route_rule$/\1/p" | while read -r route_section; do
|
||||
for field in proxy_node proxy_node_v4 proxy_node_v6 dns_proxy_node; do
|
||||
value="$(uci -q get "$route_section.$field" || :)"
|
||||
[ "$value" = 'hijpass-slb' ] && uci -q set "$route_section.$field=$node_name"
|
||||
done
|
||||
done
|
||||
|
||||
for option in lb_node_list url interval tolerance idle_timeout interrupt_exist_connections \
|
||||
strategy strategy_expected strategy_max_rtt strategy_tolerance fallback_tag \
|
||||
probe_url probe_connectivity probe_interval probe_timeout probe_sampling probe_http_method; do
|
||||
uci -q delete "hijpass.@shunt[0].$option"
|
||||
done
|
||||
}
|
||||
|
||||
migrate_shunt_load_balance
|
||||
uci -q commit hijpass
|
||||
|
||||
mkdir -p /etc/hijpass/client /etc/hijpass/server /tmp/hijpass/log /tmp/hijserver/log
|
||||
|
||||
chmod +x /usr/lib/hijpass/*
|
||||
chmod +x /etc/hijpass/hook/*
|
||||
|
||||
rm -rf /tmp/luci-*cache
|
||||
exit 0
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
#!/bin/sh
|
||||
|
||||
LOG_FILE=$(uci -q get hijpass.@hijpass[0].log_path || :)
|
||||
LOG_LEVEL=$(uci -q get hijpass.@hijpass[0].log_level || echo "INFO") # debug, info, warn, error
|
||||
LOG_DIR=$(uci -q get hijpass.@hijpass[0].log_dir || :)
|
||||
[ -z "$LOG_DIR" ] && exit 1
|
||||
[ -d "$LOG_DIR" ] || mkdir -p "$LOG_DIR"
|
||||
LOG_LEVELS="DEBUG:1 INFO:2 WARN:3 ERROR:4"
|
||||
|
||||
get_log_level_num() {
|
||||
local level="$1"
|
||||
echo "$LOG_LEVELS" | tr ' ' '\n' | grep "^$level:" | cut -d':' -f2
|
||||
}
|
||||
|
||||
log() {
|
||||
local level level_num min_level_num message datetime
|
||||
level=$(echo "$1" | tr 'a-z' 'A-Z')
|
||||
shift
|
||||
message=$*
|
||||
level_num=$(get_log_level_num "$level")
|
||||
min_level_num=$(get_log_level_num "$LOG_LEVEL")
|
||||
|
||||
if [ -n "$level_num" ] && [ -n "$min_level_num" ]; then
|
||||
if [ "$level_num" -ge "$min_level_num" ]; then
|
||||
datetime=$(date "+%Y-%m-%d %H:%M:%S")
|
||||
# 处理多行消息的对齐
|
||||
if [ "$LOG_LEVEL" = "DEBUG" ]; then
|
||||
# DEBUG模式下,第一行包含脚本名和时间戳
|
||||
printf "%s %-8s%-40s%s\n" "[$datetime]" "${level}" "$0" "$(echo "$message" | head -n1)" >>"$LOG_FILE"
|
||||
# 后续行只保留缩进,不显示时间戳
|
||||
echo "$message" | tail -n +2 | while IFS= read -r line; do
|
||||
printf "%-23s%-8s%-40s%s\n" "" "" "" "$line" >>"$LOG_FILE"
|
||||
done
|
||||
else
|
||||
# 普通模式下,第一行正常输出
|
||||
printf "%s %-8s%s\n" "[$datetime]" "${level}" "$(echo "$message" | head -n1)" >>"$LOG_FILE"
|
||||
# 后续行只保留缩进,不显示时间戳
|
||||
echo "$message" | tail -n +2 | while IFS= read -r line; do
|
||||
printf "%-23s%-8s%s\n" "" "" "$line" >>"$LOG_FILE"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
log_debug() {
|
||||
log "debug" "$@"
|
||||
}
|
||||
|
||||
log_info() {
|
||||
log "info" "$@"
|
||||
}
|
||||
|
||||
log_warn() {
|
||||
log "warn" "$@"
|
||||
}
|
||||
|
||||
log_error() {
|
||||
log "error" "$@"
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
# 检查是否启用自动更新
|
||||
UPDATE_INTERVAL=$(uci -q get hijpass.@rule[0].update_interval || echo "0")
|
||||
|
||||
if [ "$UPDATE_INTERVAL" = "0" ]; then
|
||||
log_debug "Auto update is disabled"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 检查上次更新时间
|
||||
LAST_UPDATE_FILE="/tmp/hijpass_last_update"
|
||||
CURRENT_TIME=$(date +%s)
|
||||
|
||||
if [ -f "$LAST_UPDATE_FILE" ]; then
|
||||
LAST_UPDATE=$(cat "$LAST_UPDATE_FILE" 2>/dev/null || echo "0")
|
||||
TIME_DIFF=$((CURRENT_TIME - LAST_UPDATE))
|
||||
UPDATE_INTERVAL_SECONDS=$((UPDATE_INTERVAL * 24 * 3600))
|
||||
|
||||
if [ "$TIME_DIFF" -lt "$UPDATE_INTERVAL_SECONDS" ]; then
|
||||
log_debug "Not time to update yet. Last update: $LAST_UPDATE, interval: ${UPDATE_INTERVAL} days"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
|
||||
log_info "Starting automatic rule files update..."
|
||||
|
||||
# 执行下载
|
||||
if /usr/lib/hijpass/rules.sh all; then
|
||||
log_info "Automatic update completed successfully"
|
||||
echo "$CURRENT_TIME" > "$LAST_UPDATE_FILE"
|
||||
|
||||
# 重启服务以应用新规则
|
||||
if [ "$(uci -q get hijpass.@hijpass[0].enabled)" = "1" ]; then
|
||||
log_info "Restarting hijpass service to apply new rules"
|
||||
/etc/init.d/hijpass restart
|
||||
fi
|
||||
else
|
||||
log_error "Automatic update failed"
|
||||
exit 1
|
||||
fi
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
#!/bin/sh
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
log_debug "$0"
|
||||
|
||||
GFW_FILE_PATH=$(uci -q get hijpass.@hijpass[0].gfw_list)
|
||||
CHN_FILE_PATH=$(uci -q get hijpass.@hijpass[0].chn_list)
|
||||
DOMAIN_PROXY_FILE_PATH=$(uci -q get hijpass.@hijpass[0].domain_proxy)
|
||||
DOMAIN_DIRECT_FILE_PATH=$(uci -q get hijpass.@hijpass[0].domain_direct)
|
||||
DIRECT_DNS=$(uci -q get hijpass.@dns[0].direct_dns)
|
||||
PROXY_DNS=$(uci -q get hijpass.@dns[0].proxy_dns)
|
||||
ENABLE_GFW=$(uci -q get hijpass.@dns[0].use_gfw)
|
||||
ENABLE_CHN=$(uci -q get hijpass.@dns[0].use_chn)
|
||||
CDG_PORT=$(uci -q get hijpass.@dns[0].cdg_port)
|
||||
CHINADNS_NG_CONF="/etc/hijpass/chinadns-ng.conf"
|
||||
GEOVIEW_SCRIPT="/usr/lib/hijpass/geoview.sh"
|
||||
DOMAIN_PROXY_TEMP_FILE_PATH=/tmp/domain_proxy.tmp
|
||||
DOMAIN_DIRECT_TEMP_FILE_PATH=/tmp/domain_direct.tmp
|
||||
|
||||
touch "$DOMAIN_PROXY_TEMP_FILE_PATH"
|
||||
touch "$DOMAIN_DIRECT_TEMP_FILE_PATH"
|
||||
|
||||
if [ "$DIRECT_DNS" = "custom" ]; then
|
||||
DIRECT_DNS="$(uci -q get hijpass.@dns[0].direct_dns_custom)"
|
||||
fi
|
||||
if [ "$DIRECT_DNS" = "dnsmasq" ] || [ "$DIRECT_DNS" = "system" ]; then
|
||||
dnsmasq_port=$(uci -q get dhcp.@dnsmasq[0].port || echo "53")
|
||||
DIRECT_DNS="127.0.0.1#${dnsmasq_port:-53}"
|
||||
fi
|
||||
if [ "$PROXY_DNS" = "custom" ]; then
|
||||
PROXY_DNS="$(uci -q get hijpass.@dns[0].proxy_dns_custom)"
|
||||
fi
|
||||
if [ "$PROXY_DNS" = "dnsmasq" ] || [ "$PROXY_DNS" = "system" ]; then
|
||||
dnsmasq_port=$(uci -q get dhcp.@dnsmasq[0].port || echo "53")
|
||||
PROXY_DNS="127.0.0.1#${dnsmasq_port:-53}"
|
||||
fi
|
||||
proxy_domain_list="$([ -f "$DOMAIN_PROXY_FILE_PATH" ] && cat "$DOMAIN_PROXY_FILE_PATH" || :)"
|
||||
geosite_proxy_lines=$(echo "$proxy_domain_list" | grep '^geosite:' | sed 's/^geosite://' | tr '\n' ',')
|
||||
if [ -n "$geosite_proxy_lines" ]; then
|
||||
geosite_proxy_domains=$($GEOVIEW_SCRIPT -e "geosite" "${geosite_proxy_lines%,}")
|
||||
proxy_domain_list=$(echo "$proxy_domain_list" | grep -v '^geosite:')
|
||||
proxy_domain_list="$proxy_domain_list
|
||||
$geosite_proxy_domains"
|
||||
fi
|
||||
echo "$proxy_domain_list" > "$DOMAIN_PROXY_TEMP_FILE_PATH"
|
||||
|
||||
direct_domain_list="$([ -f "$DOMAIN_DIRECT_FILE_PATH" ] && cat "$DOMAIN_DIRECT_FILE_PATH" || :)"
|
||||
geosite_direct_lines=$(echo "$direct_domain_list" | grep '^geosite:' | sed 's/^geosite://' | tr '\n' ',')
|
||||
if [ -n "$geosite_direct_lines" ]; then
|
||||
geosite_direct_domains=$($GEOVIEW_SCRIPT -e "geosite" "${geosite_direct_lines%,}")
|
||||
direct_domain_list=$(echo "$direct_domain_list" | grep -v '^geosite:')
|
||||
direct_domain_list="$direct_domain_list
|
||||
$geosite_direct_domains"
|
||||
fi
|
||||
echo "$direct_domain_list" > "$DOMAIN_DIRECT_TEMP_FILE_PATH"
|
||||
|
||||
CONF="
|
||||
# 监听地址和端口
|
||||
bind-addr ::
|
||||
bind-port $CDG_PORT
|
||||
|
||||
# 国内上游、可信上游
|
||||
china-dns $DIRECT_DNS
|
||||
trust-dns $PROXY_DNS
|
||||
|
||||
# 域名列表,用于分流
|
||||
$([ "$ENABLE_CHN" = "1" ] && echo "chnlist-file $CHN_FILE_PATH")
|
||||
$([ "$ENABLE_GFW" = "1" ] && echo "gfwlist-file $GFW_FILE_PATH")
|
||||
|
||||
$([ "$ENABLE_CHN" = "1" ] && [ "$ENABLE_GFW" != "1" ] && echo "default-tag gfw")
|
||||
$([ "$ENABLE_GFW" = "1" ] && [ "$ENABLE_CHN" != "1" ] && echo "default-tag chn")
|
||||
|
||||
group userdirect
|
||||
group-dnl $DOMAIN_DIRECT_TEMP_FILE_PATH
|
||||
group-upstream $DIRECT_DNS
|
||||
group-ipset inet@hijpass@dnsbypass4list,inet@hijpass@dnsbypass6list
|
||||
|
||||
group userproxy
|
||||
group-dnl $DOMAIN_PROXY_TEMP_FILE_PATH
|
||||
group-upstream $PROXY_DNS
|
||||
group-ipset inet@hijpass@dnsproxy4list,inet@hijpass@dnsproxy6list
|
||||
|
||||
# 收集 tag:chn、tag:gfw 域名的 IP (可选)
|
||||
add-tagchn-ip inet@hijpass@chn4list,inet@hijpass@chn6list
|
||||
add-taggfw-ip inet@hijpass@gfw4list,inet@hijpass@gfw6list
|
||||
|
||||
# 测试 tag:none 域名的 IP (针对国内上游)
|
||||
ipset-name4 inet@hijpass@chnroute4list
|
||||
ipset-name6 inet@hijpass@chnroute6list
|
||||
|
||||
# dns 缓存
|
||||
# cache 4096
|
||||
# cache-stale 86400
|
||||
# cache-refresh 20
|
||||
|
||||
# verdict 缓存 (用于 tag:none 域名)
|
||||
verdict-cache 4096
|
||||
|
||||
# 详细日志
|
||||
# verbose
|
||||
"
|
||||
|
||||
echo "$CONF" >"$CHINADNS_NG_CONF"
|
||||
@@ -0,0 +1,48 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
. /lib/functions.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
clean_log() {
|
||||
local log_file
|
||||
config_get log_file "$1" log_path
|
||||
[ -z "$log_file" ] && return
|
||||
if [ -f "$log_file" ]; then
|
||||
if tail -n 30000 "$log_file" >"$log_file.tmp" 2>/dev/null && [ -s "$log_file.tmp" ]; then
|
||||
cat "$log_file.tmp" >"$log_file"
|
||||
rm -f "$log_file.tmp"
|
||||
log_debug "Successfully cleaned log file: $log_file"
|
||||
else
|
||||
rm -f "$log_file.tmp"
|
||||
log_warn "Failed to clean log file: $log_file"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
clean)
|
||||
echo "" >"$2"
|
||||
log_info "Manually trigger log cleanup, log file: $2"
|
||||
;;
|
||||
hijpass)
|
||||
config_load hijpass
|
||||
config_foreach clean_log hijpass
|
||||
config_foreach clean_log proxy_node
|
||||
config_foreach clean_log shunt
|
||||
;;
|
||||
hijserver)
|
||||
config_load hijserver
|
||||
config_foreach clean_log server_node
|
||||
;;
|
||||
*)
|
||||
config_load hijpass
|
||||
config_foreach clean_log hijpass
|
||||
config_foreach clean_log proxy_node
|
||||
config_foreach clean_log shunt
|
||||
|
||||
config_load hijserver
|
||||
config_foreach clean_log server_node
|
||||
esac
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 用法:
|
||||
# sh net_check.sh https://www.example.com
|
||||
# sh net_check.sh https://www.example.com 5 # 超时 5 秒
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
URL="$1"
|
||||
TIMEOUT="${2:-5}" # 默认总超时 5 秒
|
||||
|
||||
if [ -z "$URL" ]; then
|
||||
echo "Usage: $0 <url> [timeout_seconds]" >&2
|
||||
exit 1
|
||||
fi
|
||||
case "$URL" in
|
||||
https://*) ;;
|
||||
*)
|
||||
echo "Only https URLs are allowed" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
case "$TIMEOUT" in
|
||||
''|*[!0-9]*)
|
||||
echo "Invalid timeout" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
TMP_ERR=/tmp/net_check_curl_err.$$.log
|
||||
|
||||
# 说明:
|
||||
# curl 的 time_* 都是秒(浮点数),这里先用秒输出,
|
||||
# 再交给 jq 乘以 1000 转换成毫秒。
|
||||
CURL_OUTPUT=$(curl -I -o /dev/null -sSk -D /dev/null \
|
||||
-w '{
|
||||
"url": "%{url_effective}",
|
||||
"http_code": %{http_code},
|
||||
"dns_lookup_time_s": %{time_namelookup},
|
||||
"tcp_connect_time_s": %{time_connect},
|
||||
"tls_handshake_time_s": %{time_appconnect},
|
||||
"server_processing_time_s": %{time_starttransfer},
|
||||
"first_byte_time_s": %{time_starttransfer},
|
||||
"total_time_s": %{time_total},
|
||||
"remote_ip": "%{remote_ip}",
|
||||
"remote_port": %{remote_port},
|
||||
"exit_code": 0
|
||||
}' \
|
||||
--max-time "$TIMEOUT" \
|
||||
"$URL" 2>"$TMP_ERR")
|
||||
|
||||
RET=$?
|
||||
|
||||
if [ $RET -ne 0 ]; then
|
||||
# 失败时输出简洁 JSON(不带原始错误文本,避免控制字符问题)
|
||||
printf '%s\n' "{
|
||||
\"url\": \"${URL}\",
|
||||
\"success\": false,
|
||||
\"exit_code\": ${RET}
|
||||
}" | jq '.'
|
||||
|
||||
# 错误详情仍然打到 stderr 便于调试
|
||||
if [ -s "$TMP_ERR" ]; then
|
||||
log_error "$(cat "$TMP_ERR")"
|
||||
fi
|
||||
rm -f "$TMP_ERR"
|
||||
exit $RET
|
||||
fi
|
||||
|
||||
rm -f "$TMP_ERR"
|
||||
|
||||
# 成功时:
|
||||
# 1. 把 *_s 的秒字段转换成 *_ms 的毫秒字段(整数)
|
||||
# 2. 保留原秒字段或删掉都可以,这里顺便删掉秒字段,只保留毫秒
|
||||
echo "$CURL_OUTPUT" \
|
||||
| jq '
|
||||
.success = true
|
||||
| .dns_lookup_time_ms = ((.dns_lookup_time_s * 1000) | floor)
|
||||
| .tcp_connect_time_ms = ((.tcp_connect_time_s * 1000) | floor)
|
||||
| .tls_handshake_time_ms = ((.tls_handshake_time_s * 1000) | floor)
|
||||
| .server_processing_time_ms = ((.server_processing_time_s * 1000) | floor)
|
||||
| .total_time_ms = ((.total_time_s * 1000) | floor)
|
||||
| .first_byte_time_ms = ((.first_byte_time_s * 1000) | floor)
|
||||
| del(.dns_lookup_time_s,
|
||||
.tcp_connect_time_s,
|
||||
.tls_handshake_time_s,
|
||||
.server_processing_time_s,
|
||||
.total_time_s,
|
||||
.first_byte_time_s)
|
||||
'
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
#!/bin/sh
|
||||
|
||||
# 用法:
|
||||
# core-update.sh version <sing-box|xray> — 输出本地版本号(JSON)
|
||||
# core-update.sh latest <sing-box|xray> — 输出官方最新版本号(JSON)
|
||||
# core-update.sh update <sing-box|xray> — 从 GitHub 下载最新版并替换
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
ACTION="$1"
|
||||
CORE="$2"
|
||||
|
||||
SINGBOX_BIN="/usr/bin/sing-box"
|
||||
XRAY_BIN="/usr/bin/xray"
|
||||
UPDATE_TMP_DIR=""
|
||||
|
||||
json_error() {
|
||||
printf '{"success":false,"error":"%s"}\n' "$1"
|
||||
}
|
||||
|
||||
case "$ACTION" in
|
||||
version|latest|update) ;;
|
||||
*)
|
||||
echo "Usage: $0 <version|latest|update> <sing-box|xray>" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$CORE" in
|
||||
sing-box|xray) ;;
|
||||
*)
|
||||
json_error "unknown core"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── 检测架构,sing-box 和 xray 的文件名规则不同 ─────────────────────
|
||||
# sing-box: linux-amd64 / linux-arm64 / linux-armv7 / linux-mips-softfloat ...
|
||||
# xray: linux-64 / linux-arm64-v8a / linux-arm32-v7a / linux-mips32 ...
|
||||
detect_arch() {
|
||||
local machine="$1" # core name: sing-box or xray
|
||||
local m="$2"
|
||||
if [ "$machine" = "xray" ]; then
|
||||
case "$m" in
|
||||
x86_64) echo "linux-64" ;;
|
||||
aarch64|arm64) echo "linux-arm64-v8a" ;;
|
||||
armv7l) echo "linux-arm32-v7a" ;;
|
||||
armv6l) echo "linux-arm32-v6" ;;
|
||||
mips) echo "linux-mips32" ;;
|
||||
mipsle) echo "linux-mips32le" ;;
|
||||
mips64) echo "linux-mips64" ;;
|
||||
mips64le) echo "linux-mips64le" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
else
|
||||
# sing-box
|
||||
case "$m" in
|
||||
x86_64) echo "linux-amd64" ;;
|
||||
aarch64|arm64) echo "linux-arm64" ;;
|
||||
armv7l) echo "linux-armv7" ;;
|
||||
armv6l) echo "linux-armv6" ;;
|
||||
mips) echo "linux-mips-softfloat" ;;
|
||||
mipsle) echo "linux-mipsle-softfloat" ;;
|
||||
mips64) echo "linux-mips64" ;;
|
||||
mips64le) echo "linux-mips64le" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
# ── 获取本地版本 ─────────────────────────────────────────────────────
|
||||
get_local_version() {
|
||||
local bin version
|
||||
case "$CORE" in
|
||||
sing-box)
|
||||
bin="$SINGBOX_BIN"
|
||||
if [ -x "$bin" ]; then
|
||||
version=$("$bin" version 2>/dev/null | grep -oE 'sing-box version [0-9]+\.[0-9]+\.[0-9]+[^ ]*' | awk '{print $3}')
|
||||
fi
|
||||
;;
|
||||
xray)
|
||||
bin="$XRAY_BIN"
|
||||
if [ -x "$bin" ]; then
|
||||
version=$("$bin" version 2>/dev/null | grep -oE 'Xray [0-9]+\.[0-9]+\.[0-9]+[^ ]*' | awk '{print $2}')
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
printf '{"error":"unknown core: %s"}\n' "$CORE"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
printf '{"core":"%s","version":"%s"}\n' "$CORE" "${version:-unknown}"
|
||||
}
|
||||
|
||||
# ── 获取 GitHub 最新版本号 ────────────────────────────────────────────
|
||||
get_release_repo() {
|
||||
case "$CORE" in
|
||||
sing-box) echo "SagerNet/sing-box" ;;
|
||||
xray) echo "XTLS/Xray-core" ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
get_latest_version() {
|
||||
local repo url latest
|
||||
repo=$(get_release_repo) || return 1
|
||||
url="https://api.github.com/repos/${repo}/releases/latest"
|
||||
latest=$(curl -sf --max-time 10 "$url" | jq -r '.tag_name // empty')
|
||||
echo "$latest"
|
||||
}
|
||||
|
||||
get_latest_version_json() {
|
||||
local latest
|
||||
latest=$(get_latest_version)
|
||||
|
||||
if [ -z "$latest" ]; then
|
||||
json_error "fetch latest version failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
printf '{"core":"%s","version":"%s"}\n' "$CORE" "$latest"
|
||||
}
|
||||
|
||||
get_asset_sha256() {
|
||||
local tag asset repo digest
|
||||
tag="$1"
|
||||
asset="$2"
|
||||
repo=$(get_release_repo) || return 1
|
||||
digest=$(curl -sf --max-time 10 \
|
||||
"https://api.github.com/repos/${repo}/releases/tags/${tag}" | \
|
||||
jq -r --arg asset "$asset" \
|
||||
'.assets[] | select(.name == $asset and .state == "uploaded") | .digest // empty' | \
|
||||
head -1)
|
||||
|
||||
echo "$digest" | grep -Eq '^sha256:[0-9a-fA-F]{64}$' || return 1
|
||||
echo "${digest#sha256:}" | tr 'A-F' 'a-f'
|
||||
}
|
||||
|
||||
verify_archive_checksum() {
|
||||
local archive tag asset expected actual
|
||||
archive="$1"
|
||||
tag="$2"
|
||||
asset="$3"
|
||||
|
||||
expected=$(get_asset_sha256 "$tag" "$asset") || return 1
|
||||
actual=$(sha256sum "$archive" 2>/dev/null | awk '{print $1}')
|
||||
[ -n "$actual" ] && [ "$actual" = "$expected" ]
|
||||
}
|
||||
|
||||
verify_binary() {
|
||||
local bin version
|
||||
bin="$1"
|
||||
|
||||
[ -f "$bin" ] || return 1
|
||||
chmod +x "$bin" 2>/dev/null || return 1
|
||||
|
||||
case "$CORE" in
|
||||
sing-box)
|
||||
version=$("$bin" version 2>/dev/null | grep -oE 'sing-box version [0-9]+\.[0-9]+\.[0-9]+[^ ]*' | awk '{print $3}')
|
||||
;;
|
||||
xray)
|
||||
version=$("$bin" version 2>/dev/null | grep -oE 'Xray [0-9]+\.[0-9]+\.[0-9]+[^ ]*' | awk '{print $2}')
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -n "$version" ]
|
||||
}
|
||||
|
||||
fail_update() {
|
||||
local message="$1"
|
||||
[ -n "$UPDATE_TMP_DIR" ] && rm -rf "$UPDATE_TMP_DIR"
|
||||
log_error "$message"
|
||||
json_error "$message"
|
||||
exit 1
|
||||
}
|
||||
|
||||
install_verified_binary() {
|
||||
local src dest tmp_dest backup_file
|
||||
src="$1"
|
||||
dest="$2"
|
||||
tmp_dest="${dest}.new.$$"
|
||||
backup_file="${UPDATE_TMP_DIR}/${CORE}.bak"
|
||||
|
||||
verify_binary "$src" || fail_update "binary verification failed"
|
||||
cp "$src" "$tmp_dest" || {
|
||||
rm -f "$tmp_dest"
|
||||
fail_update "install copy failed"
|
||||
}
|
||||
chmod +x "$tmp_dest" || {
|
||||
rm -f "$tmp_dest"
|
||||
fail_update "install chmod failed"
|
||||
}
|
||||
|
||||
if [ -f "$dest" ]; then
|
||||
cp "$dest" "$backup_file" || {
|
||||
rm -f "$tmp_dest"
|
||||
rm -f "$backup_file"
|
||||
fail_update "backup failed"
|
||||
}
|
||||
fi
|
||||
|
||||
if ! mv "$tmp_dest" "$dest"; then
|
||||
rm -f "$tmp_dest"
|
||||
[ -f "$backup_file" ] && cp "$backup_file" "$dest"
|
||||
fail_update "install failed"
|
||||
fi
|
||||
rm -f "$backup_file"
|
||||
}
|
||||
|
||||
# ── 下载并安装最新版 ─────────────────────────────────────────────────
|
||||
do_update() {
|
||||
local host_arch arch latest_tag download_url tmp_dir asset_name
|
||||
host_arch=$(uname -m)
|
||||
if ! arch=$(detect_arch "$CORE" "$host_arch"); then
|
||||
fail_update "unsupported architecture: $host_arch"
|
||||
fi
|
||||
log_info "Fetching latest $CORE release..."
|
||||
latest_tag=$(get_latest_version)
|
||||
|
||||
if [ -z "$latest_tag" ]; then
|
||||
log_error "Failed to fetch latest version for $CORE"
|
||||
json_error "fetch latest version failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
log_info "Latest $CORE version: $latest_tag, arch: $arch"
|
||||
tmp_dir=$(mktemp -d) || {
|
||||
json_error "create temp dir failed"
|
||||
exit 1
|
||||
}
|
||||
UPDATE_TMP_DIR="$tmp_dir"
|
||||
|
||||
case "$CORE" in
|
||||
sing-box)
|
||||
local version_num candidate_arch downloaded
|
||||
version_num=$(echo "$latest_tag" | sed 's/^v//')
|
||||
downloaded=""
|
||||
for candidate_arch in "${arch}-musl" "$arch"; do
|
||||
asset_name="sing-box-${version_num}-${candidate_arch}.tar.gz"
|
||||
download_url="https://github.com/SagerNet/sing-box/releases/download/${latest_tag}/${asset_name}"
|
||||
log_info "Downloading $download_url ..."
|
||||
if curl -sfL --max-time 120 -o "$tmp_dir/sing-box.tar.gz" "$download_url"; then
|
||||
downloaded="1"
|
||||
arch="$candidate_arch"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "$downloaded" ]; then
|
||||
fail_update "download failed"
|
||||
fi
|
||||
verify_archive_checksum "$tmp_dir/sing-box.tar.gz" "$latest_tag" "$asset_name" || \
|
||||
fail_update "archive checksum verification failed"
|
||||
# busybox tar 不支持 --wildcards,先全量解压再 find
|
||||
if ! tar -xzf "$tmp_dir/sing-box.tar.gz" -C "$tmp_dir" 2>/dev/null; then
|
||||
fail_update "archive extraction failed"
|
||||
fi
|
||||
local extracted
|
||||
extracted=$(find "$tmp_dir" -type f -name "sing-box" | head -1)
|
||||
if [ -z "$extracted" ]; then
|
||||
fail_update "binary not found in archive"
|
||||
fi
|
||||
install_verified_binary "$extracted" "$SINGBOX_BIN"
|
||||
;;
|
||||
xray)
|
||||
asset_name="Xray-${arch}.zip"
|
||||
download_url="https://github.com/XTLS/Xray-core/releases/download/${latest_tag}/${asset_name}"
|
||||
log_info "Downloading $download_url ..."
|
||||
if ! curl -sfL --max-time 120 -o "$tmp_dir/xray.zip" "$download_url"; then
|
||||
fail_update "download failed"
|
||||
fi
|
||||
verify_archive_checksum "$tmp_dir/xray.zip" "$latest_tag" "$asset_name" || \
|
||||
fail_update "archive checksum verification failed"
|
||||
# xray zip 根目录直接包含 xray 二进制;重定向 stdout 避免污染 JSON 输出
|
||||
unzip -o "$tmp_dir/xray.zip" xray -d "$tmp_dir" >/dev/null 2>&1
|
||||
if [ ! -f "$tmp_dir/xray" ]; then
|
||||
fail_update "binary not found in archive"
|
||||
fi
|
||||
install_verified_binary "$tmp_dir/xray" "$XRAY_BIN"
|
||||
;;
|
||||
esac
|
||||
|
||||
rm -rf "$tmp_dir"
|
||||
UPDATE_TMP_DIR=""
|
||||
log_info "$CORE updated to $latest_tag"
|
||||
printf '{"success":true,"version":"%s"}\n' "$latest_tag"
|
||||
}
|
||||
|
||||
# ── 入口 ─────────────────────────────────────────────────────────────
|
||||
case "$ACTION" in
|
||||
version) get_local_version ;;
|
||||
latest) get_latest_version_json ;;
|
||||
update) do_update ;;
|
||||
esac
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "/usr/lib/hijpass/fw4.sh"
|
||||
|
||||
NFT_HOOK=$(uci -q get hijpass.@hijpass[0].nft_hook || :)
|
||||
|
||||
NFT_CONF=/etc/hijpass/fw4-rules.nft
|
||||
TABLE_NAME=hijpass
|
||||
|
||||
if nft list tables | grep -q "$TABLE_NAME"; then
|
||||
log_info "Deleting existing table: $TABLE_NAME"
|
||||
nft delete table inet "$TABLE_NAME"
|
||||
fi
|
||||
|
||||
if [ -f "$NFT_CONF" ]; then
|
||||
log_info "Loading nftables rules from: $NFT_CONF"
|
||||
nft -f "$NFT_CONF"
|
||||
if [ -n "$NFT_HOOK" ] && [ -f "$NFT_HOOK" ]; then
|
||||
log_info "Executing nft hook script: $NFT_HOOK"
|
||||
$NFT_HOOK || log_error "Execute nft hook failed"
|
||||
fi
|
||||
fi
|
||||
+276
@@ -0,0 +1,276 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
. /lib/functions/network.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
TPROXY_MARK=0x1
|
||||
TPROXY_SHUNT_MARK=0x3
|
||||
TPROXY_PROTO=$(uci -q get hijpass.@firewall[0].tproxy_proto)
|
||||
TPROXY_SHUNT_PORT=$(uci -q get hijpass.@firewall[0].shunt_port)
|
||||
TPROXY_PORT=$(uci -q get hijpass.@firewall[0].proxy_port)
|
||||
DNS_FORWARD_SOURCE=$(uci -q get hijpass.@firewall[0].dns_forward || :)
|
||||
DNS_SERVICE=$(uci -q get hijpass.@dns[0].dns_service || :)
|
||||
DNS_FORWARD_PORT=
|
||||
|
||||
is_valid_port() {
|
||||
[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null
|
||||
}
|
||||
|
||||
case "$DNS_FORWARD_SOURCE" in
|
||||
""|none)
|
||||
;;
|
||||
pre-routing)
|
||||
if [ "$DNS_SERVICE" = "chinadns-ng" ]; then
|
||||
DNS_FORWARD_PORT=$(uci -q get hijpass.@dns[0].cdg_port || :)
|
||||
else
|
||||
log_warn "Independent DNS forwarding target is unavailable"
|
||||
fi
|
||||
;;
|
||||
routing)
|
||||
if [ "$(uci -q get hijpass.@shunt[0].enabled || :)" != "0" ]; then
|
||||
DNS_FORWARD_PORT=$(uci -q get hijpass.@shunt[0].dns_listen_port || :)
|
||||
else
|
||||
log_warn "Core DNS forwarding target is disabled"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
log_warn "Unknown DNS forwarding source: $DNS_FORWARD_SOURCE"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$DNS_FORWARD_PORT" ] && ! is_valid_port "$DNS_FORWARD_PORT"; then
|
||||
log_warn "Invalid DNS forwarding port: $DNS_FORWARD_PORT"
|
||||
DNS_FORWARD_PORT=
|
||||
fi
|
||||
|
||||
if [ -z "$TPROXY_SHUNT_PORT" ] && [ -z "$TPROXY_PORT" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RESOLV_FILE=$(uci -q get dhcp.@dnsmasq[0].resolvfile || :)
|
||||
IP_DIRECT_CONF=$(uci -q get hijpass.@hijpass[0].ip_direct || :)
|
||||
IP_PROXY_CONF=$(uci -q get hijpass.@hijpass[0].ip_proxy || :)
|
||||
CHN_ROUTE_FILE=$(uci -q get hijpass.@hijpass[0].chn_route || :)
|
||||
CHN_ROUTE6_FILE=$(uci -q get hijpass.@hijpass[0].chn_route6 || :)
|
||||
ENABLE_CHN_ROUTE=$(uci -q get hijpass.@firewall[0].use_chnroute || :)
|
||||
ENABLE_GFW_LIST=$(uci -q get hijpass.@dns[0].use_gfw || :)
|
||||
ENABLE_CHN_LIST=$(uci -q get hijpass.@dns[0].use_chn || :)
|
||||
PROXY_MAC=$(uci -q get hijpass.@firewall[0].proxy_mac_list || :)
|
||||
PROXY_MAC_EXCLUDE=$(uci -q get hijpass.@firewall[0].proxy_mac_exclude_list || :)
|
||||
PROXY_INTERFACE=$(uci -q get hijpass.@firewall[0].proxy_iface_list || :)
|
||||
PROXY_INTERFACE_EXCLUDE=$(uci -q get hijpass.@firewall[0].proxy_iface_exclude_list || :)
|
||||
ACL_DEFAULT_ALLOW=$(uci -q get hijpass.@firewall[0].acl_default_allow || :)
|
||||
PROXY_LOCAL=$(uci -q get hijpass.@firewall[0].proxy_local || :)
|
||||
|
||||
LUA_SCRIPT="/usr/lib/hijpass/luci-utils.lua"
|
||||
CONF_PATH="/etc/hijpass"
|
||||
HIJPASS_USER="hijpass"
|
||||
|
||||
network_flush_cache
|
||||
network_find_wan NET_IF
|
||||
network_find_wan6 NET_IF6
|
||||
network_get_physdev NET_L2D "${NET_IF}"
|
||||
network_get_physdev NET_L2D6 "${NET_IF6}"
|
||||
|
||||
WAN_DEVICE_NAMES=
|
||||
append_wan_device() {
|
||||
local wan_device="$1"
|
||||
[ -z "$wan_device" ] && return
|
||||
case " $WAN_DEVICE_NAMES " in
|
||||
*" $wan_device "*) return ;;
|
||||
esac
|
||||
WAN_DEVICE_NAMES="$WAN_DEVICE_NAMES $wan_device"
|
||||
}
|
||||
|
||||
for wan_device in $(fw4 zone wan 2>/dev/null | sort -u); do
|
||||
append_wan_device "$wan_device"
|
||||
done
|
||||
for wan_device in $NET_L2D $NET_L2D6; do
|
||||
[ "$wan_device" = "br-lan" ] || append_wan_device "$wan_device"
|
||||
done
|
||||
|
||||
INPUT_WAN_L2_DEV=
|
||||
for wan_device in $WAN_DEVICE_NAMES; do
|
||||
escaped_device=$(printf '%s' "$wan_device" | sed 's/\\/\\\\/g; s/"/\\"/g')
|
||||
if [ -n "$INPUT_WAN_L2_DEV" ]; then
|
||||
INPUT_WAN_L2_DEV="$INPUT_WAN_L2_DEV, "
|
||||
fi
|
||||
INPUT_WAN_L2_DEV="${INPUT_WAN_L2_DEV}\"${escaped_device}\""
|
||||
done
|
||||
|
||||
FW4_TEMPLATE=$(cat "$CONF_PATH/fw4-template.nft")
|
||||
|
||||
log_debug "Generating fw4 rules with mark:$TPROXY_MARK proxy port:$TPROXY_PORT shunt port:$TPROXY_SHUNT_PORT proto:$TPROXY_PROTO"
|
||||
|
||||
IP_DIRECT_LIST=$([ -f "$IP_DIRECT_CONF" ] && cat "$IP_DIRECT_CONF" | grep -v -e '^\s*$' -e '^\s*#' || :)
|
||||
IP_PROXY_LIST=$([ -f "$IP_PROXY_CONF" ] && cat "$IP_PROXY_CONF" | grep -v -e '^\s*$' -e '^\s*#' || :)
|
||||
|
||||
IP4_DIRECT_LIST=
|
||||
IP6_DIRECT_LIST=
|
||||
IP4_PROXY_LIST=
|
||||
IP6_PROXY_LIST=
|
||||
|
||||
for ip in $IP_DIRECT_LIST; do
|
||||
type=$(lua "$LUA_SCRIPT" "iptype" "$ip")
|
||||
if [ "$type" = "4" ]; then
|
||||
IP4_DIRECT_LIST="$IP4_DIRECT_LIST
|
||||
$ip"
|
||||
elif [ "$type" = "6" ]; then
|
||||
IP6_DIRECT_LIST="$IP6_DIRECT_LIST
|
||||
$ip"
|
||||
else
|
||||
log_warn "Invalid IP address: $ip"
|
||||
fi
|
||||
done
|
||||
|
||||
for ip in $IP_PROXY_LIST; do
|
||||
type=$(lua "$LUA_SCRIPT" "iptype" "$ip")
|
||||
if [ "$type" = "4" ]; then
|
||||
IP4_PROXY_LIST="$IP4_PROXY_LIST
|
||||
$ip"
|
||||
elif [ "$type" = "6" ]; then
|
||||
IP6_PROXY_LIST="$IP6_PROXY_LIST
|
||||
$ip"
|
||||
else
|
||||
log_warn "Invalid IP address: $ip"
|
||||
fi
|
||||
done
|
||||
|
||||
DNS_V4=
|
||||
DNS_V6=
|
||||
|
||||
if [ -f "$RESOLV_FILE" ]; then
|
||||
DNS_V4=$(grep <"$RESOLV_FILE" -E -o "[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+" | sort -u | grep -v 0.0.0.0 | grep -v 127.0.0.1)
|
||||
DNS_V6=$(grep <"$RESOLV_FILE" -E "([A-Fa-f0-9]{1,4}::?){1,7}[A-Fa-f0-9]{1,4}" | awk -F % '{print $1}' | awk -F " " '{print $2}' | sort -u | grep -v -Fx ::1 | grep -v -Fx ::)
|
||||
fi
|
||||
|
||||
LOCAL_V4_CIDR=$(ip address | grep -w inet | awk '{print $2}')
|
||||
LOCAL_V6_CIDR=$(ip address | grep -w inet6 | awk '{print $2}')
|
||||
|
||||
CHN_ROUTE=
|
||||
CHN_ROUTE6=
|
||||
|
||||
if [ "$ENABLE_CHN_ROUTE" = "1" ]; then
|
||||
CHN_ROUTE=$([ -f "$CHN_ROUTE_FILE" ] && cat "$CHN_ROUTE_FILE" | grep -v -e '^\s*$' -e '^\s*#' | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//' || :)
|
||||
CHN_ROUTE6=$([ -f "$CHN_ROUTE6_FILE" ] && cat "$CHN_ROUTE6_FILE" | grep -v -e '^\s*$' -e '^\s*#' | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//' || :)
|
||||
fi
|
||||
|
||||
IP4_DIRECT_NFT_LIST=$(echo "$DNS_V4
|
||||
$IP4_DIRECT_LIST
|
||||
$LOCAL_V4_CIDR
|
||||
" | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//')
|
||||
|
||||
IP6_DIRECT_NFT_LIST=$(echo "$DNS_V6
|
||||
$IP6_DIRECT_LIST
|
||||
$LOCAL_V6_CIDR
|
||||
" | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//')
|
||||
|
||||
IP4_PROXY_NFT_LIST=$(echo "$IP4_PROXY_LIST" | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//')
|
||||
IP6_PROXY_NFT_LIST=$(echo "$IP6_PROXY_LIST" | sort -u | awk 'NF {printf "%s,\n", $0}' | sed '$ s/,$//')
|
||||
|
||||
[ -n "$PROXY_INTERFACE" ] && PROXY_INTERFACE="${PROXY_INTERFACE// /,}"
|
||||
[ -n "$PROXY_INTERFACE_EXCLUDE" ] && PROXY_INTERFACE_EXCLUDE="${PROXY_INTERFACE_EXCLUDE// /,}"
|
||||
[ -n "$PROXY_MAC" ] && PROXY_MAC="${PROXY_MAC// /,}"
|
||||
[ -n "$PROXY_MAC_EXCLUDE" ] && PROXY_MAC_EXCLUDE="${PROXY_MAC_EXCLUDE// /,}"
|
||||
|
||||
fw_rules="
|
||||
define DNS_FORWARD_PORT = $DNS_FORWARD_PORT
|
||||
define HIJPASS_USER = $HIJPASS_USER
|
||||
define TPROXY_MARK = $TPROXY_MARK
|
||||
define TPROXY_PORT = $TPROXY_PORT
|
||||
define TPROXY_SHUNT_MARK = $TPROXY_SHUNT_MARK
|
||||
define TPROXY_SHUNT_PORT = $TPROXY_SHUNT_PORT
|
||||
define TPROXY_PROTO = { $TPROXY_PROTO }
|
||||
define WAN_DEV = { $INPUT_WAN_L2_DEV }
|
||||
define IP4_DIRECT_NFT_LIST = { $IP4_DIRECT_NFT_LIST }
|
||||
define IP6_DIRECT_NFT_LIST = { $IP6_DIRECT_NFT_LIST }
|
||||
define IP4_PROXY_NFT_LIST = { $IP4_PROXY_NFT_LIST }
|
||||
define IP6_PROXY_NFT_LIST = { $IP6_PROXY_NFT_LIST }
|
||||
define IP4_CHNROUTE_LIST = { $CHN_ROUTE }
|
||||
define IP6_CHNROUTE_LIST = { $CHN_ROUTE6 }
|
||||
define PROXY_INTERFACE = { $PROXY_INTERFACE }
|
||||
define PROXY_INTERFACE_EXCLUDE = { $PROXY_INTERFACE_EXCLUDE }
|
||||
define PROXY_MAC = { $PROXY_MAC }
|
||||
define PROXY_MAC_EXCLUDE = { $PROXY_MAC_EXCLUDE }
|
||||
|
||||
$FW4_TEMPLATE
|
||||
"
|
||||
|
||||
if [ -z "$DNS_FORWARD_PORT" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "DNS_FORWARD_")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/^ chain dns-forward {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | sed '/^ chain dns-forward-output {/,/^ }/d')
|
||||
elif [ "$PROXY_LOCAL" != "1" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | sed '/^ chain dns-forward-output {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ "$PROXY_LOCAL" != "1" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | sed '/^ chain out-route {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ -z "$INPUT_WAN_L2_DEV" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v 'define WAN_DEV =')
|
||||
fw_rules=$(echo "$fw_rules" | grep -v 'iifname $WAN_DEV counter return')
|
||||
fi
|
||||
|
||||
if [ -z "$TPROXY_SHUNT_PORT" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "TPROXY_SHUNT_PORT")
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "shunt mark")
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "@chnroute")
|
||||
fw_rules=$(echo "$fw_rules" | grep -Ev "@chn.list")
|
||||
fi
|
||||
|
||||
if [ -z "$TPROXY_PORT" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "TPROXY_PORT")
|
||||
fi
|
||||
|
||||
if [ -z "$ENABLE_CHN_ROUTE" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "@chnroute")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set chnroute.list {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ -z "$ENABLE_GFW_LIST" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "@gfw")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set gfw.list {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ -z "$ENABLE_CHN_LIST" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -Ev "@chn.list")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set chn.list {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ "$DNS_SERVICE" = "custom" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -Ev "@dns.*\dlist")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set dns.*\dlist {/,/^ }/d')
|
||||
fi
|
||||
|
||||
if [ "$ACL_DEFAULT_ALLOW" = "1" ]; then
|
||||
# 默认全代理模式:移除 deny 模式规则和 proxy 集合
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "iif != @proxy_interface_list ether saddr != @proxy_mac_list")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_mac_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_interface_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_INTERFACE =")
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_MAC =")
|
||||
if [ -z "$PROXY_MAC_EXCLUDE" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "ether saddr @proxy_mac_exclude_list")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_mac_exclude_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_MAC_EXCLUDE =")
|
||||
fi
|
||||
if [ -z "$PROXY_INTERFACE_EXCLUDE" ]; then
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "iif @proxy_interface_exclude_list")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_interface_exclude_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_INTERFACE_EXCLUDE =")
|
||||
fi
|
||||
else
|
||||
# 默认不代理模式:移除 allow 模式规则和 exclude 集合
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "iif @proxy_interface_exclude_list")
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "ether saddr @proxy_mac_exclude_list")
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_mac_exclude_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | sed '/set proxy_interface_exclude_list {/,/^ }/d')
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_MAC_EXCLUDE =")
|
||||
fw_rules=$(echo "$fw_rules" | grep -v "define PROXY_INTERFACE_EXCLUDE =")
|
||||
fi
|
||||
|
||||
echo "$fw_rules"
|
||||
Executable
+74
@@ -0,0 +1,74 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
GEO_IP_URL=$(uci -q get hijpass.@rule[0].geoip || :)
|
||||
GEO_SITE_URL=$(uci -q get hijpass.@rule[0].geosite || :)
|
||||
DATA_DIR="/usr/share/v2ray"
|
||||
|
||||
check_url() {
|
||||
local url name
|
||||
url=$1
|
||||
name=$2
|
||||
|
||||
if [ -z "$url" ]; then
|
||||
log_error "$name URL is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$url" | grep -qE '^https?://'; then
|
||||
log_error "$name URL must start with http:// or https://"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
update_file() {
|
||||
local url output name
|
||||
url=$1
|
||||
output=$2
|
||||
name=$3
|
||||
|
||||
log_info "Downloading new $name database..."
|
||||
if curl -L -o "$output.new" "$url"; then
|
||||
mv "$output.new" "$output"
|
||||
log_info "$name database updated successfully"
|
||||
else
|
||||
rm -f "$output.new"
|
||||
log_error "Failed to download $name database"
|
||||
fi
|
||||
}
|
||||
|
||||
check_url "$GEO_IP_URL" "GeoIP"
|
||||
check_url "$GEO_SITE_URL" "GeoSite"
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
log_info "Checking GeoIP database..."
|
||||
REMOTE_GEOIP_HASH=$(curl -sL "$GEO_IP_URL" | md5sum | cut -d' ' -f1)
|
||||
if [ -f "$DATA_DIR/geoip.dat" ]; then
|
||||
LOCAL_GEOIP_HASH=$(md5sum "$DATA_DIR/geoip.dat" | cut -d' ' -f1)
|
||||
else
|
||||
LOCAL_GEOIP_HASH=""
|
||||
fi
|
||||
|
||||
if [ "$REMOTE_GEOIP_HASH" != "$LOCAL_GEOIP_HASH" ]; then
|
||||
update_file "$GEO_IP_URL" "$DATA_DIR/geoip.dat" "GeoIP"
|
||||
else
|
||||
log_info "GeoIP database is up to date"
|
||||
fi
|
||||
|
||||
log_info "Checking GeoSite database..."
|
||||
REMOTE_GEOSITE_HASH=$(curl -sL "$GEO_SITE_URL" | md5sum | cut -d' ' -f1)
|
||||
if [ -f "$DATA_DIR/geosite.dat" ]; then
|
||||
LOCAL_GEOSITE_HASH=$(md5sum "$DATA_DIR/geosite.dat" | cut -d' ' -f1)
|
||||
else
|
||||
LOCAL_GEOSITE_HASH=""
|
||||
fi
|
||||
|
||||
if [ "$REMOTE_GEOSITE_HASH" != "$LOCAL_GEOSITE_HASH" ]; then
|
||||
update_file "$GEO_SITE_URL" "$DATA_DIR/geosite.dat" "GeoSite"
|
||||
else
|
||||
log_info "GeoSite database is up to date"
|
||||
fi
|
||||
+94
@@ -0,0 +1,94 @@
|
||||
#!/bin/sh
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
GEO_SITE_PATH=/usr/share/v2ray/geosite.dat
|
||||
GEO_IP_PATH=/usr/share/v2ray/geoip.dat
|
||||
RULESET_PATH=/etc/hijpass/ruleset
|
||||
GEOSITE_TAG_LIST="$(uci -q get hijpass.@shunt[0].geosite_ruleset)"
|
||||
GEOIP_TAG_LIST="$(uci -q get hijpass.@shunt[0].geoip_ruleset)"
|
||||
|
||||
is_geo_type() {
|
||||
[ "$1" = "geosite" ] || [ "$1" = "geoip" ]
|
||||
}
|
||||
|
||||
is_safe_geo_arg() {
|
||||
[ -n "$1" ] && echo "$1" | grep -Eq "^[A-Za-z0-9_.:,/!-]+$" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
extract() {
|
||||
local path
|
||||
local type="$1"
|
||||
if ! is_geo_type "$type"; then
|
||||
echo "Invalid geo type" >&2
|
||||
exit 1
|
||||
fi
|
||||
path="$(getGeoPath "$1")"
|
||||
shift
|
||||
[ $# -gt 0 ] || exit 1
|
||||
for item in "$@"; do
|
||||
is_safe_geo_arg "$item" || exit 1
|
||||
done
|
||||
geoview -type "$type" -action extract -input "$path" -list "$@" | tr 'A-Z' 'a-z'
|
||||
}
|
||||
|
||||
lookup() {
|
||||
local path
|
||||
if ! is_geo_type "$1" || ! is_safe_geo_arg "$2"; then
|
||||
echo "Invalid argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
path="$(getGeoPath "$1")"
|
||||
geoview -type "$1" -action lookup -input "$path" -value "$2" | tr 'A-Z' 'a-z'
|
||||
}
|
||||
|
||||
convert() {
|
||||
[ -z "$2" ] && return
|
||||
local path
|
||||
local type="$1"
|
||||
is_geo_type "$type" || return 1
|
||||
path="$(getGeoPath "$1")"
|
||||
for tag in $2; do
|
||||
if ! is_safe_geo_arg "$tag"; then
|
||||
log_warn "Skip invalid $type tag: $tag"
|
||||
continue
|
||||
fi
|
||||
geoview -type "$type" -action convert -format ruleset -input "$path" -regex -list "$tag" -output "$RULESET_PATH/$type-$tag.srs" \
|
||||
&& log_info "Convert $type:$tag to ruleset successfully"
|
||||
done
|
||||
}
|
||||
|
||||
getGeoPath() {
|
||||
case $1 in
|
||||
geosite)
|
||||
echo $GEO_SITE_PATH
|
||||
;;
|
||||
geoip)
|
||||
echo $GEO_IP_PATH
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main() {
|
||||
local action="$1"
|
||||
shift
|
||||
case $action in
|
||||
-e | --extract)
|
||||
extract "$@"
|
||||
;;
|
||||
-t | --convert)
|
||||
[ -d $RULESET_PATH ] || mkdir -p $RULESET_PATH
|
||||
convert "geosite" "$GEOSITE_TAG_LIST"
|
||||
convert "geoip" "$GEOIP_TAG_LIST"
|
||||
;;
|
||||
-l | --lookup)
|
||||
lookup "$@"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [-e|--extract] [-l|--lookup]"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -0,0 +1,109 @@
|
||||
require("luci.ip")
|
||||
|
||||
local params = {"iptype", "ipmatch", "domainmatch"}
|
||||
|
||||
local function ipType(ip)
|
||||
local result = nil
|
||||
|
||||
result = luci.ip.checkip4(ip)
|
||||
if result then
|
||||
print(4)
|
||||
return 0
|
||||
end
|
||||
|
||||
result = luci.ip.checkip6(ip)
|
||||
if result then
|
||||
print(6)
|
||||
return 0
|
||||
end
|
||||
|
||||
return 1
|
||||
end
|
||||
|
||||
local function domainMatch(host, rule_domain)
|
||||
if not host or not rule_domain then return false end
|
||||
|
||||
-- 全小写,避免大小写问题
|
||||
host = host:lower()
|
||||
rule_domain = rule_domain:lower()
|
||||
|
||||
if host == rule_domain then return true end
|
||||
|
||||
-- 检查是否以 ".rule_domain" 结尾
|
||||
local suffix = "." .. rule_domain
|
||||
return host:sub(-#suffix) == suffix
|
||||
end
|
||||
|
||||
local function ipMatch(ip, cidr)
|
||||
local range = luci.ip.new(cidr)
|
||||
return range:contains(ip)
|
||||
end
|
||||
|
||||
local function checkParams(val)
|
||||
for _, v in ipairs(params) do if v == val then return true end end
|
||||
return false
|
||||
end
|
||||
|
||||
local function readFile(filePath)
|
||||
local f, err = io.open(filePath, "r")
|
||||
if not f then
|
||||
io.stderr:write("cannot open cidr_file: " .. tostring(err) .. "\n")
|
||||
os.exit(2)
|
||||
end
|
||||
return f:lines()
|
||||
end
|
||||
|
||||
local function ipMatchInFile(ip, filePath)
|
||||
for line in readFile(filePath) do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" then
|
||||
if ipMatch(ip, trimmed) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function domainMatchInFile(domain, filePath)
|
||||
for line in readFile(filePath) do
|
||||
local trimmed = line:match("^%s*(.-)%s*$")
|
||||
if trimmed ~= "" then
|
||||
if domainMatch(domain, trimmed) then return true end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if not checkParams(arg[1]) then
|
||||
print("Invalid parameter")
|
||||
os.exit(1)
|
||||
end
|
||||
|
||||
if arg[1] == "iptype" then os.exit(ipType(arg[2])) end
|
||||
|
||||
if arg[1] == "ipmatch" then
|
||||
local ok, result_or_err
|
||||
if arg[3] == "-f" then
|
||||
ok, result_or_err = pcall(ipMatchInFile, arg[2], arg[4])
|
||||
else
|
||||
ok, result_or_err = pcall(ipMatch, arg[2], arg[3])
|
||||
end
|
||||
if ok and result_or_err then
|
||||
print("matched")
|
||||
os.exit(0)
|
||||
else
|
||||
print("not matched")
|
||||
os.exit(1)
|
||||
end
|
||||
end
|
||||
|
||||
if arg[1] == "domainmatch" then
|
||||
local ok, result_or_err = pcall(domainMatchInFile, arg[2], arg[3])
|
||||
|
||||
if ok and result_or_err then
|
||||
print("matched")
|
||||
os.exit(0)
|
||||
else
|
||||
print("not matched")
|
||||
os.exit(1)
|
||||
end
|
||||
end
|
||||
Executable
+101
@@ -0,0 +1,101 @@
|
||||
#!/bin/sh
|
||||
|
||||
is_port_value() {
|
||||
echo "$1" | grep -Eq "^([0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])$" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_safe_target() {
|
||||
echo "$1" | grep -Eq "^[A-Za-z0-9.-]+$" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_ipv4_value() {
|
||||
echo "$1" | grep -Eq "^([0-9]{1,3}\.){3}[0-9]{1,3}$" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_ipv6_value() {
|
||||
echo "$1" | grep -Eq "^[0-9A-Fa-f:]+$" >/dev/null 2>&1 && echo "$1" | grep -q ":" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
json_escape() {
|
||||
echo "$1" | sed 's/\\/\\\\/g; s/"/\\"/g'
|
||||
}
|
||||
|
||||
check_port() {
|
||||
if is_port_value "$1"; then
|
||||
if netstat -tlnp | grep -q ":$1 " >>/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
return 1
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local action="$1"
|
||||
shift
|
||||
case "$action" in
|
||||
"port")
|
||||
local result="{}"
|
||||
for p in "$@"; do
|
||||
if check_port "$p"; then
|
||||
result="$(echo "$result" | jq --arg k "$p" '.[$k] = true')"
|
||||
else
|
||||
result="$(echo "$result" | jq --arg k "$p" '.[$k] = false')"
|
||||
fi
|
||||
done
|
||||
echo "$result"
|
||||
;;
|
||||
"ping")
|
||||
# $1=socks_port, $2=target (optional, default www.google.com)
|
||||
local socks_port="$1"
|
||||
local target="${2:-www.google.com}"
|
||||
local output
|
||||
if ! is_port_value "$socks_port" || ! is_safe_target "$target"; then
|
||||
echo "{\"error\":\"invalid argument\"}"
|
||||
exit 1
|
||||
fi
|
||||
output=$(curl -I -o /dev/null -sSk \
|
||||
-w '{"t_namelookup":%{time_namelookup},"t_starttransfer":%{time_starttransfer},"t_total":%{time_total}}' \
|
||||
--max-time 5 \
|
||||
--socks5 "127.0.0.1:${socks_port}" \
|
||||
"https://${target}" 2>/dev/null)
|
||||
local exit_code=$?
|
||||
if [ $exit_code -eq 0 ] || [ $exit_code -eq 56 ]; then
|
||||
echo "$output" | jq '{
|
||||
ms: ((.t_starttransfer - .t_namelookup) * 1000 | floor),
|
||||
total_ms: (.t_total * 1000 | floor)
|
||||
}'
|
||||
else
|
||||
echo "{\"error\":\"timeout\"}"
|
||||
fi
|
||||
;;
|
||||
"resolve")
|
||||
local target="$1"
|
||||
local address
|
||||
if ! is_safe_target "$target" && ! is_ipv6_value "$target"; then
|
||||
echo "{\"error\":\"invalid argument\"}"
|
||||
exit 1
|
||||
fi
|
||||
if is_ipv4_value "$target" || is_ipv6_value "$target"; then
|
||||
echo "{\"address\":\"$(json_escape "$target")\"}"
|
||||
exit 0
|
||||
fi
|
||||
address=$(nslookup "$target" 2>/dev/null | awk '
|
||||
/^Address[[:space:]][0-9]+:[[:space:]]/ { print $3; exit }
|
||||
/^Address:[[:space:]]/ && $2 !~ /#/ { print $2; exit }
|
||||
')
|
||||
if [ -n "$address" ]; then
|
||||
echo "{\"address\":\"$(json_escape "$address")\"}"
|
||||
else
|
||||
echo "{\"error\":\"resolve failed\"}"
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
echo -e "Usage: \n $0 [port|ping|resolve] args"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Executable
+31
@@ -0,0 +1,31 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
NFT_GEN_SCRIPT="/usr/lib/hijpass/gen-nft.sh"
|
||||
NFT_CONF="/etc/hijpass/fw4-rules.nft"
|
||||
|
||||
case "$1" in
|
||||
show)
|
||||
nft list table inet hijpass 2>/dev/null || echo ""
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
NFT_RULES=$($NFT_GEN_SCRIPT)
|
||||
|
||||
case "$1" in
|
||||
reset)
|
||||
cat <<-EOF >"$NFT_CONF"
|
||||
$NFT_RULES
|
||||
EOF
|
||||
;;
|
||||
remove)
|
||||
rm -f "$NFT_CONF"
|
||||
;;
|
||||
esac
|
||||
|
||||
if ! fw4 reload >/dev/null 2>&1; then
|
||||
log_error "Failed to reload firewall rules"
|
||||
exit 1
|
||||
fi
|
||||
Executable
+54
@@ -0,0 +1,54 @@
|
||||
#!/bin/sh
|
||||
|
||||
[ -z "$2" ] && exit 1
|
||||
|
||||
CLIENT_DIR=$(uci -q get hijpass.@hijpass[0].client_dir || echo '/etc/hijpass/client')
|
||||
SERVER_DIR=$(uci -q get hijserver.@hijserver[0].server_dir || echo '/etc/hijpass/server')
|
||||
|
||||
is_safe_section_id() {
|
||||
case "$1" in
|
||||
''|*[!A-Za-z0-9_-]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_safe_keep_path() {
|
||||
local dir="$1"
|
||||
local section_id="$2"
|
||||
local keep_path="$3"
|
||||
|
||||
[ -z "$keep_path" ] && return 0
|
||||
case "$keep_path" in
|
||||
"$dir"/*-"$section_id".json) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
remove_node_file() {
|
||||
local dir="$1"
|
||||
local section_id="$2"
|
||||
local keep_path="$3"
|
||||
|
||||
if ! is_safe_section_id "$section_id" || ! is_safe_keep_path "$dir" "$section_id" "$keep_path"; then
|
||||
echo "Invalid argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
for file in "${dir}/"*-"${section_id}".json; do
|
||||
[ -e "$file" ] || continue
|
||||
[ -n "$keep_path" ] && [ "$file" = "$keep_path" ] && continue
|
||||
rm -f "$file"
|
||||
done
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
"proxy")
|
||||
remove_node_file "$CLIENT_DIR" "$2" "$3"
|
||||
;;
|
||||
"server")
|
||||
remove_node_file "$SERVER_DIR" "$2" "$3"
|
||||
;;
|
||||
*)
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
+262
@@ -0,0 +1,262 @@
|
||||
#!/bin/sh
|
||||
|
||||
# =================配置区域=================
|
||||
# 你的配置文件名 (例如 openclash, passwall 等)
|
||||
CONFIG_PACKAGE="hijpass"
|
||||
# =========================================
|
||||
LUA_SCRIPT="/usr/lib/hijpass/luci-utils.lua"
|
||||
GEOVIEW_SCRIPT="/usr/lib/hijpass/geoview.sh"
|
||||
|
||||
. /lib/functions.sh
|
||||
|
||||
INPUT=$1
|
||||
GEOSITE_TAGS=
|
||||
GEOIP_TAGS=
|
||||
|
||||
if [ -z "$INPUT" ]; then
|
||||
echo "用法: $0 <域名 或 IP>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo "$INPUT" | grep -Eq "^[A-Za-z0-9._:/-]{1,255}$" >/dev/null 2>&1; then
|
||||
echo "非法输入: $INPUT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# =========================================
|
||||
# 函数 1: IP/CIDR 匹配检查
|
||||
# 参数 1: 目标 IP (例如 192.168.1.5)
|
||||
# 参数 2: 规则 IP 或 CIDR (例如 192.168.1.0/24)
|
||||
# 返回: 0(命中), 1(未命中)
|
||||
# =========================================
|
||||
check_ip_cidr_match() {
|
||||
local target_ip="$1"
|
||||
local rule_cidr="$2"
|
||||
|
||||
lua $LUA_SCRIPT ipmatch "$target_ip" "$rule_cidr" >/dev/null 2>&1
|
||||
return $?
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# 函数 2: 复杂域名匹配检查 (核心更新)
|
||||
# 支持: 纯字符串, regexp:, domain:, full:
|
||||
# =========================================
|
||||
check_domain_complex_match() {
|
||||
local target="$1"
|
||||
local rule="$2"
|
||||
|
||||
# 1. 处理 "full:" (完整匹配)
|
||||
# 语法: full:www.google.com
|
||||
if echo "$rule" | grep -q "^full:"; then
|
||||
local content="${rule#full:}"
|
||||
if [ "$target" = "$content" ]; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 2. 处理 "domain:" (域名及子域名匹配)
|
||||
# 语法: domain:google.com (匹配 google.com 和 mail.google.com)
|
||||
if echo "$rule" | grep -q "^domain:"; then
|
||||
local content="${rule#domain:}"
|
||||
# 情况A: 完全相等
|
||||
if [ "$target" = "$content" ]; then
|
||||
return 0
|
||||
fi
|
||||
# 情况B: 是子域名 (即 target 以 .content 结尾)
|
||||
# 使用 case 语句进行后缀匹配,效率高且兼容性好
|
||||
case "$target" in
|
||||
*."$content") return 0 ;;
|
||||
esac
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 3. 处理 "regexp:" (正则表达式)
|
||||
# 语法: regexp:^www\.
|
||||
if echo "$rule" | grep -q "^regexp:"; then
|
||||
local content="${rule#regexp:}"
|
||||
# 使用 grep -E 进行扩展正则匹配,-i 忽略大小写
|
||||
if echo "$target" | grep -E -i -q -e "$content"; then
|
||||
return 0
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 4. 处理 "纯字符串" (关键字匹配)
|
||||
# 语法: baidu (匹配 www.baidu.com, baidu.net)
|
||||
# 只要 target 包含 rule 字符串即算命中
|
||||
if echo "$target" | grep -F -i -q -e "$rule"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# 函数 3: GeoSite/GeoIP 标签检查 (抽象层)
|
||||
# 参数 1: 规则行内容 (例如 geosite:google)
|
||||
# 返回: 1 (始终不命中,仅做日志提示)
|
||||
# 说明: Shell 无法解析 dat 文件,此函数仅用于识别标签类型
|
||||
# =========================================
|
||||
check_geo_label_match() {
|
||||
local tags="$1"
|
||||
[ -z "$tags" ] && return 1
|
||||
[ "$tags" = "notfound" ] && return 1
|
||||
local rule_line="$2"
|
||||
local type="$3"
|
||||
|
||||
for tag in $tags; do
|
||||
if [ "$type:$tag" = "$rule_line" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# =========================================
|
||||
# 主逻辑
|
||||
# =========================================
|
||||
|
||||
# 1. 识别输入类型
|
||||
if lua $LUA_SCRIPT iptype "$INPUT" >/dev/null 2>&1; then
|
||||
IS_IP=1
|
||||
TYPE="IP"
|
||||
# echo "正在检测 IP: $INPUT ..."
|
||||
else
|
||||
IS_IP=0
|
||||
TYPE="域名"
|
||||
# echo "正在检测域名: $INPUT ..."
|
||||
fi
|
||||
|
||||
MATCHED_RULE=""
|
||||
|
||||
# UCI 遍历回调函数
|
||||
handle_rule() {
|
||||
local config="$1"
|
||||
local name
|
||||
local enabled
|
||||
local domain_list
|
||||
local ip_list
|
||||
local proxy_node
|
||||
|
||||
config_get name "$config" name "Unnamed"
|
||||
config_get enabled "$config" enabled "0"
|
||||
config_get proxy_node "$config" proxy_node "default"
|
||||
|
||||
[ "$enabled" != "1" ] && return 0
|
||||
|
||||
# === 检查 IP 列表 ===
|
||||
if [ "$IS_IP" -eq 1 ]; then
|
||||
config_get ip_list "$config" ip_list
|
||||
if [ -n "$ip_list" ]; then
|
||||
[ -z "$GEOIP_TAGS" ] && {
|
||||
if echo "$ip_list" | grep -v '^[[:space:]]*#' | grep -E -q 'geoip:'; then
|
||||
GEOIP_TAGS="$($GEOVIEW_SCRIPT -l "geoip" "$INPUT")"
|
||||
[ -z "$GEOIP_TAGS" ] && GEOIP_TAGS="notfound"
|
||||
fi
|
||||
}
|
||||
while IFS= read -r line; do
|
||||
# 清理注释和空格
|
||||
clean_line=$(echo "$line" | sed 's/#.*//' | sed 's/^[ \t]*//;s/[ \t]*$//')
|
||||
[ -z "$clean_line" ] && continue
|
||||
|
||||
if echo "$clean_line" | grep -vq "geoip:"; then
|
||||
# 调用 IP 检查函数
|
||||
if check_ip_cidr_match "$INPUT" "$clean_line"; then
|
||||
print_result "$name" "$clean_line" "$proxy_node" "$config"
|
||||
exit 0
|
||||
fi
|
||||
elif echo "$clean_line" | grep -E -q '^rule-set:'; then
|
||||
continue
|
||||
else
|
||||
if check_geo_label_match "$GEOIP_TAGS" "$clean_line" "geoip"; then
|
||||
print_result "$name" "$clean_line" "$proxy_node" "$config"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done <<EOF
|
||||
$ip_list
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# === 检查 域名/Geo 列表 ===
|
||||
# 注意:即使输入是 IP,有些配置也可能在 domain_list 里放 geoip,但通常 domain_list 放域名
|
||||
# 这里逻辑是:如果输入是域名,才去检查 domain_list
|
||||
if [ "$IS_IP" -eq 0 ]; then
|
||||
config_get domain_list "$config" domain_list
|
||||
if [ -n "$domain_list" ]; then
|
||||
[ -z "$GEOSITE_TAGS" ] && {
|
||||
if echo "$domain_list" | grep -v '^[[:space:]]*#' | grep -E -q 'geosite:'; then
|
||||
GEOSITE_TAGS="$($GEOVIEW_SCRIPT -l "geosite" "$INPUT")"
|
||||
[ -z "$GEOSITE_TAGS" ] && GEOSITE_TAGS="notfound"
|
||||
fi
|
||||
}
|
||||
while IFS= read -r line; do
|
||||
clean_line=$(echo "$line" | sed 's/#.*//' | sed 's/^[ \t]*//;s/[ \t]*$//')
|
||||
[ -z "$clean_line" ] && continue
|
||||
|
||||
if echo "$clean_line" | grep -v "geosite:" >/dev/null; then
|
||||
if check_domain_complex_match "$INPUT" "$clean_line"; then
|
||||
print_result "$name" "$clean_line" "$proxy_node" "$config"
|
||||
exit 0
|
||||
fi
|
||||
elif echo "$clean_line" | grep -E -q '^rule-set:'; then
|
||||
continue
|
||||
else
|
||||
if check_geo_label_match "$GEOSITE_TAGS" "$clean_line" "geosite"; then
|
||||
print_result "$name" "$clean_line" "$proxy_node" "$config"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done <<EOF
|
||||
$domain_list
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
handle_default() {
|
||||
config="$1"
|
||||
local default_proxy_node
|
||||
config_get default_proxy_node "$config" default_proxy_node
|
||||
print_result "全局默认" "* (全部)" "$default_proxy_node" "-"
|
||||
}
|
||||
|
||||
set_geo_tags() {
|
||||
local config="$1"
|
||||
|
||||
[ -z "$GEOSITE_TAGS" ] && {
|
||||
local domain_list
|
||||
config_get domain_list "$config" domain_list
|
||||
if echo "$domain_list" | grep -v '^[[:space:]]*#' | grep -E -q 'geosite:'; then
|
||||
GEOSITE_TAGS="$($GEOVIEW_SCRIPT -l "geosite" "$INPUT")"
|
||||
fi
|
||||
}
|
||||
[ -z "$GEOIP_TAGS" ] && {
|
||||
local ip_list
|
||||
config_get ip_list "$config" ip_list
|
||||
if echo "$ip_list" | grep -v '^[[:space:]]*#' | grep -E -q 'geoip:'; then
|
||||
GEOIP_TAGS="$($GEOVIEW_SCRIPT -l "geoip" "$INPUT")"
|
||||
fi
|
||||
}
|
||||
}
|
||||
|
||||
# 辅助函数:打印结果
|
||||
print_result() {
|
||||
echo "规则名称 (Name) : $1"
|
||||
echo "匹配类型 (Type) : $TYPE"
|
||||
echo "匹配内容 (Item) : $2"
|
||||
echo "代理策略 (Node) : $3"
|
||||
# echo "配置段名 (ID) : $4"
|
||||
MATCHED_RULE="yes"
|
||||
}
|
||||
|
||||
# 加载配置
|
||||
config_load "$CONFIG_PACKAGE"
|
||||
config_foreach handle_rule shunt_route_rule
|
||||
|
||||
if [ -z "$MATCHED_RULE" ]; then
|
||||
echo -e "未匹配到任何自定义规则,除去协议、端口等规则外,将使用全局默认策略\n"
|
||||
config_foreach handle_default shunt
|
||||
fi
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
ACTION="$1"
|
||||
TABLE_NUMBER="233"
|
||||
TPROXY_MARK="0x1"
|
||||
RT_TABLES_FILE="/etc/iproute2/rt_tables"
|
||||
TABLE_NAME="hijpass"
|
||||
|
||||
add_route() {
|
||||
delete_route
|
||||
add_table
|
||||
|
||||
ip route add local default dev lo table "$TABLE_NAME"
|
||||
log_debug "Added IPv4 local route to table ${TABLE_NAME}"
|
||||
|
||||
ip rule add fwmark "$TPROXY_MARK" table "$TABLE_NAME"
|
||||
log_debug "Added IPv4 rule with fwmark ${TPROXY_MARK}"
|
||||
|
||||
ip -6 route add local default dev lo table "$TABLE_NAME"
|
||||
log_debug "Added IPv6 local route to table ${TABLE_NAME}"
|
||||
|
||||
ip -6 rule add fwmark "$TPROXY_MARK" table "$TABLE_NAME"
|
||||
log_debug "Added IPv6 rule with fwmark ${TPROXY_MARK}"
|
||||
}
|
||||
|
||||
delete_route() {
|
||||
while ip rule show | grep -q "fwmark $TPROXY_MARK lookup $TABLE_NAME"; do
|
||||
ip rule delete fwmark "$TPROXY_MARK" table "$TABLE_NAME"
|
||||
log_debug "IPv4 Rule with fwmark $TPROXY_MARK deleted from table ${TABLE_NAME}"
|
||||
done
|
||||
|
||||
while ip -6 rule show | grep -q "fwmark $TPROXY_MARK lookup $TABLE_NAME"; do
|
||||
ip -6 rule delete fwmark "$TPROXY_MARK" table "$TABLE_NAME"
|
||||
log_debug "IPv6 rule with fwmark $TPROXY_MARK deleted from table ${TABLE_NAME}"
|
||||
done
|
||||
|
||||
if ip route del local default dev lo table "$TABLE_NAME" 2>/dev/null; then
|
||||
log_debug "Removed ${TABLE_NAME} existing IPv4 default route Successfully"
|
||||
else
|
||||
log_debug "No existing IPv4 default route to remove"
|
||||
fi
|
||||
|
||||
if ip -6 route del local default dev lo table "$TABLE_NAME" 2>/dev/null; then
|
||||
log_debug "Removed ${TABLE_NAME} existing IPv6 default route Successfully"
|
||||
else
|
||||
log_debug "No existing IPv6 default route to remove"
|
||||
fi
|
||||
|
||||
delete_table
|
||||
}
|
||||
|
||||
add_table() {
|
||||
if grep -qE "^\s*${TABLE_NUMBER}\s+${TABLE_NAME}\s*$" "$RT_TABLES_FILE"; then
|
||||
log_debug "Table ${TABLE_NAME} with number ${TABLE_NUMBER} already exists"
|
||||
else
|
||||
echo "${TABLE_NUMBER} ${TABLE_NAME}" >>"$RT_TABLES_FILE"
|
||||
log_debug "Table ${TABLE_NAME} with number ${TABLE_NUMBER} added"
|
||||
fi
|
||||
}
|
||||
|
||||
delete_table() {
|
||||
if grep -qE "^\s*${TABLE_NUMBER}\s+${TABLE_NAME}\s*$" "$RT_TABLES_FILE"; then
|
||||
sed -i "/^\s*${TABLE_NUMBER}\s\+${TABLE_NAME}\s*$/d" "$RT_TABLES_FILE"
|
||||
log_debug "Deleted table ${TABLE_NAME} with number ${TABLE_NUMBER}"
|
||||
else
|
||||
log_debug "Table ${TABLE_NAME} with number ${TABLE_NUMBER} does not exist"
|
||||
fi
|
||||
}
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 [reset_hijpass_route|remove_hijpass_route]"
|
||||
exit 1
|
||||
}
|
||||
|
||||
if [ "$#" -ne 1 ]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
case "$ACTION" in
|
||||
reset_hijpass_route)
|
||||
log_info "Configuring hijpass routing..."
|
||||
add_route
|
||||
log_info "Configured hijpass routing successfully"
|
||||
;;
|
||||
remove_hijpass_route)
|
||||
log_info "Removing hijpass routing configuration..."
|
||||
delete_route
|
||||
log_info "Removed hijpass routing configuration successfully"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
+602
@@ -0,0 +1,602 @@
|
||||
#!/bin/sh
|
||||
|
||||
. /usr/lib/hijpass/app-logger.sh
|
||||
|
||||
log_debug "$0"
|
||||
|
||||
# 获取配置
|
||||
DOWNLOAD_TIMEOUT=$(uci -q get hijpass.@rule[0].download_timeout || echo "30")
|
||||
RETRY_COUNT=$(uci -q get hijpass.@rule[0].retry_count || echo "3")
|
||||
|
||||
# 文件路径
|
||||
CHN_LIST_FILE=$(uci -q get hijpass.@hijpass[0].chn_list)
|
||||
CHN_ROUTE_FILE=$(uci -q get hijpass.@hijpass[0].chn_route)
|
||||
CHN_ROUTE6_FILE=$(uci -q get hijpass.@hijpass[0].chn_route6)
|
||||
GFW_LIST_FILE=$(uci -q get hijpass.@hijpass[0].gfw_list)
|
||||
GEO_SITE_FILE="/usr/share/v2ray/geosite.dat"
|
||||
GEO_IP_FILE="/usr/share/v2ray/geoip.dat"
|
||||
|
||||
ENABLED=$(uci -q get hijpass.@hijpass[0].enabled || :)
|
||||
|
||||
LUA_SCRIPT="/usr/lib/hijpass/luci-utils.lua"
|
||||
|
||||
is_safe_query_arg() {
|
||||
[ -n "$1" ] && echo "$1" | grep -Eq "^[A-Za-z0-9._:/-]{1,255}$" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# 下载函数(单个文件)
|
||||
download_file() {
|
||||
local url output temp_file backup_file processed_file
|
||||
url="$1"
|
||||
output="$2"
|
||||
temp_file="${output}.tmp"
|
||||
backup_file="${output}.bak"
|
||||
processed_file="${output}.processed"
|
||||
|
||||
log_info "Downloading from: $url"
|
||||
|
||||
# 备份现有文件
|
||||
if [ -f "$output" ]; then
|
||||
cp "$output" "$backup_file"
|
||||
log_debug "Backed up existing file: $backup_file"
|
||||
fi
|
||||
|
||||
# 使用curl下载
|
||||
if curl -L --connect-timeout "$DOWNLOAD_TIMEOUT" \
|
||||
--max-time "$((DOWNLOAD_TIMEOUT * 2))" \
|
||||
-o "$temp_file" "$url" 2>/dev/null; then
|
||||
|
||||
# 检查文件是否有效(非空且大于100字节)
|
||||
if [ -s "$temp_file" ] && [ "$(wc -c <"$temp_file")" -gt 100 ]; then
|
||||
# 验证文件格式
|
||||
if validate_file_format "$temp_file" "$output"; then
|
||||
# 处理域名列表文件,只保留纯域名
|
||||
if process_domain_file "$temp_file" "$processed_file" "$output"; then
|
||||
mv "$processed_file" "$output"
|
||||
rm -f "$backup_file" "$temp_file"
|
||||
log_info "Successfully downloaded and processed: $output ($(wc -c <"$output") bytes)"
|
||||
return 0
|
||||
else
|
||||
mv "$temp_file" "$output"
|
||||
rm -f "$backup_file"
|
||||
log_info "Successfully downloaded: $output ($(wc -c <"$output") bytes)"
|
||||
return 0
|
||||
fi
|
||||
else
|
||||
log_warn "Downloaded file format validation failed: $url"
|
||||
# 恢复备份文件
|
||||
if [ -f "$backup_file" ]; then
|
||||
mv "$backup_file" "$output"
|
||||
log_info "Restored backup file: $output"
|
||||
fi
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_warn "Downloaded file is too small or empty: $url"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
else
|
||||
log_warn "Failed to download: $url"
|
||||
rm -f "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 下载多个URL并合并去重
|
||||
download_and_merge_files() {
|
||||
local urls output temp_file backup_file merged_file processed_file
|
||||
urls="$1"
|
||||
output="$2"
|
||||
temp_file="${output}.tmp"
|
||||
backup_file="${output}.bak"
|
||||
merged_file="${output}.merged"
|
||||
processed_file="${output}.processed"
|
||||
|
||||
log_info "Downloading and merging from multiple URLs..."
|
||||
|
||||
# 备份现有文件
|
||||
if [ -f "$output" ]; then
|
||||
cp "$output" "$backup_file"
|
||||
log_debug "Backed up existing file: $backup_file"
|
||||
fi
|
||||
|
||||
# 清空合并文件
|
||||
: >"$merged_file"
|
||||
|
||||
local success_count=0
|
||||
local total_count=0
|
||||
|
||||
# 下载所有URL
|
||||
for _url in $urls; do
|
||||
if [ -n "$_url" ]; then
|
||||
total_count=$((total_count + 1))
|
||||
local retry=0
|
||||
local downloaded=0
|
||||
|
||||
while [ $retry -lt "$RETRY_COUNT" ]; do
|
||||
if curl -L --connect-timeout "$DOWNLOAD_TIMEOUT" \
|
||||
--max-time "$((DOWNLOAD_TIMEOUT * 2))" \
|
||||
-o "$temp_file" "$_url" 2>/dev/null; then
|
||||
|
||||
# 检查文件是否有效
|
||||
if [ -s "$temp_file" ] && [ "$(wc -c <"$temp_file")" -gt 100 ]; then
|
||||
log_info "Successfully downloaded from: $_url"
|
||||
cat "$temp_file" >>"$merged_file"
|
||||
rm -f "$temp_file"
|
||||
success_count=$((success_count + 1))
|
||||
downloaded=1
|
||||
break
|
||||
fi
|
||||
fi
|
||||
retry=$((retry + 1))
|
||||
if [ $retry -lt "$RETRY_COUNT" ]; then
|
||||
log_warn "Retry $retry/$RETRY_COUNT for: $_url"
|
||||
sleep 2
|
||||
fi
|
||||
done
|
||||
|
||||
if [ $downloaded -eq 0 ]; then
|
||||
log_warn "Failed to download from: $_url after $RETRY_COUNT retries"
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
# 检查是否至少有一个URL下载成功
|
||||
if [ $success_count -eq 0 ]; then
|
||||
log_error "Failed to download from all URLs"
|
||||
# 恢复备份文件
|
||||
if [ -f "$backup_file" ]; then
|
||||
mv "$backup_file" "$output"
|
||||
log_info "Restored backup file: $output"
|
||||
fi
|
||||
rm -f "$merged_file" "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloaded from $success_count/$total_count URLs successfully"
|
||||
|
||||
# 处理合并后的文件(去重)
|
||||
if process_domain_file "$merged_file" "$processed_file" "$output"; then
|
||||
mv "$processed_file" "$output"
|
||||
rm -f "$backup_file" "$merged_file" "$temp_file"
|
||||
log_info "Successfully merged and processed: $output ($(wc -c <"$output") bytes)"
|
||||
return 0
|
||||
else
|
||||
# 如果处理失败,直接使用合并文件
|
||||
if [ -s "$merged_file" ]; then
|
||||
# 对合并文件进行基础去重
|
||||
sort "$merged_file" | uniq >"$output"
|
||||
rm -f "$backup_file" "$merged_file" "$temp_file"
|
||||
log_info "Successfully merged (without processing): $output ($(wc -c <"$output") bytes)"
|
||||
return 0
|
||||
else
|
||||
log_error "Merged file is empty"
|
||||
if [ -f "$backup_file" ]; then
|
||||
mv "$backup_file" "$output"
|
||||
log_info "Restored backup file: $output"
|
||||
fi
|
||||
rm -f "$merged_file" "$temp_file"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 检测文件格式类型
|
||||
detect_file_format() {
|
||||
local file sample_lines
|
||||
file="$1"
|
||||
|
||||
# 抽取前10行非空非注释行进行格式检测
|
||||
sample_lines=$(grep -v "^#\|^!\|^$" "$file" | head -n 10)
|
||||
|
||||
if echo "$sample_lines" | grep -q "^server=/"; then
|
||||
echo "dnsmasq"
|
||||
elif echo "$sample_lines" | grep -q "^+\."; then
|
||||
echo "adguard"
|
||||
elif echo "$sample_lines" | grep -q "^@@||.*\^"; then
|
||||
echo "adblock"
|
||||
elif echo "$sample_lines" | grep -q "^domain:\|^full:\|^regexp:\|^keyword:"; then
|
||||
echo "v2ray"
|
||||
elif echo "$sample_lines" | grep -qE "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; then
|
||||
echo "plain"
|
||||
else
|
||||
echo "unknown"
|
||||
fi
|
||||
}
|
||||
|
||||
# 处理域名文件,只保留纯域名
|
||||
process_domain_file() {
|
||||
local input_file output_file original_output format
|
||||
input_file="$1"
|
||||
output_file="$2"
|
||||
original_output="$3"
|
||||
|
||||
# 检查是否为域名列表文件
|
||||
case "$original_output" in
|
||||
*"gfw"* | *"chn-list"* | *"china"* | *"accelerated-domains"*)
|
||||
log_debug "Processing domain file: $original_output"
|
||||
|
||||
# 检测文件格式
|
||||
format=$(detect_file_format "$input_file")
|
||||
log_debug "Detected file format: $format"
|
||||
|
||||
# 根据格式选择批量处理方式
|
||||
case "$format" in
|
||||
"dnsmasq")
|
||||
# 批量处理dnsmasq格式: server=/domain.com/8.8.8.8
|
||||
grep "^server=/" "$input_file" |
|
||||
sed 's/server=\/\([^\/]*\)\/.*/\1/' |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
"adguard")
|
||||
# 批量处理AdGuard Home格式: +.domain.com
|
||||
grep "^+\." "$input_file" |
|
||||
sed 's/^+\.//' |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
"adblock")
|
||||
# 批量处理AdBlock格式: @@||domain.com^
|
||||
grep "^@@||.*\^" "$input_file" |
|
||||
sed 's/^@@||\([^|^]*\)\^.*/\1/' |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
"v2ray")
|
||||
# 批量处理V2Ray规则格式,只保留domain和full类型
|
||||
grep "^domain:\|^full:" "$input_file" |
|
||||
sed 's/^[^:]*://' |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
"plain")
|
||||
# 批量处理纯域名格式
|
||||
grep -v "^#\|^!\|^$" "$input_file" |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
"unknown")
|
||||
# 混合格式,使用通用处理方式
|
||||
{
|
||||
# dnsmasq格式
|
||||
grep "^server=/" "$input_file" | sed 's/server=\/\([^\/]*\)\/.*/\1/'
|
||||
# AdGuard Home格式
|
||||
grep "^+\." "$input_file" | sed 's/^+\.//'
|
||||
# AdBlock格式
|
||||
grep "^@@||.*\^" "$input_file" | sed 's/^@@||\([^|^]*\)\^.*/\1/'
|
||||
# V2Ray规则格式
|
||||
grep "^domain:\|^full:" "$input_file" | sed 's/^[^:]*://'
|
||||
# 纯域名格式
|
||||
grep -v "^#\|^!\|^$\|^server=\|^+\.\|^@@||\|^domain:\|^full:\|^regexp:\|^keyword:" "$input_file"
|
||||
} |
|
||||
grep -E "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" |
|
||||
sort | uniq >"$output_file"
|
||||
;;
|
||||
esac
|
||||
|
||||
# 检查处理后的文件是否有内容
|
||||
if [ -s "$output_file" ]; then
|
||||
local domain_count
|
||||
domain_count=$(wc -l <"$output_file")
|
||||
log_info "Processed domain file ($format format): extracted $domain_count unique domains"
|
||||
return 0
|
||||
else
|
||||
log_warn "No valid domains extracted from file"
|
||||
rm -f "$output_file"
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
# 非域名文件,不需要处理
|
||||
return 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 验证文件格式
|
||||
validate_file_format() {
|
||||
local file output
|
||||
file="$1"
|
||||
output="$2"
|
||||
|
||||
case "$output" in
|
||||
*"chn-list"* | *"china"* | *"accelerated-domains"*)
|
||||
# 验证域名列表格式
|
||||
if head -n 5 "$file" | grep -q "server="; then
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -qE "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; then
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*"gfw"*)
|
||||
# 验证GFW域名列表格式
|
||||
if head -n 5 "$file" | grep -qE "^[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; then
|
||||
log_debug "GFW file validated: plain domain format"
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -q "server="; then
|
||||
log_debug "GFW file validated: dnsmasq format"
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -qE "^\+\.[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"; then
|
||||
log_debug "GFW file validated: AdGuard Home format"
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -qE "^@@\|\|[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\^$"; then
|
||||
log_debug "GFW file validated: AdBlock format"
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -qE "^(domain|full|regexp|keyword):"; then
|
||||
log_debug "GFW file validated: v2ray rules format"
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*"geo"*".dat")
|
||||
# 验证GEO数据库文件(二进制文件)
|
||||
if file "$file" | grep -q "data"; then
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
*"route"* | *"ip"*)
|
||||
# 验证IP路由格式
|
||||
if head -n 5 "$file" | grep -qE "^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+(/[0-9]+)?$"; then
|
||||
return 0
|
||||
elif head -n 5 "$file" | grep -qE "^[0-9a-fA-F:]+(/[0-9]+)?$"; then
|
||||
return 0
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
log_warn "File format validation failed for: $file"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 下载CHN List
|
||||
download_chn_list() {
|
||||
if [ -z "$CHN_LIST_FILE" ]; then
|
||||
log_warn "CHN list file path not configured"
|
||||
return 1
|
||||
fi
|
||||
local chn_domain_list
|
||||
chn_domain_list="$(uci -q get hijpass.@rule[0].chn_domain_list || :)"
|
||||
if [ -z "$chn_domain_list" ]; then
|
||||
log_warn "No CHN list download URLs configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading CHN list..."
|
||||
|
||||
# 下载所有URL并合并去重
|
||||
if download_and_merge_files "$chn_domain_list" "$CHN_LIST_FILE"; then
|
||||
log_info "CHN list downloaded and merged successfully"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to download CHN list from all URLs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 下载CHN Route IPv4
|
||||
download_chn_route_ipv4() {
|
||||
if [ -z "$CHN_ROUTE_FILE" ]; then
|
||||
log_warn "CHN route IPv4 file path not configured"
|
||||
return 1
|
||||
fi
|
||||
local chn_v4_route
|
||||
chn_v4_route=$(uci -q get hijpass.@rule[0].chn_v4_route)
|
||||
if [ -z "$chn_v4_route" ]; then
|
||||
log_warn "No CHN route IPv4 download URLs configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading CHN route IPv4..."
|
||||
|
||||
# 下载所有URL并合并去重
|
||||
if download_and_merge_files "$chn_v4_route" "$CHN_ROUTE_FILE"; then
|
||||
log_info "CHN route IPv4 downloaded and merged successfully"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to download CHN route IPv4 from all URLs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 下载CHN Route IPv6
|
||||
download_chn_route_ipv6() {
|
||||
if [ -z "$CHN_ROUTE6_FILE" ]; then
|
||||
log_warn "CHN route IPv6 file path not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local chn_v6_route
|
||||
chn_v6_route=$(uci -q get hijpass.@rule[0].chn_v6_route)
|
||||
if [ -z "$chn_v6_route" ]; then
|
||||
log_warn "No CHN route IPv6 download URLs configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading CHN route IPv6..."
|
||||
|
||||
# 下载所有URL并合并去重
|
||||
if download_and_merge_files "$chn_v6_route" "$CHN_ROUTE6_FILE"; then
|
||||
log_info "CHN route IPv6 downloaded and merged successfully"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to download CHN route IPv6 from all URLs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
# 下载GeoSite
|
||||
download_geosite() {
|
||||
local url retry
|
||||
url=$(uci -q get hijpass.@rule[0].geosite)
|
||||
if [ -z "$url" ]; then
|
||||
log_warn "GeoSite download URL not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading GeoSite..."
|
||||
|
||||
retry=0
|
||||
while [ $retry -lt "$RETRY_COUNT" ]; do
|
||||
if download_file "$url" "$GEO_SITE_FILE"; then
|
||||
log_info "GeoSite downloaded successfully"
|
||||
return 0
|
||||
fi
|
||||
retry=$((retry + 1))
|
||||
log_warn "Retry $retry/$RETRY_COUNT for GeoSite"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_error "Failed to download GeoSite"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 下载GeoIP
|
||||
download_geoip() {
|
||||
local url retry
|
||||
url=$(uci -q get hijpass.@rule[0].geoip)
|
||||
if [ -z "$url" ]; then
|
||||
log_warn "GeoIP download URL not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading GeoIP..."
|
||||
|
||||
retry=0
|
||||
while [ $retry -lt "$RETRY_COUNT" ]; do
|
||||
if download_file "$url" "$GEO_IP_FILE"; then
|
||||
log_info "GeoIP downloaded successfully"
|
||||
return 0
|
||||
fi
|
||||
retry=$((retry + 1))
|
||||
log_warn "Retry $retry/$RETRY_COUNT for GeoIP"
|
||||
sleep 2
|
||||
done
|
||||
|
||||
log_error "Failed to download GeoIP"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 下载GFW
|
||||
download_gfw_list() {
|
||||
GFW_LIST_FILE=$(uci -q get hijpass.@hijpass[0].gfw_list)
|
||||
if [ -z "$GFW_LIST_FILE" ]; then
|
||||
log_warn "GFW list file path not configured"
|
||||
return 1
|
||||
fi
|
||||
local urls
|
||||
urls=$(uci -q get hijpass.@rule[0].gfw_list)
|
||||
if [ -z "$urls" ]; then
|
||||
log_warn "GFW download URLs not configured"
|
||||
return 1
|
||||
fi
|
||||
|
||||
log_info "Downloading GFW list..."
|
||||
|
||||
# 下载所有URL并合并去重
|
||||
if download_and_merge_files "$urls" "$GFW_LIST_FILE"; then
|
||||
log_info "GFW list downloaded and merged successfully"
|
||||
return 0
|
||||
else
|
||||
log_error "Failed to download GFW list from all URLs"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
query_rule() {
|
||||
if lua $LUA_SCRIPT iptype "$1" >/dev/null 2>&1; then
|
||||
find "/etc/hijpass/rules/" -type f \( -name "chn-route*" -o -name "ip*" \) | while read -r file; do
|
||||
if lua $LUA_SCRIPT ipmatch "$1" -f "$file" >/dev/null 2>&1; then
|
||||
basename "$file"
|
||||
fi
|
||||
done | sort -u
|
||||
else
|
||||
find "/etc/hijpass/rules/" -type f \( -name "*list.txt" -o -name "domain*" \) | while read -r file; do
|
||||
if lua $LUA_SCRIPT domainmatch "$1" "$file" >/dev/null 2>&1; then
|
||||
basename "$file"
|
||||
fi
|
||||
done | sort -u
|
||||
fi
|
||||
}
|
||||
|
||||
update_rule_files() {
|
||||
local failed=0
|
||||
|
||||
log_info "Update all rule files..."
|
||||
download_gfw_list || failed=1
|
||||
download_chn_list || failed=1
|
||||
download_chn_route_ipv4 || failed=1
|
||||
download_chn_route_ipv6 || failed=1
|
||||
download_geosite || failed=1
|
||||
download_geoip || failed=1
|
||||
|
||||
if [ "$ENABLED" != "1" ]; then
|
||||
log_info "Skip restart hijpass"
|
||||
elif ! /etc/init.d/hijpass restart; then
|
||||
failed=1
|
||||
fi
|
||||
|
||||
return "$failed"
|
||||
}
|
||||
|
||||
# 主函数
|
||||
main() {
|
||||
local action="$1"
|
||||
|
||||
case "$action" in
|
||||
"chnlist")
|
||||
download_chn_list
|
||||
;;
|
||||
"gfw")
|
||||
download_gfw_list
|
||||
;;
|
||||
"chnroute")
|
||||
download_chn_route_ipv4
|
||||
download_chn_route_ipv6
|
||||
;;
|
||||
"geo")
|
||||
download_geosite
|
||||
download_geoip
|
||||
;;
|
||||
"all")
|
||||
log_info "Downloading all rule files..."
|
||||
download_gfw_list
|
||||
download_chn_list
|
||||
download_chn_route_ipv4
|
||||
download_chn_route_ipv6
|
||||
download_geosite
|
||||
download_geoip
|
||||
;;
|
||||
"update")
|
||||
if update_rule_files; then
|
||||
printf '{"success":true}\n'
|
||||
else
|
||||
printf '{"success":false}\n'
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
"query")
|
||||
if ! is_safe_query_arg "$2"; then
|
||||
echo "Invalid query" >&2
|
||||
exit 1
|
||||
fi
|
||||
query_rule "$2"
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [chnlist|chnroute|geo|all|update|query]"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
# 创建必要的目录
|
||||
mkdir -p "$(dirname "$CHN_LIST_FILE")" 2>/dev/null
|
||||
mkdir -p "$(dirname "$CHN_ROUTE_FILE")" 2>/dev/null
|
||||
mkdir -p "$(dirname "$CHN_ROUTE6_FILE")" 2>/dev/null
|
||||
mkdir -p "$(dirname "$GEO_SITE_FILE")" 2>/dev/null
|
||||
mkdir -p "$(dirname "$GEO_IP_FILE")" 2>/dev/null
|
||||
mkdir -p "$(dirname "$GFW_LIST_FILE")" 2>/dev/null
|
||||
|
||||
# 执行主函数
|
||||
main "$@"
|
||||
+898
@@ -0,0 +1,898 @@
|
||||
#!/bin/sh
|
||||
|
||||
SCRIPT_NAME="${0##*/}"
|
||||
|
||||
TIMEOUT=8
|
||||
CLIENT_IP=
|
||||
METHOD=HEAD
|
||||
KEEP_TMP=0
|
||||
VERBOSE=0
|
||||
ENABLE_TCPDUMP=0
|
||||
TCPDUMP_IFACE=any
|
||||
|
||||
usage() {
|
||||
cat <<EOF
|
||||
Usage:
|
||||
$SCRIPT_NAME [-t seconds] [-X HEAD|GET] [-p] [-i iface] [-v] [-K] <url>
|
||||
$SCRIPT_NAME -c <client_ip> [-t seconds] [-p] [-i iface] [-v] [-K] <url>
|
||||
|
||||
Modes:
|
||||
default Run curl on this router and trace hijpass out-route.
|
||||
-c client_ip Trace hijpass pre-filter for a real request from the client.
|
||||
|
||||
Examples:
|
||||
$SCRIPT_NAME https://www.gstatic.com/generate_204
|
||||
$SCRIPT_NAME -p https://www.gstatic.com/generate_204
|
||||
$SCRIPT_NAME -p -i pppoe-wan https://www.gstatic.com/generate_204
|
||||
$SCRIPT_NAME -c 192.168.1.100 https://www.gstatic.com/generate_204
|
||||
|
||||
Notes:
|
||||
The default request is TCP HEAD. For transparent LAN traffic, use -c and
|
||||
run a matching curl request from the client during the trace window.
|
||||
Use -p to enable tcpdump packet capture. The default tcpdump interface is any.
|
||||
Use -v to print raw trace and log lines.
|
||||
EOF
|
||||
}
|
||||
|
||||
die() {
|
||||
printf 'error: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
has_cmd() {
|
||||
command -v "$1" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
is_number() {
|
||||
case "$1" in
|
||||
''|*[!0-9]*) return 1 ;;
|
||||
*) return 0 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
is_port() {
|
||||
is_number "$1" || return 1
|
||||
[ "$1" -ge 1 ] 2>/dev/null && [ "$1" -le 65535 ] 2>/dev/null
|
||||
}
|
||||
|
||||
is_ipv4() {
|
||||
printf '%s\n' "$1" | awk -F. '
|
||||
NF != 4 { exit 1 }
|
||||
{
|
||||
for (i = 1; i <= 4; i++) {
|
||||
if ($i !~ /^[0-9]+$/ || $i < 0 || $i > 255) {
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
is_ipv6() {
|
||||
case "$1" in
|
||||
*:*) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
ip_family() {
|
||||
if is_ipv4 "$1"; then
|
||||
printf '4\n'
|
||||
elif is_ipv6 "$1"; then
|
||||
printf '6\n'
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
upper() {
|
||||
printf '%s' "$1" | tr '[:lower:]' '[:upper:]'
|
||||
}
|
||||
|
||||
while getopts 'c:t:X:i:Kpvh' opt; do
|
||||
case "$opt" in
|
||||
c) CLIENT_IP="$OPTARG" ;;
|
||||
t) TIMEOUT="$OPTARG" ;;
|
||||
X) METHOD="$(upper "$OPTARG")" ;;
|
||||
i) TCPDUMP_IFACE="$OPTARG" ;;
|
||||
K) KEEP_TMP=1 ;;
|
||||
p) ENABLE_TCPDUMP=1 ;;
|
||||
v) VERBOSE=1 ;;
|
||||
h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
usage >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
shift $((OPTIND - 1))
|
||||
|
||||
[ "$#" -eq 1 ] || {
|
||||
usage >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
URL="$1"
|
||||
MODE=router
|
||||
[ -n "$CLIENT_IP" ] && MODE=client
|
||||
|
||||
case "$METHOD" in
|
||||
HEAD|GET) ;;
|
||||
*) die "method must be HEAD or GET" ;;
|
||||
esac
|
||||
|
||||
is_number "$TIMEOUT" || die "timeout must be an integer"
|
||||
[ "$TIMEOUT" -ge 1 ] 2>/dev/null || die "timeout must be >= 1"
|
||||
|
||||
if [ -n "$CLIENT_IP" ]; then
|
||||
ip_family "$CLIENT_IP" >/dev/null || die "invalid client IP: $CLIENT_IP"
|
||||
fi
|
||||
case "$TCPDUMP_IFACE" in
|
||||
''|*[!A-Za-z0-9_.:@-]*)
|
||||
die "invalid tcpdump interface: $TCPDUMP_IFACE"
|
||||
;;
|
||||
esac
|
||||
|
||||
has_cmd awk || die "awk is required"
|
||||
has_cmd curl || die "curl is required"
|
||||
has_cmd nft || die "nft is required"
|
||||
if [ "$ENABLE_TCPDUMP" -eq 1 ]; then
|
||||
has_cmd tcpdump || die "tcpdump is required when -p is used"
|
||||
fi
|
||||
|
||||
[ "$(id -u)" = "0" ] || die "must run as root"
|
||||
nft list table inet hijpass >/dev/null 2>&1 || die "nft table inet hijpass not found"
|
||||
|
||||
parse_url() {
|
||||
case "$URL" in
|
||||
https://*)
|
||||
SCHEME=https
|
||||
PORT=443
|
||||
REST="${URL#https://}"
|
||||
;;
|
||||
http://*)
|
||||
SCHEME=http
|
||||
PORT=80
|
||||
REST="${URL#http://}"
|
||||
;;
|
||||
*)
|
||||
die "only http:// and https:// URLs are supported"
|
||||
;;
|
||||
esac
|
||||
|
||||
HOSTPORT="${REST%%/*}"
|
||||
[ -n "$HOSTPORT" ] || die "URL host is empty"
|
||||
|
||||
case "$HOSTPORT" in
|
||||
\[*\]*)
|
||||
HOST="${HOSTPORT#\[}"
|
||||
HOST="${HOST%%\]*}"
|
||||
AFTER_BRACKET="${HOSTPORT#*\]}"
|
||||
case "$AFTER_BRACKET" in
|
||||
:*) PORT="${AFTER_BRACKET#:}" ;;
|
||||
'') ;;
|
||||
*) die "invalid IPv6 host/port: $HOSTPORT" ;;
|
||||
esac
|
||||
;;
|
||||
*:*)
|
||||
LAST_PART="${HOSTPORT##*:}"
|
||||
PREFIX_PART="${HOSTPORT%:*}"
|
||||
if is_number "$LAST_PART"; then
|
||||
case "$PREFIX_PART" in
|
||||
*:*) HOST="$HOSTPORT" ;;
|
||||
*)
|
||||
HOST="$PREFIX_PART"
|
||||
PORT="$LAST_PART"
|
||||
;;
|
||||
esac
|
||||
else
|
||||
HOST="$HOSTPORT"
|
||||
fi
|
||||
;;
|
||||
*)
|
||||
HOST="$HOSTPORT"
|
||||
;;
|
||||
esac
|
||||
|
||||
[ -n "$HOST" ] || die "URL host is empty"
|
||||
case "$HOST" in
|
||||
*[!A-Za-z0-9_.:-]*)
|
||||
die "unsupported URL host: $HOST"
|
||||
;;
|
||||
esac
|
||||
is_port "$PORT" || die "invalid URL port: $PORT"
|
||||
}
|
||||
|
||||
resolve_host() {
|
||||
if is_ipv4 "$HOST" || is_ipv6 "$HOST"; then
|
||||
printf '%s\n' "$HOST"
|
||||
return 0
|
||||
fi
|
||||
|
||||
has_cmd nslookup || return 0
|
||||
|
||||
nslookup "$HOST" 2>/dev/null | awk '
|
||||
/^Name:/ { answer = 1; next }
|
||||
answer && /^Address[[:space:]][0-9]+:/ { print $3; next }
|
||||
answer && /^Address:/ { print $2; next }
|
||||
' | sed 's/%.*//' | awk '
|
||||
index($0, ".") || index($0, ":") {
|
||||
if (!seen[$0]++) print
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
parse_url
|
||||
|
||||
TARGET_IPS=
|
||||
|
||||
TMP_DIR="${TMPDIR:-/tmp}/hijpass-trace.$$"
|
||||
NFT_LOG="$TMP_DIR/nft.trace"
|
||||
NFT_SETUP_LOG="$TMP_DIR/nft.setup"
|
||||
SYS_LOG="$TMP_DIR/system.log"
|
||||
CURL_OUT="$TMP_DIR/curl.out"
|
||||
CURL_ERR="$TMP_DIR/curl.err"
|
||||
CURL_HDR="$TMP_DIR/curl.headers"
|
||||
LOG_INDEX="$TMP_DIR/log-files.index"
|
||||
SERVICE_LOG="$TMP_DIR/service.log"
|
||||
TCPDUMP_LOG="$TMP_DIR/tcpdump.log"
|
||||
TCPDUMP_ERR="$TMP_DIR/tcpdump.err"
|
||||
TCPDUMP_FILTER=
|
||||
TRACE_COMMENT="hijpass-trace-$$"
|
||||
NFT_PID=
|
||||
LOG_PID=
|
||||
TCPDUMP_PID=
|
||||
CLEANED=0
|
||||
|
||||
mkdir -p "$TMP_DIR" || die "failed to create $TMP_DIR"
|
||||
: >"$NFT_LOG"
|
||||
: >"$NFT_SETUP_LOG"
|
||||
: >"$SYS_LOG"
|
||||
: >"$CURL_OUT"
|
||||
: >"$CURL_ERR"
|
||||
: >"$CURL_HDR"
|
||||
: >"$LOG_INDEX"
|
||||
: >"$SERVICE_LOG"
|
||||
: >"$TCPDUMP_LOG"
|
||||
: >"$TCPDUMP_ERR"
|
||||
|
||||
delete_trace_rules() {
|
||||
for chain in pre-filter out-route; do
|
||||
nft -a list chain inet hijpass "$chain" 2>/dev/null |
|
||||
awk -v c="$TRACE_COMMENT" '
|
||||
index($0, "comment \"" c "\"") {
|
||||
for (i = 1; i <= NF; i++) {
|
||||
if ($i == "handle") print $(i + 1)
|
||||
}
|
||||
}
|
||||
' |
|
||||
while read -r handle; do
|
||||
[ -n "$handle" ] || continue
|
||||
nft delete rule inet hijpass "$chain" handle "$handle" >/dev/null 2>&1
|
||||
done
|
||||
done
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
[ "$CLEANED" -eq 1 ] && return
|
||||
CLEANED=1
|
||||
|
||||
[ -n "$NFT_PID" ] && kill "$NFT_PID" >/dev/null 2>&1
|
||||
[ -n "$LOG_PID" ] && kill "$LOG_PID" >/dev/null 2>&1
|
||||
[ -n "$TCPDUMP_PID" ] && kill "$TCPDUMP_PID" >/dev/null 2>&1
|
||||
[ -n "$NFT_PID" ] && wait "$NFT_PID" >/dev/null 2>&1
|
||||
[ -n "$LOG_PID" ] && wait "$LOG_PID" >/dev/null 2>&1
|
||||
[ -n "$TCPDUMP_PID" ] && wait "$TCPDUMP_PID" >/dev/null 2>&1
|
||||
|
||||
delete_trace_rules
|
||||
|
||||
if [ "$KEEP_TMP" -eq 0 ]; then
|
||||
rm -rf "$TMP_DIR"
|
||||
else
|
||||
printf '\nTemporary files kept: %s\n' "$TMP_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
trap 'cleanup; exit 130' INT
|
||||
trap 'cleanup; exit 143' TERM
|
||||
|
||||
add_nft_rule() {
|
||||
chain="$1"
|
||||
shift
|
||||
nft insert rule inet hijpass "$chain" "$@" meta nftrace set 1 comment "$TRACE_COMMENT" \
|
||||
>>"$NFT_SETUP_LOG" 2>&1
|
||||
}
|
||||
|
||||
target_expr_family() {
|
||||
family="$(ip_family "$1" 2>/dev/null || true)"
|
||||
case "$family" in
|
||||
4) printf 'ip daddr\n' ;;
|
||||
6) printf 'ip6 daddr\n' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
client_expr_family() {
|
||||
family="$(ip_family "$1" 2>/dev/null || true)"
|
||||
case "$family" in
|
||||
4) printf 'ip saddr\n' ;;
|
||||
6) printf 'ip6 saddr\n' ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
add_router_trace_rules() {
|
||||
for target_ip in $TARGET_IPS; do
|
||||
expr="$(target_expr_family "$target_ip" || true)"
|
||||
[ -n "$expr" ] || continue
|
||||
set -- $expr "$target_ip" tcp dport "$PORT"
|
||||
add_nft_rule out-route "$@"
|
||||
done
|
||||
|
||||
# Also trace the router-local destination port. Curl may use an address
|
||||
# family different from the router-side nslookup result.
|
||||
add_nft_rule out-route tcp dport "$PORT" || return 1
|
||||
}
|
||||
|
||||
add_client_trace_rules() {
|
||||
client_expr="$(client_expr_family "$CLIENT_IP" || true)"
|
||||
[ -n "$client_expr" ] || return 1
|
||||
set -- $client_expr "$CLIENT_IP" tcp dport "$PORT"
|
||||
|
||||
# Keep one broad client+port rule so the trace still works when the
|
||||
# client resolves the host to a different address than the router.
|
||||
add_nft_rule pre-filter "$@" || return 1
|
||||
}
|
||||
|
||||
collect_log_paths() {
|
||||
has_cmd uci || return 0
|
||||
|
||||
uci -q show hijpass 2>/dev/null |
|
||||
sed -n "s/^hijpass\\..*\\.log_path='\\([^']*\\)'$/\\1/p; s/^hijpass\\..*\\.log_path=\\([^']*\\)$/\\1/p" |
|
||||
awk 'NF && !seen[$0]++'
|
||||
}
|
||||
|
||||
snapshot_log_positions() {
|
||||
collect_log_paths | while read -r log_path; do
|
||||
[ -f "$log_path" ] || continue
|
||||
line_count="$(wc -l <"$log_path" 2>/dev/null | tr -d ' ')"
|
||||
is_number "$line_count" || line_count=0
|
||||
printf '%s %s\n' "$line_count" "$log_path" >>"$LOG_INDEX"
|
||||
done
|
||||
}
|
||||
|
||||
collect_new_service_logs() {
|
||||
: >"$SERVICE_LOG"
|
||||
while read -r line_count log_path; do
|
||||
[ -n "$line_count" ] || continue
|
||||
[ -f "$log_path" ] || continue
|
||||
start_line=$((line_count + 1))
|
||||
{
|
||||
printf -- '--- %s ---\n' "$log_path"
|
||||
tail -n +"$start_line" "$log_path" 2>/dev/null
|
||||
} >>"$SERVICE_LOG"
|
||||
done <"$LOG_INDEX"
|
||||
}
|
||||
|
||||
start_monitors() {
|
||||
nft monitor trace >"$NFT_LOG" 2>&1 &
|
||||
NFT_PID="$!"
|
||||
|
||||
if has_cmd logread; then
|
||||
logread -f >"$SYS_LOG" 2>&1 &
|
||||
LOG_PID="$!"
|
||||
fi
|
||||
}
|
||||
|
||||
stop_monitors() {
|
||||
[ -n "$NFT_PID" ] && kill "$NFT_PID" >/dev/null 2>&1
|
||||
[ -n "$LOG_PID" ] && kill "$LOG_PID" >/dev/null 2>&1
|
||||
[ -n "$NFT_PID" ] && wait "$NFT_PID" >/dev/null 2>&1
|
||||
[ -n "$LOG_PID" ] && wait "$LOG_PID" >/dev/null 2>&1
|
||||
NFT_PID=
|
||||
LOG_PID=
|
||||
}
|
||||
|
||||
tcpdump_filter() {
|
||||
# Keep capture broad enough to include proxy outbound traffic using the
|
||||
# same destination port. Output is filtered later to keep the report short.
|
||||
printf 'tcp and port %s\n' "$PORT"
|
||||
}
|
||||
|
||||
start_tcpdump() {
|
||||
[ "$ENABLE_TCPDUMP" -eq 1 ] || return 0
|
||||
|
||||
TCPDUMP_FILTER="$(tcpdump_filter)"
|
||||
tcpdump -p -i "$TCPDUMP_IFACE" -nn -tt -l -s 96 "$TCPDUMP_FILTER" \
|
||||
>"$TCPDUMP_LOG" 2>"$TCPDUMP_ERR" &
|
||||
TCPDUMP_PID="$!"
|
||||
|
||||
sleep 1
|
||||
if ! kill -0 "$TCPDUMP_PID" >/dev/null 2>&1; then
|
||||
wait "$TCPDUMP_PID" >/dev/null 2>&1
|
||||
TCPDUMP_PID=
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
stop_tcpdump() {
|
||||
[ -n "$TCPDUMP_PID" ] || return 0
|
||||
kill "$TCPDUMP_PID" >/dev/null 2>&1
|
||||
wait "$TCPDUMP_PID" >/dev/null 2>&1
|
||||
TCPDUMP_PID=
|
||||
}
|
||||
|
||||
run_router_curl() {
|
||||
if [ "$METHOD" = "HEAD" ]; then
|
||||
curl -I -L -k -sS -o /dev/null -D "$CURL_HDR" \
|
||||
--connect-timeout "$TIMEOUT" \
|
||||
--max-time "$TIMEOUT" \
|
||||
-w 'url_effective=%{url_effective}
|
||||
http_code=%{http_code}
|
||||
remote_ip=%{remote_ip}
|
||||
remote_port=%{remote_port}
|
||||
time_namelookup=%{time_namelookup}
|
||||
time_connect=%{time_connect}
|
||||
time_appconnect=%{time_appconnect}
|
||||
time_starttransfer=%{time_starttransfer}
|
||||
time_total=%{time_total}
|
||||
' "$URL" >"$CURL_OUT" 2>"$CURL_ERR"
|
||||
else
|
||||
curl -L -k -sS -o /dev/null -D "$CURL_HDR" \
|
||||
--connect-timeout "$TIMEOUT" \
|
||||
--max-time "$TIMEOUT" \
|
||||
-w 'url_effective=%{url_effective}
|
||||
http_code=%{http_code}
|
||||
remote_ip=%{remote_ip}
|
||||
remote_port=%{remote_port}
|
||||
time_namelookup=%{time_namelookup}
|
||||
time_connect=%{time_connect}
|
||||
time_appconnect=%{time_appconnect}
|
||||
time_starttransfer=%{time_starttransfer}
|
||||
time_total=%{time_total}
|
||||
' "$URL" >"$CURL_OUT" 2>"$CURL_ERR"
|
||||
fi
|
||||
printf '%s\n' "$?" >"$TMP_DIR/curl.exit"
|
||||
}
|
||||
|
||||
wait_for_client_request() {
|
||||
printf 'Trace is active for %s seconds.\n' "$TIMEOUT"
|
||||
printf 'Run this from client %s now:\n' "$CLIENT_IP"
|
||||
printf ' curl -I -k %s\n' "$URL"
|
||||
sleep "$TIMEOUT"
|
||||
}
|
||||
|
||||
print_section() {
|
||||
printf '\n== %s ==\n' "$1"
|
||||
}
|
||||
|
||||
get_curl_value() {
|
||||
awk -F= -v key="$1" '$1 == key { print substr($0, length(key) + 2) }' "$CURL_OUT" 2>/dev/null | tail -n 1
|
||||
}
|
||||
|
||||
format_target_ips() {
|
||||
if [ -z "$TARGET_IPS" ]; then
|
||||
printf 'unavailable'
|
||||
return
|
||||
fi
|
||||
|
||||
printf '%s\n' "$TARGET_IPS" | awk '
|
||||
NF {
|
||||
count++
|
||||
if (count <= 6) {
|
||||
out = out ? out ", " $0 : $0
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (count > 6) {
|
||||
out = out ", +" (count - 6) " more"
|
||||
}
|
||||
print out
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
limit_unique_lines() {
|
||||
awk -v max="$1" 'NF && !seen[$0]++ { print; if (++count >= max) exit }'
|
||||
}
|
||||
|
||||
print_request_summary() {
|
||||
print_section "Trace summary"
|
||||
if [ "$MODE" = "client" ]; then
|
||||
printf 'mode: client pre-filter, client=%s\n' "$CLIENT_IP"
|
||||
else
|
||||
printf 'mode: router out-route\n'
|
||||
fi
|
||||
printf 'request: %s %s\n' "$METHOD" "$URL"
|
||||
printf 'target: %s:%s (%s)\n' "$HOST" "$PORT" "$(format_target_ips)"
|
||||
|
||||
if [ "$MODE" = "client" ]; then
|
||||
printf 'curl: not run by this script in client mode\n'
|
||||
return
|
||||
fi
|
||||
|
||||
curl_exit="$(cat "$TMP_DIR/curl.exit" 2>/dev/null || printf '?')"
|
||||
http_code="$(get_curl_value http_code)"
|
||||
remote_ip="$(get_curl_value remote_ip)"
|
||||
remote_port="$(get_curl_value remote_port)"
|
||||
total_time="$(get_curl_value time_total)"
|
||||
|
||||
[ -n "$http_code" ] || http_code="-"
|
||||
[ -n "$remote_ip" ] || remote_ip="-"
|
||||
[ -n "$remote_port" ] || remote_port="-"
|
||||
[ -n "$total_time" ] || total_time="-"
|
||||
|
||||
printf 'curl: exit=%s http=%s remote=%s:%s total=%ss\n' \
|
||||
"$curl_exit" "$http_code" "$remote_ip" "$remote_port" "$total_time"
|
||||
|
||||
if [ -s "$CURL_ERR" ]; then
|
||||
printf 'curl_error:\n'
|
||||
sed -n '1,4p' "$CURL_ERR" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
firewall_decision() {
|
||||
shunt_port="$(uci -q get hijpass.@firewall[0].shunt_port 2>/dev/null || true)"
|
||||
proxy_port="$(uci -q get hijpass.@firewall[0].proxy_port 2>/dev/null || true)"
|
||||
|
||||
if grep -q ' reject' "$NFT_LOG"; then
|
||||
printf 'rejected by proxy-port-filter or another firewall rule\n'
|
||||
elif [ -n "$proxy_port" ] && grep -q "tproxy to :$proxy_port" "$NFT_LOG"; then
|
||||
printf 'tproxy to proxy port %s\n' "$proxy_port"
|
||||
elif [ -n "$shunt_port" ] && grep -q "tproxy to :$shunt_port" "$NFT_LOG"; then
|
||||
printf 'tproxy to shunt port %s\n' "$shunt_port"
|
||||
elif grep -q 'ct mark set 0' "$NFT_LOG"; then
|
||||
printf 'bypassed by proxy-port-filter or direct rule\n'
|
||||
elif grep -q 'comment "shunt mark"' "$NFT_LOG"; then
|
||||
printf 'marked for shunt, but final tproxy was not observed\n'
|
||||
else
|
||||
printf 'unknown from captured trace\n'
|
||||
fi
|
||||
}
|
||||
|
||||
trace_path() {
|
||||
awk '
|
||||
/inet hijpass/ {
|
||||
for (i = 1; i <= NF - 2; i++) {
|
||||
if ($i == "inet" && $(i + 1) == "hijpass") {
|
||||
chain = $(i + 2)
|
||||
if (!seen[chain]++) {
|
||||
path = path ? path " -> " chain : chain
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
END {
|
||||
print path ? path : "unavailable"
|
||||
}
|
||||
' "$NFT_LOG"
|
||||
}
|
||||
|
||||
print_trace_steps() {
|
||||
awk -v c="$TRACE_COMMENT" '
|
||||
index($0, c) { next }
|
||||
/inet hijpass/ && / rule / &&
|
||||
($0 ~ /jump|return|tproxy|reject|accept|drop|ct mark set|meta mark set/) {
|
||||
line = $0
|
||||
sub(/^.*inet hijpass /, "", line)
|
||||
gsub(/ counter packets [0-9]+ bytes [0-9]+/, "", line)
|
||||
gsub(/ counter/, "", line)
|
||||
sub(/ \(verdict.*$/, "", line)
|
||||
gsub(/[[:space:]]+/, " ", line)
|
||||
if (!seen[line]++) {
|
||||
count++
|
||||
if (length(line) > 180) {
|
||||
line = substr(line, 1, 177) "..."
|
||||
}
|
||||
printf " %d. %s\n", count, line
|
||||
if (count >= 14) exit
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (count == 0) print " none"
|
||||
}
|
||||
' "$NFT_LOG"
|
||||
}
|
||||
|
||||
print_nft_summary() {
|
||||
print_section "Firewall"
|
||||
|
||||
if [ -s "$NFT_SETUP_LOG" ]; then
|
||||
printf 'setup_warnings:\n'
|
||||
sed 's/^/ /' "$NFT_SETUP_LOG"
|
||||
fi
|
||||
|
||||
line_count="$(wc -l <"$NFT_LOG" 2>/dev/null | tr -d ' ')"
|
||||
is_number "$line_count" || line_count=0
|
||||
printf 'captured: %s nft trace lines\n' "$line_count"
|
||||
|
||||
if [ "$line_count" -eq 0 ]; then
|
||||
printf 'decision: no nft trace captured\n'
|
||||
printf 'check:\n'
|
||||
printf ' - request did not match TCP port %s\n' "$PORT"
|
||||
printf ' - client mode was used but no matching client request arrived\n'
|
||||
printf ' - traffic was not handled by table inet hijpass\n'
|
||||
return
|
||||
fi
|
||||
|
||||
printf 'decision: %s\n' "$(firewall_decision)"
|
||||
printf 'path: %s\n' "$(trace_path)"
|
||||
printf 'key_rules:\n'
|
||||
print_trace_steps
|
||||
|
||||
if [ "$VERBOSE" -eq 1 ]; then
|
||||
printf 'raw_trace_tail:\n'
|
||||
grep -E 'trace id| rule | verdict |tproxy|mark|jump|return|accept|reject|drop' "$NFT_LOG" 2>/dev/null |
|
||||
tail -n 80 |
|
||||
sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
remote_ip_from_curl() {
|
||||
get_curl_value remote_ip
|
||||
}
|
||||
|
||||
tcpdump_related_lines() {
|
||||
[ -s "$TCPDUMP_LOG" ] || return 1
|
||||
|
||||
remote_ip="$(remote_ip_from_curl)"
|
||||
{
|
||||
for target_ip in $TARGET_IPS $remote_ip; do
|
||||
[ -n "$target_ip" ] && grep -F "$target_ip" "$TCPDUMP_LOG" 2>/dev/null
|
||||
done
|
||||
} | limit_unique_lines 200
|
||||
}
|
||||
|
||||
tcpdump_clean_errors() {
|
||||
[ -s "$TCPDUMP_ERR" ] || return 1
|
||||
grep -Eiv 'listening on|verbose output suppressed|packets captured|packets received|packets dropped|promiscuous mode|doesn.t support promiscuous' \
|
||||
"$TCPDUMP_ERR" 2>/dev/null
|
||||
}
|
||||
|
||||
print_tcpdump_line_summary() {
|
||||
awk '
|
||||
NF {
|
||||
iface = $2
|
||||
dir = $3
|
||||
proto = $4
|
||||
src = $5
|
||||
dst = $7
|
||||
|
||||
if (proto != "IP" && proto != "IP6") {
|
||||
iface = "-"
|
||||
dir = "-"
|
||||
src = $3
|
||||
dst = $5
|
||||
}
|
||||
|
||||
sub(/:$/, "", dst)
|
||||
|
||||
iface_key = iface "/" dir
|
||||
iface_count[iface_key]++
|
||||
if (iface == "lo") lo_count++
|
||||
|
||||
flow = src " -> " dst
|
||||
flow_count[flow]++
|
||||
|
||||
if ($0 ~ /Flags \[S\]/) syn++
|
||||
if ($0 ~ /Flags \[S\.\]/) syn_ack++
|
||||
if ($0 ~ /Flags \[F/) fin++
|
||||
if ($0 ~ /Flags \[R/) rst++
|
||||
if (match($0, /length [0-9]+/)) {
|
||||
len = substr($0, RSTART + 7, RLENGTH - 7) + 0
|
||||
if (len > 0) {
|
||||
payload_packets++
|
||||
payload_bytes += len
|
||||
}
|
||||
}
|
||||
}
|
||||
END {
|
||||
if (NR == 0) {
|
||||
print "target_summary: none"
|
||||
exit
|
||||
}
|
||||
|
||||
printf "target_summary:\n"
|
||||
printf " tcp: syn=%d syn_ack=%d fin=%d rst=%d payload_packets=%d payload_bytes=%d\n", syn + 0, syn_ack + 0, fin + 0, rst + 0, payload_packets + 0, payload_bytes + 0
|
||||
|
||||
printf " interfaces:\n"
|
||||
for (key in iface_count) {
|
||||
printf " - %s packets=%d\n", key, iface_count[key]
|
||||
}
|
||||
|
||||
printf " flows:\n"
|
||||
shown = 0
|
||||
for (key in flow_count) {
|
||||
printf " - %s packets=%d\n", key, flow_count[key]
|
||||
shown++
|
||||
if (shown >= 6) break
|
||||
}
|
||||
|
||||
if (lo_count == NR) {
|
||||
print " note: all related packets were observed on lo; this is expected for tproxy loopback interception"
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
print_tcpdump_summary() {
|
||||
[ "$ENABLE_TCPDUMP" -eq 1 ] || return 0
|
||||
|
||||
print_section "Packet capture"
|
||||
printf 'interface: %s\n' "$TCPDUMP_IFACE"
|
||||
printf 'filter: %s\n' "$TCPDUMP_FILTER"
|
||||
|
||||
tcpdump_errors="$(tcpdump_clean_errors)"
|
||||
if [ -n "$tcpdump_errors" ]; then
|
||||
printf 'tcpdump_warnings:\n'
|
||||
printf '%s\n' "$tcpdump_errors" | sed -n '1,6p' | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
packet_total="$(wc -l <"$TCPDUMP_LOG" 2>/dev/null | tr -d ' ')"
|
||||
is_number "$packet_total" || packet_total=0
|
||||
|
||||
related="$(tcpdump_related_lines)"
|
||||
related_count="$(printf '%s\n' "$related" | awk 'NF { count++ } END { print count + 0 }')"
|
||||
other_count=$((packet_total - related_count))
|
||||
[ "$other_count" -ge 0 ] 2>/dev/null || other_count=0
|
||||
|
||||
printf 'captured: total=%s related=%s other_same_port=%s\n' \
|
||||
"$packet_total" "$related_count" "$other_count"
|
||||
|
||||
if [ "$related_count" -gt 0 ]; then
|
||||
printf '%s\n' "$related" | print_tcpdump_line_summary
|
||||
else
|
||||
printf 'related_packets: none\n'
|
||||
fi
|
||||
|
||||
if [ "$other_count" -gt 0 ]; then
|
||||
printf 'other_same_port_packets: %s hidden; use -v to inspect possible proxy outbound/noise\n' "$other_count"
|
||||
fi
|
||||
|
||||
if [ "$VERBOSE" -eq 1 ] && [ "$packet_total" -gt 0 ]; then
|
||||
printf 'raw_packet_tail:\n'
|
||||
tail -n 80 "$TCPDUMP_LOG" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
log_seed_lines() {
|
||||
log_file="$1"
|
||||
[ -s "$log_file" ] || return 1
|
||||
|
||||
remote_ip="$(remote_ip_from_curl)"
|
||||
{
|
||||
[ -n "$HOST" ] && grep -F "$HOST" "$log_file" 2>/dev/null
|
||||
for target_ip in $TARGET_IPS $remote_ip; do
|
||||
[ -n "$target_ip" ] && grep -F "$target_ip" "$log_file" 2>/dev/null
|
||||
done
|
||||
} | limit_unique_lines 80
|
||||
}
|
||||
|
||||
extract_connection_ids() {
|
||||
awk '
|
||||
{
|
||||
line = $0
|
||||
while (match(line, /\[[0-9][0-9]*\]/)) {
|
||||
id = substr(line, RSTART + 1, RLENGTH - 2)
|
||||
if (!seen[id]++) print id
|
||||
line = substr(line, RSTART + RLENGTH)
|
||||
}
|
||||
}
|
||||
'
|
||||
}
|
||||
|
||||
log_related_lines() {
|
||||
log_file="$1"
|
||||
[ -s "$log_file" ] || return 1
|
||||
|
||||
seeds="$(log_seed_lines "$log_file")"
|
||||
[ -n "$seeds" ] || return 1
|
||||
|
||||
ids="$(printf '%s\n' "$seeds" | extract_connection_ids)"
|
||||
{
|
||||
printf '%s\n' "$seeds"
|
||||
for id in $ids; do
|
||||
grep -F "[$id]" "$log_file" 2>/dev/null
|
||||
done
|
||||
} | limit_unique_lines 100
|
||||
}
|
||||
|
||||
print_log_summary() {
|
||||
label="$1"
|
||||
log_file="$2"
|
||||
|
||||
related="$(log_related_lines "$log_file")"
|
||||
[ -n "$related" ] || return 1
|
||||
|
||||
printf '%s:\n' "$label"
|
||||
|
||||
issues="$(printf '%s\n' "$related" | grep -Ei 'error|failed|timeout|warn' 2>/dev/null | limit_unique_lines 8)"
|
||||
dns="$(printf '%s\n' "$related" |
|
||||
grep -Ei 'dns|lookup|resolve|resolved|query|answer|reply|cache' 2>/dev/null |
|
||||
grep -Eiv 'error|failed|timeout|warn' 2>/dev/null |
|
||||
limit_unique_lines 12)"
|
||||
flow="$(printf '%s\n' "$related" |
|
||||
grep -Ei 'inbound|outbound|routing|route|detour|accepted|connection|tproxy|mark' 2>/dev/null |
|
||||
grep -Eiv 'error|failed|timeout|warn|dns|lookup|resolve|resolved|query|answer|reply|cache' 2>/dev/null |
|
||||
limit_unique_lines 12)"
|
||||
|
||||
if [ -n "$issues" ]; then
|
||||
printf ' issues:\n'
|
||||
printf '%s\n' "$issues" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ -n "$dns" ]; then
|
||||
printf ' dns:\n'
|
||||
printf '%s\n' "$dns" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ -n "$flow" ]; then
|
||||
printf ' flow:\n'
|
||||
printf '%s\n' "$flow" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ -z "$issues" ] && [ -z "$dns" ] && [ -z "$flow" ]; then
|
||||
printf ' matched:\n'
|
||||
printf '%s\n' "$related" | limit_unique_lines 8 | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
if [ "$VERBOSE" -eq 1 ]; then
|
||||
printf ' raw_related:\n'
|
||||
printf '%s\n' "$related" | sed 's/^/ /'
|
||||
fi
|
||||
}
|
||||
|
||||
print_core_logs() {
|
||||
print_section "Core logs"
|
||||
|
||||
collect_new_service_logs
|
||||
|
||||
printed=0
|
||||
if print_log_summary "service" "$SERVICE_LOG"; then
|
||||
printed=1
|
||||
fi
|
||||
|
||||
if print_log_summary "system" "$SYS_LOG"; then
|
||||
printed=1
|
||||
fi
|
||||
|
||||
if [ "$printed" -eq 0 ]; then
|
||||
printf 'no related core/system log lines captured\n'
|
||||
printf 'hint: enable proxy/shunt logs or raise core log level for more detail\n'
|
||||
fi
|
||||
}
|
||||
|
||||
snapshot_log_positions
|
||||
start_monitors
|
||||
sleep 1
|
||||
|
||||
TARGET_IPS="$(resolve_host)"
|
||||
|
||||
if [ "$MODE" = "client" ]; then
|
||||
add_client_trace_rules || die "failed to insert nft trace rule; see $NFT_SETUP_LOG"
|
||||
else
|
||||
add_router_trace_rules || die "failed to insert nft trace rule; see $NFT_SETUP_LOG"
|
||||
fi
|
||||
|
||||
if [ "$ENABLE_TCPDUMP" -eq 1 ]; then
|
||||
start_tcpdump || die "failed to start tcpdump on $TCPDUMP_IFACE; see $TCPDUMP_ERR"
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "client" ]; then
|
||||
wait_for_client_request
|
||||
else
|
||||
run_router_curl
|
||||
fi
|
||||
|
||||
sleep 1
|
||||
stop_tcpdump
|
||||
stop_monitors
|
||||
|
||||
print_request_summary
|
||||
print_nft_summary
|
||||
print_tcpdump_summary
|
||||
print_core_logs
|
||||
@@ -0,0 +1,82 @@
|
||||
{
|
||||
"admin/services/hijpass": {
|
||||
"title": "HiJpass",
|
||||
"order": 1,
|
||||
"action": {
|
||||
"type": "firstchild"
|
||||
},
|
||||
"depends": {
|
||||
"acl": [
|
||||
"luci-app-hijpass"
|
||||
],
|
||||
"uci": {
|
||||
"hijpass": true,
|
||||
"hijserver": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/overview": {
|
||||
"title": "HiJpass",
|
||||
"order": 1,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/overview"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/shunt": {
|
||||
"title": "Proxy Routing",
|
||||
"order": 3,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/shunt"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/proxy": {
|
||||
"title": "Proxy Nodes",
|
||||
"order": 2,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/proxy"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/server": {
|
||||
"title": "Local Server",
|
||||
"order": 4,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/server"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/dns": {
|
||||
"title": "Independent DNS",
|
||||
"order": 5,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/dns"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/firewall": {
|
||||
"title": "Firewall",
|
||||
"order": 6,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/firewall"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/rule": {
|
||||
"title": "Resource Management",
|
||||
"order": 7,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/rule"
|
||||
}
|
||||
},
|
||||
"admin/services/hijpass/log": {
|
||||
"title": "Diagnostics",
|
||||
"order": 8,
|
||||
"action": {
|
||||
"type": "view",
|
||||
"path": "hijpass/log"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
{
|
||||
"luci-app-hijpass": {
|
||||
"description": "Grant access to hijpass configurations",
|
||||
"read": {
|
||||
"cgi-io": [
|
||||
"exec"
|
||||
],
|
||||
"uci": [
|
||||
"hijpass",
|
||||
"hijserver",
|
||||
"dhcp"
|
||||
],
|
||||
"file": {
|
||||
"/etc/hijpass/*": [
|
||||
"read"
|
||||
],
|
||||
"/tmp/hijpass/log/*.log": [
|
||||
"read"
|
||||
],
|
||||
"/tmp/hijserver/log/*.log": [
|
||||
"read"
|
||||
]
|
||||
},
|
||||
"ubus": {
|
||||
"service": [
|
||||
"list"
|
||||
]
|
||||
}
|
||||
},
|
||||
"write": {
|
||||
"uci": [
|
||||
"hijpass",
|
||||
"hijserver"
|
||||
],
|
||||
"file": {
|
||||
"/etc/hijpass/*": [
|
||||
"write"
|
||||
],
|
||||
"/tmp/hijpass/log/*.log": [
|
||||
"write"
|
||||
],
|
||||
"/tmp/hijserver/log/*.log": [
|
||||
"write"
|
||||
],
|
||||
"/etc/init.d/hijpass restart": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/nft.sh reset": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/nft.sh show": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/bin/tail -n [0-9]* /tmp/hijpass/log/*.log": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/bin/tail -n [0-9]* /tmp/hijserver/log/*.log": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rules.sh update": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rules.sh query [A-Za-z0-9._:/-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/bin/lua /usr/lib/hijpass/luci-utils.lua iptype [A-Za-z0-9._:/-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/connect.sh https://www.baidu.com [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/connect.sh https://www.google.com/ncr [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/connect.sh https://github.com [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/connect.sh https://www.bilibili.com [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/net.sh port [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/net.sh ping [0-9]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/geoview.sh -e geosite [A-Za-z0-9_.:,/!-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/geoview.sh -e geoip [A-Za-z0-9_.:,/!-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/geoview.sh -l geosite [A-Za-z0-9._:/-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/geoview.sh -l geoip [A-Za-z0-9._:/-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/route-match.sh [A-Za-z0-9._:/-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -v [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -c [A-Fa-f0-9:.]* [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -c [A-Fa-f0-9:.]* -v [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -p -i [A-Za-z0-9_.:@-]* [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -p -i [A-Za-z0-9_.:@-]* -v [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -c [A-Fa-f0-9:.]* -p -i [A-Za-z0-9_.:@-]* [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/trace-url.sh -t [0-9]* -X [A-Z]* -c [A-Fa-f0-9:.]* -p -i [A-Za-z0-9_.:@-]* -v [A-Za-z0-9._:/?&=%#~+-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rm.sh proxy [A-Za-z0-9_-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rm.sh proxy [A-Za-z0-9_-]* /etc/hijpass/client/*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rm.sh server [A-Za-z0-9_-]*": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/rm.sh server [A-Za-z0-9_-]* /etc/hijpass/server/*": [
|
||||
"exec"
|
||||
],
|
||||
"/bin/cat /proc/net/arp": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh version sing-box": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh version xray": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh latest sing-box": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh latest xray": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh update sing-box": [
|
||||
"exec"
|
||||
],
|
||||
"/usr/lib/hijpass/core-update.sh update xray": [
|
||||
"exec"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// build.mjs
|
||||
import {build} from 'esbuild';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {fileURLToPath} from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// 源码目录(你问的“入口目录”)
|
||||
const VIEWS_DIR = path.join(__dirname, 'views');
|
||||
// 输出目录
|
||||
const DIST_DIR = path.join(__dirname, '../htdocs/luci-static/resources/view/hijpass');
|
||||
|
||||
// 限定哪些 from 模块名当作 luci 模块(name = require name)
|
||||
const luciModuleNames = new Set(
|
||||
['view', 'fs', 'form', 'uci', 'ui', 'network', 'baseclass', 'rpc', 'poll']);
|
||||
|
||||
/**
|
||||
* 扫描 views 目录下的所有 .ts 文件,返回绝对路径列表
|
||||
*/
|
||||
function scanEntryFiles(dir) {
|
||||
const files = fs.readdirSync(dir, {withFileTypes: true});
|
||||
const entries = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (file.isDirectory()) {
|
||||
// 若你需要递归子目录,这里可以继续深入
|
||||
const subDir = path.join(dir, file.name);
|
||||
entries.push(...scanEntryFiles(subDir));
|
||||
} else if (file.isFile() && file.name.endsWith('.ts')) {
|
||||
entries.push(path.join(dir, file.name));
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* 为单个入口文件构建(entryPath 为绝对路径)
|
||||
*/
|
||||
async function buildOne(entryPath) {
|
||||
// 本次构建中用到的 luci 模块名
|
||||
const usedModules = new Set();
|
||||
|
||||
const luciPlugin = {
|
||||
name: 'luci-from-plugin',
|
||||
setup(pluginBuild) {
|
||||
pluginBuild.onResolve({filter: /.*/}, args => {
|
||||
if (luciModuleNames.has(args.path)) {
|
||||
usedModules.add(args.path);
|
||||
return {
|
||||
path: args.path,
|
||||
namespace: 'luci-virtual'
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
pluginBuild.onLoad({filter: /.*/, namespace: 'luci-virtual'}, args => {
|
||||
const moduleName = args.path;
|
||||
const code = `
|
||||
// Virtual luci module: ${moduleName}
|
||||
export default ${moduleName};
|
||||
`;
|
||||
return {
|
||||
contents: code,
|
||||
loader: 'ts'
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 计算输出文件名:views/demo.ts → dist/demo.js
|
||||
const rel = path.relative(VIEWS_DIR, entryPath); // demo.ts
|
||||
const baseName = rel.replace(/\.ts$/, ''); // demo
|
||||
const outFile = path.join(DIST_DIR, `${baseName}.js`);
|
||||
|
||||
await build({
|
||||
entryPoints: [entryPath],
|
||||
bundle: true,
|
||||
platform: 'browser',
|
||||
format: 'iife',
|
||||
target: ['es2022'],
|
||||
outfile: outFile,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
globalName: 'LuciView',
|
||||
plugins: [luciPlugin]
|
||||
});
|
||||
|
||||
const bundled = fs.readFileSync(outFile, 'utf-8');
|
||||
const requireLines = Array.from(usedModules)
|
||||
.sort()
|
||||
.map(name => `'require ${name}';`)
|
||||
.join('\n');
|
||||
|
||||
const snippet = `
|
||||
'use strict';
|
||||
${requireLines}
|
||||
|
||||
${bundled}
|
||||
|
||||
return LuciView.default;
|
||||
`;
|
||||
|
||||
fs.mkdirSync(path.dirname(outFile), {recursive: true});
|
||||
fs.writeFileSync(outFile, snippet, 'utf-8');
|
||||
console.log(`Build luci snippet: ${path.relative(__dirname, outFile)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 主流程:扫描 views 目录并构建所有 .ts
|
||||
*/
|
||||
async function bundleAll() {
|
||||
const entries = scanEntryFiles(VIEWS_DIR);
|
||||
if (entries.length === 0) {
|
||||
console.warn('No .ts files found in views/ directory');
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
// 按顺序构建每个入口;如需并行可用 Promise.all
|
||||
// 但串行 log 更清晰,出错也好排查
|
||||
await buildOne(entry);
|
||||
}
|
||||
}
|
||||
|
||||
bundleAll().catch(err => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CoreAdapter, ProxyNode } from '../module/luci'
|
||||
import { CORE_TYPE, normalizeCoreType } from '../enum/hijpass'
|
||||
import { SingBoxUtils } from './singbox/builder'
|
||||
import { XrayUtils } from './xray/builder'
|
||||
|
||||
const FactoryType = CORE_TYPE
|
||||
|
||||
const CoreAdapterFactory = {
|
||||
getCoreAdapter: function (type: string): CoreAdapter {
|
||||
switch (normalizeCoreType(type)) {
|
||||
case FactoryType.SING_BOX: return SingBoxUtils
|
||||
case FactoryType.XRAY: return XrayUtils
|
||||
default: throw new Error('Unsupported core: ' + type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const LuciConverterFactory = {
|
||||
genProxyConf: function (type: string, proxyNode: ProxyNode): {} {
|
||||
switch (normalizeCoreType(type)) {
|
||||
case FactoryType.SING_BOX: return SingBoxUtils.genProxyConf(proxyNode)
|
||||
case FactoryType.XRAY: return XrayUtils.genProxyConf(proxyNode)
|
||||
default: throw new Error('Unsupported core: ' + type)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { FactoryType, CoreAdapterFactory, LuciConverterFactory }
|
||||
@@ -0,0 +1,306 @@
|
||||
import './protocols/registry'
|
||||
import uci from 'uci'
|
||||
import {
|
||||
BlockOutbound, DirectOutbound, DNS, DnsInbound, DnsRule, DnsServer,
|
||||
Log, Outbound, RemoteRuleSet, LocalRuleSet, Route, RouteRule, RuleSet,
|
||||
SocksInbound, SocksOutbound, TproxyInbound, URLTest
|
||||
} from './models'
|
||||
import { ShuntUtils } from '../../utils/feature/shunt/section'
|
||||
import { LuciFlied, PROXY_TYPE, ShuntTag } from '../../enum/hijpass'
|
||||
import { SingBoxEnum } from '../../enum/singbox'
|
||||
import { CoreAdapter, LoadBalanceProxyNode, ProxyNode, RuleDTO, RuleSetDTO } from '../../module/luci'
|
||||
import { singboxRegistry } from './protocols/protocol'
|
||||
import { ProxyChainUtils } from '../../utils/feature/proxy/chain'
|
||||
|
||||
export const SingBoxUtils: CoreAdapter & { writeGeoRule: () => void } = {
|
||||
genShuntConf: genConf,
|
||||
genProxyConf,
|
||||
writeGeoRule
|
||||
}
|
||||
|
||||
function genConf() {
|
||||
const shuntSection = ShuntUtils.getShuntSection()
|
||||
if (!shuntSection) return
|
||||
|
||||
const dnsServerMap = new Map<string, DnsServer>()
|
||||
const ruleSetMap = new Map<string, RuleSet>()
|
||||
const dnsConf = shuntSection.dns
|
||||
const routeConf = shuntSection.route
|
||||
const route = new Route(transOutTag(routeConf.defaultProxyNode), dnsConf.defaultDnsNode)
|
||||
const dns = new DNS(
|
||||
dnsConf.defaultDnsNode,
|
||||
transDnsStrategy(dnsConf.defaultStrategy),
|
||||
dnsConf.useCache === '1'
|
||||
)
|
||||
|
||||
dnsConf.nodes.forEach(function (dnsNode) {
|
||||
const server = new DnsServer(dnsNode.type, dnsNode.tag, dnsNode.server, parseInt(dnsNode.port, 10), undefined)
|
||||
dnsServerMap.set(dnsNode.tag, server)
|
||||
})
|
||||
|
||||
let outbounds: any[] = [
|
||||
new DirectOutbound(SingBoxEnum.DIRECT_OUTBOUND_TAG),
|
||||
new BlockOutbound(SingBoxEnum.BLOCK_OUTBOUND_TAG)
|
||||
]
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section) {
|
||||
if (section.enabled === '1') {
|
||||
outbounds.push(new SocksOutbound('127.0.0.1', Number(section.socks_port), section.name))
|
||||
}
|
||||
})
|
||||
|
||||
const inbounds = [
|
||||
new TproxyInbound(parseInt(shuntSection.shuntListenPort, 10), SingBoxEnum.TPROXY_INBOUND_TAG),
|
||||
new DnsInbound(parseInt(shuntSection.dnsListenPort, 10), SingBoxEnum.DNS_INBOUND_TAG)
|
||||
]
|
||||
|
||||
if (route.default_domain_resolver && route.final) {
|
||||
const dnsTmpl = dnsServerMap.get(route.default_domain_resolver)
|
||||
if (!dnsTmpl) throw new Error(_('Default global DNS "%s" does not exist').format(route.default_domain_resolver))
|
||||
dns.servers.push(genDnsFromTmpl(dnsTmpl.tag, route.final, dnsTmpl))
|
||||
}
|
||||
|
||||
if (routeConf.rulesetOutNode) {
|
||||
const outTag = transOutTag(routeConf.rulesetOutNode)
|
||||
const node = outbounds.find(o => o.tag === outTag)
|
||||
if (node) {
|
||||
const rulesetOutNode = structuredClone(node)
|
||||
rulesetOutNode.tag = SingBoxEnum.RULESET_OUTBOUND_TAG
|
||||
if (dnsConf.rulesetDnsNode) {
|
||||
rulesetOutNode.domain_resolver = SingBoxEnum.RULESET_DNS_NAME
|
||||
const dnsTmpl = dnsServerMap.get(dnsConf.rulesetDnsNode)
|
||||
if (!dnsTmpl) throw new Error(_('%s "%s" does not exist').format(_('Rule-set DNS'), dnsConf.rulesetDnsNode))
|
||||
dns.servers.push(genDnsFromTmpl(SingBoxEnum.RULESET_DNS_NAME, SingBoxEnum.RULESET_OUTBOUND_TAG, dnsTmpl))
|
||||
}
|
||||
outbounds.push(rulesetOutNode)
|
||||
}
|
||||
}
|
||||
|
||||
routeConf.rules.forEach(function (routeRule) {
|
||||
if (routeRule.enabled !== '1') return
|
||||
const isIpVersionSplit = routeRule.ipVersionSplit === '1'
|
||||
let splitDnsRuleDTO: RuleDTO = undefined
|
||||
let splitDnsNeeded = false
|
||||
|
||||
ShuntUtils.getRouteRuleVariants(routeRule).forEach(function (variant) {
|
||||
const luciRuleDTO = ShuntUtils.parseRuleList(variant.rule)
|
||||
const hasDnsTarget = hasSingBoxDnsTargetRule(luciRuleDTO, routeConf.rulesetConvert)
|
||||
luciRuleDTO.ipVersion = variant.ipVersion
|
||||
|
||||
if (routeConf.rulesetConvert === '1') {
|
||||
convertGeoRule(luciRuleDTO)
|
||||
} else if (luciRuleDTO.containGeo()) {
|
||||
dropGeoRule(luciRuleDTO)
|
||||
if (!hasSingBoxTargetRule(luciRuleDTO)) return
|
||||
}
|
||||
|
||||
if (luciRuleDTO.isEmpty('set')) return
|
||||
|
||||
for (const rs of luciRuleDTO.ruleSet) {
|
||||
ruleSetMap.set(rs.tag, genRuleSet(rs, routeConf.rulesetOutNode ? SingBoxEnum.RULESET_OUTBOUND_TAG : undefined))
|
||||
}
|
||||
|
||||
let action = 'route'
|
||||
let outbound = transOutTag(variant.proxyNode)
|
||||
if (variant.proxyNode === ShuntTag.BLOCK_OUTBOUND_TAG) {
|
||||
action = 'reject'
|
||||
outbound = undefined
|
||||
}
|
||||
route.rules.push(new RouteRule(action, outbound).initRule(luciRuleDTO))
|
||||
|
||||
if (action === 'reject') return
|
||||
|
||||
if (isIpVersionSplit) {
|
||||
if (hasDnsTarget) {
|
||||
splitDnsRuleDTO = splitDnsRuleDTO || luciRuleDTO
|
||||
splitDnsNeeded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (luciRuleDTO.containDomain() || luciRuleDTO.containIp() || luciRuleDTO.containRuleSet()) {
|
||||
appendSingBoxDnsRule(routeRule, variant.rule.name, luciRuleDTO, transOutTag(routeRule.dnsProxyNode) || outbound, dnsServerMap, route, dns)
|
||||
}
|
||||
})
|
||||
|
||||
if (isIpVersionSplit && splitDnsNeeded && splitDnsRuleDTO) {
|
||||
const detour = transOutTag(routeRule.dnsProxyNode)
|
||||
if (!detour) {
|
||||
throw new Error(_('Route rule "%s" uses IPv4/IPv6 split and must specify a DNS exit node').format(routeRule.name))
|
||||
}
|
||||
appendSingBoxDnsRule(routeRule, routeRule.name, splitDnsRuleDTO, detour, dnsServerMap, route, dns)
|
||||
}
|
||||
})
|
||||
|
||||
ruleSetMap.forEach(value => route.rule_set.push(value))
|
||||
|
||||
return {
|
||||
log: new Log(shuntSection.logLevel),
|
||||
inbounds,
|
||||
outbounds,
|
||||
dns,
|
||||
route
|
||||
}
|
||||
}
|
||||
|
||||
function genProxyConf(proxyNode: ProxyNode): {} {
|
||||
if (proxyNode.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
return genLoadBalanceProxyConf(proxyNode as LoadBalanceProxyNode)
|
||||
}
|
||||
|
||||
const outbound: any = singboxRegistry.build(proxyNode.type, proxyNode)
|
||||
const outbounds = [outbound]
|
||||
appendProxyChainOutbounds(proxyNode, outbound, outbounds)
|
||||
|
||||
return {
|
||||
log: new Log(proxyNode.log_level),
|
||||
dns: { servers: [{ type: 'local', tag: 'local' }] },
|
||||
inbounds: [
|
||||
new TproxyInbound(Number(proxyNode.listen_port), 'tproxy-in', '127.0.0.1'),
|
||||
new SocksInbound(Number(proxyNode.socks_port), 'socks-in', '127.0.0.1')
|
||||
],
|
||||
outbounds
|
||||
}
|
||||
}
|
||||
|
||||
function genLoadBalanceProxyConf(proxyNode: LoadBalanceProxyNode): {} {
|
||||
const members = ProxyChainUtils.resolveLoadBalanceMembers(proxyNode)
|
||||
const loadBalance = new URLTest(
|
||||
SingBoxEnum.LOAD_BALANCE_OUTBOUND_TAG,
|
||||
proxyNode.url || undefined,
|
||||
proxyNode.interval || undefined,
|
||||
proxyNode.tolerance ? parseInt(proxyNode.tolerance, 10) : undefined,
|
||||
proxyNode.idle_timeout || undefined,
|
||||
proxyNode.interrupt_exist_connections === '1' ? true : undefined
|
||||
)
|
||||
loadBalance.outbounds = members.map(member => member.tag)
|
||||
|
||||
return {
|
||||
log: new Log(proxyNode.log_level),
|
||||
dns: { servers: [{ type: 'local', tag: 'local' }] },
|
||||
inbounds: [
|
||||
new TproxyInbound(Number(proxyNode.listen_port), 'tproxy-in', '127.0.0.1'),
|
||||
new SocksInbound(Number(proxyNode.socks_port), 'socks-in', '127.0.0.1')
|
||||
],
|
||||
outbounds: [
|
||||
loadBalance,
|
||||
...members.map(member => new SocksOutbound('127.0.0.1', member.socksPort, member.tag))
|
||||
],
|
||||
route: { final: SingBoxEnum.LOAD_BALANCE_OUTBOUND_TAG }
|
||||
}
|
||||
}
|
||||
|
||||
function appendProxyChainOutbounds(proxyNode: ProxyNode, outbound: any, outbounds: any[]) {
|
||||
const upstream = ProxyChainUtils.resolveUpstreamProxy(proxyNode)
|
||||
if (!upstream) return
|
||||
|
||||
outbound.detour = upstream.tag
|
||||
outbounds.push(new SocksOutbound('127.0.0.1', upstream.socksPort, upstream.tag))
|
||||
}
|
||||
|
||||
function writeGeoRule() {
|
||||
const routeConf = ShuntUtils.getRouteSection()
|
||||
const geoSiteSet = new Set<string>()
|
||||
const geoIpSet = new Set<string>()
|
||||
for (const rule of routeConf.rules) {
|
||||
if (rule.enabled !== '1') continue
|
||||
const luciRuleDTO = ShuntUtils.parseRuleList(rule)
|
||||
for (const geo of luciRuleDTO.geoSite) geoSiteSet.add(geo.replace('geosite:', ''))
|
||||
for (const geo of luciRuleDTO.geoIp) geoIpSet.add(geo.replace('geoip:', ''))
|
||||
}
|
||||
const geoSiteList = [...geoSiteSet]
|
||||
const geoIpList = [...geoIpSet]
|
||||
const originalIps = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geoip_ruleset') ?? []
|
||||
const originalSites = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geosite_ruleset') ?? []
|
||||
|
||||
if (geoIpList.length > 0) {
|
||||
if (!isArrEqual(originalIps, geoIpList)) {
|
||||
uci.set_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geoip_ruleset', geoIpList)
|
||||
}
|
||||
} else {
|
||||
uci.set_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geoip_ruleset', undefined)
|
||||
}
|
||||
if (geoSiteList.length > 0) {
|
||||
if (!isArrEqual(originalSites, geoSiteList)) {
|
||||
uci.set_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geosite_ruleset', geoSiteList)
|
||||
}
|
||||
} else {
|
||||
uci.set_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'geosite_ruleset', undefined)
|
||||
}
|
||||
}
|
||||
|
||||
function genDnsFromTmpl(tag: string, detour: string, tmpl: DnsServer): DnsServer {
|
||||
return new DnsServer(tmpl.type, tag, tmpl.server, tmpl.server_port, detour)
|
||||
}
|
||||
|
||||
function genRuleSet(rs: RuleSetDTO, detour: string): RuleSet {
|
||||
if (rs.type === 'remote') return new RemoteRuleSet(rs.tag, rs.format, rs.pathOrUrl, detour, undefined)
|
||||
return new LocalRuleSet(rs.tag, rs.format, rs.pathOrUrl)
|
||||
}
|
||||
|
||||
function transDnsStrategy(strategy: string) {
|
||||
switch (strategy) {
|
||||
case '4': return 'ipv4_only'
|
||||
case '6': return 'ipv6_only'
|
||||
case '64': return 'prefer_ipv6'
|
||||
case '46': return 'prefer_ipv4'
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function appendSingBoxDnsRule(routeRule: any, ruleName: string, luciRuleDTO: RuleDTO, detour: string, dnsServerMap: Map<string, DnsServer>, route: Route, dns: DNS) {
|
||||
const dnsTag = routeRule.dnsNode
|
||||
const dnsTmpl = dnsTag
|
||||
? dnsServerMap.get(dnsTag)
|
||||
: dnsServerMap.get(route.default_domain_resolver)
|
||||
if (!dnsTmpl) {
|
||||
throw new Error(_('Route rule "%s" DNS node "%s" does not exist').format(routeRule.name || ruleName, routeRule.dnsNode || route.default_domain_resolver || ''))
|
||||
}
|
||||
dns.servers.push(genDnsFromTmpl(transDnsTag(ruleName), detour, dnsTmpl))
|
||||
dns.rules.push(
|
||||
new DnsRule('route', transDnsTag(ruleName), transDnsStrategy(routeRule.dnsStrategy))
|
||||
.initRule(luciRuleDTO)
|
||||
)
|
||||
}
|
||||
|
||||
function transOutTag(tag: string) {
|
||||
if (tag === ShuntTag.BLOCK_OUTBOUND_TAG) return SingBoxEnum.BLOCK_OUTBOUND_TAG
|
||||
if (tag === ShuntTag.DIRECT_OUTBOUND_TAG) return SingBoxEnum.DIRECT_OUTBOUND_TAG
|
||||
return tag
|
||||
}
|
||||
|
||||
function transDnsTag(name: string) { return name + '-dns' }
|
||||
|
||||
function convertGeoRule(luciRuleDTO: RuleDTO) {
|
||||
luciRuleDTO.ruleSet = []
|
||||
for (const geo of luciRuleDTO.geoSite) {
|
||||
const tag = geo.replace('geosite:', 'geosite-')
|
||||
luciRuleDTO.ruleSet.push({ tag, type: 'local', format: 'binary', pathOrUrl: '/etc/hijpass/ruleset/' + tag + '.srs' })
|
||||
}
|
||||
for (const geo of luciRuleDTO.geoIp) {
|
||||
const tag = geo.replace('geoip:', 'geoip-')
|
||||
luciRuleDTO.ruleSet.push({ tag, type: 'local', format: 'binary', pathOrUrl: '/etc/hijpass/ruleset/' + tag + '.srs' })
|
||||
}
|
||||
}
|
||||
|
||||
function dropGeoRule(luciRuleDTO: RuleDTO) {
|
||||
luciRuleDTO.geoSite = []
|
||||
luciRuleDTO.geoIp = []
|
||||
}
|
||||
|
||||
function hasSingBoxTargetRule(luciRuleDTO: RuleDTO) {
|
||||
return luciRuleDTO.containDomain()
|
||||
|| luciRuleDTO.containIp()
|
||||
|| luciRuleDTO.containRuleSet()
|
||||
}
|
||||
|
||||
function hasSingBoxDnsTargetRule(luciRuleDTO: RuleDTO, rulesetConvert: string) {
|
||||
return luciRuleDTO.containDomain()
|
||||
|| luciRuleDTO.containRuleSet()
|
||||
|| (rulesetConvert === '1' && luciRuleDTO.geoSite.length > 0)
|
||||
}
|
||||
|
||||
function isArrEqual(arr1: any[], arr2: any[]) {
|
||||
if (!arr1 || !arr2) return false
|
||||
return arr1.length === arr2.length && arr1.every((val, i) => val === arr2[i])
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
import { SingBoxEnum } from '../../enum/singbox'
|
||||
import { RuleDTO } from '../../module/luci'
|
||||
|
||||
export class Log {
|
||||
disabled: boolean
|
||||
level: string
|
||||
timestamp: boolean
|
||||
|
||||
constructor(logLevel: string) {
|
||||
this.disabled = false
|
||||
this.level = logLevel || SingBoxEnum.DEFAULT_LOG_LEVEL
|
||||
this.timestamp = true
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsServer {
|
||||
type: string
|
||||
tag: string
|
||||
server: string
|
||||
server_port?: number
|
||||
detour?: string
|
||||
|
||||
constructor(type: string, tag: string, server: string, port: number | undefined, detour?: string) {
|
||||
this.type = type
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = Number.isFinite(port) && port > 0 ? port : undefined
|
||||
this.detour = detour === SingBoxEnum.DIRECT_OUTBOUND_TAG ? undefined : detour
|
||||
}
|
||||
}
|
||||
|
||||
export class DNS {
|
||||
servers: DnsServer[]
|
||||
rules: DnsRule[]
|
||||
strategy: string
|
||||
disable_cache: boolean
|
||||
disable_expire: boolean
|
||||
final: string
|
||||
|
||||
constructor(defaultTag: string, defaultStrategy: string, cache: boolean) {
|
||||
this.servers = []
|
||||
this.rules = []
|
||||
this.strategy = defaultStrategy || SingBoxEnum.DEFAULT_DNS_STRATEGY
|
||||
this.disable_cache = !cache
|
||||
this.disable_expire = !cache
|
||||
this.final = defaultTag
|
||||
}
|
||||
}
|
||||
|
||||
export class Rule {
|
||||
action: string
|
||||
outbound: string
|
||||
|
||||
constructor(action: string, outbound: string) {
|
||||
this.action = action
|
||||
this.outbound = outbound
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteRule extends Rule {
|
||||
ip_version?: 4 | 6
|
||||
protocol: string[]
|
||||
port: number[]
|
||||
port_range: string[]
|
||||
domain: string[]
|
||||
domain_suffix: string[]
|
||||
domain_keyword: string[]
|
||||
domain_regex: string[]
|
||||
rule_set: string[]
|
||||
ip_cidr: string[]
|
||||
network: string[]
|
||||
|
||||
constructor(action: string, outbound: string) {
|
||||
super(action, outbound)
|
||||
}
|
||||
|
||||
initRule(rule: RuleDTO): RouteRule {
|
||||
this.ip_version = rule.ipVersion
|
||||
this.network = rule.network
|
||||
this.protocol = rule.protocol
|
||||
this.port = rule.port
|
||||
this.port_range = rule.portRange.map(range => range.replace('-', ':'))
|
||||
this.domain = rule.domain
|
||||
this.domain_suffix = rule.domainSuffix
|
||||
this.domain_keyword = rule.domainKeyword
|
||||
this.domain_regex = rule.domainRegex
|
||||
this.rule_set = rule.ruleSet.map(r => r.tag)
|
||||
this.ip_cidr = rule.ipCidr
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsRule extends Rule {
|
||||
domain: string[]
|
||||
domain_keyword: string[]
|
||||
domain_regex: string[]
|
||||
domain_suffix: string[]
|
||||
rule_set: string[]
|
||||
server: string
|
||||
strategy: string
|
||||
disable_cache: boolean
|
||||
disable_expire: boolean
|
||||
|
||||
constructor(action: string, server: string, strategy: string) {
|
||||
super(action, undefined)
|
||||
this.action = action
|
||||
this.server = server
|
||||
this.strategy = strategy
|
||||
this.disable_cache = true
|
||||
}
|
||||
|
||||
initRule(rule: RuleDTO): DnsRule {
|
||||
this.domain = rule.domain
|
||||
this.domain_suffix = rule.domainSuffix
|
||||
this.domain_keyword = rule.domainKeyword
|
||||
this.domain_regex = rule.domainRegex
|
||||
this.rule_set = rule.ruleSet.map(r => r.tag)
|
||||
return this
|
||||
}
|
||||
}
|
||||
|
||||
export class SniffRule extends RouteRule {
|
||||
constructor() { super('sniff', undefined) }
|
||||
}
|
||||
|
||||
export class HijackDnsRule extends RouteRule {
|
||||
constructor() {
|
||||
super('hijack-dns', undefined)
|
||||
this.protocol = ['dns']
|
||||
}
|
||||
}
|
||||
|
||||
export class RuleSet {
|
||||
type: string
|
||||
tag: string
|
||||
format: string
|
||||
|
||||
constructor(tag: string, type: string, format: string) {
|
||||
this.type = type
|
||||
this.tag = tag
|
||||
this.format = format
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoteRuleSet extends RuleSet {
|
||||
url: string
|
||||
http_client?: HTTPClient
|
||||
update_interval: number
|
||||
|
||||
constructor(tag: string, format: string, url: string, download_detour: string, update_interval: number) {
|
||||
super(tag, 'remote', format)
|
||||
this.url = url
|
||||
if (download_detour) this.http_client = new HTTPClient(download_detour)
|
||||
this.update_interval = update_interval
|
||||
}
|
||||
}
|
||||
|
||||
export class HTTPClient {
|
||||
detour: string
|
||||
|
||||
constructor(detour: string) {
|
||||
this.detour = detour
|
||||
}
|
||||
}
|
||||
|
||||
export class LocalRuleSet extends RuleSet {
|
||||
path: string
|
||||
|
||||
constructor(tag: string, format: string, path: string) {
|
||||
super(tag, 'local', format)
|
||||
this.path = path
|
||||
}
|
||||
}
|
||||
|
||||
export class Route {
|
||||
rules: RouteRule[]
|
||||
rule_set: RuleSet[]
|
||||
default_domain_resolver: string
|
||||
final: string
|
||||
|
||||
constructor(defaultProxyTag: string, resolver: string) {
|
||||
this.rules = [new SniffRule(), new HijackDnsRule()]
|
||||
this.rule_set = []
|
||||
this.default_domain_resolver = resolver
|
||||
this.final = defaultProxyTag
|
||||
}
|
||||
}
|
||||
|
||||
export interface Inbound {
|
||||
type: 'tproxy' | 'direct' | 'socks'
|
||||
tag: string
|
||||
listen: string
|
||||
listen_port: number
|
||||
}
|
||||
|
||||
export interface Listener {
|
||||
listen: string
|
||||
listen_port: number
|
||||
}
|
||||
|
||||
export class TproxyInbound implements Inbound, Listener {
|
||||
type: 'tproxy' = 'tproxy'
|
||||
tag: string
|
||||
listen: string
|
||||
listen_port: number
|
||||
|
||||
constructor(port: number, tag: string, address?: string) {
|
||||
this.tag = tag
|
||||
this.listen = address ?? '::'
|
||||
this.listen_port = port
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsInbound implements Inbound, Listener {
|
||||
type: 'direct' = 'direct'
|
||||
tag: string
|
||||
listen: string
|
||||
listen_port: number
|
||||
|
||||
constructor(port: number, tag: string) {
|
||||
this.tag = tag
|
||||
this.listen = '::'
|
||||
this.listen_port = port
|
||||
}
|
||||
}
|
||||
|
||||
export class SocksInbound implements Inbound, Listener {
|
||||
type: 'socks' = 'socks'
|
||||
tag: string
|
||||
listen: string
|
||||
listen_port: number
|
||||
users?: [{ username: string; password: string }]
|
||||
|
||||
constructor(port: number, tag: string, address?: string) {
|
||||
this.tag = tag
|
||||
this.listen = address ?? '::'
|
||||
this.listen_port = port
|
||||
}
|
||||
}
|
||||
|
||||
export interface Outbound {
|
||||
type: string
|
||||
tag: string
|
||||
}
|
||||
|
||||
export interface Server {
|
||||
server: string
|
||||
server_port: number
|
||||
}
|
||||
|
||||
export class BlockOutbound implements Outbound {
|
||||
type = 'block'
|
||||
tag: string
|
||||
constructor(tag: string) { this.tag = tag }
|
||||
}
|
||||
|
||||
export class DirectOutbound implements Outbound {
|
||||
type = 'direct'
|
||||
tag: string
|
||||
domain_resolver: string
|
||||
constructor(tag: string) { this.tag = tag }
|
||||
}
|
||||
|
||||
export class URLTest implements Outbound {
|
||||
type = 'urltest'
|
||||
tag: string
|
||||
url: string
|
||||
interval: string
|
||||
tolerance: number
|
||||
idle_timeout: string
|
||||
interrupt_exist_connections: boolean
|
||||
outbounds: string[]
|
||||
|
||||
constructor(
|
||||
tag: string,
|
||||
url: string,
|
||||
interval: string,
|
||||
tolerance: number,
|
||||
idle_timeout: string,
|
||||
interrupt_exist_connections: boolean
|
||||
) {
|
||||
this.tag = tag
|
||||
this.outbounds = []
|
||||
this.url = url
|
||||
this.interval = interval
|
||||
this.tolerance = tolerance
|
||||
this.idle_timeout = idle_timeout
|
||||
this.interrupt_exist_connections = interrupt_exist_connections
|
||||
}
|
||||
}
|
||||
|
||||
export class SocksOutbound implements Server, Outbound {
|
||||
type = 'socks'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
version: '4' | '5' = '5'
|
||||
domain_resolver: string
|
||||
|
||||
constructor(server: string, server_port: number, tag: string) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.version = '5'
|
||||
}
|
||||
}
|
||||
|
||||
export class TlsConfig {
|
||||
enabled: boolean
|
||||
server_name: string
|
||||
insecure?: boolean
|
||||
reality?: { enabled: boolean; public_key: string; short_id: string }
|
||||
|
||||
constructor(serverName: string) {
|
||||
this.enabled = true
|
||||
this.server_name = serverName
|
||||
}
|
||||
}
|
||||
|
||||
export class BrutalConfig {
|
||||
enabled: boolean
|
||||
up_mbps?: number
|
||||
down_mbps?: number
|
||||
|
||||
constructor() { this.enabled = true }
|
||||
}
|
||||
|
||||
export class MultiplexConfig {
|
||||
enabled: boolean
|
||||
protocol?: 'smux' | 'yamux' | 'h2mux'
|
||||
max_connections?: number
|
||||
min_streams?: number
|
||||
max_streams?: number
|
||||
brutal?: BrutalConfig
|
||||
|
||||
constructor() { this.enabled = true }
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { Hysteria2Config } from '../../../module/luci'
|
||||
import { TlsConfig, type Outbound } from '../models'
|
||||
import { singboxRegistry, type SingBoxProtocol } from './protocol'
|
||||
|
||||
export class ObfsConfig {
|
||||
type: 'salamander'
|
||||
password?: string
|
||||
|
||||
constructor(password: string) {
|
||||
this.type = 'salamander'
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
|
||||
export class Hysteria2Outbound implements Outbound {
|
||||
type = 'hysteria2'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
password?: string
|
||||
obfs?: ObfsConfig
|
||||
up_mbps?: number
|
||||
down_mbps?: number
|
||||
server_ports?: string[]
|
||||
hop_interval?: string
|
||||
network?: string
|
||||
tls: TlsConfig
|
||||
|
||||
constructor(tag: string, server: string, server_port: number, serverName: string) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.tls = new TlsConfig(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const hysteria2Protocol: SingBoxProtocol = {
|
||||
type: 'hysteria2',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as Hysteria2Config
|
||||
const outbound = new Hysteria2Outbound(
|
||||
c.name,
|
||||
c.server,
|
||||
parseInt(c.server_port),
|
||||
c.tls_server_name || c.server
|
||||
)
|
||||
if (c.password) outbound.password = c.password
|
||||
if (c.up_mbps) outbound.up_mbps = Number(c.up_mbps)
|
||||
if (c.down_mbps) outbound.down_mbps = Number(c.down_mbps)
|
||||
if (c.server_ports && c.server_ports.length > 0) {
|
||||
outbound.server_ports = c.server_ports
|
||||
.map(range => range.replace('-', ':'))
|
||||
.filter(range => range.length > 0)
|
||||
}
|
||||
if (c.hop_interval) outbound.hop_interval = c.hop_interval
|
||||
if (c.network) outbound.network = c.network
|
||||
if (c.obfs_type === 'salamander' && c.obfs_password) {
|
||||
outbound.obfs = new ObfsConfig(c.obfs_password)
|
||||
}
|
||||
outbound.tls.insecure = c.tls_insecure === '1'
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
singboxRegistry.register(hysteria2Protocol)
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import type { Outbound } from '../models'
|
||||
|
||||
export interface SingBoxProtocol {
|
||||
readonly type: string
|
||||
build(config: ProxyNode): Outbound
|
||||
}
|
||||
|
||||
export class SingBoxProtocolRegistry {
|
||||
private readonly protocols = new Map<string, SingBoxProtocol>()
|
||||
|
||||
register(p: SingBoxProtocol): void {
|
||||
this.protocols.set(p.type, p)
|
||||
}
|
||||
|
||||
build(type: string, config: ProxyNode): Outbound {
|
||||
const p = this.protocols.get(type)
|
||||
if (!p) throw new Error(`Unsupported sing-box protocol: ${type}`)
|
||||
return p.build(config)
|
||||
}
|
||||
}
|
||||
|
||||
export const singboxRegistry = new SingBoxProtocolRegistry()
|
||||
@@ -0,0 +1,7 @@
|
||||
// Register all supported sing-box protocols.
|
||||
// Adding a new protocol: create its file, then add one import here.
|
||||
import './hysteria2'
|
||||
import './shadowsocks'
|
||||
import './vless'
|
||||
import './tuic'
|
||||
import './shadowtls'
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { ShadowsocksConfig } from '../../../module/luci'
|
||||
import { MultiplexConfig, BrutalConfig, type Outbound, type Server } from '../models'
|
||||
import { singboxRegistry, type SingBoxProtocol } from './protocol'
|
||||
|
||||
export class ShadowsocksOutbound implements Server, Outbound {
|
||||
type = 'shadowsocks'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
method: string
|
||||
password: string
|
||||
network?: 'tcp' | 'udp'
|
||||
udp_over_tcp?: boolean
|
||||
plugin?: string
|
||||
plugin_opts?: string
|
||||
multiplex?: MultiplexConfig
|
||||
|
||||
constructor(tag: string, server: string, server_port: number, method: string, password: string) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.method = method
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
|
||||
function buildMultiplex(config: ShadowsocksConfig): MultiplexConfig {
|
||||
const m = new MultiplexConfig()
|
||||
if (config.multiplex_protocol) m.protocol = config.multiplex_protocol as 'smux' | 'yamux' | 'h2mux'
|
||||
if (config.multiplex_max_connections) m.max_connections = parseInt(config.multiplex_max_connections)
|
||||
if (config.multiplex_min_streams) m.min_streams = parseInt(config.multiplex_min_streams)
|
||||
if (config.multiplex_max_streams) m.max_streams = parseInt(config.multiplex_max_streams)
|
||||
if (config.multiplex_brutal_enabled === '1') {
|
||||
const brutal = new BrutalConfig()
|
||||
if (config.multiplex_brutal_up_mbps) brutal.up_mbps = parseInt(config.multiplex_brutal_up_mbps)
|
||||
if (config.multiplex_brutal_down_mbps) brutal.down_mbps = parseInt(config.multiplex_brutal_down_mbps)
|
||||
m.brutal = brutal
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const shadowsocksProtocol: SingBoxProtocol = {
|
||||
type: 'shadowsocks',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as ShadowsocksConfig
|
||||
const outbound = new ShadowsocksOutbound(
|
||||
c.name, c.server, parseInt(c.server_port), c.method, c.password
|
||||
)
|
||||
if (c.network) outbound.network = c.network as 'tcp' | 'udp'
|
||||
if (c.udp_over_tcp === '1') outbound.udp_over_tcp = true
|
||||
if (c.plugin) outbound.plugin = c.plugin
|
||||
if (c.plugin_opts) outbound.plugin_opts = c.plugin_opts
|
||||
if (c.multiplex_enabled === '1') outbound.multiplex = buildMultiplex(c)
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
singboxRegistry.register(shadowsocksProtocol)
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { ShadowTLSConfig } from '../../../module/luci'
|
||||
import { TlsConfig, type Outbound, type Server } from '../models'
|
||||
import { singboxRegistry, type SingBoxProtocol } from './protocol'
|
||||
|
||||
export class ShadowTLSOutbound implements Server, Outbound {
|
||||
type = 'shadowtls'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
version: number
|
||||
password?: string
|
||||
tls: TlsConfig
|
||||
|
||||
constructor(tag: string, server: string, server_port: number, version: number, serverName: string) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.version = version
|
||||
this.tls = new TlsConfig(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const shadowtlsProtocol: SingBoxProtocol = {
|
||||
type: 'shadowtls',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as ShadowTLSConfig
|
||||
const outbound = new ShadowTLSOutbound(
|
||||
c.name, c.server, parseInt(c.server_port),
|
||||
parseInt(c.version), c.tls_server_name || c.server
|
||||
)
|
||||
if (c.password) outbound.password = c.password
|
||||
outbound.tls.insecure = c.tls_insecure === '1'
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
singboxRegistry.register(shadowtlsProtocol)
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { TuicConfig } from '../../../module/luci'
|
||||
import { TlsConfig, type Outbound, type Server } from '../models'
|
||||
import { singboxRegistry, type SingBoxProtocol } from './protocol'
|
||||
|
||||
export class TuicOutbound implements Server, Outbound {
|
||||
type = 'tuic'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid: string
|
||||
password: string
|
||||
congestion_control?: 'bbr' | 'cubic' | 'new_reno'
|
||||
udp_relay_mode?: 'native' | 'quic'
|
||||
udp_over_stream?: boolean
|
||||
zero_rtt_handshake?: boolean
|
||||
heartbeat?: string
|
||||
network?: 'tcp' | 'udp'
|
||||
tls: TlsConfig
|
||||
|
||||
constructor(
|
||||
tag: string, server: string, server_port: number,
|
||||
uuid: string, password: string, serverName: string
|
||||
) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.uuid = uuid
|
||||
this.password = password
|
||||
this.tls = new TlsConfig(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
const tuicProtocol: SingBoxProtocol = {
|
||||
type: 'tuic',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as TuicConfig
|
||||
const outbound = new TuicOutbound(
|
||||
c.name, c.server, parseInt(c.server_port),
|
||||
c.uuid, c.password, c.tls_server_name || c.server
|
||||
)
|
||||
if (c.congestion_control) outbound.congestion_control = c.congestion_control as 'bbr' | 'cubic' | 'new_reno'
|
||||
if (c.udp_relay_mode) outbound.udp_relay_mode = c.udp_relay_mode as 'native' | 'quic'
|
||||
if (c.udp_over_stream === '1') outbound.udp_over_stream = true
|
||||
if (c.zero_rtt_handshake === '1') outbound.zero_rtt_handshake = true
|
||||
if (c.heartbeat) outbound.heartbeat = c.heartbeat
|
||||
if (c.network) outbound.network = c.network as 'tcp' | 'udp'
|
||||
outbound.tls.insecure = c.tls_insecure === '1'
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
singboxRegistry.register(tuicProtocol)
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { VlessConfig } from '../../../module/luci'
|
||||
import { TlsConfig, MultiplexConfig, BrutalConfig, type Outbound, type Server } from '../models'
|
||||
import { singboxRegistry, type SingBoxProtocol } from './protocol'
|
||||
|
||||
export interface HttpTransport {
|
||||
type: 'http'
|
||||
host?: string
|
||||
path?: string
|
||||
method?: string
|
||||
idle_timeout?: string
|
||||
ping_timeout?: string
|
||||
}
|
||||
|
||||
export interface WebSocketTransport {
|
||||
type: 'ws'
|
||||
path?: string
|
||||
headers?: Record<string, string>
|
||||
max_early_data?: number
|
||||
early_data_header_name?: string
|
||||
}
|
||||
|
||||
export interface GrpcTransport {
|
||||
type: 'grpc'
|
||||
service_name?: string
|
||||
idle_timeout?: string
|
||||
ping_timeout?: string
|
||||
permit_without_stream?: boolean
|
||||
}
|
||||
|
||||
export interface HttpUpgradeTransport {
|
||||
type: 'httpupgrade'
|
||||
host?: string
|
||||
path?: string
|
||||
}
|
||||
|
||||
export type TransportConfig =
|
||||
| HttpTransport
|
||||
| WebSocketTransport
|
||||
| GrpcTransport
|
||||
| HttpUpgradeTransport
|
||||
| { type: string; [key: string]: any }
|
||||
|
||||
export class VlessOutbound implements Server, Outbound {
|
||||
type = 'vless'
|
||||
tag: string
|
||||
server: string
|
||||
server_port: number
|
||||
uuid: string
|
||||
flow?: 'xtls-rprx-vision'
|
||||
network?: 'tcp' | 'udp'
|
||||
packet_encoding?: 'packetaddr' | 'xudp'
|
||||
tls: TlsConfig
|
||||
transport?: TransportConfig
|
||||
multiplex?: MultiplexConfig
|
||||
|
||||
constructor(tag: string, server: string, server_port: number, uuid: string, serverName: string) {
|
||||
this.tag = tag
|
||||
this.server = server
|
||||
this.server_port = server_port
|
||||
this.uuid = uuid
|
||||
this.tls = new TlsConfig(serverName)
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransport(c: VlessConfig): TransportConfig | undefined {
|
||||
switch (c.transport_type) {
|
||||
case 'http': {
|
||||
const t: HttpTransport = { type: 'http' }
|
||||
if (c.transport_http_host) t.host = c.transport_http_host
|
||||
if (c.transport_http_path) t.path = c.transport_http_path
|
||||
if (c.transport_http_method) t.method = c.transport_http_method
|
||||
if (c.transport_http_idle_timeout) t.idle_timeout = c.transport_http_idle_timeout
|
||||
if (c.transport_http_ping_timeout) t.ping_timeout = c.transport_http_ping_timeout
|
||||
return t
|
||||
}
|
||||
case 'ws': {
|
||||
const t: WebSocketTransport = { type: 'ws' }
|
||||
if (c.transport_ws_path) t.path = c.transport_ws_path
|
||||
if (c.transport_ws_host) t.headers = { Host: c.transport_ws_host }
|
||||
if (c.transport_ws_max_early_data) t.max_early_data = parseInt(c.transport_ws_max_early_data)
|
||||
if (c.transport_ws_early_data_header_name) t.early_data_header_name = c.transport_ws_early_data_header_name
|
||||
return t
|
||||
}
|
||||
case 'grpc': {
|
||||
const t: GrpcTransport = { type: 'grpc' }
|
||||
if (c.transport_grpc_service_name) t.service_name = c.transport_grpc_service_name
|
||||
if (c.transport_grpc_idle_timeout) t.idle_timeout = c.transport_grpc_idle_timeout
|
||||
if (c.transport_grpc_ping_timeout) t.ping_timeout = c.transport_grpc_ping_timeout
|
||||
if (c.transport_grpc_permit_without_stream === '1') t.permit_without_stream = true
|
||||
return t
|
||||
}
|
||||
case 'httpupgrade': {
|
||||
const t: HttpUpgradeTransport = { type: 'httpupgrade' }
|
||||
if (c.transport_httpupgrade_host) t.host = c.transport_httpupgrade_host
|
||||
if (c.transport_httpupgrade_path) t.path = c.transport_httpupgrade_path
|
||||
return t
|
||||
}
|
||||
default: return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function buildMultiplex(c: VlessConfig): MultiplexConfig {
|
||||
const m = new MultiplexConfig()
|
||||
if (c.multiplex_protocol) m.protocol = c.multiplex_protocol as 'smux' | 'yamux' | 'h2mux'
|
||||
if (c.multiplex_max_connections) m.max_connections = parseInt(c.multiplex_max_connections)
|
||||
if (c.multiplex_min_streams) m.min_streams = parseInt(c.multiplex_min_streams)
|
||||
if (c.multiplex_max_streams) m.max_streams = parseInt(c.multiplex_max_streams)
|
||||
if (c.multiplex_brutal_enabled === '1') {
|
||||
const brutal = new BrutalConfig()
|
||||
if (c.multiplex_brutal_up_mbps) brutal.up_mbps = parseInt(c.multiplex_brutal_up_mbps)
|
||||
if (c.multiplex_brutal_down_mbps) brutal.down_mbps = parseInt(c.multiplex_brutal_down_mbps)
|
||||
m.brutal = brutal
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const vlessProtocol: SingBoxProtocol = {
|
||||
type: 'vless',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as VlessConfig
|
||||
const outbound = new VlessOutbound(
|
||||
c.name, c.server, parseInt(c.server_port), c.uuid,
|
||||
c.tls_server_name || c.server
|
||||
)
|
||||
if (c.flow) outbound.flow = c.flow as 'xtls-rprx-vision'
|
||||
if (c.network) outbound.network = c.network as 'tcp' | 'udp'
|
||||
if (c.packet_encoding) outbound.packet_encoding = c.packet_encoding as 'packetaddr' | 'xudp'
|
||||
outbound.tls.insecure = c.tls_insecure === '1'
|
||||
if (c.tls_reality_enabled === '1' && c.tls_reality_public_key) {
|
||||
outbound.tls.reality = {
|
||||
enabled: true,
|
||||
public_key: c.tls_reality_public_key,
|
||||
short_id: c.tls_reality_short_id || ''
|
||||
}
|
||||
}
|
||||
if (c.transport_type) outbound.transport = buildTransport(c)
|
||||
if (c.multiplex_enabled === '1') outbound.multiplex = buildMultiplex(c)
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
singboxRegistry.register(vlessProtocol)
|
||||
@@ -0,0 +1,370 @@
|
||||
import './protocols/registry'
|
||||
import uci from 'uci'
|
||||
import {XrayEnum} from '../../enum/xray'
|
||||
import {ShuntUtils} from '../../utils/feature/shunt/section'
|
||||
import {
|
||||
Balancer, BurstObservatory,
|
||||
DNS, DnsOutbound, DnsRule, DnsServer,
|
||||
Freedom, Inbound, Log, Outbound, Route,
|
||||
RouteRule, Rule, Socks, BlockOutbound
|
||||
} from './models'
|
||||
import {LuciFlied, PROXY_TYPE, ShuntTag} from '../../enum/hijpass'
|
||||
import { CoreAdapter, LoadBalanceProxyNode, ProxyNode, RuleDTO } from '../../module/luci'
|
||||
import {xrayRegistry} from './protocols/protocol'
|
||||
import { LOAD_BALANCE_MEMBER_TAG_PREFIX, ProxyChainUtils } from '../../utils/feature/proxy/chain'
|
||||
|
||||
export const XrayUtils: CoreAdapter = {
|
||||
genShuntConf: genConf,
|
||||
genProxyConf
|
||||
}
|
||||
|
||||
function genConf() {
|
||||
const shuntSection = ShuntUtils.getShuntSection()
|
||||
|
||||
const dnsServerMap = new Map<string, DnsServer>()
|
||||
const route = new Route<RouteRule | DnsRule>()
|
||||
|
||||
const dnsConf = shuntSection.dns
|
||||
const dns = new DNS(transDnsStrategy(dnsConf.defaultStrategy), dnsConf.useCache === '1')
|
||||
dnsConf.nodes.forEach(function (dnsNode) {
|
||||
const server = new DnsServer(dnsNode.type, dnsNode.tag, dnsNode.server, Number(dnsNode.port))
|
||||
dnsServerMap.set(dnsNode.tag, server)
|
||||
})
|
||||
|
||||
const routeConf = shuntSection.route
|
||||
const outbounds: Outbound[] = []
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section) {
|
||||
if (section.enabled === '1') {
|
||||
const proxyNode = new Socks('127.0.0.1', Number(section.socks_port), transOutTag(section.name))
|
||||
if (section.name === routeConf.defaultProxyNode) {
|
||||
outbounds.unshift(proxyNode)
|
||||
} else {
|
||||
outbounds.push(proxyNode)
|
||||
}
|
||||
}
|
||||
})
|
||||
if (routeConf.defaultProxyNode === ShuntTag.DIRECT_OUTBOUND_TAG) {
|
||||
outbounds.unshift(new Freedom(XrayEnum.DIRECT_OUTBOUND_TAG))
|
||||
} else {
|
||||
outbounds.push(new Freedom(XrayEnum.DIRECT_OUTBOUND_TAG))
|
||||
}
|
||||
outbounds.push(new BlockOutbound(XrayEnum.BLOCK_OUTBOUND_TAG))
|
||||
outbounds.push(new DnsOutbound(XrayEnum.DNS_OUTBOUND_TAG))
|
||||
|
||||
const inbounds = [
|
||||
new Inbound('tproxy', parseInt(shuntSection.shuntListenPort, 10), XrayEnum.TPROXY_INBOUND_TAG),
|
||||
new Inbound('direct', parseInt(shuntSection.dnsListenPort, 10), XrayEnum.DNS_INBOUND_TAG, '::')
|
||||
]
|
||||
|
||||
const dnsRules = [new DnsRule([XrayEnum.DNS_INBOUND_TAG], XrayEnum.DNS_OUTBOUND_TAG)]
|
||||
const routeRules: RouteRule[] = []
|
||||
const defaultDnsTmpl = dnsServerMap.get(dnsConf.defaultDnsNode)
|
||||
if (!defaultDnsTmpl) {
|
||||
throw new Error(_('Default global DNS "%s" does not exist').format(dnsConf.defaultDnsNode || ''))
|
||||
}
|
||||
const dnsInOutMap = new Map<string, string[]>()
|
||||
|
||||
routeConf.rules.forEach(function (rule) {
|
||||
if (rule.enabled !== '1') return
|
||||
const isIpVersionSplit = rule.ipVersionSplit === '1'
|
||||
let splitDnsRuleDTO: RuleDTO = undefined
|
||||
let splitDnsNeeded = false
|
||||
|
||||
ShuntUtils.getRouteRuleVariants(rule).forEach(function (variant) {
|
||||
const ruleOutBoundTag = transOutTag(variant.proxyNode) || outbounds[0].tag
|
||||
|
||||
const luciRuleDTO = ShuntUtils.parseRuleList(variant.rule)
|
||||
const hasDnsTarget = hasXrayDnsTargetRule(luciRuleDTO)
|
||||
luciRuleDTO.ipVersion = variant.ipVersion
|
||||
if (shouldSkipXrayRule(luciRuleDTO)) return
|
||||
|
||||
buildXrayRouteRules(variant.rule.name, ruleOutBoundTag, luciRuleDTO).forEach((routeRule) => {
|
||||
routeRules.push(routeRule)
|
||||
})
|
||||
|
||||
if (ruleOutBoundTag === XrayEnum.BLOCK_OUTBOUND_TAG) return
|
||||
|
||||
if (isIpVersionSplit) {
|
||||
if (hasDnsTarget) {
|
||||
splitDnsRuleDTO = splitDnsRuleDTO || luciRuleDTO
|
||||
splitDnsNeeded = true
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (hasDnsTarget) {
|
||||
const outboundTag = transOutTag(rule.dnsProxyNode) || ruleOutBoundTag
|
||||
appendXrayDnsServer(rule, variant.rule.name, outboundTag, luciRuleDTO, dnsServerMap, defaultDnsTmpl, dns, dnsInOutMap)
|
||||
}
|
||||
})
|
||||
|
||||
if (isIpVersionSplit && splitDnsNeeded && splitDnsRuleDTO) {
|
||||
const outboundTag = transOutTag(rule.dnsProxyNode)
|
||||
if (!outboundTag) {
|
||||
throw new Error(_('Route rule "%s" uses IPv4/IPv6 split and must specify a DNS exit node').format(rule.name))
|
||||
}
|
||||
appendXrayDnsServer(rule, rule.name, outboundTag, splitDnsRuleDTO, dnsServerMap, defaultDnsTmpl, dns, dnsInOutMap)
|
||||
}
|
||||
})
|
||||
|
||||
const defaultDnsServer = structuredClone(defaultDnsTmpl)
|
||||
defaultDnsServer.tag = transDnsTag('default')
|
||||
defaultDnsServer.skipFallback = false
|
||||
dns.servers.push(defaultDnsServer)
|
||||
|
||||
dnsInOutMap.forEach((v, k) => dnsRules.push(new DnsRule(v, k)))
|
||||
|
||||
route.rules = route.rules.concat(dnsRules)
|
||||
route.rules = route.rules.concat(routeRules)
|
||||
|
||||
const log = new Log(transXrayLogLevel(shuntSection.logLevel))
|
||||
|
||||
return {
|
||||
log,
|
||||
inbounds,
|
||||
outbounds,
|
||||
dns,
|
||||
routing: route
|
||||
}
|
||||
}
|
||||
|
||||
function genProxyConf(config: ProxyNode): {} {
|
||||
if (config.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
return genLoadBalanceProxyConf(config as LoadBalanceProxyNode)
|
||||
}
|
||||
|
||||
const outbound = xrayRegistry.build(config.type, config)
|
||||
return buildBaseProxyConf(config, outbound)
|
||||
}
|
||||
|
||||
function buildBaseProxyConf(config: ProxyNode, outbound: Outbound): {} {
|
||||
const inbounds = buildProxyInbounds(config)
|
||||
|
||||
const outbounds = [outbound]
|
||||
appendProxyChainOutbounds(config, outbound, outbounds)
|
||||
|
||||
return {
|
||||
log: new Log(transXrayLogLevel(config.log_level)),
|
||||
inbounds,
|
||||
outbounds
|
||||
}
|
||||
}
|
||||
|
||||
function buildProxyInbounds(config: ProxyNode): Inbound[] {
|
||||
const tproxyInbound = new Inbound('tproxy', Number(config.listen_port), 'tproxy-in')
|
||||
tproxyInbound.sniffing = undefined
|
||||
|
||||
const socksInbound = new Inbound('direct', Number(config.socks_port), 'socks-in')
|
||||
socksInbound.settings = {udp: true}
|
||||
socksInbound.protocol = 'socks'
|
||||
|
||||
return [tproxyInbound, socksInbound]
|
||||
}
|
||||
|
||||
function genLoadBalanceProxyConf(config: LoadBalanceProxyNode): {} {
|
||||
const members = ProxyChainUtils.resolveLoadBalanceMembers(config)
|
||||
const route = new Route<Rule>()
|
||||
route.rules.push(new Rule(undefined, ['tproxy-in', 'socks-in'], XrayEnum.LOAD_BALANCE_OUTBOUND_TAG))
|
||||
|
||||
const balancer = new Balancer(XrayEnum.LOAD_BALANCE_OUTBOUND_TAG)
|
||||
balancer.selector = [LOAD_BALANCE_MEMBER_TAG_PREFIX]
|
||||
const fallback = members.find(member => member.name === config.fallback_tag) || members[0]
|
||||
balancer.fallbackTag = fallback.tag
|
||||
|
||||
const strategy = config.strategy || 'leastLoad'
|
||||
balancer.strategy.type = strategy
|
||||
if (strategy === 'leastLoad') {
|
||||
if (config.strategy_expected) balancer.strategy.settings.expected = parseInt(config.strategy_expected, 10)
|
||||
if (config.strategy_max_rtt) balancer.strategy.settings.maxRTT = config.strategy_max_rtt
|
||||
if (config.strategy_tolerance) balancer.strategy.settings.tolerance = parseFloat(config.strategy_tolerance)
|
||||
} else {
|
||||
balancer.strategy.settings = undefined
|
||||
}
|
||||
route.balancers.push(balancer)
|
||||
|
||||
const burstObservatory = new BurstObservatory(
|
||||
config.probe_url || undefined,
|
||||
config.probe_connectivity || undefined,
|
||||
config.probe_interval || undefined,
|
||||
config.probe_timeout || undefined,
|
||||
config.probe_sampling ? parseInt(config.probe_sampling, 10) : undefined,
|
||||
config.probe_http_method || undefined
|
||||
)
|
||||
burstObservatory.subjectSelector = [LOAD_BALANCE_MEMBER_TAG_PREFIX]
|
||||
|
||||
const outbounds = members.map(member => new Socks('127.0.0.1', member.socksPort, member.tag))
|
||||
|
||||
return {
|
||||
log: new Log(transXrayLogLevel(config.log_level)),
|
||||
inbounds: buildProxyInbounds(config),
|
||||
outbounds,
|
||||
routing: route,
|
||||
burstObservatory
|
||||
}
|
||||
}
|
||||
|
||||
function transXrayLogLevel(logLevel: string) {
|
||||
return logLevel === 'warn' ? 'warning' : logLevel
|
||||
}
|
||||
|
||||
function appendProxyChainOutbounds(config: ProxyNode, outbound: Outbound, outbounds: Outbound[]) {
|
||||
const upstream = ProxyChainUtils.resolveUpstreamProxy(config)
|
||||
if (!upstream) return
|
||||
|
||||
setDialerProxy(outbound, upstream.tag)
|
||||
outbounds.push(new Socks('127.0.0.1', upstream.socksPort, upstream.tag))
|
||||
}
|
||||
|
||||
function setDialerProxy(outbound: Outbound, tag: string) {
|
||||
outbound.streamSettings = outbound.streamSettings || {}
|
||||
outbound.streamSettings.sockopt = outbound.streamSettings.sockopt || {}
|
||||
outbound.streamSettings.sockopt.dialerProxy = tag
|
||||
outbound.streamSettings.sockopt.domainStrategy = 'UseIP'
|
||||
}
|
||||
|
||||
function transDnsTag(name: string) {
|
||||
return name + '-dns'
|
||||
}
|
||||
|
||||
function shouldSkipXrayRule(luciRuleDTO: RuleDTO) {
|
||||
if (luciRuleDTO.isEmpty('geo')) return true
|
||||
return luciRuleDTO.containRuleSet()
|
||||
&& !luciRuleDTO.containDomain()
|
||||
&& !luciRuleDTO.containIp()
|
||||
&& !luciRuleDTO.containGeo()
|
||||
}
|
||||
|
||||
function hasXrayDnsTargetRule(luciRuleDTO: RuleDTO) {
|
||||
return luciRuleDTO.containDomain() || luciRuleDTO.geoSite.length > 0
|
||||
}
|
||||
|
||||
function appendXrayDnsServer(rule: any, ruleName: string, outboundTag: string, luciRuleDTO: RuleDTO, dnsServerMap: Map<string, DnsServer>, defaultDnsTmpl: DnsServer, dns: DNS, dnsInOutMap: Map<string, string[]>) {
|
||||
const dnsTag = transDnsTag(ruleName)
|
||||
if (dnsInOutMap.has(outboundTag)) {
|
||||
dnsInOutMap.get(outboundTag).push(dnsTag)
|
||||
} else {
|
||||
dnsInOutMap.set(outboundTag, [dnsTag])
|
||||
}
|
||||
const dnsTmpl = rule.dnsNode ? dnsServerMap.get(rule.dnsNode) : defaultDnsTmpl
|
||||
if (!dnsTmpl) {
|
||||
throw new Error(_('Route rule "%s" DNS node "%s" does not exist').format(rule.name || ruleName, rule.dnsNode || ''))
|
||||
}
|
||||
const server = structuredClone(dnsTmpl)
|
||||
server.tag = dnsTag
|
||||
server.queryStrategy = transDnsStrategy(rule.dnsStrategy)
|
||||
server.domains = buildXrayDomains(luciRuleDTO)
|
||||
dns.servers.push(server)
|
||||
}
|
||||
|
||||
function buildXrayRouteRules(name: string, outboundTag: string, luciRuleDTO: RuleDTO): RouteRule[] {
|
||||
const routeRule = new RouteRule(name, outboundTag)
|
||||
routeRule.network = luciRuleDTO.network
|
||||
routeRule.protocol = transRouteProtocol(luciRuleDTO.protocol)
|
||||
|
||||
let allPorts: string[] = luciRuleDTO.port.map(p => String(p))
|
||||
if (luciRuleDTO.portRange && luciRuleDTO.portRange.length > 0) {
|
||||
allPorts = allPorts.concat(luciRuleDTO.portRange)
|
||||
}
|
||||
routeRule.port = allPorts.join(',')
|
||||
|
||||
routeRule.domain = buildXrayDomains(luciRuleDTO)
|
||||
routeRule.domain = routeRule.domain.length > 0 ? routeRule.domain : undefined
|
||||
routeRule.ip = buildXrayIps(luciRuleDTO)
|
||||
routeRule.ip = routeRule.ip.length > 0 ? routeRule.ip : undefined
|
||||
|
||||
const rules: RouteRule[] = []
|
||||
const ipVersionRange = transXrayIpVersion(luciRuleDTO.ipVersion)
|
||||
if (routeRule.domain) {
|
||||
const domainRule = structuredClone(routeRule)
|
||||
if (ipVersionRange) domainRule.ip = [ipVersionRange]
|
||||
else domainRule.ip = undefined
|
||||
rules.push(domainRule)
|
||||
}
|
||||
if (routeRule.ip) {
|
||||
const ipRule = structuredClone(routeRule)
|
||||
ipRule.domain = undefined
|
||||
rules.push(ipRule)
|
||||
}
|
||||
if (!routeRule.domain && !routeRule.ip) {
|
||||
if (ipVersionRange) routeRule.ip = [ipVersionRange]
|
||||
rules.push(routeRule)
|
||||
}
|
||||
return rules
|
||||
}
|
||||
|
||||
function buildXrayDomains(luciRuleDTO: RuleDTO): string[] {
|
||||
const domainArrays = [
|
||||
addPrefixToElements(luciRuleDTO.domain, 'full:'),
|
||||
addPrefixToElements(luciRuleDTO.domainSuffix, 'domain:'),
|
||||
luciRuleDTO.domainKeyword,
|
||||
addPrefixToElements(luciRuleDTO.domainRegex, 'regexp:'),
|
||||
luciRuleDTO.geoSite
|
||||
]
|
||||
return ([] as string[]).concat(...domainArrays.filter(a => Array.isArray(a) && a.length > 0))
|
||||
}
|
||||
|
||||
function buildXrayIps(luciRuleDTO: RuleDTO): string[] {
|
||||
const ipArrays = [
|
||||
filterIpRulesByVersion(luciRuleDTO.ipCidr, luciRuleDTO.ipVersion),
|
||||
luciRuleDTO.geoIp
|
||||
]
|
||||
return ([] as string[]).concat(...ipArrays.filter(a => Array.isArray(a) && a.length > 0))
|
||||
}
|
||||
|
||||
function filterIpRulesByVersion(rules: string[], ipVersion?: 4 | 6): string[] {
|
||||
if (!ipVersion || !Array.isArray(rules)) return rules || []
|
||||
return rules.filter((rule) => {
|
||||
const normalized = rule.replace(/^!/, '')
|
||||
if (normalized.startsWith('geoip:') || normalized.startsWith('ext:')) return true
|
||||
if (normalized.indexOf(':') >= 0) return ipVersion === 6
|
||||
if (/^\d{1,3}(\.\d{1,3}){3}(\/\d+)?$/.test(normalized)) return ipVersion === 4
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function transXrayIpVersion(ipVersion?: 4 | 6) {
|
||||
if (ipVersion === 4) return '0.0.0.0/0'
|
||||
if (ipVersion === 6) return '::/0'
|
||||
return undefined
|
||||
}
|
||||
|
||||
function transOutTag(tag: string) {
|
||||
if (tag === ShuntTag.BLOCK_OUTBOUND_TAG) return XrayEnum.BLOCK_OUTBOUND_TAG
|
||||
if (tag === ShuntTag.DIRECT_OUTBOUND_TAG) return XrayEnum.DIRECT_OUTBOUND_TAG
|
||||
return tag
|
||||
}
|
||||
|
||||
function transDnsStrategy(strategy: string) {
|
||||
switch (strategy) {
|
||||
case '4':
|
||||
return 'UseIPv4'
|
||||
case '6':
|
||||
return 'UseIPv6'
|
||||
case '64':
|
||||
case '46':
|
||||
return 'UseIP'
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function transRouteProtocol(protocol: string[]) {
|
||||
if (!protocol) return undefined
|
||||
const res = protocol.map((pro: string) => {
|
||||
switch (pro) {
|
||||
case 'http':
|
||||
case 'tls':
|
||||
case 'quic':
|
||||
case 'bittorrent':
|
||||
return pro
|
||||
default:
|
||||
throw new Error('Unsupported xray rule protocol: ' + pro)
|
||||
}
|
||||
}).filter(p => p !== undefined)
|
||||
return res.length === 0 ? undefined : res
|
||||
}
|
||||
|
||||
function addPrefixToElements(arr: string[], prefix: string) {
|
||||
if (!Array.isArray(arr)) return []
|
||||
return arr.map(item => prefix + item)
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
import { XrayEnum } from '../../enum/xray'
|
||||
|
||||
export class Log {
|
||||
loglevel: string
|
||||
dnsLog: boolean
|
||||
|
||||
constructor(logLevel: string) {
|
||||
this.loglevel = logLevel || XrayEnum.DEFAULT_LOG_LEVEL
|
||||
this.dnsLog = false
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsServer {
|
||||
tag: string
|
||||
address: string
|
||||
port: number
|
||||
queryStrategy: string
|
||||
domains: string[]
|
||||
skipFallback: boolean
|
||||
disableCache: boolean
|
||||
|
||||
constructor(type: string, tag: string, address: string, port: number) {
|
||||
switch (type) {
|
||||
case 'tcp':
|
||||
this.address = 'tcp://' + address
|
||||
this.port = port
|
||||
break
|
||||
case 'https':
|
||||
this.address = 'https://' + address
|
||||
if (port) this.address += ':' + port
|
||||
this.address += '/dns-query'
|
||||
break
|
||||
case 'quic':
|
||||
this.address = 'quic://' + address
|
||||
this.port = port
|
||||
break
|
||||
case 'udp':
|
||||
this.address = address
|
||||
this.port = port
|
||||
break
|
||||
default:
|
||||
throw new Error('Invalid xray DNS server type: ' + type)
|
||||
}
|
||||
this.tag = tag
|
||||
this.domains = []
|
||||
this.skipFallback = true
|
||||
}
|
||||
}
|
||||
|
||||
export class DNS {
|
||||
servers: DnsServer[]
|
||||
rules: DnsRule[]
|
||||
queryStrategy: string
|
||||
disableCache: boolean
|
||||
serveStale: boolean
|
||||
serveExpiredTTL: number
|
||||
disableFallback: boolean
|
||||
disableFallbackIfMatch: boolean
|
||||
|
||||
constructor(queryStrategy: string, cache: boolean) {
|
||||
this.servers = []
|
||||
this.rules = []
|
||||
this.queryStrategy = queryStrategy || XrayEnum.DEFAULT_DNS_STRATEGY
|
||||
this.disableCache = !cache
|
||||
this.serveStale = !cache
|
||||
this.serveExpiredTTL = 3600
|
||||
this.disableFallback = false
|
||||
this.disableFallbackIfMatch = true
|
||||
}
|
||||
}
|
||||
|
||||
export class Rule {
|
||||
ruleTag: string
|
||||
inboundTag: string[]
|
||||
outboundTag: string
|
||||
balancerTag: string
|
||||
|
||||
constructor(ruleTag: string, inboundTag: string[], outbound: string) {
|
||||
this.ruleTag = ruleTag
|
||||
this.inboundTag = inboundTag
|
||||
if (outbound === XrayEnum.LOAD_BALANCE_OUTBOUND_TAG) {
|
||||
this.balancerTag = outbound
|
||||
} else {
|
||||
this.outboundTag = outbound
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteRule extends Rule {
|
||||
domain: string[]
|
||||
network: string[]
|
||||
ip: string[]
|
||||
protocol: string[]
|
||||
port: string
|
||||
|
||||
constructor(tag: string, outbound: string) {
|
||||
super(tag, undefined, outbound)
|
||||
this.inboundTag = [XrayEnum.TPROXY_INBOUND_TAG]
|
||||
this.domain = []
|
||||
this.network = []
|
||||
this.ip = []
|
||||
this.protocol = []
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsRule extends Rule {
|
||||
constructor(tag: string[], outbound: string) {
|
||||
super(undefined, tag, outbound)
|
||||
}
|
||||
}
|
||||
|
||||
export class Route<T extends Rule> {
|
||||
rules: T[]
|
||||
domainStrategy: string
|
||||
balancers: Balancer[]
|
||||
|
||||
constructor() {
|
||||
this.rules = []
|
||||
this.domainStrategy = 'AsIs'
|
||||
this.balancers = []
|
||||
}
|
||||
}
|
||||
|
||||
export interface Outbound {
|
||||
settings?: any
|
||||
protocol: string
|
||||
tag: string
|
||||
streamSettings?: any
|
||||
}
|
||||
|
||||
export class BlockOutbound implements Outbound {
|
||||
settings?: any
|
||||
protocol: string
|
||||
tag: string
|
||||
streamSettings?: any
|
||||
|
||||
constructor(tag: string) {
|
||||
this.protocol = 'blackhole'
|
||||
this.tag = tag
|
||||
}
|
||||
}
|
||||
|
||||
export class Socks implements Outbound {
|
||||
settings: any
|
||||
protocol: string
|
||||
tag: string
|
||||
|
||||
constructor(address: string, port: number, tag: string) {
|
||||
this.protocol = 'socks'
|
||||
this.tag = tag
|
||||
this.settings = { address, port }
|
||||
}
|
||||
}
|
||||
|
||||
export class DnsOutbound implements Outbound {
|
||||
settings: any
|
||||
protocol: string
|
||||
tag: string
|
||||
|
||||
constructor(tag: string) {
|
||||
this.protocol = 'dns'
|
||||
this.tag = tag
|
||||
this.settings = {
|
||||
rules: [
|
||||
{ action: 'hijack', qType: '1,28' },
|
||||
{ action: 'return', rCode: 5 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Freedom implements Outbound {
|
||||
settings: any
|
||||
protocol: string
|
||||
tag: string
|
||||
|
||||
constructor(tag: string) {
|
||||
this.protocol = 'freedom'
|
||||
this.tag = tag
|
||||
this.settings = { domainStrategy: XrayEnum.DEFAULT_DNS_STRATEGY }
|
||||
}
|
||||
}
|
||||
|
||||
export interface TlsObject {
|
||||
serverName: string
|
||||
alpn?: string[]
|
||||
}
|
||||
|
||||
export interface SockoptObject {
|
||||
mark?: number
|
||||
tcpMaxSeg?: number
|
||||
tcpFastOpen?: boolean | number
|
||||
tproxy?: string
|
||||
domainStrategy?: string
|
||||
dialerProxy?: string
|
||||
acceptProxyProtocol?: boolean
|
||||
tcpKeepAliveInterval?: number
|
||||
tcpKeepAliveIdle?: number
|
||||
tcpUserTimeout?: number
|
||||
tcpCongestion?: string
|
||||
interface?: string
|
||||
v6only?: boolean
|
||||
tcpWindowClamp?: number
|
||||
tcpMptcp?: boolean
|
||||
}
|
||||
|
||||
export class Balancer {
|
||||
tag: string
|
||||
selector: string[]
|
||||
fallbackTag: string
|
||||
strategy: {
|
||||
type: string
|
||||
settings?: { expected: number; maxRTT: string; tolerance: number }
|
||||
}
|
||||
|
||||
constructor(tag: string) {
|
||||
this.tag = tag
|
||||
this.selector = []
|
||||
this.fallbackTag = undefined
|
||||
this.strategy = {
|
||||
type: 'leastLoad',
|
||||
settings: { expected: 1, maxRTT: '1s', tolerance: 0.05 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class BurstObservatory {
|
||||
subjectSelector: string[]
|
||||
pingConfig: {
|
||||
destination: string
|
||||
connectivity: string
|
||||
interval: string
|
||||
timeout: string
|
||||
sampling: number
|
||||
httpMethod: string
|
||||
}
|
||||
|
||||
constructor(
|
||||
destination: string,
|
||||
connectivity: string,
|
||||
interval: string,
|
||||
timeout: string,
|
||||
sampling: number,
|
||||
httpMethod: string
|
||||
) {
|
||||
this.subjectSelector = []
|
||||
this.pingConfig = {
|
||||
destination: destination || 'https://connectivitycheck.gstatic.com/generate_204',
|
||||
connectivity: connectivity || undefined,
|
||||
interval: interval || '60s',
|
||||
timeout: timeout || '2s',
|
||||
sampling: sampling || 3,
|
||||
httpMethod: httpMethod || undefined,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Inbound {
|
||||
protocol: string
|
||||
tag: string
|
||||
listen?: string
|
||||
port: number
|
||||
settings: any
|
||||
streamSettings: any
|
||||
sniffing: any
|
||||
|
||||
constructor(type: string, port: number, tag: string, listen?: string) {
|
||||
this.protocol = 'dokodemo-door'
|
||||
this.tag = tag
|
||||
this.listen = listen
|
||||
this.port = port
|
||||
this.settings = { network: 'tcp,udp', followRedirect: undefined }
|
||||
if (type === 'tproxy') {
|
||||
this.settings.followRedirect = true
|
||||
this.streamSettings = { sockopt: { tproxy: 'tproxy' } }
|
||||
this.sniffing = {
|
||||
enabled: true,
|
||||
destOverride: ['http', 'tls', 'quic'],
|
||||
routeOnly: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { Hysteria2Config } from '../../../module/luci'
|
||||
import type { Outbound, TlsObject } from '../models'
|
||||
import { xrayRegistry, type XrayProtocol } from './protocol'
|
||||
|
||||
interface HysteriaSettings {
|
||||
version: 2
|
||||
auth: string
|
||||
udpIdleTimeout?: number
|
||||
}
|
||||
|
||||
interface Hysteria2StreamSettings {
|
||||
network: 'hysteria'
|
||||
security: 'tls'
|
||||
tlsSettings: TlsObject
|
||||
hysteriaSettings: HysteriaSettings
|
||||
finalmask?: {
|
||||
quicParams: {
|
||||
brutalUp?: string
|
||||
brutalDown?: string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class Hysteria2Outbound implements Outbound {
|
||||
settings: { version: 2; address: string; port: number }
|
||||
protocol: string
|
||||
tag: string
|
||||
streamSettings: Hysteria2StreamSettings
|
||||
|
||||
constructor(
|
||||
address: string,
|
||||
port: number,
|
||||
tag: string,
|
||||
password: string,
|
||||
serverName: string,
|
||||
udpIdleTimeout?: number,
|
||||
brutalUp?: string,
|
||||
brutalDown?: string
|
||||
) {
|
||||
this.protocol = 'hysteria'
|
||||
this.tag = tag
|
||||
this.settings = { version: 2, address, port }
|
||||
this.streamSettings = {
|
||||
network: 'hysteria',
|
||||
security: 'tls',
|
||||
tlsSettings: { serverName, alpn: ['h3'] },
|
||||
hysteriaSettings: {
|
||||
version: 2,
|
||||
auth: password,
|
||||
...(udpIdleTimeout !== undefined ? { udpIdleTimeout } : {})
|
||||
},
|
||||
...((brutalUp || brutalDown) ? {
|
||||
finalmask: {
|
||||
quicParams: {
|
||||
...(brutalUp ? { brutalUp } : {}),
|
||||
...(brutalDown ? { brutalDown } : {})
|
||||
}
|
||||
}
|
||||
} : {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function formatBrutalMbps(value: string | undefined): string | undefined {
|
||||
const mbps = (value || '').trim()
|
||||
if (!mbps) return undefined
|
||||
return mbps === '0' ? '0' : mbps + ' mbps'
|
||||
}
|
||||
|
||||
const hysteria2Protocol: XrayProtocol = {
|
||||
type: 'hysteria2',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as Hysteria2Config
|
||||
const udpIdleTimeout = c.udp_idle_timeout ? Number(c.udp_idle_timeout) : undefined
|
||||
const brutalUp = formatBrutalMbps(c.up_mbps)
|
||||
const brutalDown = formatBrutalMbps(c.down_mbps)
|
||||
return new Hysteria2Outbound(
|
||||
c.server,
|
||||
Number(c.server_port),
|
||||
c.name,
|
||||
c.password || '',
|
||||
c.tls_server_name || c.server,
|
||||
udpIdleTimeout,
|
||||
brutalUp,
|
||||
brutalDown
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
xrayRegistry.register(hysteria2Protocol)
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import type { Outbound } from '../models'
|
||||
|
||||
export interface XrayProtocol {
|
||||
readonly type: string
|
||||
build(config: ProxyNode): Outbound
|
||||
}
|
||||
|
||||
export class XrayProtocolRegistry {
|
||||
private readonly protocols = new Map<string, XrayProtocol>()
|
||||
|
||||
register(p: XrayProtocol): void {
|
||||
this.protocols.set(p.type, p)
|
||||
}
|
||||
|
||||
build(type: string, config: ProxyNode): Outbound {
|
||||
const p = this.protocols.get(type)
|
||||
if (!p) throw new Error(`Unsupported xray protocol: ${type}`)
|
||||
return p.build(config)
|
||||
}
|
||||
}
|
||||
|
||||
export const xrayRegistry = new XrayProtocolRegistry()
|
||||
@@ -0,0 +1,5 @@
|
||||
// Register all supported xray protocols.
|
||||
// Adding a new protocol: create its file, then add one import here.
|
||||
import './hysteria2'
|
||||
import './shadowsocks'
|
||||
import './vless'
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { ShadowsocksConfig } from '../../../module/luci'
|
||||
import type { Outbound } from '../models'
|
||||
import { xrayRegistry, type XrayProtocol } from './protocol'
|
||||
|
||||
export class ShadowsocksXrayOutbound implements Outbound {
|
||||
settings: {
|
||||
address: string
|
||||
port: number
|
||||
method: string
|
||||
password: string
|
||||
uot?: boolean
|
||||
level: number
|
||||
}
|
||||
protocol: string
|
||||
tag: string
|
||||
|
||||
constructor(
|
||||
address: string,
|
||||
port: number,
|
||||
tag: string,
|
||||
method: string,
|
||||
password: string,
|
||||
uot?: boolean
|
||||
) {
|
||||
this.protocol = 'shadowsocks'
|
||||
this.tag = tag
|
||||
this.settings = { address, port, method, password, uot: uot || undefined, level: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
const shadowsocksProtocol: XrayProtocol = {
|
||||
type: 'shadowsocks',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as ShadowsocksConfig
|
||||
return new ShadowsocksXrayOutbound(
|
||||
c.server,
|
||||
Number(c.server_port),
|
||||
c.name,
|
||||
c.method,
|
||||
c.password,
|
||||
c.udp_over_tcp === '1' ? true : undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
xrayRegistry.register(shadowsocksProtocol)
|
||||
@@ -0,0 +1,201 @@
|
||||
import type { ProxyNode } from '../../../module/luci'
|
||||
import { VlessConfig } from '../../../module/luci'
|
||||
import type { Outbound, TlsObject } from '../models'
|
||||
import { xrayRegistry, type XrayProtocol } from './protocol'
|
||||
|
||||
interface WsObject {
|
||||
host?: string
|
||||
path?: string
|
||||
headers?: Record<string, string>
|
||||
heartbeatPeriod?: number
|
||||
}
|
||||
|
||||
interface GrpcObject {
|
||||
serviceName?: string
|
||||
multiMode?: boolean
|
||||
authority?: string
|
||||
user_agent?: string
|
||||
idle_timeout?: number
|
||||
health_check_timeout?: number
|
||||
permit_without_stream?: boolean
|
||||
initial_windows_size?: number
|
||||
}
|
||||
|
||||
interface HttpObject {
|
||||
host?: string[]
|
||||
path?: string
|
||||
method?: string
|
||||
}
|
||||
|
||||
interface HttpUpgradeObject {
|
||||
host?: string
|
||||
path?: string
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
interface XHTTPObject {
|
||||
host?: string
|
||||
path?: string
|
||||
mode?: string
|
||||
uplinkHTTPMethod?: string
|
||||
}
|
||||
|
||||
interface RealityObject {
|
||||
show?: boolean
|
||||
target?: string
|
||||
xver?: number
|
||||
serverNames?: string[]
|
||||
privateKey?: string
|
||||
minClientVer?: string
|
||||
maxClientVer?: string
|
||||
maxTimeDiff?: number
|
||||
shortIds?: string[]
|
||||
mldsa65Seed?: string
|
||||
fingerprint?: string
|
||||
serverName?: string
|
||||
password?: string
|
||||
shortId?: string
|
||||
mldsa65Verify?: string
|
||||
spiderX?: string
|
||||
}
|
||||
|
||||
interface VlessStreamSettings {
|
||||
network?: string
|
||||
security: string
|
||||
xhttpSettings?: XHTTPObject
|
||||
wsSettings?: WsObject
|
||||
grpcSettings?: GrpcObject
|
||||
httpSettings?: HttpObject
|
||||
httpupgradeSettings?: HttpUpgradeObject
|
||||
realitySettings?: RealityObject
|
||||
tlsSettings?: TlsObject
|
||||
}
|
||||
|
||||
export class VlessOutbound implements Outbound {
|
||||
settings: any
|
||||
protocol: string
|
||||
tag: string
|
||||
streamSettings?: any
|
||||
|
||||
constructor(
|
||||
address: string,
|
||||
port: number,
|
||||
id: string,
|
||||
tag: string,
|
||||
encryption: string = 'none',
|
||||
flow?: string,
|
||||
level: number = 0
|
||||
) {
|
||||
this.protocol = 'vless'
|
||||
this.tag = tag
|
||||
this.settings = { address, port, id, encryption, level }
|
||||
if (flow) this.settings.flow = flow
|
||||
}
|
||||
}
|
||||
|
||||
function parseDurationSeconds(value?: string): number | undefined {
|
||||
const raw = (value || '').trim()
|
||||
if (!raw) return undefined
|
||||
const match = raw.match(/^(\d+)(?:s)?$/)
|
||||
if (!match) return undefined
|
||||
const seconds = Number(match[1])
|
||||
return Number.isFinite(seconds) && seconds > 0 ? seconds : undefined
|
||||
}
|
||||
|
||||
function appendEarlyData(path: string, maxEarlyData?: string): string {
|
||||
const ed = (maxEarlyData || '').trim()
|
||||
if (!ed) return path
|
||||
|
||||
const base = path || '/'
|
||||
const hashIndex = base.indexOf('#')
|
||||
const pathAndQuery = hashIndex >= 0 ? base.slice(0, hashIndex) : base
|
||||
const hash = hashIndex >= 0 ? base.slice(hashIndex) : ''
|
||||
const separator = pathAndQuery.indexOf('?') >= 0 ? '&' : '?'
|
||||
return pathAndQuery + separator + 'ed=' + encodeURIComponent(ed) + hash
|
||||
}
|
||||
|
||||
const vlessProtocol: XrayProtocol = {
|
||||
type: 'vless',
|
||||
build(config: ProxyNode): Outbound {
|
||||
const c = config as VlessConfig
|
||||
|
||||
const outbound = new VlessOutbound(
|
||||
c.server,
|
||||
Number(c.server_port),
|
||||
c.uuid,
|
||||
c.name,
|
||||
'none',
|
||||
c.flow || undefined,
|
||||
0
|
||||
)
|
||||
|
||||
const useReality = c.tls_reality_enabled === '1'
|
||||
const realitySettings: RealityObject | undefined = useReality ? {
|
||||
fingerprint: 'chrome',
|
||||
serverName: c.tls_server_name || c.server,
|
||||
password: c.tls_reality_public_key || '',
|
||||
shortId: c.tls_reality_short_id || ''
|
||||
} : undefined
|
||||
|
||||
const tlsSettings: TlsObject | undefined = !useReality ? {
|
||||
alpn: ['h2', 'http/1.1'],
|
||||
serverName: c.tls_server_name || c.server,
|
||||
} : undefined
|
||||
|
||||
const security = useReality
|
||||
? 'reality'
|
||||
: (c.tls_server_name ? 'tls' : 'none')
|
||||
|
||||
const streamSettings: VlessStreamSettings = { network: 'raw', security }
|
||||
if (realitySettings) streamSettings.realitySettings = realitySettings
|
||||
if (tlsSettings && security === 'tls') streamSettings.tlsSettings = tlsSettings
|
||||
|
||||
switch (c.transport_type || '') {
|
||||
case 'xhttp':
|
||||
streamSettings.network = 'xhttp'
|
||||
streamSettings.xhttpSettings = {
|
||||
host: c.transport_xhttp_host || undefined,
|
||||
path: c.transport_xhttp_path || '/',
|
||||
mode: c.transport_xhttp_mode || 'auto'
|
||||
}
|
||||
break
|
||||
case 'ws':
|
||||
streamSettings.network = 'websocket'
|
||||
streamSettings.wsSettings = {
|
||||
host: c.transport_ws_host || undefined,
|
||||
path: appendEarlyData(c.transport_ws_path || '/', c.transport_ws_max_early_data)
|
||||
}
|
||||
break
|
||||
case 'grpc':
|
||||
streamSettings.network = 'grpc'
|
||||
streamSettings.grpcSettings = {
|
||||
serviceName: c.transport_grpc_service_name || 'TunService',
|
||||
...(parseDurationSeconds(c.transport_grpc_idle_timeout) ? { idle_timeout: parseDurationSeconds(c.transport_grpc_idle_timeout) } : {}),
|
||||
...(parseDurationSeconds(c.transport_grpc_ping_timeout) ? { health_check_timeout: parseDurationSeconds(c.transport_grpc_ping_timeout) } : {}),
|
||||
...(c.transport_grpc_permit_without_stream === '1' ? { permit_without_stream: true } : {})
|
||||
}
|
||||
break
|
||||
case 'http':
|
||||
streamSettings.network = 'xhttp'
|
||||
streamSettings.xhttpSettings = {
|
||||
...(c.transport_http_host ? { host: c.transport_http_host } : {}),
|
||||
path: c.transport_http_path || '/',
|
||||
mode: 'stream-one',
|
||||
...(c.transport_http_method ? { uplinkHTTPMethod: c.transport_http_method } : {})
|
||||
}
|
||||
break
|
||||
case 'httpupgrade':
|
||||
streamSettings.network = 'httpupgrade'
|
||||
streamSettings.httpupgradeSettings = {
|
||||
host: c.transport_httpupgrade_host || undefined,
|
||||
path: c.transport_httpupgrade_path || '/'
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
outbound['streamSettings'] = streamSettings
|
||||
return outbound
|
||||
}
|
||||
}
|
||||
|
||||
xrayRegistry.register(vlessProtocol)
|
||||
@@ -0,0 +1,142 @@
|
||||
export enum LuciFlied {
|
||||
CONF_NAME = 'hijpass',
|
||||
SERVER_CONF_NAME = 'hijserver',
|
||||
|
||||
// 匿名节点类型
|
||||
GLOBAL_SECTION_TYPE = 'hijpass',
|
||||
|
||||
SHUNT_SECTION_TYPE = 'shunt',
|
||||
|
||||
FIREWALL_SECTION_TYPE = 'firewall',
|
||||
DNS_SECTION_TYPE = 'dns',
|
||||
RULE_SECTION_TYPE = 'rule',
|
||||
|
||||
// 命名节点类型
|
||||
SHUNT_ROUTE_RULE_TYPE = 'shunt_route_rule',
|
||||
SHUNT_DNS_NODE_TYPE = 'shunt_dns_node',
|
||||
PROXY_NODE_TYPE = 'proxy_node',
|
||||
SERVER_NODE_TYPE = 'server_node'
|
||||
}
|
||||
|
||||
export const PROXY_TYPE = {
|
||||
CUSTOM: 'custom',
|
||||
LOAD_BALANCE: 'load_balance',
|
||||
HYSTERIA2: 'hysteria2',
|
||||
SHADOWSOCKS: 'shadowsocks',
|
||||
TUIC: 'tuic',
|
||||
SHADOWTLS: 'shadowtls',
|
||||
VLESS: 'vless'
|
||||
} as const;
|
||||
|
||||
export const CORE_TYPE = {
|
||||
XRAY: 'xray',
|
||||
SING_BOX: 'sing-box',
|
||||
CUSTOM: 'custom',
|
||||
} as const;
|
||||
|
||||
export const FIREWALL_DNS_FORWARD = {
|
||||
NONE: 'none',
|
||||
PRE_ROUTING: 'pre-routing',
|
||||
ROUTING: 'routing',
|
||||
} as const;
|
||||
|
||||
export type FirewallDnsForward = typeof FIREWALL_DNS_FORWARD[keyof typeof FIREWALL_DNS_FORWARD];
|
||||
|
||||
// 'custom' 与 xray/sing-box 平级,是独立的核心类型
|
||||
export type CoreType = typeof CORE_TYPE[keyof typeof CORE_TYPE];
|
||||
export type ProxyType = typeof PROXY_TYPE[keyof typeof PROXY_TYPE];
|
||||
|
||||
export function normalizeCoreType(type?: string): string {
|
||||
const normalized = (type || '').trim();
|
||||
return normalized === 'singbox' ? CORE_TYPE.SING_BOX : normalized;
|
||||
}
|
||||
|
||||
// 仅 xray/sing-box 有协议列表;custom 无需协议选择
|
||||
export const CORE_PROTOCOLS: Partial<Record<CoreType, ProxyType[]>> = {
|
||||
[CORE_TYPE.XRAY]: [PROXY_TYPE.SHADOWSOCKS, PROXY_TYPE.HYSTERIA2, PROXY_TYPE.VLESS],
|
||||
[CORE_TYPE.SING_BOX]: [PROXY_TYPE.SHADOWSOCKS, PROXY_TYPE.HYSTERIA2, PROXY_TYPE.VLESS,
|
||||
PROXY_TYPE.TUIC, PROXY_TYPE.SHADOWTLS],
|
||||
};
|
||||
|
||||
export const HijpassValues = {
|
||||
DNS_STRATEGY_VALUES: [
|
||||
{label: 'Prefer IPv6 (sing-box only, xray uses dual stack by default)', value: '64'},
|
||||
{label: 'Prefer IPv4 (sing-box only, xray uses dual stack by default)', value: '46'},
|
||||
{label: 'IPv6 only', value: '6'},
|
||||
{label: 'IPv4 only', value: '4'}
|
||||
],
|
||||
SHUNT_LOG_LEVELS: ['info', 'warn', 'error', 'debug'] as const,
|
||||
PROXY_LOG_LEVELS: ['info', 'warn', 'error', 'debug'] as const,
|
||||
SHUNT_CONF_TYPE: [CORE_TYPE.XRAY, CORE_TYPE.SING_BOX] as const,
|
||||
PROXY_TYPES: [
|
||||
{label: 'Load Balancing', value: 'load_balance'},
|
||||
{label: 'Shadowsocks', value: 'shadowsocks'},
|
||||
{label: 'Hysteria2', value: 'hysteria2'},
|
||||
{label: 'VLESS', value: 'vless'},
|
||||
{label: 'TUIC', value: 'tuic'},
|
||||
{label: 'ShadowTLS', value: 'shadowtls'}
|
||||
] as const,
|
||||
SHADOWSOCKS_METHODS: [
|
||||
{label: '2022-blake3-aes-128-gcm', value: '2022-blake3-aes-128-gcm'},
|
||||
{label: '2022-blake3-aes-256-gcm', value: '2022-blake3-aes-256-gcm'},
|
||||
{label: '2022-blake3-chacha20-poly1305', value: '2022-blake3-chacha20-poly1305'},
|
||||
{label: 'aes-128-gcm', value: 'aes-128-gcm'},
|
||||
{label: 'aes-192-gcm', value: 'aes-192-gcm'},
|
||||
{label: 'aes-256-gcm', value: 'aes-256-gcm'},
|
||||
{label: 'chacha20-ietf-poly1305', value: 'chacha20-ietf-poly1305'},
|
||||
{label: 'xchacha20-ietf-poly1305', value: 'xchacha20-ietf-poly1305'}
|
||||
] as const,
|
||||
MULTIPLEX_PROTOCOLS: [
|
||||
{label: 'smux', value: 'smux'},
|
||||
{label: 'yamux', value: 'yamux'},
|
||||
{label: 'h2mux', value: 'h2mux'}
|
||||
] as const,
|
||||
TUIC_CONGESTION_CONTROLS: [
|
||||
{label: 'cubic', value: 'cubic'},
|
||||
{label: 'new_reno', value: 'new_reno'},
|
||||
{label: 'bbr', value: 'bbr'}
|
||||
] as const,
|
||||
TUIC_UDP_RELAY_MODES: [
|
||||
{label: 'Native UDP', value: 'native'},
|
||||
{label: 'QUIC Stream', value: 'quic'}
|
||||
] as const,
|
||||
SHADOWTLS_VERSIONS: [
|
||||
{label: 'v1', value: '1'},
|
||||
{label: 'v2', value: '2'},
|
||||
{label: 'v3', value: '3'}
|
||||
] as const,
|
||||
VLESS_FLOWS: [
|
||||
{label: 'None', value: ''},
|
||||
{label: 'xtls-rprx-vision', value: 'xtls-rprx-vision'},
|
||||
{label: 'xtls-rprx-vision-udp443', value: 'xtls-rprx-vision-udp443'}
|
||||
] as const,
|
||||
VLESS_PACKET_ENCODINGS: [
|
||||
{label: 'Disabled', value: ''},
|
||||
{label: 'packetaddr', value: 'packetaddr'},
|
||||
{label: 'xudp', value: 'xudp'}
|
||||
] as const,
|
||||
// xray 支持的传输层(含 xhttp;sing-box 不支持 xhttp,由 validate 阻断)
|
||||
V2RAY_TRANSPORT_TYPES_XRAY: [
|
||||
{label: 'None', value: ''},
|
||||
{label: 'HTTP', value: 'http'},
|
||||
{label: 'WebSocket', value: 'ws'},
|
||||
{label: 'gRPC', value: 'grpc'},
|
||||
{label: 'HTTPUpgrade', value: 'httpupgrade'},
|
||||
{label: 'XHTTP', value: 'xhttp'}
|
||||
] as const,
|
||||
HYSTERIA2_OBFS_TYPES: [
|
||||
{label: 'Disabled', value: ''},
|
||||
{label: 'Salamander', value: 'salamander'}
|
||||
] as const,
|
||||
// 通用 core 选项(支持双核心的协议使用)
|
||||
DUAL_CORES: [
|
||||
{label: 'sing-box', value: CORE_TYPE.SING_BOX},
|
||||
{label: 'xray', value: CORE_TYPE.XRAY},
|
||||
{label: 'Custom', value: CORE_TYPE.CUSTOM}
|
||||
] as const,
|
||||
}
|
||||
|
||||
export enum ShuntTag {
|
||||
DIRECT_OUTBOUND_TAG = "hijpass-direct",
|
||||
BLOCK_OUTBOUND_TAG = "hijpass-block",
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum SingBoxEnum {
|
||||
DIRECT_OUTBOUND_TAG = "direct-out",
|
||||
BLOCK_OUTBOUND_TAG = "block",
|
||||
TPROXY_INBOUND_TAG = "tproxy-in",
|
||||
DNS_INBOUND_TAG = "dns-in",
|
||||
LOAD_BALANCE_OUTBOUND_TAG = "slb",
|
||||
DEFAULT_LOG_LEVEL = "info",
|
||||
DEFAULT_DNS_STRATEGY = "prefer_ipv6",
|
||||
RULESET_OUTBOUND_TAG = "ruleset-out",
|
||||
RULESET_DNS_NAME = "ruleset-dns-resolver",
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum XrayEnum {
|
||||
DIRECT_OUTBOUND_TAG = "direct-out",
|
||||
BLOCK_OUTBOUND_TAG = "block",
|
||||
TPROXY_INBOUND_TAG = "tproxy-in",
|
||||
DNS_INBOUND_TAG = "dns-in",
|
||||
DNS_OUTBOUND_TAG = "dns-out",
|
||||
LOAD_BALANCE_OUTBOUND_TAG = "slb",
|
||||
DEFAULT_LOG_LEVEL = "info",
|
||||
DEFAULT_DNS_STRATEGY = "UseIP"
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
import { CORE_TYPE } from "../enum/hijpass";
|
||||
|
||||
export class DnsNode {
|
||||
tag: string;
|
||||
type: string;
|
||||
server: string;
|
||||
port: string;
|
||||
}
|
||||
|
||||
export class DnsSection {
|
||||
defaultDnsNode: string;
|
||||
defaultStrategy: string;
|
||||
rulesetDnsNode: string;
|
||||
useCache: string;
|
||||
nodes: DnsNode[];
|
||||
|
||||
constructor() {
|
||||
this.nodes = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class ShuntRouteRule {
|
||||
enabled: string;
|
||||
name: string;
|
||||
proxyNode: string;
|
||||
ipVersionSplit: string;
|
||||
proxyNodeV4: string;
|
||||
proxyNodeV6: string;
|
||||
|
||||
protocol: string[];
|
||||
network: string[];
|
||||
port: string[];
|
||||
portRange: string[];
|
||||
domainList: string;
|
||||
ipList: string;
|
||||
|
||||
dnsNode: string;
|
||||
dnsProxyNode: string;
|
||||
dnsStrategy: string;
|
||||
|
||||
constructor() {
|
||||
this.port = [];
|
||||
this.portRange = [];
|
||||
this.network = [];
|
||||
this.protocol = [];
|
||||
this.ipVersionSplit = '0';
|
||||
}
|
||||
}
|
||||
|
||||
export class RouteSection {
|
||||
defaultProxyNode: string;
|
||||
rulesetOutNode: string;
|
||||
rulesetConvert: string;
|
||||
rules: ShuntRouteRule[];
|
||||
|
||||
constructor() {
|
||||
this.rules = [];
|
||||
}
|
||||
}
|
||||
|
||||
export class ShuntSection {
|
||||
type: string;
|
||||
configType: string;
|
||||
shuntListenPort: string;
|
||||
dnsListenPort: string;
|
||||
logLevel: string;
|
||||
logPath: string;
|
||||
route: RouteSection;
|
||||
dns: DnsSection;
|
||||
}
|
||||
|
||||
export class RuleDTO {
|
||||
domain: string[];
|
||||
domainRegex: string[];
|
||||
domainKeyword: string[];
|
||||
domainSuffix: string[];
|
||||
ipCidr: string[];
|
||||
|
||||
network: string[];
|
||||
protocol: string[];
|
||||
port: number[];
|
||||
portRange: string[];
|
||||
ipVersion?: 4 | 6;
|
||||
|
||||
ruleSet: RuleSetDTO[]; // 规则集合
|
||||
geoIp: string[];
|
||||
geoSite: string[];
|
||||
|
||||
constructor() {
|
||||
this.domain = [];
|
||||
this.domainRegex = [];
|
||||
this.domainKeyword = [];
|
||||
this.domainSuffix = [];
|
||||
this.ruleSet = [];
|
||||
this.ipCidr = [];
|
||||
this.geoIp = [];
|
||||
this.geoSite = [];
|
||||
this.network = [];
|
||||
this.protocol = [];
|
||||
this.port = [];
|
||||
this.portRange = [];
|
||||
}
|
||||
|
||||
containDomain(): boolean {
|
||||
return this.domain.length > 0
|
||||
|| this.domainRegex.length > 0
|
||||
|| this.domainSuffix.length > 0
|
||||
|| this.domainKeyword.length > 0
|
||||
}
|
||||
|
||||
containGeo(): boolean {
|
||||
return this.geoSite.length > 0 || this.geoIp.length > 0
|
||||
}
|
||||
|
||||
containRuleSet(): boolean {
|
||||
return this.ruleSet.length > 0
|
||||
}
|
||||
|
||||
containIp(): boolean {
|
||||
return this.ipCidr.length > 0
|
||||
|| this.geoIp.length > 0
|
||||
}
|
||||
|
||||
isEmpty(collectionType: "geo" | "set"): boolean {
|
||||
return !(this.containDomain()
|
||||
|| this.containIp()
|
||||
|| (collectionType === "geo" && this.containGeo())
|
||||
|| (collectionType === "set" && this.containRuleSet())
|
||||
|| this.network.length > 0
|
||||
|| this.port.length > 0
|
||||
|| this.portRange.length > 0
|
||||
|| this.protocol.length > 0)
|
||||
}
|
||||
}
|
||||
|
||||
export interface ShuntRouteRuleVariant {
|
||||
rule: ShuntRouteRule;
|
||||
proxyNode: string;
|
||||
ipVersion?: 4 | 6;
|
||||
}
|
||||
|
||||
export interface RuleSetDTO {
|
||||
tag: string;
|
||||
type: string;
|
||||
format: string;
|
||||
pathOrUrl: string;
|
||||
}
|
||||
|
||||
export interface CoreAdapter {
|
||||
genShuntConf: () => {}
|
||||
genProxyConf: (proxyNode: ProxyNode) => {}
|
||||
}
|
||||
|
||||
export interface LuciConvertUtils {
|
||||
convert: (ProxyNode: ProxyNode) => {}
|
||||
}
|
||||
|
||||
/**
|
||||
* 代理节点基础配置
|
||||
*/
|
||||
export interface ProxyNode {
|
||||
name: string;
|
||||
cfgid?: string;
|
||||
type: string;
|
||||
core?: string;
|
||||
enabled: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
upstream_proxy_node?: string;
|
||||
procd_env: string[];
|
||||
log_level?: string;
|
||||
log_path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 负载均衡代理节点。
|
||||
*
|
||||
* 成员节点只通过其本地 SOCKS 端口接入,不复制成员的协议配置。
|
||||
*/
|
||||
export class LoadBalanceProxyNode implements ProxyNode {
|
||||
name: string;
|
||||
type: 'load_balance' = 'load_balance';
|
||||
core: string = CORE_TYPE.SING_BOX;
|
||||
enabled: string = '1';
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
member_node: string[];
|
||||
procd_env: string[];
|
||||
log_level: string;
|
||||
log_path: string;
|
||||
|
||||
// sing-box urltest
|
||||
url: string;
|
||||
interval: string;
|
||||
tolerance: string;
|
||||
idle_timeout: string;
|
||||
interrupt_exist_connections: string;
|
||||
|
||||
// Xray balancer
|
||||
strategy: string;
|
||||
strategy_expected: string;
|
||||
strategy_max_rtt: string;
|
||||
strategy_tolerance: string;
|
||||
fallback_tag: string;
|
||||
|
||||
// Xray burstObservatory
|
||||
probe_url: string;
|
||||
probe_connectivity: string;
|
||||
probe_interval: string;
|
||||
probe_timeout: string;
|
||||
probe_sampling: string;
|
||||
probe_http_method: string;
|
||||
|
||||
constructor() {
|
||||
this.member_node = [];
|
||||
this.procd_env = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义代理配置
|
||||
*/
|
||||
export class CustomProxyNode implements ProxyNode {
|
||||
name: string;
|
||||
type: 'custom' = 'custom';
|
||||
enabled: string = '1';
|
||||
command: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
procd_env: string[];
|
||||
log_enabled: string;
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'custom';
|
||||
this.enabled = '1';
|
||||
this.procd_env = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hysteria2 代理配置
|
||||
*/
|
||||
export class Hysteria2Config implements ProxyNode {
|
||||
name: string;
|
||||
type: 'hysteria2' = 'hysteria2';
|
||||
enabled: string = '1';
|
||||
/** 核心选择:'' 或 'sing-box' 使用 sing-box,'xray' 使用 xray */
|
||||
core: string;
|
||||
server: string;
|
||||
server_port: string;
|
||||
// sing-box 独有
|
||||
server_ports: string[];
|
||||
hop_interval: string;
|
||||
obfs_type: string;
|
||||
obfs_password: string;
|
||||
brutal_debug: string;
|
||||
// xray 独有
|
||||
udp_idle_timeout: string;
|
||||
// 共有
|
||||
up_mbps: string;
|
||||
down_mbps: string;
|
||||
password: string;
|
||||
network: string;
|
||||
tls_server_name: string;
|
||||
tls_insecure: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
|
||||
procd_env: string[];
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'hysteria2';
|
||||
this.enabled = '1';
|
||||
this.core = CORE_TYPE.SING_BOX;
|
||||
this.server_ports = [];
|
||||
this.network = '';
|
||||
this.brutal_debug = '0';
|
||||
this.tls_insecure = '0';
|
||||
// TLS 默认启用(不可配置)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shadowsocks 代理配置
|
||||
*/
|
||||
export class ShadowsocksConfig implements ProxyNode {
|
||||
name: string;
|
||||
type: 'shadowsocks' = 'shadowsocks';
|
||||
enabled: string = '1';
|
||||
/** 核心选择:'sing-box'(默认)或 'xray' */
|
||||
core: string;
|
||||
server: string;
|
||||
server_port: string;
|
||||
method: string;
|
||||
password: string;
|
||||
plugin: string;
|
||||
plugin_opts: string;
|
||||
network: string;
|
||||
udp_over_tcp: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
multiplex_enabled: string;
|
||||
multiplex_protocol: string;
|
||||
multiplex_max_connections: string;
|
||||
multiplex_min_streams: string;
|
||||
multiplex_max_streams: string;
|
||||
multiplex_brutal_enabled: string;
|
||||
multiplex_brutal_up_mbps: string;
|
||||
multiplex_brutal_down_mbps: string;
|
||||
|
||||
procd_env: string[];
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'shadowsocks';
|
||||
this.enabled = '1';
|
||||
this.core = CORE_TYPE.SING_BOX;
|
||||
this.network = '';
|
||||
this.udp_over_tcp = '0';
|
||||
this.multiplex_enabled = '0';
|
||||
this.multiplex_protocol = 'h2mux';
|
||||
this.multiplex_brutal_enabled = '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TUIC 代理配置
|
||||
*/
|
||||
export class TuicConfig implements ProxyNode {
|
||||
name: string;
|
||||
type: 'tuic' = 'tuic';
|
||||
enabled: string = '1';
|
||||
core: string;
|
||||
server: string;
|
||||
server_port: string;
|
||||
uuid: string;
|
||||
password: string;
|
||||
congestion_control: string;
|
||||
udp_relay_mode: string;
|
||||
udp_over_stream: string;
|
||||
zero_rtt_handshake: string;
|
||||
heartbeat: string;
|
||||
network: string;
|
||||
tls_server_name: string;
|
||||
tls_insecure: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
|
||||
procd_env: string[];
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'tuic';
|
||||
this.enabled = '1';
|
||||
this.core = CORE_TYPE.SING_BOX;
|
||||
this.congestion_control = 'cubic';
|
||||
this.udp_relay_mode = '';
|
||||
this.udp_over_stream = '0';
|
||||
this.zero_rtt_handshake = '0';
|
||||
this.heartbeat = '10s';
|
||||
this.network = '';
|
||||
this.tls_insecure = '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ShadowTLS 代理配置
|
||||
*/
|
||||
export class ShadowTLSConfig implements ProxyNode {
|
||||
name: string;
|
||||
type: 'shadowtls' = 'shadowtls';
|
||||
enabled: string = '1';
|
||||
core: string;
|
||||
server: string;
|
||||
server_port: string;
|
||||
version: string;
|
||||
password: string;
|
||||
tls_server_name: string;
|
||||
tls_insecure: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
procd_env: string[];
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'shadowtls';
|
||||
this.enabled = '1';
|
||||
this.core = CORE_TYPE.SING_BOX;
|
||||
this.version = '1';
|
||||
this.tls_insecure = '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* VLESS 代理配置
|
||||
*/
|
||||
export class VlessConfig implements ProxyNode {
|
||||
name: string;
|
||||
type: 'vless' = 'vless';
|
||||
enabled: string = '1';
|
||||
/** 核心选择:'sing-box' 使用 sing-box,'xray'(默认)使用 xray */
|
||||
core: string;
|
||||
server: string;
|
||||
server_port: string;
|
||||
uuid: string;
|
||||
flow: string;
|
||||
network: string;
|
||||
packet_encoding: string;
|
||||
tls_server_name: string;
|
||||
tls_insecure: string;
|
||||
tls_reality_enabled: string;
|
||||
tls_reality_public_key: string;
|
||||
tls_reality_short_id: string;
|
||||
listen_port: string;
|
||||
socks_port: string;
|
||||
multiplex_enabled: string;
|
||||
multiplex_protocol: string;
|
||||
multiplex_max_connections: string;
|
||||
multiplex_min_streams: string;
|
||||
multiplex_max_streams: string;
|
||||
multiplex_brutal_enabled: string;
|
||||
multiplex_brutal_up_mbps: string;
|
||||
multiplex_brutal_down_mbps: string;
|
||||
// V2Ray 传输层
|
||||
transport_type: string;
|
||||
transport_http_host: string;
|
||||
transport_http_path: string;
|
||||
transport_http_method: string;
|
||||
transport_http_idle_timeout: string;
|
||||
transport_http_ping_timeout: string;
|
||||
transport_ws_host: string;
|
||||
transport_ws_path: string;
|
||||
transport_ws_max_early_data: string;
|
||||
transport_ws_early_data_header_name: string;
|
||||
transport_grpc_service_name: string;
|
||||
transport_grpc_idle_timeout: string;
|
||||
transport_grpc_ping_timeout: string;
|
||||
transport_grpc_permit_without_stream: string;
|
||||
transport_httpupgrade_host: string;
|
||||
transport_httpupgrade_path: string;
|
||||
// XHTTP 传输层
|
||||
transport_xhttp_host: string;
|
||||
transport_xhttp_path: string;
|
||||
transport_xhttp_mode: string;
|
||||
|
||||
procd_env: string[];
|
||||
log_path: string;
|
||||
|
||||
constructor() {
|
||||
this.type = 'vless';
|
||||
this.enabled = '1';
|
||||
this.core = CORE_TYPE.XRAY; // VLESS 默认用 xray(支持 Vision/XHTTP+REALITY)
|
||||
this.network = 'tcp';
|
||||
this.packet_encoding = '';
|
||||
this.tls_insecure = '0';
|
||||
this.tls_reality_enabled = '0';
|
||||
this.multiplex_enabled = '0';
|
||||
this.multiplex_protocol = 'h2mux';
|
||||
this.multiplex_brutal_enabled = '0';
|
||||
// 传输层默认值
|
||||
this.transport_type = '';
|
||||
this.transport_http_idle_timeout = '15s';
|
||||
this.transport_http_ping_timeout = '15s';
|
||||
this.transport_grpc_idle_timeout = '15s';
|
||||
this.transport_grpc_ping_timeout = '15s';
|
||||
this.transport_grpc_permit_without_stream = '0';
|
||||
// XHTTP 传输层默认值
|
||||
this.transport_xhttp_path = '/';
|
||||
this.transport_xhttp_mode = 'auto';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
/**
|
||||
* 右侧滑动弹窗通知组件
|
||||
* 支持成功、错误、警告、信息四种类型
|
||||
*/
|
||||
|
||||
export interface NotificationOptions {
|
||||
message?: string; // 兼容旧版本,如果只传 message 则作为 title
|
||||
title?: string; // 通知标题
|
||||
content?: string; // 通知内容
|
||||
type?: 'success' | 'error' | 'warning' | 'info';
|
||||
duration?: number; // 毫秒,0 表示不自动关闭
|
||||
icon?: boolean; // 是否显示图标
|
||||
}
|
||||
|
||||
class NotificationManager {
|
||||
private container: HTMLElement | null = null;
|
||||
private notifications: Map<string, HTMLElement> = new Map();
|
||||
private notificationId: number = 0;
|
||||
private autoCloseTimers: Map<string, any> = new Map();
|
||||
|
||||
constructor() {
|
||||
this.initContainer();
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化通知容器
|
||||
*/
|
||||
private initContainer() {
|
||||
if (this.container) return;
|
||||
|
||||
this.container = document.createElement('div');
|
||||
this.container.id = 'notification-container';
|
||||
this.container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
pointer-events: none;
|
||||
`;
|
||||
document.body.appendChild(this.container);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取通知类型对应的颜色和图标
|
||||
*/
|
||||
private getTypeConfig(type: string) {
|
||||
const configs: Record<string, any> = {
|
||||
success: {
|
||||
bgColor: '#4caf50',
|
||||
borderColor: '#45a049',
|
||||
icon: '✓',
|
||||
textColor: '#fff'
|
||||
},
|
||||
error: {
|
||||
bgColor: '#f44336',
|
||||
borderColor: '#da190b',
|
||||
icon: '✕',
|
||||
textColor: '#fff'
|
||||
},
|
||||
warning: {
|
||||
bgColor: '#ff9800',
|
||||
borderColor: '#e68900',
|
||||
icon: '⚠',
|
||||
textColor: '#fff'
|
||||
},
|
||||
info: {
|
||||
bgColor: '#2196f3',
|
||||
borderColor: '#0b7dda',
|
||||
icon: 'ℹ',
|
||||
textColor: '#fff'
|
||||
}
|
||||
};
|
||||
return configs[type] || configs.info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示通知
|
||||
*/
|
||||
show(options: NotificationOptions) {
|
||||
const {
|
||||
message,
|
||||
title,
|
||||
content,
|
||||
type = 'info',
|
||||
duration = 3000,
|
||||
icon = false
|
||||
} = options;
|
||||
|
||||
this.initContainer();
|
||||
|
||||
const id = `notification-${++this.notificationId}`;
|
||||
const config = this.getTypeConfig(type);
|
||||
|
||||
// 确定标题和内容
|
||||
// 如果提供了 title 和 content,使用它们
|
||||
// 否则,如果提供了 message,使用 message 作为标题
|
||||
const notificationTitle = title || message;
|
||||
const notificationContent = content;
|
||||
|
||||
// 创建通知元素
|
||||
const notification = document.createElement('div');
|
||||
notification.id = id;
|
||||
notification.style.cssText = `
|
||||
background-color: ${config.bgColor};
|
||||
border-left: 4px solid ${config.borderColor};
|
||||
color: ${config.textColor};
|
||||
padding: 16px 20px;
|
||||
margin-bottom: 10px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
min-width: 300px;
|
||||
max-width: 400px;
|
||||
word-break: break-word;
|
||||
animation: slideInRight 0.3s ease-out;
|
||||
pointer-events: auto;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
`;
|
||||
|
||||
// 创建图标
|
||||
if (icon) {
|
||||
const iconEl = document.createElement('span');
|
||||
iconEl.style.cssText = `
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
margin-top: 2px;
|
||||
`;
|
||||
iconEl.textContent = config.icon;
|
||||
notification.appendChild(iconEl);
|
||||
}
|
||||
|
||||
// 创建内容容器
|
||||
const contentContainer = document.createElement('div');
|
||||
contentContainer.style.cssText = `
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
`;
|
||||
|
||||
// 创建标题
|
||||
if (notificationTitle) {
|
||||
const titleEl = document.createElement('div');
|
||||
titleEl.style.cssText = `
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
line-height: 1.4;
|
||||
`;
|
||||
titleEl.textContent = notificationTitle;
|
||||
contentContainer.appendChild(titleEl);
|
||||
}
|
||||
|
||||
// 创建内容
|
||||
if (notificationContent) {
|
||||
const contentEl = document.createElement('div');
|
||||
contentEl.style.cssText = `
|
||||
font-weight: 400;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.9;
|
||||
white-space: pre-line;
|
||||
`;
|
||||
contentEl.textContent = notificationContent;
|
||||
contentContainer.appendChild(contentEl);
|
||||
}
|
||||
|
||||
notification.appendChild(contentContainer);
|
||||
|
||||
// 创建关闭按钮
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.style.cssText = `
|
||||
background: none;
|
||||
border: none;
|
||||
color: ${config.textColor};
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0.7;
|
||||
transition: opacity 0.2s;
|
||||
`;
|
||||
closeBtn.innerHTML = '×';
|
||||
closeBtn.onmouseover = () => closeBtn.style.opacity = '1';
|
||||
closeBtn.onmouseout = () => closeBtn.style.opacity = '0.7';
|
||||
closeBtn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
this.remove(id);
|
||||
};
|
||||
notification.appendChild(closeBtn);
|
||||
|
||||
// 添加到容器
|
||||
this.container!.appendChild(notification);
|
||||
this.notifications.set(id, notification);
|
||||
|
||||
// 自动关闭
|
||||
if (duration > 0) {
|
||||
const timer = setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, duration);
|
||||
this.autoCloseTimers.set(id, timer);
|
||||
|
||||
// 鼠标悬停时暂停自动关闭
|
||||
notification.onmouseenter = () => {
|
||||
const existingTimer = this.autoCloseTimers.get(id);
|
||||
if (existingTimer) {
|
||||
clearTimeout(existingTimer);
|
||||
this.autoCloseTimers.delete(id);
|
||||
}
|
||||
};
|
||||
|
||||
// 鼠标离开时恢复自动关闭
|
||||
notification.onmouseleave = () => {
|
||||
if (this.notifications.has(id)) {
|
||||
const newTimer = setTimeout(() => {
|
||||
this.remove(id);
|
||||
}, duration);
|
||||
this.autoCloseTimers.set(id, newTimer);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除通知
|
||||
*/
|
||||
private remove(id: string) {
|
||||
const notification = this.notifications.get(id);
|
||||
if (!notification) return;
|
||||
|
||||
// 清除自动关闭定时器
|
||||
const timer = this.autoCloseTimers.get(id);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.autoCloseTimers.delete(id);
|
||||
}
|
||||
|
||||
notification.style.animation = 'slideOutRight 0.3s ease-in';
|
||||
notification.style.opacity = '0';
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove();
|
||||
this.notifications.delete(id);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示成功通知
|
||||
*/
|
||||
success(message: string, duration?: number) {
|
||||
return this.show({ message, type: 'success', duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示错误通知
|
||||
*/
|
||||
error(message: string, duration?: number) {
|
||||
return this.show({ message, type: 'error', duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示警告通知
|
||||
*/
|
||||
warning(message: string, duration?: number) {
|
||||
return this.show({ message, type: 'warning', duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示信息通知
|
||||
*/
|
||||
info(message: string, duration?: number) {
|
||||
return this.show({ message, type: 'info', duration });
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有通知
|
||||
*/
|
||||
clear() {
|
||||
this.notifications.forEach((_, id) => {
|
||||
this.remove(id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 创建全局单例
|
||||
const notificationManager = new NotificationManager();
|
||||
|
||||
// 注入 CSS 动画
|
||||
function injectStyles() {
|
||||
if (document.getElementById('notification-styles')) return;
|
||||
|
||||
const style = document.createElement('style');
|
||||
style.id = 'notification-styles';
|
||||
style.textContent = `
|
||||
@keyframes slideInRight {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideOutRight {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#notification-container {
|
||||
left: 10px !important;
|
||||
right: 10px !important;
|
||||
top: 10px !important;
|
||||
}
|
||||
|
||||
#notification-container > div {
|
||||
min-width: auto !important;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
// 页面加载时注入样式
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', injectStyles);
|
||||
} else {
|
||||
injectStyles();
|
||||
}
|
||||
|
||||
export default notificationManager;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "luci-app-hijpass",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "node build.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.25.12",
|
||||
"typescript": "5.9.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const path = require('node:path');
|
||||
|
||||
const context = vm.createContext({
|
||||
exports: {}, require: () => ({}),
|
||||
L: { url: value => '/cgi-bin/luci/' + value },
|
||||
E: (tag, attrs, children) => ({ tag, attrs, children }),
|
||||
});
|
||||
vm.runInContext(ts.transpileModule(readFileSync(path.join(__dirname,
|
||||
'../utils/feature/overview/flow-preview.ts'), 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText, context);
|
||||
|
||||
test('configuration cards use native links with correct LuCI paths', () => {
|
||||
for (const page of ['firewall', 'proxy', 'shunt', 'dns', 'dhcp']) {
|
||||
const card = context.renderStep({ title: page, page });
|
||||
assert.equal(card.tag, 'a');
|
||||
assert.equal(card.attrs.href, '/cgi-bin/luci/' +
|
||||
(page === 'dhcp' ? 'admin/network/dhcp' : 'admin/services/hijpass/' + page));
|
||||
}
|
||||
const result = context.renderStep({ title: 'Direct' });
|
||||
assert.equal(result.tag, 'div');
|
||||
assert.equal(result.attrs.href, undefined);
|
||||
});
|
||||
|
||||
test('parallel and ordered configuration conditions support navigation', () => {
|
||||
for (const render of [context.renderParallelBranch, context.renderOrderedBranch]) {
|
||||
const branch = render({ condition: 'DNS list', page: 'dns', steps: [] });
|
||||
const card = branch.children.find(child => child.tag === 'a');
|
||||
assert.equal(card.attrs.href, '/cgi-bin/luci/admin/services/hijpass/dns');
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
const path = require('node:path');
|
||||
|
||||
function preview(shunt, firewall, nodes = []) {
|
||||
const sections = { hijpass: { ip_proxy: '/rules/proxy' }, firewall, shunt, dns: {} };
|
||||
const context = vm.createContext({ exports: {}, require(name) {
|
||||
if (name === 'uci') return {
|
||||
get_first: (_, type) => sections[type],
|
||||
sections: () => [{ enabled: '1', dns_node: 'dns', proxy_node: 'other' }]
|
||||
};
|
||||
if (name.endsWith('/hijpass')) return {
|
||||
LuciFlied: { CONF_NAME: 'hijpass', GLOBAL_SECTION_TYPE: 'hijpass',
|
||||
FIREWALL_SECTION_TYPE: 'firewall', DNS_SECTION_TYPE: 'dns', SHUNT_SECTION_TYPE: 'shunt' },
|
||||
FIREWALL_DNS_FORWARD: { NONE: 'none' }, PROXY_TYPE: { LOAD_BALANCE: 'load_balance' }
|
||||
};
|
||||
if (name.endsWith('/dns-forward')) return { FirewallDnsForwardUtils: { getSource: () => 'none' } };
|
||||
if (name.endsWith('/chain')) return { ProxyChainUtils: {
|
||||
getProxySectionMap: () => new Map(nodes.map(node => [node.name, node])),
|
||||
getProxyCore: node => node.core,
|
||||
collectProxyChain: name => ({ chain: [name] })
|
||||
} };
|
||||
throw new Error(name);
|
||||
} });
|
||||
vm.runInContext("String.prototype.format = function (...args) { let i = 0; return this.replace(/%s/g, () => args[i++]); }; function _(text) { return text; }", context);
|
||||
const source = readFileSync(path.join(__dirname, '../utils/feature/overview/flow-model.ts'), 'utf8');
|
||||
vm.runInContext(ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, esModuleInterop: true }
|
||||
}).outputText, context);
|
||||
return context.exports.buildConfigurationFlowPreview().diagrams[1];
|
||||
}
|
||||
|
||||
for (const type of ['sing-box', 'xray']) {
|
||||
for (const config_type of ['tmpl', 'custom']) {
|
||||
test(`${type} ${config_type}: show port and routing resource without internal outbounds`, () => {
|
||||
const diagram = preview({ enabled: '1', shunt_listen_port: '18081', type, config_type }, { shunt_port: '18081' });
|
||||
for (const branch of diagram.branches) {
|
||||
assert.equal(branch.steps.length, 2);
|
||||
assert.equal(branch.steps[0].title, 'Default shunt port: 18081');
|
||||
assert.equal(branch.steps[0].detail, undefined);
|
||||
assert.equal(branch.steps[1].title, 'Default shunt');
|
||||
assert.equal(branch.steps[0].page, 'firewall');
|
||||
assert.equal(branch.steps[1].page, 'shunt');
|
||||
assert.equal(branch.steps[1].detail, type === 'xray' ? 'Xray' : type);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('both default and proxy ports show the actual node resource', () => {
|
||||
const diagram = preview({ shunt_listen_port: '18081' }, { shunt_port: '7890', proxy_port: '7890' },
|
||||
[{ enabled: '1', name: 'node', listen_port: '7890', core: 'sing-box' }]);
|
||||
assert.equal(diagram.branches[0].steps.length, 2);
|
||||
assert.equal(diagram.branches[0].steps[0].title, 'Proxy port: 7890');
|
||||
assert.equal(diagram.branches.at(-1).steps[0].title, 'Default shunt port: 7890');
|
||||
for (const branch of diagram.branches) {
|
||||
assert.equal(branch.steps.length, 2);
|
||||
assert.equal(branch.steps[1].title, 'Proxy node: node');
|
||||
assert.equal(branch.steps[0].page, 'firewall');
|
||||
assert.equal(branch.steps[1].page, 'proxy');
|
||||
assert.equal(branch.steps[1].detail, 'sing-box');
|
||||
}
|
||||
});
|
||||
|
||||
test('disabled routing service retains warning', () => {
|
||||
const diagram = preview({ enabled: '0', shunt_listen_port: '18081' }, { shunt_port: '18081' });
|
||||
assert.equal(diagram.branches.at(-1).steps[0].tone, 'warning');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const { readFileSync } = require('node:fs');
|
||||
const { test } = require('node:test');
|
||||
const vm = require('node:vm');
|
||||
const ts = require('typescript');
|
||||
|
||||
test('service status does not cause its parent form to be saved twice', async () => {
|
||||
const source = readFileSync(new URL('../utils/base/luci/service.ts', `file://${__filename}`), 'utf8');
|
||||
const { outputText } = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, esModuleInterop: true }
|
||||
});
|
||||
function E(tag, attrs, children) {
|
||||
const node = { tag, attrs, children: Array.isArray(children) ? children : [children] };
|
||||
for (const child of node.children) {
|
||||
if (child && typeof child === 'object') child.parentNode = node;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
const sandbox = {
|
||||
exports: {}, E, _: value => value,
|
||||
window: { setTimeout() {} },
|
||||
require(name) {
|
||||
if (name === 'rpc') return { declare: () => () => Promise.resolve({}) };
|
||||
if (name === 'poll') return { add() {} };
|
||||
return {};
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(outputText, sandbox);
|
||||
const status = sandbox.exports.ServiceUtils.renderServiceStatus('dns')();
|
||||
const root = E('div', { class: 'cbi-map' }, status);
|
||||
let saves = 0;
|
||||
let deletes = 0;
|
||||
let customOptionExists = true;
|
||||
root.instance = { async save() {
|
||||
saves++;
|
||||
deletes++;
|
||||
assert.ok(customOptionExists, 'duplicate delete would return UCI not found');
|
||||
customOptionExists = false;
|
||||
} };
|
||||
const maps = [];
|
||||
function visit(node) {
|
||||
if (!node || typeof node !== 'object') return;
|
||||
if ((node.attrs.class || '').split(/\s+/).includes('cbi-map')) maps.push(node);
|
||||
node.children.forEach(visit);
|
||||
}
|
||||
visit(root);
|
||||
// LuCI handleSave scans .cbi-map; findClassInstance walks up to the owner.
|
||||
await Promise.all(maps.map(node => {
|
||||
while (!node.instance) node = node.parentNode;
|
||||
return node.instance.save();
|
||||
}));
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(deletes, 1);
|
||||
assert.equal(status.children[0].attrs.class, 'cbi-section');
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": false,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"useDefineForClassFields": true
|
||||
},
|
||||
"include": [
|
||||
"views/**/*.ts",
|
||||
"types/**/*.d.ts",
|
||||
"enum/**/*.ts",
|
||||
"module/**/*.ts",
|
||||
"utils/**/*.ts",
|
||||
"core/**/*.ts"
|
||||
]
|
||||
}
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
// luci.d.ts
|
||||
|
||||
declare namespace LuCI {
|
||||
interface View {
|
||||
extend?(view: View): View;
|
||||
|
||||
load?(): any | Promise<any>;
|
||||
|
||||
render?(load_results?: any | null): Node | Promise.<Node>;
|
||||
|
||||
handleSave?(ev?: Event): any | Promise<any>;
|
||||
|
||||
handleSaveApply?(ev?: Event, mode?: string | number): any | Promise<any>;
|
||||
|
||||
handleReset?(ev?: Event): any | Promise<any>;
|
||||
|
||||
addFooter?(): DocumentFragment
|
||||
|
||||
prototype?: View;
|
||||
}
|
||||
|
||||
interface L {
|
||||
Poll: LuCI.poll.Poll
|
||||
dom: LuCI.dom.DOM
|
||||
|
||||
resolveDefault(value: any, defvalue?: any): Promise<any>;
|
||||
|
||||
bind(fn: function, self, ...args?): function
|
||||
|
||||
url(...parts: string[]): string
|
||||
}
|
||||
|
||||
declare namespace form {
|
||||
interface Form {
|
||||
Map: new (config: string, title: string, description?: string) => LuCI.form.Map;
|
||||
TypedSection: LuCI.form.TypedSection;
|
||||
Value: LuCI.form.Value;
|
||||
Flag: LuCI.form.FlagValue;
|
||||
ListValue: LuCI.form.ListValue;
|
||||
Button: LuCI.form.ButtonValue;
|
||||
DynamicList: LuCI.form.DynamicList;
|
||||
SectionValue: LuCI.form.SectionValue;
|
||||
TextValue: LuCI.form.TextValue;
|
||||
GridSection: LuCI.form.GridSection;
|
||||
MultiValue: LuCI.form.MultiValue;
|
||||
AbstractElement: LuCI.form.AbstractElement;
|
||||
DummyValue: LuCI.form.DummyValue;
|
||||
}
|
||||
|
||||
interface AbstractElement {
|
||||
append(obj): void
|
||||
|
||||
parse(): Promise<void>
|
||||
|
||||
render(): Node | Promise.<Node>;
|
||||
|
||||
stripTags(s): string
|
||||
|
||||
titleFn(property, fmt_args): (string | null)
|
||||
}
|
||||
|
||||
interface AbstractValue extends LuCI.form.AbstractElement {
|
||||
datatype: string
|
||||
default: any
|
||||
editable: boolean
|
||||
modalonly: boolean
|
||||
onchange: function
|
||||
optional: boolean
|
||||
readonly: boolean
|
||||
retain: boolean
|
||||
rmempty: boolean
|
||||
uciconfig: string
|
||||
ucioption: string
|
||||
ucisection: string
|
||||
validate: function
|
||||
width: number | string
|
||||
section: LuCI.form.AbstractSection
|
||||
|
||||
cbid(section_id: string): string
|
||||
|
||||
cfgvalue(section_id: string): any
|
||||
|
||||
depends(field: (string | Object.<string, (string | RegExp)>), value?: (string | RegExp)): void
|
||||
|
||||
formvalue(section_id: string): any
|
||||
|
||||
getUIElement(section_id: string): (LuCI.ui.AbstractElement | null)
|
||||
|
||||
getValidationError(section_id: string): string
|
||||
|
||||
isActive(section_id: string): boolean
|
||||
|
||||
isValid(section_id: string): boolean
|
||||
|
||||
load(section_id: string): (* | Promise<*>)
|
||||
|
||||
parse(section_id: string): (Promise<void>)
|
||||
|
||||
remove(section_id: string): void
|
||||
|
||||
textvalue(section_id: string): string | Node
|
||||
|
||||
write(section_id: string, formvalue: string | Array<string>): void
|
||||
|
||||
renderWidget(section_id: string, option_index: number, cfgvalue: string): Node;
|
||||
|
||||
}
|
||||
|
||||
interface AbstractSection extends LuCI.form.AbstractElement {
|
||||
map: LuCI.form.Map
|
||||
parentoption: AbstractValue
|
||||
selected_tab?: string
|
||||
|
||||
prototype?: AbstractSection;
|
||||
|
||||
append(obj): Array.<string>
|
||||
|
||||
cfgvalue(section_id: string, option: string): any
|
||||
|
||||
filter(section_id: string): boolean
|
||||
|
||||
formvalue(section_id: string, option?: string): any
|
||||
|
||||
getOption(option: string): null | LuCI.form.AbstractValue | Object.<string, LuCI.form.AbstractValue>
|
||||
|
||||
getUIElement(section_id, option): null | LuCI.ui.AbstractElement | Object.<string, (null | LuCI.ui.AbstractElement)>
|
||||
|
||||
load(): (Promise.<void>)
|
||||
|
||||
option<T extends LuCI.form.AbstractValue>(optionclass: T, ...classargs: any): T
|
||||
|
||||
parse(): Promise.<void>
|
||||
|
||||
tab(name: string, title?: string, description?: string)
|
||||
|
||||
// 重载签名 1:有 subsection 参数,返回 T
|
||||
taboption<T extends LuCI.form.AbstractSection>(
|
||||
tabName: string,
|
||||
optionclass: LuCI.form.SectionValue,
|
||||
option: string,
|
||||
subsection: T,
|
||||
...classargs: any[]
|
||||
): LuCI.form.SectionValue<T>;
|
||||
|
||||
// 重载签名 2:没有 subsection,只传一个 optionclass,返回 T
|
||||
taboption<T extends LuCI.form.AbstractValue>(
|
||||
tabName: string,
|
||||
optionclass: T,
|
||||
...classargs: any[]
|
||||
): T;
|
||||
}
|
||||
|
||||
interface Map extends LuCI.form.AbstractElement {
|
||||
data: any;
|
||||
|
||||
lookupOption(name: string, section_id?: string, config_name?: string): [LuCI.form.AbstractValue, string] | null;
|
||||
|
||||
section<T extends LuCI.form.AbstractSection>(sectionclass: T, ...classargs: any): T
|
||||
}
|
||||
|
||||
interface TypedSection extends LuCI.form.AbstractSection {
|
||||
|
||||
anonymous: boolean;
|
||||
addremove: boolean;
|
||||
sortable: boolean;
|
||||
nodescriptions: boolean;
|
||||
rowcolors: boolean;
|
||||
|
||||
sectiontitle(sectionId: string): string;
|
||||
|
||||
handleAdd?(ev: Event, name?: string): any;
|
||||
|
||||
handleRemove?(sectionId: string, ev: Event): any;
|
||||
}
|
||||
|
||||
interface MultiValue extends LuCI.form.DynamicList {
|
||||
}
|
||||
|
||||
interface FlagValue extends LuCI.form.Value {
|
||||
}
|
||||
|
||||
interface ListValue extends LuCI.form.Value {
|
||||
widget: string;
|
||||
}
|
||||
|
||||
interface ButtonValue extends LuCI.form.Value {
|
||||
inputtitle: string;
|
||||
inputstyle: string;
|
||||
onclick: function;
|
||||
}
|
||||
|
||||
interface DynamicList extends LuCI.form.Value {
|
||||
}
|
||||
|
||||
interface SectionValue<T extends LuCI.form.AbstractSection> extends LuCI.form.Value {
|
||||
readonly subsection: T;
|
||||
}
|
||||
|
||||
interface TextValue extends LuCI.form.Value {
|
||||
}
|
||||
|
||||
interface Value extends LuCI.form.AbstractValue {
|
||||
forcewrite: boolean;
|
||||
prototype: Value
|
||||
monospace: boolean;
|
||||
rows: number;
|
||||
placeholder: string
|
||||
password: boolean;
|
||||
|
||||
value(key: string, display?: string): void;
|
||||
}
|
||||
|
||||
interface GridSection extends LuCI.form.TableSection {
|
||||
prototype?: GridSection;
|
||||
modaltitle: string | function;
|
||||
cloneable: boolean;
|
||||
}
|
||||
|
||||
interface TableSection extends LuCI.form.TypedSection {
|
||||
rowcolors: boolean
|
||||
}
|
||||
|
||||
interface DummyValue extends LuCI.form.Value {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
declare namespace ui {
|
||||
interface UI {
|
||||
changes: {
|
||||
apply(checked?: boolean): void;
|
||||
};
|
||||
|
||||
showModal(modal: any, e: any): void;
|
||||
|
||||
hideModal(): void;
|
||||
|
||||
addTimeLimitedNotification(title?: string, children: any, timeout?: number, classes?: string): Node
|
||||
}
|
||||
|
||||
interface AbstractElement {
|
||||
getValidationError(): string
|
||||
|
||||
getValue(): string | Array<string> | null
|
||||
|
||||
isChanged(): boolean
|
||||
|
||||
isValid(): boolean
|
||||
|
||||
registerEvents(targetNode: Node, synevent: string, events: Array<String>)
|
||||
|
||||
render(): Node
|
||||
|
||||
setChangeEvents(targetNode: Node, events: string)
|
||||
|
||||
setPlaceholder(value: string | Array<string> | null)
|
||||
|
||||
setUpdateEvents(targetNode: Node, events: string)
|
||||
|
||||
setValue(value: string | Array<string> | null)
|
||||
|
||||
triggerValidation()
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace uci {
|
||||
interface UCI {
|
||||
state: object;
|
||||
|
||||
load(config: string): Promise<void>;
|
||||
|
||||
add(config: string, type: string, name?: string): string;
|
||||
|
||||
sections(config: string, type: string, callback: (section: any) => void): any[];
|
||||
|
||||
sections(config: string, type: string): any[];
|
||||
|
||||
get(config: string, sectionId: string, option?: string): any;
|
||||
|
||||
get_first(config: string, type: string, option?: string): any;
|
||||
|
||||
set(config: string, sectionId: string, option: string, value: any): void;
|
||||
|
||||
unset(conf: string, sid: string, opt: string): void
|
||||
|
||||
remove(config: string, sectionId: string): void;
|
||||
|
||||
clone(config: string, type: string, sectionId: string, anonymous?: boolean, name?: string): void;
|
||||
|
||||
set_first(config: string, sectionId: string, option: string, value: any): void;
|
||||
|
||||
changes(): Promise.<Object.<string, Array.<string>>>
|
||||
|
||||
save(): Promise<string[]>;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace fs {
|
||||
interface FS {
|
||||
exec(command: string, args?: string[]): Promise<{ code: number; stdout: string; stderr: string; }>;
|
||||
|
||||
write(path: string, content: string): Promise<void>;
|
||||
|
||||
trimmed(path: string): Promise<string>;
|
||||
|
||||
read(logFile: any): any;
|
||||
|
||||
exec_direct(command: string, params?: string[], type?: "blob" | "text" | "json", latin1?: boolean): Promise<any>;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace rpc {
|
||||
interface RPC {
|
||||
declare(spec: {
|
||||
object: string;
|
||||
method: string;
|
||||
params: string[];
|
||||
expect: any;
|
||||
}): (...args: any[]) => Promise<any>;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace poll {
|
||||
interface Poll {
|
||||
add(fn: function, interval?: number): boolean;
|
||||
|
||||
remove(fn: function): boolean;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace baseclass {
|
||||
interface BaseClass {
|
||||
extend(obj: any): any;
|
||||
}
|
||||
}
|
||||
|
||||
declare namespace network {
|
||||
interface Network {
|
||||
getNetworks(): Promise.<Array.<LuCI.network.Protocol>>
|
||||
|
||||
getDevices(): Promise.<Array.<LuCI.network.Device>>
|
||||
|
||||
getHostHints(): Promise.<LuCI.network.Hosts>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 运行时存在的全局(由 luci 注入)
|
||||
// declare const view: LuCI.View;
|
||||
declare const L: LuCI.L;
|
||||
|
||||
// 全局函数
|
||||
declare function _(text: string): string;
|
||||
|
||||
declare function E(tag: string, attrs?: Record<string, any>, ...children: any[]): Node;
|
||||
|
||||
// 声明String.format方法
|
||||
declare interface String {
|
||||
format(template: string, ...args: any[]): string;
|
||||
}
|
||||
|
||||
// 模块名 = require 名(view → 'require view')
|
||||
|
||||
declare module 'view' {
|
||||
const v: LuCI.View;
|
||||
export default v;
|
||||
}
|
||||
|
||||
declare module 'form' {
|
||||
const f: LuCI.form.Form;
|
||||
export default f;
|
||||
}
|
||||
|
||||
declare module 'uci' {
|
||||
const uci: LuCI.uci.UCI
|
||||
export default uci
|
||||
}
|
||||
|
||||
declare module 'fs' {
|
||||
const f: LuCI.fs.FS;
|
||||
export default f;
|
||||
}
|
||||
|
||||
declare module 'rpc' {
|
||||
const r: LuCI.rpc.RPC;
|
||||
export default r;
|
||||
}
|
||||
|
||||
declare module 'poll' {
|
||||
const p: LuCI.poll.Poll;
|
||||
export default p;
|
||||
}
|
||||
|
||||
declare module 'baseclass' {
|
||||
const b: LuCI.baseclass.BaseClass;
|
||||
export default b;
|
||||
}
|
||||
|
||||
declare module 'ui' {
|
||||
const u: LuCI.ui.UI;
|
||||
export default u;
|
||||
}
|
||||
|
||||
declare module 'network' {
|
||||
const u: LuCI.network.Network;
|
||||
export default u;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
# Utils Structure
|
||||
|
||||
The first directory level shows dependency direction. The second level shows
|
||||
the feature or base capability.
|
||||
|
||||
Dependency direction:
|
||||
|
||||
`actions -> feature -> base`
|
||||
|
||||
Current structure:
|
||||
|
||||
- `actions/save-apply`: Save & Apply orchestration, prechecks, and stale generated file cleanup.
|
||||
- `feature/shunt`: shunt UCI section parsing, shunt rule parsing, and shunt config file generation.
|
||||
- `feature/proxy`: proxy node UCI section parsing and generated proxy config files.
|
||||
- `feature/firewall`: firewall form helpers and nft rules viewer behavior.
|
||||
- `feature/log`: log page data sources, actions, and rendering helpers.
|
||||
- `feature/overview`: overview page service actions and connectivity test behavior.
|
||||
- `feature/rule`: rule update and rule query panel behavior.
|
||||
- `base/luci`: LuCI-facing helpers for form controls, service status, UCI helpers, validation, and notifications.
|
||||
- `base/files`: generated file paths, file content normalization, and JSON output helpers.
|
||||
|
||||
Core adapter selection lives in `core/adapter.ts`, next to the concrete core
|
||||
builders.
|
||||
|
||||
Public utility APIs should export one `XxxUtils` object from the bottom of the
|
||||
file. Keep standalone functions private to the file unless there is a strong
|
||||
reason to expose them.
|
||||
|
||||
Boundary rules:
|
||||
|
||||
- `base` must not import `feature`, `actions`, `core`, or `views`.
|
||||
- `feature/*/section.ts` should not import `core/adapter.ts` or `actions`.
|
||||
- `feature/*/config-file.ts` may import `core/adapter.ts`.
|
||||
- `actions` may compose `feature`, `base`, and `core`, but must not import `views`.
|
||||
|
||||
If parsing code needs config generation or save/apply behavior, move that code
|
||||
into the feature's `config-file.ts` or into `actions/save-apply` instead of
|
||||
importing back into `section.ts`.
|
||||
@@ -0,0 +1,65 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import fs from "fs";
|
||||
import uci from "uci";
|
||||
import ui from "ui";
|
||||
import { SingBoxUtils } from "../../../core/singbox/builder";
|
||||
import { FactoryType } from "../../../core/adapter";
|
||||
import { NodeFileCleanupUtils } from "./node-file-cleanup";
|
||||
import { FilePathUtils } from "../../base/files/paths";
|
||||
import { ShuntConfigUtils } from "../../feature/shunt/config-file";
|
||||
import { NotificationUtils } from "../../base/luci/notification";
|
||||
import { ProxyConfigFileUtils } from "../../feature/proxy/config-file";
|
||||
import { FormUtils } from "../../base/luci/form";
|
||||
import { PrecheckUtils } from "./precheck";
|
||||
|
||||
function isGlobalServiceDisabled() {
|
||||
return uci.get_first(LuciFlied.CONF_NAME, LuciFlied.GLOBAL_SECTION_TYPE, 'enabled') === '0';
|
||||
}
|
||||
|
||||
const SaveApplyUtils = {
|
||||
genHandleSaveApply: function () {
|
||||
return async function (ev: Event, mode?: string | number) {
|
||||
try {
|
||||
await this.handleSave(ev)
|
||||
|
||||
if (isGlobalServiceDisabled()) {
|
||||
await uci.save();
|
||||
await ui.changes.apply(mode == '0');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!await PrecheckUtils.runSaveApplyPrechecks()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 生成分流配置并写入文件
|
||||
const shuntCoreType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'type')
|
||||
if (shuntCoreType === FactoryType.SING_BOX
|
||||
&& uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_convert') === '1') {
|
||||
SingBoxUtils.writeGeoRule()
|
||||
}
|
||||
const shuntConfigType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'config_type')
|
||||
if (shuntConfigType === 'tmpl') {
|
||||
let data = ShuntConfigUtils.getShuntConfData()
|
||||
await fs.write(FilePathUtils.getFilePath('shunt_conf'), data);
|
||||
uci.set_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE,
|
||||
'shunt_hash', data ? String(FormUtils.simpleHash(data)) : '-1');
|
||||
}
|
||||
|
||||
await uci.save();
|
||||
let changes = await uci.changes()
|
||||
await NodeFileCleanupUtils.cleanupChangedNodeFiles(changes)
|
||||
|
||||
// 生成各代理节点客户端配置文件
|
||||
await ProxyConfigFileUtils.writeGeneratedProxyConfigs();
|
||||
|
||||
await ui.changes.apply(mode == '0');
|
||||
} catch (e) {
|
||||
console.log(e)
|
||||
NotificationUtils.error(_('Configuration Apply Failed'), _(e.message), 5000);
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
export { SaveApplyUtils }
|
||||
@@ -0,0 +1,43 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import fs from "fs";
|
||||
import uci from "uci";
|
||||
import { FilePathUtils } from "../../base/files/paths";
|
||||
|
||||
async function cleanupChangedNodeFiles(changes: any) {
|
||||
async function cleanup(type: string, sectionId: string, keepPath?: string) {
|
||||
const args = keepPath ? [type, sectionId, keepPath] : [type, sectionId];
|
||||
await fs.exec('/usr/lib/hijpass/rm.sh', args);
|
||||
}
|
||||
|
||||
if (changes?.hijpass) {
|
||||
for (let change of changes.hijpass) {
|
||||
if (change.length === 2 && change[0] === 'remove') {
|
||||
await cleanup('proxy', change[1]);
|
||||
}
|
||||
if (change.length === 4 && change[0] === 'set' && change[2] === 'name') {
|
||||
const section = uci.get(LuciFlied.CONF_NAME, change[1]);
|
||||
const keepPath = section ? FilePathUtils.getProxyConfigFilePath(section) : undefined;
|
||||
await cleanup('proxy', change[1], keepPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changes?.hijserver) {
|
||||
for (let change of changes.hijserver) {
|
||||
if (change.length === 2 && change[0] === 'remove') {
|
||||
await cleanup('server', change[1]);
|
||||
}
|
||||
if (change.length === 4 && change[0] === 'set' && change[2] === 'name') {
|
||||
const section = uci.get(LuciFlied.SERVER_CONF_NAME, change[1]);
|
||||
const keepPath = section ? FilePathUtils.getServerConfigFilePath(section) : undefined;
|
||||
await cleanup('server', change[1], keepPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const NodeFileCleanupUtils = {
|
||||
cleanupChangedNodeFiles,
|
||||
}
|
||||
|
||||
export { NodeFileCleanupUtils }
|
||||
@@ -0,0 +1,578 @@
|
||||
import { CORE_TYPE, LuciFlied, PROXY_TYPE, normalizeCoreType, ShuntTag } from "../../../enum/hijpass";
|
||||
import uci from "uci";
|
||||
import ui from "ui";
|
||||
import { NotificationUtils } from "../../base/luci/notification";
|
||||
import { FactoryType } from "../../../core/adapter";
|
||||
import { ShuntUtils } from "../../feature/shunt/section";
|
||||
import { MAX_PROXY_CHAIN_NODES, ProxyChainUtils } from "../../feature/proxy/chain";
|
||||
import { FirewallDnsForwardUtils } from "../../feature/firewall/dns-forward";
|
||||
|
||||
const XRAY_ROUTE_PROTOCOLS = ['http', 'tls', 'quic', 'bittorrent'];
|
||||
const XRAY_TLS_PROTOCOLS = ['hysteria2', 'vless'];
|
||||
|
||||
function normalizeList(value: any): string[] {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
return Array.isArray(value) ? value.filter((item: string) => item) : [value];
|
||||
}
|
||||
|
||||
function isSpecialProxyNode(node: string) {
|
||||
return node === ShuntTag.DIRECT_OUTBOUND_TAG
|
||||
|| node === ShuntTag.BLOCK_OUTBOUND_TAG;
|
||||
}
|
||||
|
||||
function reportWarnings(title: string, warnings: string[]): boolean {
|
||||
if (warnings.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
NotificationUtils.warning(title, warnings.join('\n'), 8000);
|
||||
return false;
|
||||
}
|
||||
|
||||
function isTemplateShuntActive() {
|
||||
return uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'enabled') !== '0'
|
||||
&& uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'config_type') === 'tmpl';
|
||||
}
|
||||
|
||||
function checkMissingProxyRefs() {
|
||||
const existingNodes = new Set<string>();
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.name) {
|
||||
existingNodes.add(section.name);
|
||||
}
|
||||
});
|
||||
|
||||
const warnings: string[] = [];
|
||||
function checkRef(ref: string, message: string) {
|
||||
if (ref && !isSpecialProxyNode(ref) && !existingNodes.has(ref)) {
|
||||
warnings.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
const defaultNode = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'default_proxy_node');
|
||||
if (!defaultNode) {
|
||||
warnings.push(_('Default global exit is not configured'));
|
||||
} else {
|
||||
checkRef(defaultNode, _('Default global exit "%s" does not exist').format(defaultNode));
|
||||
}
|
||||
|
||||
const rulesetNode = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_out_node');
|
||||
checkRef(rulesetNode, _('Rule-set exit "%s" does not exist').format(rulesetNode));
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_ROUTE_RULE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
const ruleName = section.name || section['.name'];
|
||||
if (section.ip_version_split === '1') {
|
||||
if (!section.proxy_node_v4) {
|
||||
warnings.push(_('Route rule "%s" IPv4 proxy node is not configured').format(ruleName));
|
||||
} else {
|
||||
checkRef(section.proxy_node_v4, _('Route rule "%s" IPv4 proxy node "%s" does not exist').format(ruleName, section.proxy_node_v4));
|
||||
}
|
||||
if (!section.proxy_node_v6) {
|
||||
warnings.push(_('Route rule "%s" IPv6 proxy node is not configured').format(ruleName));
|
||||
} else {
|
||||
checkRef(section.proxy_node_v6, _('Route rule "%s" IPv6 proxy node "%s" does not exist').format(ruleName, section.proxy_node_v6));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!section.proxy_node) {
|
||||
warnings.push(_('Route rule "%s" proxy node is not configured').format(ruleName));
|
||||
} else {
|
||||
checkRef(section.proxy_node, _('Route rule "%s" proxy node "%s" does not exist').format(ruleName, section.proxy_node));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration contains missing proxy nodes'), warnings);
|
||||
}
|
||||
|
||||
function checkDisabledProxyRefs() {
|
||||
const disabledNodes = new Set<string>();
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1' && section.name) {
|
||||
disabledNodes.add(section.name);
|
||||
}
|
||||
});
|
||||
|
||||
if (disabledNodes.size === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
|
||||
const defaultNode = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'default_proxy_node');
|
||||
if (defaultNode && disabledNodes.has(defaultNode)) {
|
||||
warnings.push(_('Default global exit "%s" is not enabled').format(defaultNode));
|
||||
}
|
||||
|
||||
const rulesetNode = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_out_node');
|
||||
if (rulesetNode && disabledNodes.has(rulesetNode)) {
|
||||
warnings.push(_('Rule-set exit "%s" is not enabled').format(rulesetNode));
|
||||
}
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_ROUTE_RULE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
const ruleName = section.name || section['.name'];
|
||||
if (section.ip_version_split === '1') {
|
||||
if (section.proxy_node_v4 && disabledNodes.has(section.proxy_node_v4)) {
|
||||
warnings.push(_('Route rule "%s" IPv4 proxy node "%s" is not enabled').format(ruleName, section.proxy_node_v4));
|
||||
}
|
||||
if (section.proxy_node_v6 && disabledNodes.has(section.proxy_node_v6)) {
|
||||
warnings.push(_('Route rule "%s" IPv6 proxy node "%s" is not enabled').format(ruleName, section.proxy_node_v6));
|
||||
}
|
||||
} else if (section.proxy_node && disabledNodes.has(section.proxy_node)) {
|
||||
warnings.push(_('Route rule "%s" proxy node "%s" is not enabled').format(ruleName, section.proxy_node));
|
||||
}
|
||||
if (section.dns_proxy_node && disabledNodes.has(section.dns_proxy_node)) {
|
||||
warnings.push(_('Route rule "%s" DNS exit node "%s" is not enabled').format(ruleName, section.dns_proxy_node));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration contains disabled nodes'), warnings);
|
||||
}
|
||||
|
||||
function checkLoadBalanceRefs() {
|
||||
const sections = ProxyChainUtils.getProxySectionMap();
|
||||
const warnings: string[] = [];
|
||||
|
||||
sections.forEach((section: any) => {
|
||||
if (section.enabled !== '1' || section.type !== PROXY_TYPE.LOAD_BALANCE) return;
|
||||
|
||||
const nodeName = ProxyChainUtils.getProxyName(section);
|
||||
const core = normalizeCoreType(section.core);
|
||||
if (core !== CORE_TYPE.SING_BOX && core !== CORE_TYPE.XRAY) {
|
||||
warnings.push(_('Load balancing node "%s" has an unsupported backend core').format(nodeName));
|
||||
}
|
||||
|
||||
const members = ProxyChainUtils.normalizeProxyNodeList(section.member_node);
|
||||
if (members.length === 0) {
|
||||
warnings.push(_('Load balancing node "%s" has no member nodes').format(nodeName));
|
||||
return;
|
||||
}
|
||||
|
||||
const duplicateMembers = members.filter((member, index) => members.indexOf(member) !== index);
|
||||
if (duplicateMembers.length > 0) {
|
||||
warnings.push(_('Load balancing node "%s" contains duplicate member nodes: %s')
|
||||
.format(nodeName, Array.from(new Set(duplicateMembers)).join(', ')));
|
||||
}
|
||||
|
||||
members.forEach((memberName) => {
|
||||
if (memberName === nodeName) {
|
||||
warnings.push(_('Load balancing node "%s" cannot include itself').format(nodeName));
|
||||
return;
|
||||
}
|
||||
|
||||
const member = sections.get(memberName);
|
||||
if (!member) {
|
||||
warnings.push(_('Load balancing node "%s" member node "%s" does not exist')
|
||||
.format(nodeName, memberName));
|
||||
return;
|
||||
}
|
||||
if (member.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
warnings.push(_('Load balancing node "%s" cannot contain load balancing member "%s"')
|
||||
.format(nodeName, memberName));
|
||||
}
|
||||
if (member.enabled !== '1') {
|
||||
warnings.push(_('Load balancing node "%s" member node "%s" is not enabled')
|
||||
.format(nodeName, memberName));
|
||||
}
|
||||
if (!ProxyChainUtils.isPort(member.socks_port)) {
|
||||
warnings.push(_('Load balancing node "%s" member node "%s" has no valid SOCKS port configured')
|
||||
.format(nodeName, memberName));
|
||||
}
|
||||
});
|
||||
|
||||
if (section.fallback_tag && members.indexOf(section.fallback_tag) < 0) {
|
||||
warnings.push(_('Load balancing node "%s" fallback node "%s" is not a member')
|
||||
.format(nodeName, section.fallback_tag));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration contains invalid load balancing nodes'), warnings);
|
||||
}
|
||||
|
||||
function checkProxyChainRefs() {
|
||||
const sections = ProxyChainUtils.getProxySectionMap();
|
||||
const warnings: string[] = [];
|
||||
|
||||
sections.forEach((section: any) => {
|
||||
if (section.enabled !== '1'
|
||||
|| section.type === PROXY_TYPE.LOAD_BALANCE
|
||||
|| !section.upstream_proxy_node) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeName = ProxyChainUtils.getProxyName(section);
|
||||
const upstreamName = section.upstream_proxy_node;
|
||||
const upstreamSection = sections.get(upstreamName);
|
||||
|
||||
if (!upstreamSection) {
|
||||
warnings.push(_('Proxy node "%s" upstream proxy node "%s" does not exist').format(nodeName, upstreamName));
|
||||
return;
|
||||
}
|
||||
if (upstreamSection.enabled !== '1') {
|
||||
warnings.push(_('Proxy node "%s" upstream proxy node "%s" is not enabled').format(nodeName, upstreamName));
|
||||
}
|
||||
if (!ProxyChainUtils.isPort(upstreamSection.socks_port)) {
|
||||
warnings.push(_('Proxy node "%s" upstream proxy node "%s" has no valid SOCKS port configured').format(nodeName, upstreamName));
|
||||
}
|
||||
|
||||
const result = ProxyChainUtils.collectProxyChain(nodeName, sections);
|
||||
if (!result.cycle && result.chain.length > MAX_PROXY_CHAIN_NODES) {
|
||||
warnings.push(_('Proxy node "%s" nesting chain exceeds %d nodes: %s')
|
||||
.format(nodeName, MAX_PROXY_CHAIN_NODES, result.chain.join(' -> ')));
|
||||
}
|
||||
});
|
||||
|
||||
const dependencyCycle = ProxyChainUtils.findProxyDependencyCycle(sections);
|
||||
if (dependencyCycle) {
|
||||
warnings.push(_('Proxy node dependency chain has a cycle: %s').format(dependencyCycle.join(' -> ')));
|
||||
}
|
||||
|
||||
return reportWarnings(_('Configuration contains invalid upstream proxy nodes'), warnings);
|
||||
}
|
||||
|
||||
function checkInvalidDnsNodeRefs() {
|
||||
const existingDnsNodes = new Set<string>();
|
||||
const disabledDnsNodes = new Set<string>();
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_DNS_NODE_TYPE, function (section: any) {
|
||||
if (!section.tag) {
|
||||
return;
|
||||
}
|
||||
existingDnsNodes.add(section.tag);
|
||||
if (section.enabled === '0') {
|
||||
disabledDnsNodes.add(section.tag);
|
||||
}
|
||||
});
|
||||
|
||||
const missingWarnings: string[] = [];
|
||||
const disabledWarnings: string[] = [];
|
||||
function checkRef(ref: string, label: string) {
|
||||
if (!ref) {
|
||||
return;
|
||||
}
|
||||
if (!existingDnsNodes.has(ref)) {
|
||||
missingWarnings.push(_('%s "%s" does not exist').format(label, ref));
|
||||
return;
|
||||
}
|
||||
if (disabledDnsNodes.has(ref)) {
|
||||
disabledWarnings.push(_('%s "%s" is not enabled').format(label, ref));
|
||||
}
|
||||
}
|
||||
|
||||
const defaultDnsNode = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'default_dns_node');
|
||||
if (!defaultDnsNode) {
|
||||
missingWarnings.push(_('Default global DNS is not configured'));
|
||||
} else {
|
||||
checkRef(defaultDnsNode, _('Default global DNS'));
|
||||
}
|
||||
checkRef(
|
||||
uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_dns_node'),
|
||||
_('Rule-set DNS')
|
||||
);
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_ROUTE_RULE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
checkRef(section.dns_node, _('Route rule "%s" DNS node').format(section.name || section['.name']));
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration contains missing DNS nodes'), missingWarnings)
|
||||
&& reportWarnings(_('Configuration contains disabled DNS nodes'), disabledWarnings);
|
||||
}
|
||||
|
||||
function checkMissingDnsExitRefs() {
|
||||
const existingNodeNames = new Set<string>();
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.name) existingNodeNames.add(section.name);
|
||||
});
|
||||
|
||||
const shuntType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'type');
|
||||
const rulesetConvert = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_convert');
|
||||
const missingDnsExits: string[] = [];
|
||||
ShuntUtils.getRouteSection().rules.forEach(function (section: any) {
|
||||
if (section.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
const dnsExit = section.dnsProxyNode;
|
||||
const ruleName = section.name || _('Unnamed Rule');
|
||||
if (routeRuleRequiresDnsExit(section, shuntType, rulesetConvert) && !dnsExit) {
|
||||
missingDnsExits.push(_('Route rule "%s" uses IPv4/IPv6 split and must specify a DNS exit node').format(ruleName));
|
||||
return;
|
||||
}
|
||||
if (dnsExit && dnsExit !== ShuntTag.DIRECT_OUTBOUND_TAG && !existingNodeNames.has(dnsExit)) {
|
||||
missingDnsExits.push(_('Route rule "%s" DNS exit node "%s" does not exist').format(ruleName, dnsExit));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration contains invalid DNS exit nodes'), missingDnsExits);
|
||||
}
|
||||
|
||||
function routeRuleRequiresDnsExit(rule: any, shuntType: string, rulesetConvert: string) {
|
||||
if (rule.ipVersionSplit !== '1') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ruleDTO = ShuntUtils.parseRuleList(rule);
|
||||
if (shuntType === FactoryType.XRAY) {
|
||||
return ruleDTO.containDomain() || ruleDTO.geoSite.length > 0;
|
||||
}
|
||||
|
||||
return ruleDTO.containDomain()
|
||||
|| ruleDTO.containRuleSet()
|
||||
|| (rulesetConvert === '1' && ruleDTO.geoSite.length > 0);
|
||||
}
|
||||
|
||||
function checkFirewallPortTargets() {
|
||||
const enabledProxyPorts = new Set<string>();
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.enabled === '1' && section.listen_port) {
|
||||
enabledProxyPorts.add(section.listen_port);
|
||||
}
|
||||
});
|
||||
|
||||
const proxyPort = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.FIREWALL_SECTION_TYPE, 'proxy_port');
|
||||
const shuntPort = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.FIREWALL_SECTION_TYPE, 'shunt_port');
|
||||
const shuntEnabled = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'enabled') !== '0';
|
||||
const shuntListenPort = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'shunt_listen_port');
|
||||
const warnings: string[] = [];
|
||||
|
||||
if (proxyPort && !enabledProxyPorts.has(proxyPort)) {
|
||||
warnings.push(_('Firewall proxy port %s does not reference an enabled proxy node').format(proxyPort));
|
||||
}
|
||||
if (shuntPort
|
||||
&& !enabledProxyPorts.has(shuntPort)
|
||||
&& !(shuntEnabled && shuntListenPort === shuntPort)) {
|
||||
warnings.push(_('Firewall shunt port %s does not reference an enabled proxy node or shunt service').format(shuntPort));
|
||||
}
|
||||
|
||||
return reportWarnings(_('Configuration conflict'), warnings);
|
||||
}
|
||||
|
||||
function checkFirewallDnsForwardTarget() {
|
||||
const error = FirewallDnsForwardUtils.validateSource(FirewallDnsForwardUtils.getSource());
|
||||
return error === true || reportWarnings(_('Configuration conflict'), [error]);
|
||||
}
|
||||
|
||||
function checkDnsRoutingTargets() {
|
||||
const dnsService = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE, 'dns_service');
|
||||
if (dnsService !== 'chinadns-ng') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const routingTarget = FirewallDnsForwardUtils.getRoutingTarget();
|
||||
const routingServer = routingTarget.port ? '127.0.0.1#' + routingTarget.port : '';
|
||||
const warnings: string[] = [];
|
||||
[
|
||||
['direct_dns', _('Direct Domain DNS')],
|
||||
['proxy_dns', _('Proxy Domain DNS')],
|
||||
].forEach(([option, label]) => {
|
||||
const value = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE, option);
|
||||
if (!value || value === 'dnsmasq' || value === 'system' || value === 'custom') {
|
||||
return;
|
||||
}
|
||||
if (!routingTarget.available || value !== routingServer) {
|
||||
warnings.push(_('%s references an unavailable Core DNS port').format(label));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Configuration conflict'), warnings);
|
||||
}
|
||||
|
||||
function checkXrayRouteProtocols() {
|
||||
const shuntType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'type');
|
||||
if (shuntType !== FactoryType.XRAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_ROUTE_RULE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const unsupported = normalizeList(section.protocol)
|
||||
.filter((protocol) => XRAY_ROUTE_PROTOCOLS.indexOf(protocol) === -1);
|
||||
if (unsupported.length > 0) {
|
||||
warnings.push(_('Route rule "%s" contains protocols unsupported by Xray: %s')
|
||||
.format(section.name || section['.name'], unsupported.join(', ')));
|
||||
}
|
||||
});
|
||||
|
||||
if (warnings.length > 0) {
|
||||
NotificationUtils.warning(
|
||||
_('Unsupported Xray protocol configuration'),
|
||||
warnings.join('\n') + '\n' + _('Xray route protocol only supports: %s').format(XRAY_ROUTE_PROTOCOLS.join(', ')),
|
||||
8000
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkXrayDnsServerTypes() {
|
||||
const shuntType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'type');
|
||||
if (shuntType !== FactoryType.XRAY) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const warnings: string[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_DNS_NODE_TYPE, function (section: any) {
|
||||
if (section.enabled === '0') {
|
||||
return;
|
||||
}
|
||||
if (section.type === 'tls') {
|
||||
warnings.push(_('DNS node "%s" uses TLS, which is only supported by sing-box').format(section.tag || section['.name']));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Unsupported Xray DNS configuration'), warnings);
|
||||
}
|
||||
|
||||
function checkXrayTlsInsecure() {
|
||||
const warnings: string[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.enabled !== '1' || normalizeCoreType(section.core) !== FactoryType.XRAY || section.tls_insecure !== '1') {
|
||||
return;
|
||||
}
|
||||
if (XRAY_TLS_PROTOCOLS.indexOf(section.type) !== -1) {
|
||||
warnings.push(_('Proxy node "%s" enables insecure TLS, which is not supported by current Xray').format(section.name || section['.name']));
|
||||
}
|
||||
});
|
||||
|
||||
return reportWarnings(_('Unsupported Xray TLS configuration'), warnings);
|
||||
}
|
||||
|
||||
function confirmWarning(title: string, description: string, warnings: string[]) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
ui.showModal(title, [
|
||||
E('div', {
|
||||
style: [
|
||||
'box-sizing:border-box',
|
||||
'width:100%',
|
||||
'margin:0 0 14px 0',
|
||||
'padding:14px 16px',
|
||||
'border-radius:4px',
|
||||
'background:rgba(127,127,127,0.08)',
|
||||
'line-height:1.55'
|
||||
].join(';')
|
||||
}, [
|
||||
E('div', {style: 'margin:0 0 12px 0'}, description),
|
||||
E('div', {style: 'display:flex;flex-direction:column;gap:6px;margin:0'}, warnings.map((warning) => (
|
||||
E('div', {style: 'margin:0;word-break:break-word'}, warning)
|
||||
)))
|
||||
]),
|
||||
E('div', {style: 'display:flex;justify-content:flex-end;gap:10px;margin:0'}, [
|
||||
E('button', {
|
||||
class: 'btn cbi-button',
|
||||
click: function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
ui.hideModal();
|
||||
resolve(false);
|
||||
}
|
||||
}, _('Cancel')),
|
||||
E('button', {
|
||||
class: 'btn cbi-button-action',
|
||||
click: function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
ui.hideModal();
|
||||
resolve(true);
|
||||
}
|
||||
}, _('Continue Apply'))
|
||||
])
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
function getSkippedRouteRuleMessage(rule: any, shuntType: string, rulesetConvert: string) {
|
||||
const ruleDTO = ShuntUtils.parseRuleList(rule);
|
||||
const ruleName = rule.name || rule['.name'] || _('Unnamed Rule');
|
||||
const canConvertGeoRule = rulesetConvert === '1';
|
||||
|
||||
if (shuntType === FactoryType.XRAY) {
|
||||
if (ruleDTO.isEmpty('geo')) {
|
||||
return _('Route rule "%s" has no valid match conditions for Xray').format(ruleName);
|
||||
}
|
||||
if (ruleDTO.containRuleSet()
|
||||
&& !ruleDTO.containDomain()
|
||||
&& !ruleDTO.containIp()
|
||||
&& !ruleDTO.containGeo()) {
|
||||
return _('Route rule "%s" only contains rule-set target conditions unsupported by Xray').format(ruleName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (shuntType === FactoryType.SING_BOX) {
|
||||
if (!canConvertGeoRule && ruleDTO.containGeo()) {
|
||||
ruleDTO.geoSite = [];
|
||||
ruleDTO.geoIp = [];
|
||||
if (!ruleDTO.containDomain() && !ruleDTO.containIp() && !ruleDTO.containRuleSet()) {
|
||||
return _('Route rule "%s" has no target conditions for sing-box after removing unconverted geosite/geoip').format(ruleName);
|
||||
}
|
||||
}
|
||||
if (ruleDTO.isEmpty('set') && !(canConvertGeoRule && ruleDTO.containGeo())) {
|
||||
return _('Route rule "%s" has no valid match conditions for sing-box').format(ruleName);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function confirmSkippedRouteRules() {
|
||||
const shuntType = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'type');
|
||||
const rulesetConvert = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'ruleset_convert');
|
||||
const warnings: string[] = [];
|
||||
|
||||
ShuntUtils.getRouteSection().rules.forEach((rule) => {
|
||||
if (rule.enabled !== '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
const message = getSkippedRouteRuleMessage(rule, shuntType, rulesetConvert);
|
||||
if (message) warnings.push(message);
|
||||
});
|
||||
|
||||
if (warnings.length > 0) {
|
||||
return confirmWarning(
|
||||
_('Some route rules will be skipped'),
|
||||
_('The following rules have no valid configuration for the current core and options, so they will not be written to the final configuration. Continue applying the configuration?'),
|
||||
warnings
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const PrecheckUtils = {
|
||||
runSaveApplyPrechecks: async function () {
|
||||
if (!checkProxyChainRefs()
|
||||
|| !checkLoadBalanceRefs()
|
||||
|| !checkXrayTlsInsecure()
|
||||
|| !checkFirewallPortTargets()
|
||||
|| !checkFirewallDnsForwardTarget()
|
||||
|| !checkDnsRoutingTargets()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isTemplateShuntActive()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return checkMissingProxyRefs()
|
||||
&& checkDisabledProxyRefs()
|
||||
&& checkInvalidDnsNodeRefs()
|
||||
&& checkMissingDnsExitRefs()
|
||||
&& await confirmSkippedRouteRules()
|
||||
&& checkXrayRouteProtocols()
|
||||
&& checkXrayDnsServerTypes();
|
||||
},
|
||||
}
|
||||
|
||||
export { PrecheckUtils }
|
||||
@@ -0,0 +1,21 @@
|
||||
function omitEmptyReplacer(_: string, value: any) {
|
||||
if (Array.isArray(value) && value.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'number' && !Number.isFinite(value)) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value === 'string' && !value.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
const JsonUtils = {
|
||||
omitEmptyReplacer,
|
||||
}
|
||||
|
||||
export { JsonUtils }
|
||||
@@ -0,0 +1,67 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import uci from "uci";
|
||||
|
||||
const DEFAULT_PATHS: Record<string, Record<string, string>> = {
|
||||
[LuciFlied.CONF_NAME]: {
|
||||
client_dir: '/etc/hijpass/client',
|
||||
shunt_conf: '/etc/hijpass/shunt.json',
|
||||
ip_direct: '/etc/hijpass/rules/ip-direct.txt',
|
||||
ip_proxy: '/etc/hijpass/rules/ip-proxy.txt',
|
||||
domain_direct: '/etc/hijpass/rules/domain-direct.txt',
|
||||
domain_proxy: '/etc/hijpass/rules/domain-proxy.txt',
|
||||
nft: '/etc/hijpass/fw4-template.nft',
|
||||
nft_hook: '/etc/hijpass/hook/nft-hook.sh',
|
||||
dns_hook: '/etc/hijpass/hook/dns-hook.sh',
|
||||
},
|
||||
[LuciFlied.SERVER_CONF_NAME]: {
|
||||
server_dir: '/etc/hijpass/server',
|
||||
},
|
||||
};
|
||||
|
||||
function normalizeFileContent(value?: string) {
|
||||
const content = (value || '').trim().replace(/\r\n/g, '\n');
|
||||
return content ? content + '\n' : '';
|
||||
}
|
||||
|
||||
function getFilePath(option: any, confName: string = LuciFlied.CONF_NAME) {
|
||||
let path = uci.get_first(confName, confName, option)
|
||||
if (path) {
|
||||
return path
|
||||
}
|
||||
const defaultPath = DEFAULT_PATHS[confName]?.[option];
|
||||
if (defaultPath) {
|
||||
return defaultPath;
|
||||
}
|
||||
console.log(option + ' path is empty')
|
||||
return ''
|
||||
}
|
||||
|
||||
function getNodeConfigFilePath(confName: string, dirPath: string, sectionId: string, optionContext?: any) {
|
||||
if (!dirPath) {
|
||||
throw new Error(_('Configuration file directory is empty'));
|
||||
}
|
||||
|
||||
const formName = optionContext?.section?.formvalue?.(sectionId, 'name');
|
||||
const name = formName || uci.get(confName, sectionId, 'name') || sectionId;
|
||||
return dirPath + '/' + name + '-' + sectionId + '.json';
|
||||
}
|
||||
|
||||
function getProxyConfigFilePath(section: any) {
|
||||
const name = section.name || section['.name'];
|
||||
return getFilePath('client_dir') + '/' + name + '-' + section['.name'] + '.json';
|
||||
}
|
||||
|
||||
function getServerConfigFilePath(section: any) {
|
||||
const name = section.name || section['.name'];
|
||||
return getFilePath('server_dir', LuciFlied.SERVER_CONF_NAME) + '/' + name + '-' + section['.name'] + '.json';
|
||||
}
|
||||
|
||||
const FilePathUtils = {
|
||||
normalizeFileContent,
|
||||
getFilePath,
|
||||
getNodeConfigFilePath,
|
||||
getProxyConfigFilePath,
|
||||
getServerConfigFilePath,
|
||||
}
|
||||
|
||||
export { FilePathUtils }
|
||||
@@ -0,0 +1,172 @@
|
||||
import { LuciFlied, ShuntTag } from "../../../enum/hijpass";
|
||||
import fs from "fs";
|
||||
import form from "form";
|
||||
import uci from "uci";
|
||||
import { FilePathUtils } from "../files/paths";
|
||||
|
||||
const FormUtils = {
|
||||
setAnonymousGridSectionMembers: function (section: any) {
|
||||
FormUtils.setGridSectionMembers(section);
|
||||
section.anonymous = true;
|
||||
section.sectiontitle = function (sectionId: any) {
|
||||
return uci.get(LuciFlied.CONF_NAME, sectionId, 'name')
|
||||
|| uci.get(LuciFlied.CONF_NAME, sectionId, 'tag')
|
||||
|| sectionId;
|
||||
};
|
||||
},
|
||||
|
||||
setGridSectionMembers: function (section: any) {
|
||||
section.anonymous = false;
|
||||
section.addremove = true;
|
||||
section.sortable = true;
|
||||
section.nodescriptions = true;
|
||||
section.rowcolors = true;
|
||||
},
|
||||
|
||||
setAnonymousSection: function (subsection: any) {
|
||||
subsection.anonymous = true;
|
||||
subsection.addremove = false;
|
||||
},
|
||||
|
||||
simpleHash: function (str: string) {
|
||||
let hash = 0;
|
||||
str = str ? str : '';
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = ((hash << 5) - hash) + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return hash;
|
||||
},
|
||||
|
||||
setEditorOptionMembers: function (o: LuCI.form.TextValue, filePath: string, fieldName: string) {
|
||||
o.rows = 30;
|
||||
o.load = function (_) {
|
||||
return fs.trimmed(filePath);
|
||||
};
|
||||
|
||||
async function writeFile(sectionId: string, formvalue?: string) {
|
||||
let currentHash = String(FormUtils.simpleHash(formvalue))
|
||||
let oldHash = uci.get(LuciFlied.CONF_NAME, sectionId, fieldName)
|
||||
if (currentHash === oldHash) {
|
||||
return
|
||||
}
|
||||
uci.set(LuciFlied.CONF_NAME, sectionId, fieldName, currentHash)
|
||||
await fs.write(filePath, (formvalue || '').trim().replace(/\r\n/g, '\n') + '\n');
|
||||
}
|
||||
|
||||
o.write = writeFile
|
||||
o.remove = writeFile
|
||||
o.monospace = true;
|
||||
|
||||
return o;
|
||||
},
|
||||
|
||||
setConfContentOptionMembers: function (
|
||||
o: LuCI.form.TextValue,
|
||||
confName: string,
|
||||
dirPath: string,
|
||||
) {
|
||||
o.monospace = true;
|
||||
o.rows = 20;
|
||||
|
||||
o.load = async function (section_id: string) {
|
||||
return L.resolveDefault(fs.read(FilePathUtils.getNodeConfigFilePath(confName, dirPath, section_id)), '');
|
||||
};
|
||||
o.write = async function (section_id: string, value: string) {
|
||||
const content = FilePathUtils.normalizeFileContent(value);
|
||||
await fs.write(FilePathUtils.getNodeConfigFilePath(confName, dirPath, section_id, this), content);
|
||||
uci.set(confName, section_id, 'custom_conf_hash', String(FormUtils.simpleHash(content)));
|
||||
};
|
||||
o.remove = async function (section_id: string) {
|
||||
await fs.write(FilePathUtils.getNodeConfigFilePath(confName, dirPath, section_id, this), '');
|
||||
uci.set(confName, section_id, 'custom_conf_hash', '');
|
||||
};
|
||||
},
|
||||
|
||||
createTextOption: function (s: LuCI.form.AbstractSection, tabName: string,
|
||||
fieldName: string, title: string, description: string, filePath: string) {
|
||||
let o = s.taboption(tabName, form.TextValue, fieldName, title, description);
|
||||
FormUtils.setEditorOptionMembers(o, filePath, fieldName);
|
||||
return o;
|
||||
},
|
||||
|
||||
createLogFlagOption: function (s: LuCI.form.AbstractSection, conf_type: string, tab: string) {
|
||||
let log_dir = uci.get_first(conf_type, conf_type, 'log_dir');
|
||||
|
||||
let o = tab ? s.taboption(tab, form.Flag, 'log_enabled', _('Record Logs'))
|
||||
: s.option(form.Flag, 'log_enabled', _('Record Logs'));
|
||||
o.modalonly = true;
|
||||
o.write = function (section_id: string, value: string) {
|
||||
let log_file = 'shunt.log'
|
||||
let name = uci.get(conf_type, section_id, 'name');
|
||||
if (name) {
|
||||
log_file = name + '-' + section_id + '.log'
|
||||
}
|
||||
|
||||
uci.set(conf_type, section_id, 'log_path', log_dir + '/' + log_file);
|
||||
}
|
||||
o.remove = function (section_id: string) {
|
||||
uci.unset(conf_type, section_id, 'log_path');
|
||||
}
|
||||
o.load = function (section_id: string) {
|
||||
if (uci.get(conf_type, section_id, 'log_path')) {
|
||||
return '1'
|
||||
}
|
||||
return '0'
|
||||
}
|
||||
o.rmempty = true;
|
||||
return o;
|
||||
},
|
||||
|
||||
appendEnabledNodeListenPorts: function (option: LuCI.form.ListValue) {
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (section: any) => {
|
||||
if (section.listen_port && section.enabled === '1') {
|
||||
let displayName = _('Proxy node %s: %s').format(section.name, section.listen_port);
|
||||
option.value(section.listen_port, displayName);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
loadProxyNodeOptions: function (option: LuCI.form.AbstractValue, includeSlb: boolean = true) {
|
||||
option.load = function (section_id: string) {
|
||||
delete this.keylist;
|
||||
delete this.vallist;
|
||||
|
||||
this.value(ShuntTag.DIRECT_OUTBOUND_TAG, _('Direct'));
|
||||
if (includeSlb) {
|
||||
this.value(ShuntTag.BLOCK_OUTBOUND_TAG, _('Block'));
|
||||
}
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
if (section.name && section.enabled === '1') {
|
||||
let displayName = section.name;
|
||||
if (section.socks_port) displayName += ' (' + section.socks_port + ')';
|
||||
this.value(section.name, displayName);
|
||||
}
|
||||
}.bind(this));
|
||||
|
||||
return form.ListValue.prototype.load.apply(this, [section_id]);
|
||||
};
|
||||
},
|
||||
|
||||
getEnabledProxyNodeOptions: function (includeSpecial: boolean = true): { value: string, label: string }[] {
|
||||
const options: { value: string, label: string }[] = [];
|
||||
|
||||
if (includeSpecial) {
|
||||
options.push({ value: ShuntTag.DIRECT_OUTBOUND_TAG, label: _('Direct') });
|
||||
options.push({ value: ShuntTag.BLOCK_OUTBOUND_TAG, label: _('Block') });
|
||||
}
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (section: any) => {
|
||||
if (section.name && section.enabled === '1') {
|
||||
let label = section.name;
|
||||
if (section.socks_port) label += ' (' + section.socks_port + ')';
|
||||
options.push({ value: section.name, label });
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
},
|
||||
}
|
||||
|
||||
export { FormUtils }
|
||||
@@ -0,0 +1,29 @@
|
||||
import notificationManager from "../../../module/notification";
|
||||
|
||||
const NotificationUtils = {
|
||||
show: function (options: any) {
|
||||
return notificationManager.show(options);
|
||||
},
|
||||
|
||||
success: function (title: string, content?: string, duration?: number) {
|
||||
return notificationManager.show({ title, content, type: 'success', duration });
|
||||
},
|
||||
|
||||
error: function (title: string, content?: string, duration?: number) {
|
||||
return notificationManager.show({ title, content, type: 'error', duration });
|
||||
},
|
||||
|
||||
warning: function (title: string, content?: string, duration?: number) {
|
||||
return notificationManager.show({ title, content, type: 'warning', duration });
|
||||
},
|
||||
|
||||
info: function (title: string, content?: string, duration?: number) {
|
||||
return notificationManager.show({ title, content, type: 'info', duration });
|
||||
},
|
||||
|
||||
clear: function () {
|
||||
return notificationManager.clear();
|
||||
},
|
||||
}
|
||||
|
||||
export { NotificationUtils }
|
||||
@@ -0,0 +1,142 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import form from "form";
|
||||
import poll from "poll";
|
||||
import rpc from "rpc";
|
||||
import uci from "uci";
|
||||
|
||||
const callServiceList = rpc.declare({
|
||||
object: 'service',
|
||||
method: 'list',
|
||||
params: ['name'],
|
||||
expect: { '': {} }
|
||||
})
|
||||
|
||||
const statusRefreshers: Record<string, () => Promise<void>> = {};
|
||||
|
||||
function getStatusKey(type?: string) {
|
||||
return type || 'all';
|
||||
}
|
||||
|
||||
function getStatusElementId(type?: string) {
|
||||
return 'service_status_' + getStatusKey(type);
|
||||
}
|
||||
|
||||
const ServiceUtils = {
|
||||
getServiceInstances: async function (confName: string): Promise<any> {
|
||||
const res = await callServiceList(confName);
|
||||
return res?.[confName]?.['instances'] ?? {};
|
||||
},
|
||||
|
||||
getServiceInfo: async function () {
|
||||
const hijpass = await callServiceList(LuciFlied.CONF_NAME);
|
||||
const hijserver = await callServiceList(LuciFlied.SERVER_CONF_NAME);
|
||||
let serverInstances = hijserver?.[LuciFlied.SERVER_CONF_NAME]?.['instances'] ?? {};
|
||||
let instances = hijpass?.[LuciFlied.CONF_NAME]?.['instances'] ?? {};
|
||||
return {
|
||||
proxyInfos: ServiceUtils.getInstanceName(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE).map(
|
||||
(name) => {
|
||||
return instances?.[name] ?? undefined;
|
||||
}
|
||||
).filter(instance => instance),
|
||||
shuntInfo: instances?.[ServiceUtils.getInstanceName(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE)[0]] ?? undefined,
|
||||
dnsInfo: instances?.[ServiceUtils.getInstanceName(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE)[0]] ?? undefined,
|
||||
serverInfos: ServiceUtils.getInstanceName(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE).map(
|
||||
(name) => {
|
||||
return serverInstances?.[name] ?? undefined;
|
||||
}
|
||||
).filter(instance => instance),
|
||||
}
|
||||
},
|
||||
|
||||
getInstanceName: function (confName: string, sectionType: string): string[] {
|
||||
return uci.sections(confName, sectionType).map((section) => {
|
||||
let cid = section['.name'];
|
||||
let name = section['name'] ?? section['tag'] ?? section['dns_service']
|
||||
if (!name) {
|
||||
name = sectionType
|
||||
}
|
||||
return cid + '-' + name;
|
||||
})
|
||||
},
|
||||
|
||||
renderServiceStatusText: function (res: any, type?: string) {
|
||||
let renderHTML = "";
|
||||
let spanTemp = '<em><span style="color:%s"><strong>%s %s</strong></span></em>';
|
||||
let isProxyRunning = (res?.proxyInfos ?? [])
|
||||
.filter(instance => instance?.running)
|
||||
.length > 0
|
||||
let isServerRunning = (res?.serverInfos ?? [])
|
||||
.filter(instance => instance?.running)
|
||||
.length > 0
|
||||
let isShuntRunning = res?.shuntInfo?.running ?? false
|
||||
let isDnsRunning = res?.dnsInfo?.running ?? false
|
||||
|
||||
let getColorSpan = function (title: string, isRunning: any) {
|
||||
if (isRunning) {
|
||||
return spanTemp.format('green', title, _("Running"));
|
||||
} else {
|
||||
return spanTemp.format('red', title, _("Not Running"));
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'dns':
|
||||
renderHTML += getColorSpan(_("Independent DNS"), isDnsRunning)
|
||||
break;
|
||||
case 'shunt':
|
||||
renderHTML += getColorSpan(_("Proxy Routing"), isShuntRunning)
|
||||
break;
|
||||
case 'server':
|
||||
renderHTML += getColorSpan(_("Local Server"), isServerRunning)
|
||||
break;
|
||||
case 'proxy':
|
||||
renderHTML += getColorSpan(_("Proxy Node"), isProxyRunning)
|
||||
break;
|
||||
default:
|
||||
renderHTML += getColorSpan(_("Proxy Node"), isProxyRunning)
|
||||
renderHTML += getColorSpan(_("Proxy Routing"), isShuntRunning)
|
||||
renderHTML += getColorSpan(_("Independent DNS"), isDnsRunning)
|
||||
renderHTML += getColorSpan(_("Local Server"), isServerRunning)
|
||||
}
|
||||
|
||||
return renderHTML;
|
||||
},
|
||||
|
||||
refreshServiceStatus: function (type?: string) {
|
||||
const key = getStatusKey(type);
|
||||
if (!statusRefreshers[key]) {
|
||||
const elementId = getStatusElementId(type);
|
||||
statusRefreshers[key] = async function () {
|
||||
const res = await L.resolveDefault(ServiceUtils.getServiceInfo());
|
||||
let view = document.getElementById(elementId);
|
||||
if (view) view.innerHTML = ServiceUtils.renderServiceStatusText(res, type);
|
||||
}
|
||||
}
|
||||
return statusRefreshers[key];
|
||||
},
|
||||
|
||||
renderServiceStatus: function (type?: string) {
|
||||
return function () {
|
||||
const refresh = ServiceUtils.refreshServiceStatus(type);
|
||||
poll.add(refresh);
|
||||
window.setTimeout(refresh, 0);
|
||||
|
||||
// Only actual form maps may use cbi-map: LuCI saves every matching node.
|
||||
return E('div', { class: 'hijpass-service-status' },
|
||||
E('fieldset', { class: 'cbi-section' }, [
|
||||
E('p', { id: getStatusElementId(type), style: 'display: flex; gap: 1rem;' },
|
||||
_('Fetching Status...'))
|
||||
])
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
createStatusSection: function (m: LuCI.form.Map, type?: string) {
|
||||
let s = m.section(form.TypedSection);
|
||||
s.anonymous = true;
|
||||
s.render = ServiceUtils.renderServiceStatus(type);
|
||||
return s;
|
||||
},
|
||||
}
|
||||
|
||||
export { ServiceUtils }
|
||||
@@ -0,0 +1,39 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import uci from "uci";
|
||||
|
||||
const UciUtils = {
|
||||
camelToSnake: function (str: string) {
|
||||
return str.replace(/([A-Z])/g, function (match) {
|
||||
return '_' + match.toLowerCase();
|
||||
});
|
||||
},
|
||||
|
||||
transFromUci: function (object: any, section: any) {
|
||||
Object.keys(object).forEach(key => {
|
||||
let uciOption = UciUtils.camelToSnake(key)
|
||||
if (section[uciOption]) {
|
||||
object[key] = section[uciOption]
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
generateUniqueSectionId: function (confName: string = LuciFlied.CONF_NAME) {
|
||||
let chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
let result = '';
|
||||
|
||||
for (; ;) {
|
||||
result = '';
|
||||
for (let i = 0; i < 8; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
|
||||
if (!uci.get(confName, result)) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
export { UciUtils }
|
||||
@@ -0,0 +1,121 @@
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
import uci from "uci";
|
||||
|
||||
const ValidationUtils = {
|
||||
createTagValidator: function (confName: string, sectionType: string, fieldName: string) {
|
||||
return function (section_id: any, value: any) {
|
||||
let formatResult = ValidationUtils.validateNameFormat(section_id, value);
|
||||
if (formatResult !== true) {
|
||||
return formatResult;
|
||||
}
|
||||
|
||||
let duplicate = false;
|
||||
uci.sections(confName, sectionType, function (section) {
|
||||
if (section['.name'] !== section_id && section[fieldName] === value) {
|
||||
duplicate = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (duplicate) {
|
||||
return _('%s already exists, use another %s').format(fieldName === 'tag' ? _('Tag') : _('Name'), fieldName === 'tag' ? _('Tag') : _('Name'));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
},
|
||||
|
||||
getUsedPorts: function (): number[] {
|
||||
let ports: number[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (section) => {
|
||||
if (section.listen_port) ports.push(Number(section.listen_port));
|
||||
if (section.socks_port) ports.push(Number(section.socks_port));
|
||||
});
|
||||
uci.sections(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE, (section) => {
|
||||
if (section.listen_port) ports.push(Number(section.listen_port));
|
||||
});
|
||||
const shunt = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE);
|
||||
if (shunt?.shunt_listen_port) ports.push(Number(shunt.shunt_listen_port));
|
||||
if (shunt?.dns_listen_port) ports.push(Number(shunt.dns_listen_port));
|
||||
const dns = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE);
|
||||
if (dns?.cdg_port) ports.push(Number(dns.cdg_port));
|
||||
return ports;
|
||||
},
|
||||
|
||||
getUsedPortsExcluding: function (excludeSectionId: string): number[] {
|
||||
let ports: number[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (section) => {
|
||||
if (section['.name'] === excludeSectionId) return;
|
||||
if (section.listen_port) ports.push(Number(section.listen_port));
|
||||
if (section.socks_port) ports.push(Number(section.socks_port));
|
||||
});
|
||||
uci.sections(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE, (section) => {
|
||||
if (section.listen_port) ports.push(Number(section.listen_port));
|
||||
});
|
||||
const shunt = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE);
|
||||
if (shunt?.shunt_listen_port) ports.push(Number(shunt.shunt_listen_port));
|
||||
if (shunt?.dns_listen_port) ports.push(Number(shunt.dns_listen_port));
|
||||
const dns = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE);
|
||||
if (dns?.cdg_port) ports.push(Number(dns.cdg_port));
|
||||
return ports;
|
||||
},
|
||||
|
||||
findAvailablePort: function (startPort: number, extraExclude: number[] = []): number {
|
||||
const used = [...ValidationUtils.getUsedPorts(), ...extraExclude];
|
||||
let port = startPort;
|
||||
for (let i = 0; i < 1000; i++, port++) {
|
||||
if (!used.includes(port)) return port;
|
||||
}
|
||||
return startPort;
|
||||
},
|
||||
|
||||
validatePortConflict: function (sectionId: string, value: string) {
|
||||
let shuntSection = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE)
|
||||
if (shuntSection && shuntSection[".name"] !== sectionId && (shuntSection.dns_listen_port === value
|
||||
|| shuntSection.shunt_listen_port === value)) {
|
||||
return _('Port %s is used by shunt configuration').format(value);
|
||||
}
|
||||
|
||||
let dnsSection = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE)
|
||||
if (dnsSection && dnsSection[".name"] !== sectionId && dnsSection.cdg_port === value) {
|
||||
return _('Port %s is used by DNS configuration').format(value);
|
||||
}
|
||||
|
||||
let proxySections = uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE);
|
||||
for (let i = 0; i < proxySections.length; i++) {
|
||||
let section = proxySections[i]
|
||||
if (section[".name"] === sectionId) {
|
||||
continue
|
||||
}
|
||||
if (section.listen_port === value || section.socks_port === value) {
|
||||
return _('Port %s is used by proxy node "%s"').format(value, section.name);
|
||||
}
|
||||
}
|
||||
|
||||
let serverSections = uci.sections(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE);
|
||||
for (let i = 0; i < serverSections.length; i++) {
|
||||
let section = serverSections[i]
|
||||
if (section[".name"] === sectionId) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (section.listen_port === value) {
|
||||
return _('Port %s is used by server node "%s"').format(value, section.name);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
|
||||
validateNameFormat: function (sectionId: any, value: any) {
|
||||
if (!value || value === '') {
|
||||
return _('Node name cannot be empty');
|
||||
}
|
||||
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(value)) {
|
||||
return _('Node name can only contain letters, numbers, underscores, and hyphens');
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
}
|
||||
|
||||
export { ValidationUtils }
|
||||
@@ -0,0 +1,73 @@
|
||||
import uci from "uci";
|
||||
import { FIREWALL_DNS_FORWARD, type FirewallDnsForward, LuciFlied } from "../../../enum/hijpass";
|
||||
|
||||
type DnsForwardTarget = {
|
||||
available: boolean;
|
||||
port: string;
|
||||
}
|
||||
|
||||
function isPort(value: string) {
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function getPreRoutingTarget(): DnsForwardTarget {
|
||||
const dns = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.DNS_SECTION_TYPE);
|
||||
const port = dns?.cdg_port || '';
|
||||
return {
|
||||
available: dns?.dns_service === 'chinadns-ng' && isPort(port),
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
function getRoutingTarget(): DnsForwardTarget {
|
||||
const shunt = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE);
|
||||
const port = shunt?.dns_listen_port || '';
|
||||
return {
|
||||
available: shunt?.enabled !== '0' && isPort(port),
|
||||
port,
|
||||
};
|
||||
}
|
||||
|
||||
function getTargetLabel(name: string, target: DnsForwardTarget) {
|
||||
let label = name;
|
||||
if (target.port) {
|
||||
label += ': ' + target.port;
|
||||
}
|
||||
if (!target.available) {
|
||||
label += ' - ' + _('Not available');
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const FirewallDnsForwardUtils = {
|
||||
getSource: function (): FirewallDnsForward | string {
|
||||
return uci.get_first(LuciFlied.CONF_NAME, LuciFlied.FIREWALL_SECTION_TYPE, 'dns_forward')
|
||||
|| FIREWALL_DNS_FORWARD.NONE;
|
||||
},
|
||||
|
||||
getPreRoutingTarget,
|
||||
|
||||
getRoutingTarget,
|
||||
|
||||
getTargetLabel,
|
||||
|
||||
validateSource: function (source: FirewallDnsForward | string): true | string {
|
||||
if (!source || source === FIREWALL_DNS_FORWARD.NONE) {
|
||||
return true;
|
||||
}
|
||||
if (source === FIREWALL_DNS_FORWARD.PRE_ROUTING) {
|
||||
return getPreRoutingTarget().available
|
||||
? true
|
||||
: _('Independent DNS is unavailable or has no valid listen port');
|
||||
}
|
||||
if (source === FIREWALL_DNS_FORWARD.ROUTING) {
|
||||
return getRoutingTarget().available
|
||||
? true
|
||||
: _('Core DNS is disabled or has no valid listen port');
|
||||
}
|
||||
return _('Unknown DNS forwarding source: %s').format(source);
|
||||
},
|
||||
}
|
||||
|
||||
export { FirewallDnsForwardUtils }
|
||||
@@ -0,0 +1,204 @@
|
||||
import fs from "fs";
|
||||
import form from "form";
|
||||
import network from "network";
|
||||
import uci from "uci";
|
||||
|
||||
type AclContext = {
|
||||
hostHints: any,
|
||||
interfaces: AclInterfaceOption[],
|
||||
}
|
||||
|
||||
type AclInterfaceOption = {
|
||||
device: string,
|
||||
networks: string[],
|
||||
zones: string[],
|
||||
}
|
||||
|
||||
function buildMacLabel(hostHints: any, mac: string) {
|
||||
let name = hostHints[mac]?.name;
|
||||
let ip = hostHints[mac]?.ipaddrs?.[0];
|
||||
let label = ((name ?? '') + ' ' + (ip ?? '')).trim();
|
||||
return mac + (label ? ' (' + label + ')' : '');
|
||||
}
|
||||
|
||||
function toStringArray(value: any): string[] {
|
||||
if (Array.isArray(value)) return value.filter((item: any) => typeof item === 'string' && item);
|
||||
return typeof value === 'string' ? value.trim().split(/\s+/).filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function getDeviceName(device: any): string {
|
||||
return device?.getName?.() ?? device?.device ?? '';
|
||||
}
|
||||
|
||||
function appendZone(map: Map<string, string[]>, key: string, zone: string) {
|
||||
if (!key) return;
|
||||
const zones = map.get(key) ?? [];
|
||||
if (!zones.includes(zone)) zones.push(zone);
|
||||
map.set(key, zones);
|
||||
}
|
||||
|
||||
function buildFirewallZoneContext() {
|
||||
const zonesByNetwork = new Map<string, string[]>();
|
||||
const zonesByDevice = new Map<string, string[]>();
|
||||
const wanNetworks = new Set<string>(['wan', 'wan6']);
|
||||
const wanDevices = new Set<string>();
|
||||
|
||||
uci.sections('firewall', 'zone', (zone: any) => {
|
||||
const zoneName = zone.name ?? '';
|
||||
toStringArray(zone.network).forEach((networkName) => {
|
||||
appendZone(zonesByNetwork, networkName, zoneName);
|
||||
if (zoneName.toLowerCase() === 'wan') wanNetworks.add(networkName);
|
||||
});
|
||||
toStringArray(zone.device).forEach((deviceName) => {
|
||||
appendZone(zonesByDevice, deviceName, zoneName);
|
||||
if (zoneName.toLowerCase() === 'wan') wanDevices.add(deviceName);
|
||||
});
|
||||
});
|
||||
|
||||
return {zonesByNetwork, zonesByDevice, wanNetworks, wanDevices};
|
||||
}
|
||||
|
||||
function buildAclInterfaceOptions(networks: any[], devices: any[]): AclInterfaceOption[] {
|
||||
const knownDevices = new Set(devices.map(getDeviceName).filter(Boolean));
|
||||
const {zonesByNetwork, zonesByDevice, wanNetworks, wanDevices} = buildFirewallZoneContext();
|
||||
const records = networks.map((logicalNetwork: any) => {
|
||||
const networkName = logicalNetwork.getName?.() ?? '';
|
||||
const l3Device = logicalNetwork.getL3Device?.();
|
||||
const configuredDevice = logicalNetwork.getDevice?.();
|
||||
const l3DeviceName = getDeviceName(l3Device);
|
||||
const configuredDeviceName = getDeviceName(configuredDevice);
|
||||
const device = l3DeviceName || (knownDevices.has(configuredDeviceName) ? configuredDeviceName : '');
|
||||
const zones = Array.from(new Set([
|
||||
...(zonesByNetwork.get(networkName) ?? []),
|
||||
...(zonesByDevice.get(device) ?? []),
|
||||
]));
|
||||
return {
|
||||
network: networkName,
|
||||
zones,
|
||||
device,
|
||||
};
|
||||
});
|
||||
const blockedWanDevices = new Set([
|
||||
...wanDevices,
|
||||
...records
|
||||
.filter((record) => wanNetworks.has(record.network) || record.zones.some((zone) => zone.toLowerCase() === 'wan'))
|
||||
.map((record) => record.device)
|
||||
.filter(Boolean),
|
||||
]);
|
||||
const options = new Map<string, AclInterfaceOption>();
|
||||
|
||||
records.forEach((record) => {
|
||||
if (!record.device || record.device === 'lo' || record.network === 'loopback') return;
|
||||
if (wanNetworks.has(record.network) || record.zones.some((zone) => zone.toLowerCase() === 'wan')) return;
|
||||
if (blockedWanDevices.has(record.device)) return;
|
||||
|
||||
let option = options.get(record.device);
|
||||
if (!option) {
|
||||
option = {device: record.device, networks: [], zones: []};
|
||||
options.set(record.device, option);
|
||||
}
|
||||
if (!option.networks.includes(record.network)) option.networks.push(record.network);
|
||||
record.zones.forEach((zone) => {
|
||||
if (!option.zones.includes(zone)) option.zones.push(zone);
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(options.values()).sort((a, b) => a.device.localeCompare(b.device));
|
||||
}
|
||||
|
||||
function buildInterfaceLabel(iface: AclInterfaceOption) {
|
||||
const scope = [_('Networks: %s').format(iface.networks.join(', '))];
|
||||
if (iface.zones.length > 0) scope.push(_('Firewall zones: %s').format(iface.zones.join(', ')));
|
||||
return iface.device + ' (' + scope.join('; ') + ')';
|
||||
}
|
||||
|
||||
const FirewallFormUtils = {
|
||||
loadAclContext: async function (): Promise<AclContext> {
|
||||
let hostHints: any = {};
|
||||
let networks: any[] = [];
|
||||
let devices: any[] = [];
|
||||
await Promise.all([
|
||||
network.getHostHints().then((r: any) => { hostHints = r?.hosts ?? {}; }),
|
||||
network.getNetworks().then((r: any) => { networks = r ?? []; }),
|
||||
network.getDevices().then((r: any) => { devices = r ?? []; }),
|
||||
]);
|
||||
return {hostHints, interfaces: buildAclInterfaceOptions(networks, devices)};
|
||||
},
|
||||
|
||||
appendHostHintOptions: function (option: LuCI.form.Value, hostHints: any) {
|
||||
Object.keys(hostHints).forEach((mac: any) => {
|
||||
option.value(mac, buildMacLabel(hostHints, mac));
|
||||
});
|
||||
},
|
||||
|
||||
appendInterfaceOptions: function (option: LuCI.form.Value, interfaces: AclInterfaceOption[]) {
|
||||
interfaces.forEach((iface) => {
|
||||
option.value(iface.device, buildInterfaceLabel(iface));
|
||||
});
|
||||
},
|
||||
|
||||
createIfaceValidator: function (interfaces: AclInterfaceOption[]) {
|
||||
return function (_sectionId: string, value: string) {
|
||||
if (!value) return true;
|
||||
const exists = interfaces.some((iface) => iface.device === value);
|
||||
if (!exists) return _('Interface "%s" Not Found').format(value);
|
||||
return true;
|
||||
};
|
||||
},
|
||||
|
||||
createNftViewOption: function (s: LuCI.form.AbstractSection) {
|
||||
let nftViewOpt = s.taboption('nftables', form.TextValue, '_nft_view');
|
||||
nftViewOpt.rows = 30;
|
||||
nftViewOpt.monospace = true;
|
||||
nftViewOpt.load = function () { return ''; };
|
||||
nftViewOpt.write = function () { };
|
||||
nftViewOpt.renderWidget = function (section_id: string, option_index: number, cfgvalue: any) {
|
||||
let widget = form.TextValue.prototype.renderWidget.call(this, section_id, option_index, cfgvalue) as HTMLElement;
|
||||
let textarea = widget.querySelector('textarea') as HTMLTextAreaElement;
|
||||
let loaded = false;
|
||||
let loading = false;
|
||||
const loadPrompt = _('Click to View Firewall Rules');
|
||||
|
||||
textarea.readOnly = true;
|
||||
textarea.placeholder = loadPrompt;
|
||||
textarea.setAttribute('aria-label', loadPrompt);
|
||||
textarea.title = loadPrompt;
|
||||
textarea.style.cursor = 'pointer';
|
||||
|
||||
const loadRules = function () {
|
||||
if (loaded || loading) return;
|
||||
|
||||
loading = true;
|
||||
textarea.setAttribute('aria-busy', 'true');
|
||||
textarea.style.cursor = 'progress';
|
||||
textarea.value = _('Loading');
|
||||
|
||||
fs.exec_direct('/usr/lib/hijpass/nft.sh', ['show'], 'text')
|
||||
.then((res: any) => {
|
||||
textarea.value = res?.trim() || _('No Rules');
|
||||
textarea.setAttribute('aria-label', _('Firewall Rules'));
|
||||
textarea.removeAttribute('title');
|
||||
loaded = true;
|
||||
})
|
||||
.catch(() => { textarea.value = _('Read Failed'); })
|
||||
.finally(() => {
|
||||
loading = false;
|
||||
textarea.removeAttribute('aria-busy');
|
||||
textarea.style.cursor = loaded ? 'text' : 'pointer';
|
||||
});
|
||||
};
|
||||
|
||||
textarea.addEventListener('click', loadRules);
|
||||
textarea.addEventListener('keydown', function (event: KeyboardEvent) {
|
||||
if (!loaded && (event.key === 'Enter' || event.key === ' ')) {
|
||||
event.preventDefault();
|
||||
loadRules();
|
||||
}
|
||||
});
|
||||
|
||||
return widget;
|
||||
};
|
||||
},
|
||||
}
|
||||
|
||||
export { FirewallFormUtils }
|
||||
@@ -0,0 +1,497 @@
|
||||
import fs from "fs";
|
||||
import uci from "uci";
|
||||
import { LuciFlied } from "../../../enum/hijpass";
|
||||
|
||||
function createLogFileOptions() {
|
||||
let options = [];
|
||||
|
||||
let hijpassLog = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.CONF_NAME, 'log_path');
|
||||
options.push(E('option', {'value': hijpassLog}, _('Main Log')));
|
||||
|
||||
let shuntLog = uci.get_first(LuciFlied.CONF_NAME, LuciFlied.SHUNT_SECTION_TYPE, 'log_path');
|
||||
if (shuntLog) {
|
||||
options.push(E('option', {'value': shuntLog}, _('Shunt Log')));
|
||||
}
|
||||
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, (proxySection) => {
|
||||
let proxyName = proxySection['.name'];
|
||||
let displayName = proxySection.name || proxyName;
|
||||
let log_path = proxySection.log_path;
|
||||
|
||||
if (log_path) {
|
||||
options.push(E('option', {'value': log_path}, _('Proxy Log') + ' - ' + displayName));
|
||||
}
|
||||
});
|
||||
|
||||
uci.sections(LuciFlied.SERVER_CONF_NAME, LuciFlied.SERVER_NODE_TYPE, (serverSection) => {
|
||||
let proxyName = serverSection['.name'];
|
||||
let displayName = serverSection.name || proxyName;
|
||||
let log_path = serverSection.log_path;
|
||||
|
||||
if (log_path) {
|
||||
options.push(E('option', {'value': log_path}, _('Server Log') + ' - ' + displayName));
|
||||
}
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
function loadLogs(
|
||||
logFileSelect: HTMLSelectElement,
|
||||
levelFilterSelect: HTMLSelectElement,
|
||||
linesCountSelect: HTMLSelectElement,
|
||||
statusElement: HTMLElement,
|
||||
logContentElement: HTMLElement
|
||||
) {
|
||||
let logFile = logFileSelect.value;
|
||||
let levelFilter = levelFilterSelect.value;
|
||||
let linesCount = linesCountSelect.value;
|
||||
|
||||
updateLogStatus(statusElement, _('Loading Logs'));
|
||||
|
||||
fs.exec('/usr/bin/tail', ['-n', linesCount, logFile]).then(function (result) {
|
||||
let logContent = result.stdout || '';
|
||||
|
||||
if (levelFilter) {
|
||||
let upperFilter = levelFilter.toUpperCase();
|
||||
let filteredLines = logContent.split('\n').filter(function (line) {
|
||||
let upperLine = line.toUpperCase();
|
||||
if (upperFilter === 'WARN') {
|
||||
return upperLine.includes('WARN') || upperLine.includes('WARNING');
|
||||
}
|
||||
|
||||
return upperLine.includes(upperFilter);
|
||||
});
|
||||
logContent = filteredLines.join('\n');
|
||||
}
|
||||
|
||||
logContent = highlightLogContent(logContent);
|
||||
|
||||
logContentElement.innerHTML = logContent;
|
||||
logContentElement.scrollTop = logContentElement.scrollHeight;
|
||||
|
||||
updateLogStatus(statusElement, _('Log Updated') + ' - ' + new Date().toLocaleTimeString());
|
||||
}).catch(function (error) {
|
||||
updateLogStatus(statusElement, _('Failed to Load Log') + error.message);
|
||||
logContentElement.textContent = _('Failed to Load Log File') + logFile;
|
||||
});
|
||||
}
|
||||
|
||||
function highlightLogContent(content) {
|
||||
content = content.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
|
||||
content = convertAnsiToHtml(content);
|
||||
|
||||
content = content.replace(/\s+\[?(ERROR|WARN|WARNING|INFO|DEBUG)\]?\s+/gmi, function (match, level) {
|
||||
if (match.includes('<span')) return match;
|
||||
let color = getLogLevelColor(level);
|
||||
return '<span style="color: ' + color + '; font-weight: bold;">' + match + '</span>';
|
||||
});
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
function getLogLevelColor(level) {
|
||||
switch (level.toUpperCase()) {
|
||||
case 'ERROR':
|
||||
return '#ff6b6b';
|
||||
case 'WARN':
|
||||
case 'WARNING':
|
||||
return '#ffa500';
|
||||
case 'INFO':
|
||||
return '#4ecdc4';
|
||||
case 'DEBUG':
|
||||
return '#95a5a6';
|
||||
default:
|
||||
return '#ffffff';
|
||||
}
|
||||
}
|
||||
|
||||
function convertAnsiToHtml(text) {
|
||||
const ansiColorMap = {
|
||||
'30': '#2e3436',
|
||||
'31': '#cc0000',
|
||||
'32': '#4e9a06',
|
||||
'33': '#c4a000',
|
||||
'34': '#3465a4',
|
||||
'35': '#75507b',
|
||||
'36': '#06989a',
|
||||
'37': '#d3d7cf',
|
||||
'90': '#555753',
|
||||
'91': '#ef2929',
|
||||
'92': '#8ae234',
|
||||
'93': '#fce94f',
|
||||
'94': '#729fcf',
|
||||
'95': '#ad7fa8',
|
||||
'96': '#34e2e2',
|
||||
'97': '#eeeeec'
|
||||
};
|
||||
|
||||
const ansiBgColorMap = {
|
||||
'40': '#2e3436',
|
||||
'41': '#cc0000',
|
||||
'42': '#4e9a06',
|
||||
'43': '#c4a000',
|
||||
'44': '#3465a4',
|
||||
'45': '#75507b',
|
||||
'46': '#06989a',
|
||||
'47': '#d3d7cf',
|
||||
'100': '#555753',
|
||||
'101': '#ef2929',
|
||||
'102': '#8ae234',
|
||||
'103': '#fce94f',
|
||||
'104': '#729fcf',
|
||||
'105': '#ad7fa8',
|
||||
'106': '#34e2e2',
|
||||
'107': '#eeeeec'
|
||||
};
|
||||
|
||||
let result = text;
|
||||
let openTags = [];
|
||||
|
||||
result = result.replace(/\x1b\[([0-9;]*)m/g, function (match, codes) {
|
||||
if (!codes) codes = '0';
|
||||
|
||||
let codeArray = codes.split(';');
|
||||
let html = '';
|
||||
|
||||
for (let code of codeArray) {
|
||||
code = code.trim();
|
||||
|
||||
if (code === '0' || code === '') {
|
||||
while (openTags.length > 0) {
|
||||
html += '</span>';
|
||||
openTags.pop();
|
||||
}
|
||||
} else if (code === '1') {
|
||||
html += '<span style="font-weight: bold;">';
|
||||
openTags.push('bold');
|
||||
} else if (code === '4') {
|
||||
html += '<span style="text-decoration: underline;">';
|
||||
openTags.push('underline');
|
||||
} else if (ansiColorMap[code]) {
|
||||
html += '<span style="color: ' + ansiColorMap[code] + ';">';
|
||||
openTags.push('color');
|
||||
} else if (ansiBgColorMap[code]) {
|
||||
html += '<span style="background-color: ' + ansiBgColorMap[code] + ';">';
|
||||
openTags.push('bgcolor');
|
||||
}
|
||||
}
|
||||
|
||||
return html;
|
||||
});
|
||||
|
||||
while (openTags.length > 0) {
|
||||
result += '</span>';
|
||||
openTags.pop();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function downloadLogs(logFileSelect: HTMLSelectElement, statusElement: HTMLElement) {
|
||||
let logFile = logFileSelect.value;
|
||||
|
||||
updateLogStatus(statusElement, _('Preparing Download'));
|
||||
|
||||
fs.read(logFile).then(function (content) {
|
||||
let blob = new Blob([content], {type: 'text/plain'});
|
||||
let url = window.URL.createObjectURL(blob);
|
||||
let a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = logFile.split('/').pop() + '_' + new Date().toISOString().slice(0, 19).replace(/:/g, '-') + '.log';
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
window.URL.revokeObjectURL(url);
|
||||
|
||||
updateLogStatus(statusElement, _('Log Downloaded'));
|
||||
}).catch(function (error) {
|
||||
updateLogStatus(statusElement, _('Download Failed') + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function clearLogs(
|
||||
logFileSelect: HTMLSelectElement,
|
||||
levelFilterSelect: HTMLSelectElement,
|
||||
linesCountSelect: HTMLSelectElement,
|
||||
statusElement: HTMLElement,
|
||||
logContentElement: HTMLElement
|
||||
) {
|
||||
let logFile = logFileSelect.value;
|
||||
|
||||
updateLogStatus(statusElement, _('Clearing Logs'));
|
||||
|
||||
fs.write(logFile, '').then(function () {
|
||||
updateLogStatus(statusElement, _('Log Cleared'));
|
||||
loadLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
||||
}).catch(function (error) {
|
||||
updateLogStatus(statusElement, _('Clear Failed') + error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function updateLogStatus(statusElement: HTMLElement, message: string) {
|
||||
statusElement.textContent = message;
|
||||
statusElement.style.color = '#666';
|
||||
}
|
||||
|
||||
type TraceDiagnosticsValues = {
|
||||
url: string;
|
||||
mode: string;
|
||||
clientIp: string;
|
||||
method: string;
|
||||
timeout: string;
|
||||
tcpdump: boolean;
|
||||
tcpdumpInterface: string;
|
||||
verbose: boolean;
|
||||
};
|
||||
|
||||
function setTraceStatus(statusElement: HTMLElement, message: string, color?: string) {
|
||||
statusElement.textContent = message;
|
||||
statusElement.style.display = message ? 'block' : 'none';
|
||||
statusElement.style.color = color || '#666';
|
||||
}
|
||||
|
||||
function setTraceOutput(outputElement: HTMLElement, content: string) {
|
||||
outputElement.innerHTML = highlightLogContent(content);
|
||||
outputElement.scrollTop = 0;
|
||||
}
|
||||
|
||||
function buildTraceArgs(values: TraceDiagnosticsValues) {
|
||||
const url = values.url.trim();
|
||||
const timeout = values.timeout.trim();
|
||||
const method = values.method || 'HEAD';
|
||||
const args = [];
|
||||
|
||||
if (!/^https?:\/\/[A-Za-z0-9_.:\-/?#%=&+~[\]]+$/.test(url)) {
|
||||
throw new Error(_('Please enter a valid HTTP or HTTPS URL'));
|
||||
}
|
||||
|
||||
if (!/^[0-9]+$/.test(timeout) || parseInt(timeout, 10) < 1 || parseInt(timeout, 10) > 120) {
|
||||
throw new Error(_('Timeout must be an integer between 1 and 120'));
|
||||
}
|
||||
|
||||
args.push('-t', timeout);
|
||||
args.push('-X', method);
|
||||
|
||||
if (values.mode === 'client') {
|
||||
const clientIp = values.clientIp.trim();
|
||||
if (!/^[A-Fa-f0-9:.]+$/.test(clientIp)) {
|
||||
throw new Error(_('Please enter a valid client IP address'));
|
||||
}
|
||||
args.push('-c', clientIp);
|
||||
}
|
||||
|
||||
if (values.tcpdump) {
|
||||
const iface = values.tcpdumpInterface.trim() || 'any';
|
||||
args.push('-p');
|
||||
if (!/^[A-Za-z0-9_.:@-]+$/.test(iface)) {
|
||||
throw new Error(_('Please enter a valid tcpdump interface'));
|
||||
}
|
||||
args.push('-i', iface);
|
||||
}
|
||||
|
||||
if (values.verbose) {
|
||||
args.push('-v');
|
||||
}
|
||||
|
||||
args.push(url);
|
||||
return args;
|
||||
}
|
||||
|
||||
function runTraceDiagnostics(
|
||||
values: TraceDiagnosticsValues,
|
||||
statusElement: HTMLElement,
|
||||
outputElement: HTMLElement,
|
||||
runButton: HTMLButtonElement
|
||||
) {
|
||||
let args;
|
||||
|
||||
try {
|
||||
args = buildTraceArgs(values);
|
||||
} catch (error) {
|
||||
setTraceStatus(statusElement, (error as Error).message, '#b00020');
|
||||
return;
|
||||
}
|
||||
|
||||
outputElement.innerHTML = '';
|
||||
runButton.disabled = true;
|
||||
setTraceStatus(statusElement, _('Running Trace Diagnostics'));
|
||||
|
||||
fs.exec('/usr/lib/hijpass/trace-url.sh', args).then(function (result) {
|
||||
const stdout = result.stdout || '';
|
||||
const stderr = result.stderr || '';
|
||||
setTraceOutput(outputElement, stdout || stderr || _('No Output'));
|
||||
|
||||
if (result.code === 0) {
|
||||
setTraceStatus(statusElement, _('Trace Completed') + ' - ' + new Date().toLocaleTimeString());
|
||||
} else {
|
||||
setTraceStatus(statusElement, _('Trace Failed') + ' - ' + (stderr || _('Exit Code') + ': ' + result.code), '#b00020');
|
||||
}
|
||||
}).catch(function (error) {
|
||||
setTraceOutput(outputElement, error.message || String(error));
|
||||
setTraceStatus(statusElement, _('Trace Failed') + ' - ' + error.message, '#b00020');
|
||||
}).finally(function () {
|
||||
runButton.disabled = false;
|
||||
});
|
||||
}
|
||||
|
||||
function createTraceOutputPanel() {
|
||||
const statusElement = E('div', {
|
||||
'style': 'display: none; font-size: 12px; color: #666; margin-bottom: 6px;'
|
||||
}) as HTMLElement;
|
||||
const outputElement = E('pre', {
|
||||
'style': 'width: 100%; min-height: 260px; max-height: 520px; box-sizing: border-box; margin: 0; font-family: monospace; font-size: 12px; background: #101010; color: #eee; border: 1px solid #ccc; padding: 10px; overflow: auto; white-space: pre-wrap; line-height: 1.45;'
|
||||
}, _('Trace output will be shown here')) as HTMLElement;
|
||||
|
||||
return {
|
||||
statusElement,
|
||||
outputElement,
|
||||
node: E('div', {}, [
|
||||
statusElement,
|
||||
outputElement
|
||||
]) as HTMLElement
|
||||
};
|
||||
}
|
||||
|
||||
const LogPanelUtils = {
|
||||
createLogControlPanel: function () {
|
||||
const logFileSelect = E('select', {
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'margin-right: 10px;'
|
||||
}, createLogFileOptions()) as HTMLSelectElement;
|
||||
const levelFilterSelect = E('select', {
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'margin-right: 10px;'
|
||||
}, [
|
||||
E('option', {'value': ''}, _('All')),
|
||||
E('option', {'value': 'DEBUG'}, _('Debug')),
|
||||
E('option', {'value': 'INFO'}, _('Info')),
|
||||
E('option', {'value': 'WARN'}, _('Warning')),
|
||||
E('option', {'value': 'ERROR'}, _('Error'))
|
||||
]) as HTMLSelectElement;
|
||||
const linesCountSelect = E('select', {
|
||||
'class': 'cbi-input-select',
|
||||
'style': 'margin-right: 10px;'
|
||||
}, [
|
||||
E('option', {'value': '50'}, '50'),
|
||||
E('option', {'value': '100', 'selected': 'selected'}, '100'),
|
||||
E('option', {'value': '200'}, '200'),
|
||||
E('option', {'value': '500'}, '500'),
|
||||
E('option', {'value': '1000'}, '1000')
|
||||
]) as HTMLSelectElement;
|
||||
const statusElement = E('div', {
|
||||
'style': 'font-size: 12px; color: #666; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;'
|
||||
}) as HTMLElement;
|
||||
const logContentElement = E('div', {
|
||||
'class': 'log-display',
|
||||
'style': 'width: 100%; height: 500px; font-family: monospace; font-size: 12px; background: #101010; color: #eee; border: 1px solid #ccc; padding: 10px; overflow: auto; white-space: pre; line-height: 1.4;'
|
||||
}, _('Log Content')) as HTMLElement;
|
||||
let panel: HTMLElement;
|
||||
let autoRefreshInterval: number | null = null;
|
||||
|
||||
const load = function () {
|
||||
loadLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
||||
};
|
||||
const stopAutoRefresh = function (button: HTMLElement) {
|
||||
if (autoRefreshInterval !== null) {
|
||||
clearInterval(autoRefreshInterval);
|
||||
autoRefreshInterval = null;
|
||||
}
|
||||
button.textContent = _('Auto Refresh');
|
||||
button.className = 'cbi-button cbi-button-neutral';
|
||||
};
|
||||
const autoRefreshButton = E('button', {
|
||||
'class': 'cbi-button cbi-button-neutral',
|
||||
'style': 'margin-right: 5px;',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (autoRefreshInterval === null) {
|
||||
autoRefreshButton.textContent = _('Stop Auto Refresh');
|
||||
autoRefreshButton.className = 'cbi-button cbi-button-negative';
|
||||
autoRefreshInterval = window.setInterval(function () {
|
||||
if (!panel.isConnected) {
|
||||
stopAutoRefresh(autoRefreshButton);
|
||||
return;
|
||||
}
|
||||
load();
|
||||
}, 3000);
|
||||
updateLogStatus(statusElement, _('Auto Refresh Enabled'));
|
||||
} else {
|
||||
stopAutoRefresh(autoRefreshButton);
|
||||
updateLogStatus(statusElement, _('Auto Refresh Disabled'));
|
||||
}
|
||||
}
|
||||
}, _('Auto Refresh')) as HTMLElement;
|
||||
|
||||
logFileSelect.addEventListener('change', load);
|
||||
levelFilterSelect.addEventListener('change', load);
|
||||
linesCountSelect.addEventListener('change', load);
|
||||
|
||||
panel = E('div', {'class': 'cbi-section'}, [
|
||||
E('div', {'class': 'cbi-section-node'}, [
|
||||
E('div', {'class': 'cbi-value'}, [
|
||||
E('label', {'class': 'cbi-value-title'}, _('Log File')),
|
||||
E('div', {'class': 'cbi-value-field'}, [logFileSelect])
|
||||
]),
|
||||
E('div', {'class': 'cbi-value'}, [
|
||||
E('label', {'class': 'cbi-value-title'}, _('Log Level Filter')),
|
||||
E('div', {'class': 'cbi-value-field'}, [levelFilterSelect])
|
||||
]),
|
||||
E('div', {'class': 'cbi-value'}, [
|
||||
E('label', {'class': 'cbi-value-title'}, _('Display Lines')),
|
||||
E('div', {'class': 'cbi-value-field'}, [linesCountSelect])
|
||||
]),
|
||||
E('div', {'class': 'cbi-value'}, [
|
||||
E('label', {'class': 'cbi-value-title'}, _('Actions')),
|
||||
E('div', {'class': 'cbi-value-field'}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'margin-right: 5px;',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
load();
|
||||
}
|
||||
}, _('Refresh')),
|
||||
autoRefreshButton,
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-positive',
|
||||
'style': 'margin-right: 5px;',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
downloadLogs(logFileSelect, statusElement);
|
||||
}
|
||||
}, _('Download')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-negative',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
if (confirm(_('Clear Log Confirmation'))) {
|
||||
clearLogs(logFileSelect, levelFilterSelect, linesCountSelect, statusElement, logContentElement);
|
||||
}
|
||||
}
|
||||
}, _('Clear'))
|
||||
])
|
||||
]),
|
||||
E('div', {'class': 'cbi-value'}, [
|
||||
statusElement,
|
||||
logContentElement
|
||||
])
|
||||
])
|
||||
]) as HTMLElement;
|
||||
|
||||
load();
|
||||
return panel;
|
||||
},
|
||||
|
||||
createTraceOutputPanel: createTraceOutputPanel,
|
||||
runTraceDiagnostics: runTraceDiagnostics,
|
||||
}
|
||||
|
||||
export { LogPanelUtils }
|
||||
@@ -0,0 +1,315 @@
|
||||
import uci from "uci";
|
||||
import { FIREWALL_DNS_FORWARD, LuciFlied, PROXY_TYPE } from "../../../enum/hijpass";
|
||||
import { FirewallDnsForwardUtils } from "../firewall/dns-forward";
|
||||
import { ProxyChainUtils } from "../proxy/chain";
|
||||
|
||||
export type FlowTone = 'normal' | 'direct' | 'proxy' | 'muted' | 'warning';
|
||||
export type FlowPage = 'firewall' | 'proxy' | 'shunt' | 'dns' | 'dhcp';
|
||||
|
||||
export type FlowStep = {
|
||||
title: string;
|
||||
detail?: string;
|
||||
tone?: FlowTone;
|
||||
page?: FlowPage;
|
||||
}
|
||||
|
||||
export type FlowBranch = {
|
||||
condition: string;
|
||||
page?: FlowPage;
|
||||
detail?: string;
|
||||
steps: FlowStep[];
|
||||
}
|
||||
|
||||
export type FlowDiagram = {
|
||||
id: string;
|
||||
title: string;
|
||||
steps: FlowStep[];
|
||||
branches?: FlowBranch[];
|
||||
branchMode?: 'parallel' | 'ordered';
|
||||
branchDescription?: string;
|
||||
}
|
||||
|
||||
export type ConfigurationFlowPreview = {
|
||||
diagrams: FlowDiagram[];
|
||||
}
|
||||
|
||||
function getFirstSection(type: string): any {
|
||||
return uci.get_first(LuciFlied.CONF_NAME, type) || {};
|
||||
}
|
||||
|
||||
function normalizeList(value: any): string[] {
|
||||
if (!value) return [];
|
||||
return (Array.isArray(value) ? value : [value]).filter(Boolean);
|
||||
}
|
||||
|
||||
function isPort(value: any): boolean {
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function formatEndpoint(value: string): string {
|
||||
return value ? value.replace('#', ':') : _('Not configured');
|
||||
}
|
||||
|
||||
function getAclMode(firewall: any): string {
|
||||
return firewall.acl_default_allow === '1' ? _('Bypass mode') : _('Proxy mode');
|
||||
}
|
||||
|
||||
function getAclTitle(firewall: any): string {
|
||||
return _('Firewall ACL') + ' · ' + getAclMode(firewall);
|
||||
}
|
||||
|
||||
function getAclDetail(firewall: any): string {
|
||||
if (firewall.acl_default_allow === '1') {
|
||||
const devices = normalizeList(firewall.proxy_mac_exclude_list).length;
|
||||
const interfaces = normalizeList(firewall.proxy_iface_exclude_list).length;
|
||||
return _('Excluded: %s devices, %s interfaces').format(String(devices), String(interfaces));
|
||||
}
|
||||
|
||||
const devices = normalizeList(firewall.proxy_mac_list).length;
|
||||
const interfaces = normalizeList(firewall.proxy_iface_list).length;
|
||||
return _('Selected: %s devices, %s interfaces').format(String(devices), String(interfaces));
|
||||
}
|
||||
|
||||
function getProxyTarget(port: string, portLabel: string): FlowStep[] {
|
||||
if (!isPort(port)) {
|
||||
return [{ title: portLabel, detail: _('No valid listen port'), tone: 'warning', page: 'firewall' }];
|
||||
}
|
||||
|
||||
const sections = ProxyChainUtils.getProxySectionMap();
|
||||
const proxy = Array.from(sections.values()).find(section =>
|
||||
section.enabled === '1' && section.listen_port === port);
|
||||
const steps: FlowStep[] = [{ title: portLabel + ': ' + port, tone: 'proxy', page: 'firewall' }];
|
||||
|
||||
if (!proxy) {
|
||||
steps.push({ title: _('Proxy node unavailable'), detail: _('No enabled node uses this port'), tone: 'warning', page: 'proxy' });
|
||||
return steps;
|
||||
}
|
||||
|
||||
if (proxy.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
const members = ProxyChainUtils.normalizeProxyNodeList(proxy.member_node);
|
||||
steps.push({
|
||||
title: _('Load balancing: %s').format(proxy.name),
|
||||
page: 'proxy',
|
||||
detail: members.length > 0 ? _('Members: %s').format(members.join(', ')) : _('No member nodes'),
|
||||
tone: members.length > 0 ? 'proxy' : 'warning',
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
steps.push({
|
||||
title: _('Proxy node: %s').format(proxy.name),
|
||||
page: 'proxy',
|
||||
detail: ProxyChainUtils.getProxyCore(proxy),
|
||||
tone: 'proxy',
|
||||
});
|
||||
|
||||
const chain = ProxyChainUtils.collectProxyChain(proxy.name, sections);
|
||||
chain.chain.slice(1).forEach(name => {
|
||||
const upstream = sections.get(name);
|
||||
steps.push({
|
||||
title: _('Upstream proxy: %s').format(name),
|
||||
page: 'proxy',
|
||||
detail: upstream?.socks_port ? _('SOCKS port %s').format(upstream.socks_port) : _('Node unavailable'),
|
||||
tone: upstream ? 'proxy' : 'warning',
|
||||
});
|
||||
});
|
||||
if (chain.cycle) {
|
||||
steps.push({ title: _('Proxy dependency cycle'), tone: 'warning', page: 'proxy' });
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
function getRoutingSteps(shunt: any, path: 'dns' | 'traffic'): FlowStep[] {
|
||||
const port = path === 'dns' ? shunt.dns_listen_port : shunt.shunt_listen_port;
|
||||
const title = path === 'dns' ? _('Core DNS') : _('Default shunt port');
|
||||
if (shunt.enabled === '0' || !isPort(port)) {
|
||||
return [{ title, detail: _('Disabled or invalid listen port'), tone: 'warning', page: 'shunt' }];
|
||||
}
|
||||
|
||||
// Show the routing resource, not its condition-dependent outbound nodes.
|
||||
if (path === 'traffic') {
|
||||
return [
|
||||
{ title: title + ': ' + port, tone: 'proxy', page: 'firewall' },
|
||||
{ title: _('Default shunt'), detail: shunt.type === 'xray' ? 'Xray' : shunt.type || '', tone: 'proxy', page: 'shunt' },
|
||||
];
|
||||
}
|
||||
|
||||
const steps: FlowStep[] = [{
|
||||
title: title + ': ' + port,
|
||||
page: 'shunt',
|
||||
detail: shunt.type === 'xray' ? 'Xray' : shunt.type || '',
|
||||
tone: 'proxy',
|
||||
}];
|
||||
|
||||
if (shunt.config_type === 'custom') {
|
||||
steps.push({ title: _('Custom core configuration'), detail: _('Internal path cannot be expanded'), tone: 'muted', page: 'shunt' });
|
||||
return steps;
|
||||
}
|
||||
|
||||
const rules = uci.sections(LuciFlied.CONF_NAME, LuciFlied.SHUNT_ROUTE_RULE_TYPE)
|
||||
.filter((rule: any) => rule.enabled === '1');
|
||||
const dnsRules = rules.filter((rule: any) => Boolean(rule.dns_node));
|
||||
steps.push({
|
||||
title: _('DNS routing rules'),
|
||||
page: 'shunt',
|
||||
detail: _('%s enabled rules, default DNS: %s')
|
||||
.format(String(dnsRules.length), shunt.default_dns_node || _('Not configured')),
|
||||
tone: 'normal',
|
||||
});
|
||||
return steps;
|
||||
}
|
||||
|
||||
function getDnsUpstreamSteps(value: string, customValue: string, shunt: any): FlowStep[] {
|
||||
if (value === 'custom') {
|
||||
return [{ title: _('Custom DNS'), detail: formatEndpoint(customValue), tone: 'normal', page: 'dns' }];
|
||||
}
|
||||
if (value === 'dnsmasq' || value === 'system') {
|
||||
const dnsmasqPort = uci.get_first('dhcp', 'dnsmasq', 'port') || '53';
|
||||
return [{ title: 'dnsmasq', detail: _('Local port %s').format(dnsmasqPort), tone: 'direct', page: 'dhcp' }];
|
||||
}
|
||||
|
||||
const routingPort = shunt.dns_listen_port || '';
|
||||
if (value === FIREWALL_DNS_FORWARD.ROUTING || value === '127.0.0.1#' + routingPort) {
|
||||
return getRoutingSteps(shunt, 'dns');
|
||||
}
|
||||
return [{ title: _('DNS upstream'), detail: formatEndpoint(value), tone: 'normal', page: 'dns' }];
|
||||
}
|
||||
|
||||
function buildDnsDiagram(global: any, firewall: any, dns: any, shunt: any): FlowDiagram {
|
||||
const source = FirewallDnsForwardUtils.getSource();
|
||||
const aclDetail = source === FIREWALL_DNS_FORWARD.NONE
|
||||
? getAclDetail(firewall)
|
||||
: [
|
||||
getAclDetail(firewall),
|
||||
firewall.proxy_local === '1' ? _('Router traffic included') : _('Router traffic excluded'),
|
||||
].join('\n');
|
||||
const steps: FlowStep[] = [
|
||||
{ title: _('Client DNS query'), detail: _('TCP/UDP port 53') },
|
||||
{ title: getAclTitle(firewall), detail: aclDetail, page: 'firewall' },
|
||||
];
|
||||
|
||||
if (source === FIREWALL_DNS_FORWARD.NONE) {
|
||||
steps.push({ title: _('No DNS redirect'), detail: _('Keep the original destination'), tone: 'muted', page: 'firewall' });
|
||||
return { id: 'dns', title: _('DNS flow'), steps };
|
||||
}
|
||||
if (source === FIREWALL_DNS_FORWARD.ROUTING) {
|
||||
steps.push(...getRoutingSteps(shunt, 'dns'));
|
||||
return { id: 'dns', title: _('DNS flow'), steps };
|
||||
}
|
||||
if (source !== FIREWALL_DNS_FORWARD.PRE_ROUTING) {
|
||||
steps.push({ title: _('Unknown DNS forwarding source'), detail: source, tone: 'warning' });
|
||||
return { id: 'dns', title: _('DNS flow'), steps };
|
||||
}
|
||||
if (dns.dns_service !== 'chinadns-ng' || !isPort(dns.cdg_port)) {
|
||||
steps.push({ title: _('Independent DNS unavailable'), detail: _('Disabled or invalid listen port'), tone: 'warning', page: 'dns' });
|
||||
return { id: 'dns', title: _('DNS flow'), steps };
|
||||
}
|
||||
|
||||
steps.push({ title: _('Independent DNS: %s').format(dns.cdg_port), detail: 'chinadns-ng', tone: 'proxy', page: 'dns' });
|
||||
const directConditions: string[] = [];
|
||||
const proxyConditions: string[] = [];
|
||||
if (global.domain_direct) directConditions.push(_('Custom direct domain list'));
|
||||
if (global.domain_proxy) proxyConditions.push(_('Custom proxy domain list'));
|
||||
if (dns.use_chn === '1') directConditions.push(_('CHN domain list'));
|
||||
if (dns.use_gfw === '1') proxyConditions.push(_('GFW domain list'));
|
||||
|
||||
return {
|
||||
id: 'dns',
|
||||
title: _('DNS flow'),
|
||||
steps,
|
||||
branches: [
|
||||
{
|
||||
condition: _('Direct domain branch'),
|
||||
page: 'dns',
|
||||
detail: directConditions.join(' + ') || undefined,
|
||||
steps: getDnsUpstreamSteps(dns.direct_dns, dns.direct_dns_custom, shunt),
|
||||
},
|
||||
{
|
||||
condition: _('Proxy domain branch'),
|
||||
page: 'dns',
|
||||
detail: proxyConditions.join(' + ') || undefined,
|
||||
steps: getDnsUpstreamSteps(dns.proxy_dns, dns.proxy_dns_custom, shunt),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function getDefaultTrafficTarget(firewall: any, shunt: any): FlowStep[] {
|
||||
const port = firewall.shunt_port || '';
|
||||
if (!isPort(port)) return [{ title: _('Direct'), detail: _('Default shunt port is disabled'), tone: 'direct', page: 'firewall' }];
|
||||
if (port === shunt.shunt_listen_port) return getRoutingSteps(shunt, 'traffic');
|
||||
return getProxyTarget(port, _('Default shunt port'));
|
||||
}
|
||||
|
||||
function getProxyTrafficTarget(firewall: any, shunt: any): FlowStep[] {
|
||||
if (isPort(firewall.proxy_port)) return getProxyTarget(firewall.proxy_port, _('Proxy port'));
|
||||
if (isPort(firewall.shunt_port)) return getDefaultTrafficTarget(firewall, shunt);
|
||||
return [{ title: _('No proxy target'), tone: 'warning', page: 'firewall' }];
|
||||
}
|
||||
|
||||
function buildTrafficDiagram(global: any, firewall: any, dns: any, shunt: any): FlowDiagram {
|
||||
const protocols = (firewall.tproxy_proto || '').split(',').filter(Boolean).map((item: string) => item.toUpperCase());
|
||||
const sourceStep: FlowStep = {
|
||||
title: _('Client traffic'),
|
||||
page: 'firewall',
|
||||
detail: protocols.join(' + ') || _('No protocol selected'),
|
||||
};
|
||||
const aclStep: FlowStep = {
|
||||
title: getAclTitle(firewall),
|
||||
page: 'firewall',
|
||||
detail: [
|
||||
getAclDetail(firewall),
|
||||
firewall.proxy_local === '1' ? _('Router traffic included') : _('Router traffic excluded'),
|
||||
].join('\n'),
|
||||
};
|
||||
const hasProxyPort = isPort(firewall.proxy_port);
|
||||
const hasShuntPort = isPort(firewall.shunt_port);
|
||||
|
||||
if (!hasProxyPort && !hasShuntPort) {
|
||||
return {
|
||||
id: 'traffic',
|
||||
title: _('Traffic flow'),
|
||||
steps: [sourceStep, aclStep, { title: _('No proxy target'), tone: 'warning', page: 'firewall' }],
|
||||
};
|
||||
}
|
||||
|
||||
const branches: FlowBranch[] = [];
|
||||
if (global.ip_proxy || (dns.dns_service === 'chinadns-ng' && global.domain_proxy)) {
|
||||
branches.push({ condition: _('Forced proxy address'), page: 'firewall', steps: getProxyTrafficTarget(firewall, shunt) });
|
||||
}
|
||||
if (global.ip_direct || (dns.dns_service === 'chinadns-ng' && global.domain_direct)) {
|
||||
branches.push({ condition: _('Forced direct address'), page: 'firewall', steps: [{ title: _('Direct'), tone: 'direct' }] });
|
||||
}
|
||||
if (dns.dns_service === 'chinadns-ng' && dns.use_gfw === '1') {
|
||||
branches.push({ condition: _('GFW domain set'), page: 'dns', steps: getProxyTrafficTarget(firewall, shunt) });
|
||||
}
|
||||
if (hasShuntPort && dns.dns_service === 'chinadns-ng' && dns.use_chn === '1') {
|
||||
branches.push({ condition: _('CHN domain set'), page: 'dns', steps: [{ title: _('Direct'), tone: 'direct' }] });
|
||||
}
|
||||
if (hasShuntPort && firewall.use_chnroute === '1') {
|
||||
branches.push({ condition: _('CHN route set'), page: 'firewall', steps: [{ title: _('Direct'), tone: 'direct' }] });
|
||||
}
|
||||
branches.push({ condition: _('No address rule matched'), steps: getDefaultTrafficTarget(firewall, shunt) });
|
||||
|
||||
return {
|
||||
id: 'traffic',
|
||||
title: _('Traffic flow'),
|
||||
steps: [sourceStep, aclStep],
|
||||
branches,
|
||||
branchMode: 'ordered',
|
||||
branchDescription: _('Address rules are evaluated from top to bottom'),
|
||||
};
|
||||
}
|
||||
|
||||
function buildConfigurationFlowPreview(): ConfigurationFlowPreview {
|
||||
const global = getFirstSection(LuciFlied.GLOBAL_SECTION_TYPE);
|
||||
const firewall = getFirstSection(LuciFlied.FIREWALL_SECTION_TYPE);
|
||||
const dns = getFirstSection(LuciFlied.DNS_SECTION_TYPE);
|
||||
const shunt = getFirstSection(LuciFlied.SHUNT_SECTION_TYPE);
|
||||
return {
|
||||
diagrams: [buildDnsDiagram(global, firewall, dns, shunt), buildTrafficDiagram(global, firewall, dns, shunt)],
|
||||
};
|
||||
}
|
||||
|
||||
export { buildConfigurationFlowPreview };
|
||||
@@ -0,0 +1,428 @@
|
||||
import { buildConfigurationFlowPreview, type FlowBranch, type FlowDiagram, type FlowStep, type FlowPage } from "./flow-model";
|
||||
|
||||
const FLOW_STYLE = `
|
||||
a.hijpass-flow-step,
|
||||
a.hijpass-flow-branch__condition {
|
||||
display: block;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a.hijpass-flow-step:hover,
|
||||
a.hijpass-flow-branch__condition:hover {
|
||||
border-color: var(--primary-color, #4b6cb7);
|
||||
}
|
||||
|
||||
a.hijpass-flow-step:focus-visible,
|
||||
a.hijpass-flow-branch__condition:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #4b6cb7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hijpass-flow-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hijpass-flow-step[data-tone="warning"] {
|
||||
color: var(--warning-color, #b26a00);
|
||||
}
|
||||
|
||||
.hijpass-flow-list {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hijpass-flow-card {
|
||||
--hijpass-flow-node-width: 14rem;
|
||||
--hijpass-flow-condition-width: 13rem;
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hijpass-flow-scroll {
|
||||
max-width: 100%;
|
||||
margin-top: .75rem;
|
||||
padding: 0 0 .5rem;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
overscroll-behavior-inline: contain;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
.hijpass-flow-scroll:focus-visible {
|
||||
outline: 2px solid var(--primary-color, #4b6cb7);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.hijpass-flow-tree {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.hijpass-flow-chain {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: .5rem;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hijpass-flow-chain > li:not(.hijpass-flow-arrow) {
|
||||
display: flex;
|
||||
flex: 0 0 var(--hijpass-flow-node-width);
|
||||
width: var(--hijpass-flow-node-width);
|
||||
}
|
||||
|
||||
.hijpass-flow-step {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: .75rem;
|
||||
border: 1px solid rgba(127, 127, 127, .45);
|
||||
border-left-width: 4px;
|
||||
border-radius: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hijpass-flow-step[data-tone="proxy"] {
|
||||
border-left-color: var(--primary-color, #4b6cb7);
|
||||
}
|
||||
|
||||
.hijpass-flow-step[data-tone="direct"] {
|
||||
border-left-color: var(--success-color, #2e7d32);
|
||||
}
|
||||
|
||||
.hijpass-flow-step[data-tone="muted"] {
|
||||
opacity: .7;
|
||||
}
|
||||
|
||||
.hijpass-flow-step[data-tone="warning"] {
|
||||
border-left-color: currentColor;
|
||||
}
|
||||
|
||||
.hijpass-flow-step__detail,
|
||||
.hijpass-flow-branch__detail {
|
||||
display: block;
|
||||
margin-top: .25rem;
|
||||
color: var(--text-color-medium, #777);
|
||||
font-size: .8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.hijpass-flow-step__detail {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.hijpass-flow-arrow {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color-medium, #777);
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.hijpass-flow-branches {
|
||||
position: relative;
|
||||
display: grid;
|
||||
gap: .75rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.hijpass-flow-branches--parallel {
|
||||
margin-left: 1.5rem;
|
||||
}
|
||||
|
||||
.hijpass-flow-branches--parallel::before {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
right: 100%;
|
||||
width: 1.5rem;
|
||||
border-top: 2px solid rgba(127, 127, 127, .6);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.hijpass-flow-branch {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
gap: .5rem;
|
||||
min-height: 4.25rem;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--parallel {
|
||||
grid-template-columns: 1.5rem var(--hijpass-flow-condition-width) 1.5rem auto;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch__joint {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-self: stretch;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
color: var(--text-color-medium, #777);
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch__joint::after {
|
||||
position: absolute;
|
||||
top: -.375rem;
|
||||
bottom: -.375rem;
|
||||
left: 0;
|
||||
border-left: 2px solid rgba(127, 127, 127, .6);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--parallel:first-child .hijpass-flow-branch__joint::after {
|
||||
top: 50%;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--parallel:last-child .hijpass-flow-branch__joint::after {
|
||||
bottom: 50%;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--parallel:only-child .hijpass-flow-branch__joint::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch__condition {
|
||||
box-sizing: border-box;
|
||||
width: var(--hijpass-flow-condition-width);
|
||||
padding: .75rem;
|
||||
border: 1px dashed rgba(127, 127, 127, .55);
|
||||
border-radius: 4px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch .hijpass-flow-chain {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto auto;
|
||||
grid-template-rows: auto auto auto;
|
||||
column-gap: .5rem;
|
||||
width: max-content;
|
||||
min-width: max-content;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__prefix {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__entry {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
align-self: center;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__description {
|
||||
display: flex;
|
||||
grid-column: 3;
|
||||
grid-row: 1;
|
||||
align-items: center;
|
||||
height: 1.5rem;
|
||||
margin: 0 0 .25rem;
|
||||
color: var(--text-color-medium, #777);
|
||||
font-size: .8125rem;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__first,
|
||||
.hijpass-flow-ordered__rest {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__first {
|
||||
grid-row: 2;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.hijpass-flow-ordered__rest {
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.hijpass-flow-branches--ordered {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--ordered {
|
||||
grid-template-columns: var(--hijpass-flow-condition-width) 1.5rem auto;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--ordered .hijpass-flow-branch__condition,
|
||||
.hijpass-flow-branch--ordered .hijpass-flow-step {
|
||||
display: flex;
|
||||
min-height: 4.25rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hijpass-flow-branch--ordered .hijpass-flow-chain {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.hijpass-flow-order-arrow {
|
||||
display: flex;
|
||||
width: var(--hijpass-flow-condition-width);
|
||||
height: 1.25rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-color-medium, #777);
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.hijpass-flow-card {
|
||||
--hijpass-flow-node-width: 13rem;
|
||||
--hijpass-flow-condition-width: 12rem;
|
||||
}
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
function ensureFlowPreviewStyles() {
|
||||
if (document.getElementById('hijpass-flow-preview-styles')) return;
|
||||
const style = document.createElement('style');
|
||||
style.id = 'hijpass-flow-preview-styles';
|
||||
style.textContent = FLOW_STYLE;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function pageUrl(page: FlowPage): string {
|
||||
return L.url(page === 'dhcp' ? 'admin/network/dhcp' : 'admin/services/hijpass/' + page);
|
||||
}
|
||||
|
||||
function renderStep(step: FlowStep) {
|
||||
return E(step.page ? 'a' : 'div', {
|
||||
...(step.page ? { href: pageUrl(step.page) } : {}),
|
||||
'class': 'hijpass-flow-step',
|
||||
'data-tone': step.tone || 'normal',
|
||||
}, [
|
||||
E('strong', {}, step.title),
|
||||
step.detail ? E('span', { 'class': 'hijpass-flow-step__detail' }, step.detail) : '',
|
||||
]);
|
||||
}
|
||||
|
||||
function renderChain(steps: FlowStep[]) {
|
||||
const children: HTMLElement[] = [];
|
||||
steps.forEach((step, index) => {
|
||||
children.push(E('li', {}, [renderStep(step)]) as HTMLElement);
|
||||
if (index < steps.length - 1) {
|
||||
children.push(E('li', { 'class': 'hijpass-flow-arrow', 'aria-hidden': 'true' }, '→') as HTMLElement);
|
||||
}
|
||||
});
|
||||
return E('ol', { 'class': 'hijpass-flow-chain' }, children);
|
||||
}
|
||||
|
||||
function renderParallelBranch(branch: FlowBranch) {
|
||||
return E('li', { 'class': 'hijpass-flow-branch hijpass-flow-branch--parallel' }, [
|
||||
E('span', { 'class': 'hijpass-flow-branch__joint', 'aria-hidden': 'true' }, '→'),
|
||||
E(branch.page ? 'a' : 'div', { 'class': 'hijpass-flow-branch__condition', ...(branch.page ? { href: pageUrl(branch.page) } : {}) }, [
|
||||
E('strong', {}, branch.condition),
|
||||
branch.detail ? E('span', { 'class': 'hijpass-flow-branch__detail' }, branch.detail) : '',
|
||||
]),
|
||||
E('span', { 'class': 'hijpass-flow-arrow', 'aria-hidden': 'true' }, '→'),
|
||||
renderChain(branch.steps),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderOrderedBranch(branch: FlowBranch) {
|
||||
return E('li', { 'class': 'hijpass-flow-branch hijpass-flow-branch--ordered' }, [
|
||||
E(branch.page ? 'a' : 'div', { 'class': 'hijpass-flow-branch__condition', ...(branch.page ? { href: pageUrl(branch.page) } : {}) }, [
|
||||
E('strong', {}, branch.condition),
|
||||
branch.detail ? E('span', { 'class': 'hijpass-flow-branch__detail' }, branch.detail) : '',
|
||||
]),
|
||||
E('span', { 'class': 'hijpass-flow-arrow', 'aria-hidden': 'true' }, '→'),
|
||||
renderChain(branch.steps),
|
||||
]);
|
||||
}
|
||||
|
||||
function renderOrderedTree(steps: FlowStep[], branches: FlowBranch[], description?: string) {
|
||||
const remaining: HTMLElement[] = [];
|
||||
branches.slice(1).forEach(branch => {
|
||||
remaining.push(E('li', {
|
||||
'class': 'hijpass-flow-order-arrow',
|
||||
'aria-hidden': 'true',
|
||||
}, '↓') as HTMLElement);
|
||||
remaining.push(renderOrderedBranch(branch) as HTMLElement);
|
||||
});
|
||||
|
||||
return E('div', {
|
||||
'class': 'hijpass-flow-tree hijpass-flow-ordered',
|
||||
'data-branch-mode': 'ordered',
|
||||
}, [
|
||||
E('p', { 'class': 'hijpass-flow-ordered__description' }, description || ''),
|
||||
E('div', { 'class': 'hijpass-flow-ordered__prefix' }, renderChain(steps)),
|
||||
E('span', { 'class': 'hijpass-flow-arrow hijpass-flow-ordered__entry', 'aria-hidden': 'true' }, '→'),
|
||||
E('ol', {
|
||||
'class': 'hijpass-flow-branches hijpass-flow-branches--ordered hijpass-flow-ordered__first',
|
||||
}, [renderOrderedBranch(branches[0])]),
|
||||
remaining.length > 0 ? E('ol', {
|
||||
'class': 'hijpass-flow-branches hijpass-flow-branches--ordered hijpass-flow-ordered__rest',
|
||||
}, remaining) : '',
|
||||
]);
|
||||
}
|
||||
|
||||
function renderBranches(diagram: FlowDiagram) {
|
||||
if (!diagram.branches?.length) return '';
|
||||
return E('ul', {
|
||||
'class': 'hijpass-flow-branches hijpass-flow-branches--parallel',
|
||||
}, diagram.branches.map(renderParallelBranch));
|
||||
}
|
||||
|
||||
function renderTree(diagram: FlowDiagram) {
|
||||
const tree = diagram.branchMode === 'ordered' && diagram.branches?.length
|
||||
? renderOrderedTree(diagram.steps, diagram.branches, diagram.branchDescription)
|
||||
: E('div', {
|
||||
'class': 'hijpass-flow-tree',
|
||||
'data-branch-mode': diagram.branchMode || 'parallel',
|
||||
}, [
|
||||
renderChain(diagram.steps),
|
||||
renderBranches(diagram),
|
||||
]);
|
||||
|
||||
return E('div', {
|
||||
'class': 'hijpass-flow-scroll',
|
||||
'role': 'region',
|
||||
'aria-label': diagram.title,
|
||||
'tabindex': '0',
|
||||
}, [tree]);
|
||||
}
|
||||
|
||||
const FlowPreviewUtils = {
|
||||
createConfigurationFlowPreview: function () {
|
||||
ensureFlowPreviewStyles();
|
||||
const preview = buildConfigurationFlowPreview();
|
||||
return E('div', { 'class': 'hijpass-flow-preview' }, [
|
||||
E('div', { 'class': 'cbi-section-descr' },
|
||||
_('Generated from saved settings. Branches show configured decisions, not a live packet trace.')),
|
||||
E('div', { 'class': 'hijpass-flow-list' }, preview.diagrams.map(diagram =>
|
||||
E('section', {
|
||||
'class': 'hijpass-flow-card',
|
||||
'aria-labelledby': 'hijpass-flow-' + diagram.id,
|
||||
}, [
|
||||
E('h3', { 'id': 'hijpass-flow-' + diagram.id }, diagram.title),
|
||||
renderTree(diagram),
|
||||
])))
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
export { FlowPreviewUtils };
|
||||
@@ -0,0 +1,196 @@
|
||||
import fs from "fs";
|
||||
|
||||
type TestSite = {
|
||||
id: string,
|
||||
name: string,
|
||||
url: string,
|
||||
svg: string
|
||||
}
|
||||
|
||||
type ActionStatus = 'success' | 'error' | 'neutral';
|
||||
|
||||
const TEST_SITES: TestSite[] = [
|
||||
{
|
||||
id: 'baidu',
|
||||
name: 'Baidu',
|
||||
url: 'https://www.baidu.com',
|
||||
svg: '<svg height="32" style="flex:none;line-height:1" viewBox="0 0 24 24" width="32" xmlns="http://www.w3.org/2000/svg"><title>Baidu</title><path d="M8.859 11.735c1.017-1.71 4.059-3.083 6.202.286 1.579 2.284 4.284 4.397 4.284 4.397s2.027 1.601.73 4.684c-1.24 2.956-5.64 1.607-6.005 1.49l-.024-.009s-1.746-.568-3.776-.112c-2.026.458-3.773.286-3.773.286l-.045-.001c-.328-.01-2.38-.187-3.001-2.968-.675-3.028 2.365-4.687 2.592-4.968.226-.288 1.802-1.37 2.816-3.085zm.986 1.738v2.032h-1.64s-1.64.138-2.213 2.014c-.2 1.252.177 1.99.242 2.148.067.157.596 1.073 1.927 1.342h3.078v-7.514l-1.394-.022zm3.588 2.191l-1.44.024v3.956s.064.985 1.44 1.344h3.541v-5.3h-1.528v3.979h-1.46s-.466-.068-.553-.447v-3.556zM9.82 16.715v3.06H8.58s-.863-.045-1.126-1.049c-.136-.445.02-.959.088-1.16.063-.203.353-.671.951-.85H9.82zm9.525-9.036c2.086 0 2.646 2.06 2.646 2.742 0 .688.284 3.597-2.309 3.655-2.595.057-2.704-1.77-2.704-3.08 0-1.374.277-3.317 2.367-3.317zM4.24 6.08c1.523-.135 2.645 1.55 2.762 2.513.07.625.393 3.486-1.975 4-2.364.515-3.244-2.249-2.984-3.544 0 0 .28-2.797 2.197-2.969zm8.847-1.483c.14-1.31 1.69-3.316 2.931-3.028 1.236.285 2.367 1.944 2.137 3.37-.224 1.428-1.345 3.313-3.095 3.082-1.748-.226-2.143-1.823-1.973-3.424zM9.425 1c1.307 0 2.364 1.519 2.364 3.398 0 1.879-1.057 3.4-2.364 3.4s-2.367-1.521-2.367-3.4C7.058 2.518 8.118 1 9.425 1z" fill="#2932E1" fill-rule="nonzero"></path></svg>'
|
||||
},
|
||||
{
|
||||
id: 'google',
|
||||
name: 'Google',
|
||||
url: 'https://www.google.com/ncr',
|
||||
svg: '<svg height="32" width="32" style="flex:none;line-height:1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Google</title><path d="M23 12.245c0-.905-.075-1.565-.236-2.25h-10.54v4.083h6.186c-.124 1.014-.797 2.542-2.294 3.569l-.021.136 3.332 2.53.23.022C21.779 18.417 23 15.593 23 12.245z" fill="#4285F4"></path><path d="M12.225 23c3.03 0 5.574-.978 7.433-2.665l-3.542-2.688c-.948.648-2.22 1.1-3.891 1.1a6.745 6.745 0 01-6.386-4.572l-.132.011-3.465 2.628-.045.124C4.043 20.531 7.835 23 12.225 23z" fill="#34A853"></path><path d="M5.84 14.175A6.65 6.65 0 015.463 12c0-.758.138-1.491.361-2.175l-.006-.147-3.508-2.67-.115.054A10.831 10.831 0 001 12c0 1.772.436 3.447 1.197 4.938l3.642-2.763z" fill="#FBBC05"></path><path d="M12.225 5.253c2.108 0 3.529.892 4.34 1.638l3.167-3.031C17.787 2.088 15.255 1 12.225 1 7.834 1 4.043 3.469 2.197 7.062l3.63 2.763a6.77 6.77 0 016.398-4.572z" fill="#EB4335"></path></svg>'
|
||||
},
|
||||
{
|
||||
id: 'github',
|
||||
name: 'GitHub',
|
||||
url: 'https://github.com',
|
||||
svg: '<svg fill="currentColor" fill-rule="evenodd" height="32" width="32" style="flex:none;line-height:1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>Github</title><path d="M12 0c6.63 0 12 5.276 12 11.79-.001 5.067-3.29 9.567-8.175 11.187-.6.118-.825-.25-.825-.56 0-.398.015-1.665.015-3.242 0-1.105-.375-1.813-.81-2.181 2.67-.295 5.475-1.297 5.475-5.822 0-1.297-.465-2.344-1.23-3.169.12-.295.54-1.503-.12-3.125 0 0-1.005-.324-3.3 1.209a11.32 11.32 0 00-3-.398c-1.02 0-2.04.133-3 .398-2.295-1.518-3.3-1.209-3.3-1.209-.66 1.622-.24 2.83-.12 3.125-.765.825-1.23 1.887-1.23 3.169 0 4.51 2.79 5.527 5.46 5.822-.345.294-.66.81-.765 1.577-.69.31-2.415.81-3.495-.973-.225-.354-.9-1.223-1.845-1.209-1.005.015-.405.56.015.781.51.28 1.095 1.327 1.23 1.666.24.663 1.02 1.93 4.035 1.385 0 .988.015 1.916.015 2.196 0 .31-.225.664-.825.56C3.303 21.374-.003 16.867 0 11.791 0 5.276 5.37 0 12 0z"></path></svg>'
|
||||
},
|
||||
{
|
||||
id: 'bilibili',
|
||||
name: 'Bilibili',
|
||||
url: 'https://www.bilibili.com',
|
||||
svg: '<svg height="32" width="32" style="flex:none;line-height:1" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><title>bilibili</title><path clip-rule="evenodd" d="M4.977 3.561a1.31 1.31 0 111.818-1.884l2.828 2.728c.08.078.149.163.205.254h4.277a1.32 1.32 0 01.205-.254l2.828-2.728a1.31 1.31 0 011.818 1.884L17.82 4.66h.848A5.333 5.333 0 0124 9.992v7.34a5.333 5.333 0 01-5.333 5.334H5.333A5.333 5.333 0 010 17.333V9.992a5.333 5.333 0 015.333-5.333h.781L4.977 3.56zm.356 3.67a2.667 2.667 0 00-2.666 2.667v7.529a2.667 2.667 0 002.666 2.666h13.334a2.667 2.667 0 002.666-2.666v-7.53a2.667 2.667 0 00-2.666-2.666H5.333zm1.334 5.192a1.333 1.333 0 112.666 0v1.192a1.333 1.333 0 11-2.666 0v-1.192zM16 11.09c-.736 0-1.333.597-1.333 1.333v1.192a1.333 1.333 0 102.666 0v-1.192c0-.736-.597-1.333-1.333-1.333z" fill="#1b7be1" fill-rule="evenodd"></path></svg>'
|
||||
}
|
||||
];
|
||||
|
||||
function restartService(statusElement: HTMLElement) {
|
||||
updateActionStatus(statusElement, _('Restarting Service'), 'neutral');
|
||||
|
||||
fs.exec('/etc/init.d/hijpass', ['restart']).then(function (result) {
|
||||
if (result.code === 0) {
|
||||
updateActionStatus(statusElement, _('Service Restarted'), 'success');
|
||||
setTimeout(function () {
|
||||
location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
updateActionStatus(statusElement, _('Service Restart Failed') + (result.stderr || result.stdout), 'error');
|
||||
}
|
||||
}).catch(function (error) {
|
||||
updateActionStatus(statusElement, _('Service Restart Failed') + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function reloadFirewall(statusElement: HTMLElement) {
|
||||
updateActionStatus(statusElement, _('Refreshing Firewall'), 'neutral');
|
||||
|
||||
fs.exec('/usr/lib/hijpass/nft.sh', ['reset']).then(function (result) {
|
||||
if (result.code === 0) {
|
||||
updateActionStatus(statusElement, _('Firewall Refreshed'), 'success');
|
||||
} else {
|
||||
updateActionStatus(statusElement, _('Firewall Refresh Failed') + (result.stderr || result.stdout), 'error');
|
||||
}
|
||||
}).catch(function (error) {
|
||||
updateActionStatus(statusElement, _('Firewall Refresh Failed') + error.message, 'error');
|
||||
});
|
||||
}
|
||||
|
||||
function updateActionStatus(statusElement: HTMLElement, message: string, status: ActionStatus) {
|
||||
statusElement.textContent = message;
|
||||
statusElement.style.color = status === 'success' ? '#4caf50' :
|
||||
status === 'error' ? '#f44336' : '#666';
|
||||
}
|
||||
|
||||
function createTestButton(info: TestSite) {
|
||||
const resultElement = E('div', {
|
||||
'style': 'font-size: 13px; color: #f44336;'
|
||||
}, _('Click to Test')) as HTMLElement;
|
||||
let buttonElement: HTMLElement;
|
||||
|
||||
buttonElement = E('div', {
|
||||
'class': 'connectivity-btn cbi-section',
|
||||
'style': 'width: 100%; height: 100px; display: flex; ' +
|
||||
'flex-direction: column; align-items: center; justify-content: center; ' +
|
||||
'font-size: 15px; border-radius: 8px; padding: 0; ' +
|
||||
'box-shadow: 0 0 2rem 0 rgba(136, 152, 170, .15); border:1px solid rgba(0, 0, 0, .05); ' +
|
||||
'cursor: pointer;',
|
||||
'click': function () {
|
||||
testConnectivity(info.url, buttonElement, resultElement);
|
||||
}
|
||||
}, [
|
||||
E('div', {
|
||||
'style': 'display: flex; align-items: center; justify-content: center;'
|
||||
}, [
|
||||
E('span', {
|
||||
'style': 'width: 40px; height: 40px; display: flex; align-items: center; justify-content: center;'
|
||||
}, [E('raw', {}, info.svg)])
|
||||
]),
|
||||
E('div', {
|
||||
'style': 'font-weight: bold; font-size: 16px; margin-bottom: 2px;'
|
||||
}, _(info.name)),
|
||||
resultElement
|
||||
]) as HTMLElement;
|
||||
|
||||
return E('div', {
|
||||
'class': 'connectivity-test-button',
|
||||
'style': 'flex: 1; min-width: 120px;'
|
||||
}, [buttonElement])
|
||||
}
|
||||
|
||||
function testConnectivity(url: string, buttonElement: HTMLElement, resultElement: HTMLElement) {
|
||||
if (buttonElement.dataset.loading === '1') {
|
||||
return;
|
||||
}
|
||||
|
||||
resultElement.textContent = _('Testing');
|
||||
resultElement.style.color = '#666';
|
||||
buttonElement.dataset.loading = '1';
|
||||
buttonElement.style.opacity = '0.7';
|
||||
|
||||
fs.exec_direct("/usr/lib/hijpass/connect.sh", [url, '5'], 'json')
|
||||
.then(r => {
|
||||
updateTestResult(buttonElement, resultElement, r.success, r);
|
||||
})
|
||||
.catch(e => {
|
||||
console.error(e)
|
||||
updateTestResult(buttonElement, resultElement, false, undefined)
|
||||
})
|
||||
}
|
||||
|
||||
function updateTestResult(buttonElement: HTMLElement, resultElement: HTMLElement, success: boolean, response: any) {
|
||||
delete buttonElement.dataset.loading;
|
||||
buttonElement.style.opacity = '1';
|
||||
|
||||
if (success) {
|
||||
if (/^([45])\d{2}$/.test(response.http_code)) {
|
||||
resultElement.textContent = _('Connection Abnormal') + response.http_code;
|
||||
resultElement.style.color = '#f44336';
|
||||
} else {
|
||||
resultElement.textContent = response.tls_handshake_time_ms + ' ms'
|
||||
resultElement.style.color = '#4caf50';
|
||||
}
|
||||
} else {
|
||||
resultElement.textContent = _('Test Failed');
|
||||
resultElement.style.color = '#f44336';
|
||||
}
|
||||
}
|
||||
|
||||
const OverviewPanelUtils = {
|
||||
createActionButtons: function () {
|
||||
const statusElement = E('span', {
|
||||
'style': 'margin-left: 10px; font-size: 12px; color: #666;'
|
||||
}) as HTMLElement;
|
||||
|
||||
return E('div', {'class': 'cbi-value', 'style': 'margin-bottom: 2rem;'}, [
|
||||
E('label', {'class': 'cbi-value-title', 'style': 'visibility: hidden'}, _('Service Control')),
|
||||
E('div', {'class': 'cbi-value-field'}, [
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'margin-right: 10px;',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
restartService(statusElement);
|
||||
}
|
||||
}, _('Restart Service')),
|
||||
E('button', {
|
||||
'class': 'cbi-button cbi-button-action',
|
||||
'style': 'margin-right: 10px;',
|
||||
'type': 'button',
|
||||
'click': function (ev: Event) {
|
||||
ev.preventDefault();
|
||||
reloadFirewall(statusElement);
|
||||
}
|
||||
}, _('Refresh Firewall')),
|
||||
statusElement
|
||||
])
|
||||
])
|
||||
},
|
||||
|
||||
createConnectivityTest: function () {
|
||||
return E('div', {
|
||||
'id': 'custom-connectivity-test',
|
||||
'class': 'cbi-map'
|
||||
}, [
|
||||
E('h2', {}, _('Website Connectivity Test')),
|
||||
E('div', {
|
||||
'class': 'cbi-section',
|
||||
'style': 'display: flex; gap: 18px; padding: 18px; justify-content: center;'
|
||||
}, TEST_SITES.map(site => createTestButton(site)))
|
||||
]);
|
||||
},
|
||||
}
|
||||
|
||||
export { OverviewPanelUtils }
|
||||
@@ -0,0 +1,214 @@
|
||||
import uci from "uci";
|
||||
import { CORE_TYPE, LuciFlied, PROXY_TYPE, normalizeCoreType } from "../../../enum/hijpass";
|
||||
import type { LoadBalanceProxyNode, ProxyNode } from "../../../module/luci";
|
||||
|
||||
export const MAX_PROXY_CHAIN_NODES = 3;
|
||||
export const LOAD_BALANCE_MEMBER_TAG_PREFIX = 'lb-member-';
|
||||
|
||||
export interface ResolvedUpstreamProxy {
|
||||
name: string;
|
||||
socksPort: number;
|
||||
tag: string;
|
||||
core: string;
|
||||
section: any;
|
||||
}
|
||||
|
||||
export interface ResolvedLoadBalanceMember extends ResolvedUpstreamProxy {
|
||||
tag: string;
|
||||
}
|
||||
|
||||
function getProxyName(section: any): string {
|
||||
return section?.name || section?.['.name'] || '';
|
||||
}
|
||||
|
||||
function getProxyCore(section: any): string {
|
||||
return normalizeCoreType(section?.core || CORE_TYPE.SING_BOX);
|
||||
}
|
||||
|
||||
function isPort(value: any): boolean {
|
||||
if (!value) return false;
|
||||
const port = Number(value);
|
||||
return Number.isInteger(port) && port > 0 && port <= 65535;
|
||||
}
|
||||
|
||||
function getProxySections(): any[] {
|
||||
const sections: any[] = [];
|
||||
uci.sections(LuciFlied.CONF_NAME, LuciFlied.PROXY_NODE_TYPE, function (section: any) {
|
||||
sections.push(section);
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function getProxySectionMap(): Map<string, any> {
|
||||
const sections = new Map<string, any>();
|
||||
getProxySections().forEach((section) => {
|
||||
const name = getProxyName(section);
|
||||
if (name) sections.set(name, section);
|
||||
});
|
||||
return sections;
|
||||
}
|
||||
|
||||
function getUpstreamProxyOutboundTag(name: string): string {
|
||||
return 'upstream-' + name;
|
||||
}
|
||||
|
||||
function normalizeProxyNodeList(value: any): string[] {
|
||||
if (!value) return [];
|
||||
return (Array.isArray(value) ? value : [value]).filter((name) => Boolean(name));
|
||||
}
|
||||
|
||||
function getLoadBalanceMemberOutboundTag(index: number): string {
|
||||
return LOAD_BALANCE_MEMBER_TAG_PREFIX + index;
|
||||
}
|
||||
|
||||
function resolveLoadBalanceMembers(
|
||||
proxyNode: LoadBalanceProxyNode,
|
||||
sections: Map<string, any> = getProxySectionMap()
|
||||
): ResolvedLoadBalanceMember[] {
|
||||
const memberNames = normalizeProxyNodeList(proxyNode.member_node);
|
||||
if (memberNames.length === 0) {
|
||||
throw new Error('Load balancing node has no members: ' + proxyNode.name);
|
||||
}
|
||||
|
||||
return memberNames.map((memberName, index) => {
|
||||
if (memberName === proxyNode.name) {
|
||||
throw new Error('Load balancing node cannot include itself: ' + memberName);
|
||||
}
|
||||
|
||||
const section = sections.get(memberName);
|
||||
if (!section) {
|
||||
throw new Error('Load balancing member node not found: ' + memberName);
|
||||
}
|
||||
if (section.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
throw new Error('Nested load balancing members are not supported: ' + memberName);
|
||||
}
|
||||
if (section.enabled !== '1') {
|
||||
throw new Error('Load balancing member node disabled: ' + memberName);
|
||||
}
|
||||
if (!isPort(section.socks_port)) {
|
||||
throw new Error('Load balancing member node SOCKS port invalid: ' + memberName);
|
||||
}
|
||||
|
||||
return {
|
||||
name: memberName,
|
||||
socksPort: Number(section.socks_port),
|
||||
tag: getLoadBalanceMemberOutboundTag(index),
|
||||
core: getProxyCore(section),
|
||||
section,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function resolveUpstreamProxy(
|
||||
proxyNode: ProxyNode,
|
||||
sections: Map<string, any> = getProxySectionMap()
|
||||
): ResolvedUpstreamProxy | undefined {
|
||||
const upstreamName = proxyNode.upstream_proxy_node;
|
||||
if (!upstreamName) return undefined;
|
||||
if (upstreamName === proxyNode.name) {
|
||||
throw new Error('Upstream proxy node cannot be itself: ' + upstreamName);
|
||||
}
|
||||
|
||||
const upstreamSection = sections.get(upstreamName);
|
||||
if (!upstreamSection) {
|
||||
throw new Error('Upstream proxy node not found: ' + upstreamName);
|
||||
}
|
||||
if (upstreamSection.enabled !== '1') {
|
||||
throw new Error('Upstream proxy node disabled: ' + upstreamName);
|
||||
}
|
||||
if (!isPort(upstreamSection.socks_port)) {
|
||||
throw new Error('Upstream proxy node SOCKS port invalid: ' + upstreamName);
|
||||
}
|
||||
|
||||
return {
|
||||
name: upstreamName,
|
||||
socksPort: Number(upstreamSection.socks_port),
|
||||
tag: getUpstreamProxyOutboundTag(upstreamName),
|
||||
core: getProxyCore(upstreamSection),
|
||||
section: upstreamSection,
|
||||
};
|
||||
}
|
||||
|
||||
function collectProxyChain(startName: string, sections: Map<string, any>) {
|
||||
const chain: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let currentName = startName;
|
||||
|
||||
while (currentName) {
|
||||
if (seen.has(currentName)) {
|
||||
chain.push(currentName);
|
||||
return { chain, cycle: true };
|
||||
}
|
||||
|
||||
seen.add(currentName);
|
||||
chain.push(currentName);
|
||||
|
||||
const section = sections.get(currentName);
|
||||
if (!section || !section.upstream_proxy_node) break;
|
||||
currentName = section.upstream_proxy_node;
|
||||
}
|
||||
|
||||
return { chain, cycle: false };
|
||||
}
|
||||
|
||||
function getProxyDependencies(section: any): string[] {
|
||||
const dependencies: string[] = [];
|
||||
if (section?.type === PROXY_TYPE.LOAD_BALANCE) {
|
||||
dependencies.push(...normalizeProxyNodeList(section.member_node));
|
||||
} else if (section?.upstream_proxy_node) {
|
||||
dependencies.push(section.upstream_proxy_node);
|
||||
}
|
||||
return dependencies;
|
||||
}
|
||||
|
||||
function findProxyDependencyCycle(sections: Map<string, any>): string[] | undefined {
|
||||
const visited = new Set<string>();
|
||||
const visiting = new Set<string>();
|
||||
const stack: string[] = [];
|
||||
|
||||
function visit(name: string): string[] | undefined {
|
||||
if (visiting.has(name)) {
|
||||
const cycleStart = stack.indexOf(name);
|
||||
return stack.slice(cycleStart).concat(name);
|
||||
}
|
||||
if (visited.has(name)) return undefined;
|
||||
|
||||
const section = sections.get(name);
|
||||
if (!section || section.enabled !== '1') return undefined;
|
||||
|
||||
visiting.add(name);
|
||||
stack.push(name);
|
||||
for (const dependency of getProxyDependencies(section)) {
|
||||
const cycle = visit(dependency);
|
||||
if (cycle) return cycle;
|
||||
}
|
||||
stack.pop();
|
||||
visiting.delete(name);
|
||||
visited.add(name);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
for (const name of sections.keys()) {
|
||||
const cycle = visit(name);
|
||||
if (cycle) return cycle;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const ProxyChainUtils = {
|
||||
getProxyName,
|
||||
getProxyCore,
|
||||
getProxySections,
|
||||
getProxySectionMap,
|
||||
isPort,
|
||||
getUpstreamProxyOutboundTag,
|
||||
normalizeProxyNodeList,
|
||||
getLoadBalanceMemberOutboundTag,
|
||||
resolveUpstreamProxy,
|
||||
resolveLoadBalanceMembers,
|
||||
collectProxyChain,
|
||||
getProxyDependencies,
|
||||
findProxyDependencyCycle,
|
||||
}
|
||||
|
||||
export { ProxyChainUtils }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user