🔥 Sync 2026-09-07 20:49:02

This commit is contained in:
github-actions[bot]
2026-09-07 20:49:02 +08:00
parent c05bdbe2bc
commit 6e956beb69
160 changed files with 152126 additions and 957 deletions
@@ -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'));
+7 -1
View File
@@ -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 ""
+12
View File
@@ -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/
+118
View File
@@ -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
+31
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
zh_Hans
File diff suppressed because it is too large Load Diff
+88
View File
@@ -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
View File
@@ -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
View File
@@ -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": {}
}
+243
View File
@@ -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
}
+149
View File
@@ -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
View File
@@ -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
View File
@@ -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" "$@"
}
+45
View File
@@ -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
View File
@@ -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"
+48
View File
@@ -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
View File
@@ -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
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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"
+74
View File
@@ -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
View File
@@ -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
+101
View File
@@ -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 "$@"
+31
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"
]
}
}
}
}
+131
View File
@@ -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);
});
+28
View File
@@ -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)
}
+283
View File
@@ -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)
+142
View File
@@ -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 支持的传输层(含 xhttpsing-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",
}
+11
View File
@@ -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",
}
+10
View File
@@ -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"
}
+473
View File
@@ -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;
+12
View File
@@ -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');
});
+20
View File
@@ -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
View File
@@ -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;
}
+38
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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