🎁 Sync 2026-07-31 00:21:49

This commit is contained in:
github-actions[bot] committed 2026-07-31 00:21:49 +08:00
1 parent f6c3fbbbbe
commit c71b32c9fb
204 files changed
+187145 -10038

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2022-2023 ImmortalWrt.org
NAME="homeproxy"
log_max_size="50" #KB
main_log_file="/var/run/$NAME/$NAME.log"
hiddifyc_log_file="/var/run/$NAME/hiddify-c.log"
while true; do
sleep 180
for i in "$main_log_file" "$hiddifyc_log_file"; do
[ -s "$i" ] || continue
[ "$(( $(ls -l "$i" | awk -F ' ' '{print $5}') / 1024 >= log_max_size))" -eq "0" ] || echo "" > "$i"
done
done
@@ -0,0 +1,600 @@
#!/usr/bin/utpl -S
{%-
import { readfile } from 'fs';
import { cursor } from 'uci';
import { isEmpty } from '/etc/homeproxy/scripts/homeproxy.uc';
const fw4 = require('fw4');
function array_to_nftarr(array) {
if (type(array) !== 'array')
return null;
return `{ ${join(', ', uniq(array))} }`;
}
function resolve_ipv6(str) {
if (isEmpty(str))
return null;
let ipv6 = fw4.parse_subnet(str)?.[0];
if (!ipv6 || ipv6.family !== 6)
return null;
if (ipv6.bits > -1)
return `${ipv6.addr}/${ipv6.bits}`;
else
return `& ${ipv6.mask} == ${ipv6.addr}`;
}
function resolve_mark(str) {
if (isEmpty(str))
return null;
let mark = fw4.parse_mark(str);
if (isEmpty(mark))
return null;
if (mark.mask === 0xffffffff)
return fw4.hex(mark.mark);
else if (mark.mark === 0)
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)}`;
else if (mark.mark === mark.mask)
return `mark or ${fw4.hex(mark.mark)}`;
else if (mark.mask === 0)
return `mark xor ${fw4.hex(mark.mark)}`;
else
return `mark and ${fw4.hex(~mark.mask & 0xffffffff)} xor ${fw4.hex(mark.mark)}`;
}
/* Misc config */
const resources_dir = '/etc/homeproxy/resources';
/* UCI config start */
const cfgname = 'homeproxy';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'proxy_banned_ru';
let outbound_node, outbound_udp_node;
if (routing_mode !== 'custom') {
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
} else {
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
}
let routing_port = uci.get(cfgname, 'config', 'routing_port');
if (routing_port === 'common')
routing_port = uci.get(cfgname, 'infra', 'common_port') || '22,53,80,143,443,465,587,853,873,993,995,8080,8443,9418';
const proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy',
ipv6_support = uci.get(cfgname, 'config', 'ipv6_support') || '0';
let self_mark, redirect_port,
tproxy_port, tproxy_mark,
tun_name, tun_mark;
/* ByeDPI runs as root (needed for TCP_MD5SIG etc.) but under a dedicated group, so its
* egress can be excluded from the redirect by socket group (skgid) to break the proxy
* loop — ciadpi has no fwmark option, and root traffic can't be told apart by skuid. */
const byedpi_enabled = uci.get(cfgname, 'config', 'byedpi_enabled') || '0';
const byedpi_gid = uci.get(cfgname, 'infra', 'byedpi_gid') || '8181';
/* zapret: sing-box's `zapret-out` direct outbound stamps egress with zapret_mark (a
* routing_mark that OVERRIDES default_mark). That mark must (a) escape the redirect loop
* like self_mark does, and (b) be steered into NFQUEUE so nfqws can desync the handshake.
* nfqws reinjects its fake packets carrying its own desync mark (0x40000000) — those must
* also escape the redirect, and must NOT be re-queued. */
const zapret_enabled = uci.get(cfgname, 'config', 'zapret_enabled') || '0';
const zapret_mark = uci.get(cfgname, 'config', 'zapret_mark') || '110';
const zapret_qnum = uci.get(cfgname, 'config', 'zapret_qnum') || '200';
const zapret_desync_mark = '0x40000000'; /* nfqws DESYNC_MARK (its reinjected fakes) */
if (match(proxy_mode, /redirect/)) {
self_mark = uci.get(cfgname, 'infra', 'self_mark') || '100';
redirect_port = uci.get(cfgname, 'infra', 'redirect_port') || '5331';
}
if (match(proxy_mode, /tproxy/))
if (outbound_udp_node !== 'nil' || routing_mode === 'custom' || routing_mode === 'proxy_banned_ru') {
tproxy_port = uci.get(cfgname, 'infra', 'tproxy_port') || '5332';
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101');
}
if (match(proxy_mode, /tun/)) {
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102');
}
const control_options = [
"listen_interfaces", "lan_proxy_mode",
"lan_direct_mac_addrs", "lan_direct_ipv4_ips", "lan_direct_ipv6_ips",
"lan_proxy_mac_addrs", "lan_proxy_ipv4_ips", "lan_proxy_ipv6_ips",
"lan_gaming_mode_mac_addrs", "lan_gaming_mode_ipv4_ips", "lan_gaming_mode_ipv6_ips",
"lan_global_proxy_mac_addrs", "lan_global_proxy_ipv4_ips", "lan_global_proxy_ipv6_ips",
"wan_proxy_ipv4_ips", "wan_proxy_ipv6_ips",
"wan_direct_ipv4_ips", "wan_direct_ipv6_ips"
];
const control_info = {};
for (let i in control_options)
control_info[i] = uci.get(cfgname, 'control', i);
const dns_redirect = uci.get(cfgname, 'infra', 'dns_redirect') || '1';
const dns_hijacked = uci.get('dhcp', '@dnsmasq[0]', 'dns_redirect') || '0',
dns_port = uci.get('dhcp', '@dnsmasq[0]', 'port') || '53';
/* UCI config end */
-%}
{# Reserved addresses -#}
set homeproxy_local_addr_v4 {
type ipv4_addr
flags interval
auto-merge
elements = {
0.0.0.0/8,
10.0.0.0/8,
100.64.0.0/10,
127.0.0.0/8,
169.254.0.0/16,
172.16.0.0/12,
192.0.0.0/24,
192.0.2.0/24,
192.31.196.0/24,
192.52.193.0/24,
192.88.99.0/24,
192.168.0.0/16,
192.175.48.0/24,
198.18.0.0/15,
198.51.100.0/24,
203.0.113.0/24,
224.0.0.0/4,
240.0.0.0/4
}
}
{% if (ipv6_support === '1'): %}
set homeproxy_local_addr_v6 {
type ipv6_addr
flags interval
auto-merge
elements = {
::/128,
::1/128,
::ffff:0:0/96,
100::/64,
64:ff9b::/96,
2001::/32,
2001:10::/28,
2001:20::/28,
2001:db8::/28,
2002::/16,
fc00::/7,
fe80::/10,
ff00::/8
}
}
{% endif %}
{# WAN ACL addresses #}
set homeproxy_wan_proxy_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv4_ips): %}
elements = { {{ join(', ', control_info.wan_proxy_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_proxy_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_proxy_ipv6_ips): %}
elements = { {{ join(', ', control_info.wan_proxy_ipv6_ips) }} }
{% endif /* wan_proxy_ipv6_ips*/ %}
}
{% endif /* ipv6_support */ %}
set homeproxy_wan_direct_addr_v4 {
type ipv4_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv4_ips): %}
elements = { {{ join(', ', control_info.wan_direct_ipv4_ips) }} }
{% endif %}
}
{% if (ipv6_support === '1'): %}
set homeproxy_wan_direct_addr_v6 {
type ipv6_addr
flags interval
auto-merge
{% if (control_info.wan_direct_ipv6_ips): %}
elements = { {{ join(', ', control_info.wan_direct_ipv6_ips) }} }
{% endif /* wan_direct_ipv6_ips */ %}
}
{% endif /* ipv6_support */ %}
{% if (routing_port): %}
set homeproxy_routing_port {
type inet_service
flags interval
auto-merge
elements = { {{ join(', ', split(routing_port, ',')) }} }
}
{% endif %}
{# DNS hijack & TCP redirect #}
chain dstnat {
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
{% if (control_info.listen_interfaces): %}
meta iifname {{ array_to_nftarr(control_info.listen_interfaces) }}
{%- endif /* listen_interfaces */ %}
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dns_port }} comment "!{{ cfgname }}: DNS hijack"
{% endif /* dns_hijacked */ %}
{% if (match(proxy_mode, /redirect/)): %}
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect_lanac
{% endif /* proxy_mode */ %}
}
{# TCP redirect #}
{% if (match(proxy_mode, /redirect/)): %}
chain homeproxy_redirect_proxy {
meta l4proto tcp counter redirect to :{{ redirect_port }}
}
chain homeproxy_redirect_proxy_port {
{% if (routing_port): %}
tcp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_redirect_proxy
}
chain homeproxy_redirect_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_redirect
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_redirect
{% endif %}
}
chain homeproxy_redirect {
meta mark {{ self_mark }} counter return
{% if (zapret_enabled === '1'): %}
meta mark {{ zapret_mark }} counter return
meta mark and {{ zapret_desync_mark }} != 0 counter return
{% endif /* zapret_enabled */ %}
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_redirect_proxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_redirect_proxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_redirect_proxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_redirect_proxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_redirect_proxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_redirect_proxy_port
}
chain homeproxy_output_redir {
type nat hook output priority filter -105; policy accept
{% if (byedpi_enabled === '1'): %}
meta skgid {{ byedpi_gid }} counter return
{% endif %}
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
}
{% endif %}
{# NFQUEUE chain: feed ALL Zapret-marked (mark 110) flows to nfqws2 — any port, TCP
and UDP — and let the strategy's own --filter-tcp/--filter-udp profiles decide what
to desync (the rest passes through via `bypass`). This mirrors how a list→outbound
rule routes all matching traffic: once sing-box marks a flow for zapret-out, the
queue feeds it, regardless of port (so discord.media on 2053/8443, voice UDP, QUIC,
etc. all work with no per-port firewall rules). `ct original packets 1-12` caps it
to each flow's handshake; nfqws's reinjected fakes carry DESYNC_MARK (not mark 110)
so they are never re-queued. #}
{% if (zapret_enabled === '1'): %}
chain homeproxy_zapret_queue {
type filter hook postrouting priority mangle; policy accept;
meta mark {{ zapret_mark }} meta l4proto tcp ct original packets 1-12 counter queue num {{ zapret_qnum }} bypass
meta mark {{ zapret_mark }} meta l4proto udp ct original packets 1-12 counter queue num {{ zapret_qnum }} bypass
}
{% endif %}
{# UDP tproxy #}
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom' || routing_mode === 'proxy_banned_ru')): %}
chain homeproxy_mangle_tproxy {
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip to 127.0.0.1:{{ tproxy_port }} counter accept
{% if (ipv6_support === '1'): %}
meta l4proto udp meta mark set {{ tproxy_mark }} tproxy ip6 to [::1]:{{ tproxy_port }} counter accept
{% endif %}
}
chain homeproxy_mangle_tproxy_port {
{% if (routing_port): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
goto homeproxy_mangle_tproxy
}
chain homeproxy_mangle_mark {
{% if (routing_port): %}
udp dport != @homeproxy_routing_port counter return
{% endif %}
meta l4proto udp meta mark set {{ tproxy_mark }} counter accept
}
chain homeproxy_mangle_lanac {
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(uniq([...control_info.listen_interfaces, ...['lo']])) }} counter return
{% endif %}
meta iifname != lo udp dport 53 counter return
meta mark {{ self_mark }} counter return
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_prerouting
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_prerouting
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_mangle_prerouting
{% endif %}
}
chain homeproxy_mangle_prerouting {
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tproxy_port
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tproxy_port
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy_port
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tproxy_port
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tproxy
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter goto homeproxy_mangle_tproxy
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_mangle_tproxy_port
}
chain homeproxy_mangle_output {
{% if (byedpi_enabled === '1'): %}
meta skgid {{ byedpi_gid }} counter return
{% endif %}
meta mark {{ self_mark }} counter return
{% if (zapret_enabled === '1'): %}
meta mark {{ zapret_mark }} counter return
meta mark and {{ zapret_desync_mark }} != 0 counter return
{% endif /* zapret_enabled */ %}
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
ip daddr @homeproxy_wan_direct_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_direct_addr_v6 counter return
{% endif /* ipv6_support */ %}
counter goto homeproxy_mangle_mark
}
chain mangle_prerouting {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto udp jump homeproxy_mangle_output
}
{% endif %}
{# TUN #}
{% if (match(proxy_mode, /tun/)): %}
chain homeproxy_mangle_lanac {
iifname {{ tun_name }} counter return
udp dport 53 counter return
{% if (control_info.listen_interfaces): %}
meta iifname != {{ array_to_nftarr(control_info.listen_interfaces) }} counter return
{% endif %}
{% if (control_info.lan_proxy_mode === 'listed_only'): %}
{% if (!isEmpty(control_info.lan_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun
{% endfor /* lan_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun
{% endif /* lan_proxy_mac_addrs */ %}
{% elif (control_info.lan_proxy_mode === 'except_listed'): %}
{% if (!isEmpty(control_info.lan_direct_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
{% endif /* lan_direct_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_direct_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter return
{% endfor /* lan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_direct_mac_addrs) }} counter return
{% endif /* lan_direct_mac_addrs */ %}
{% endif /* lan_proxy_mode */ %}
{% if (control_info.lan_proxy_mode !== 'listed_only'): %}
counter goto homeproxy_mangle_tun
{% endif %}
}
chain homeproxy_mangle_tun_mark {
{% if (routing_port): %}
{% if (proxy_mode === 'tun'): %}
tcp dport != @homeproxy_routing_port counter return
{% endif /* proxy_mode */ %}
udp dport != @homeproxy_routing_port counter return
{% endif /* routing_port */ %}
counter meta mark set {{ tun_mark }}
}
chain homeproxy_mangle_tun {
iifname {{ tun_name }} counter return
ip daddr @homeproxy_wan_proxy_addr_v4 counter goto homeproxy_mangle_tun_mark
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_wan_proxy_addr_v6 counter goto homeproxy_mangle_tun_mark
{% endif %}
ip daddr @homeproxy_local_addr_v4 counter return
{% if (ipv6_support === '1'): %}
ip6 daddr @homeproxy_local_addr_v6 counter return
{% endif %}
{% if (routing_mode !== 'custom'): %}
{% if (!isEmpty(control_info.lan_global_proxy_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_global_proxy_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter goto homeproxy_mangle_tun_mark
{% endfor /* lan_global_proxy_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_global_proxy_mac_addrs) }} counter goto homeproxy_mangle_tun_mark
{% endif /* lan_global_proxy_mac_addrs */ %}
{% endif /* routing_mode */ %}
{% if (control_info.wan_direct_ipv4_ips): %}
ip daddr {{ array_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
{% endif /* wan_direct_ipv4_ips */ %}
{% if (control_info.wan_direct_ipv6_ips): %}
ip6 daddr {{ array_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
{% endif /* wan_direct_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
ip saddr {{ array_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_ipv4_ips */ %}
{% for (let ipv6 in control_info.lan_gaming_mode_ipv6_ips): %}
ip6 saddr {{ resolve_ipv6(ipv6) }} counter meta mark set {{ tun_mark }}
{% endfor /* lan_gaming_mode_ipv6_ips */ %}
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
ether saddr {{ array_to_nftarr(control_info.lan_gaming_mode_mac_addrs) }} counter meta mark set {{ tun_mark }}
{% endif /* lan_gaming_mode_mac_addrs */ %}
counter goto homeproxy_mangle_tun_mark
}
chain mangle_prerouting {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_lanac
}
chain mangle_output {
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto { {{ (proxy_mode === 'tun') ? 'tcp, udp' : 'udp' }} } jump homeproxy_mangle_tun
}
{% endif %}
@@ -0,0 +1,51 @@
#!/usr/bin/ucode
'use strict';
import { writefile } from 'fs';
import { cursor } from 'uci';
import { isEmpty, RUN_DIR } from 'homeproxy';
const cfgname = 'homeproxy';
const uci = cursor();
uci.load(cfgname);
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'proxy_banned_ru',
proxy_mode = uci.get(cfgname, 'config', 'proxy_mode') || 'redirect_tproxy';
let outbound_node, tun_name;
if (match(proxy_mode, /tun/)) {
if (routing_mode === 'custom')
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
else
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
if (outbound_node !== 'nil')
tun_name = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
}
const server_enabled = uci.get(cfgname, 'server', 'enabled');
let forward = [],
input = [];
if (tun_name) {
push(forward, `oifname ${tun_name} counter accept comment "!${cfgname}: accept tun forward"`);
push(input ,`iifname ${tun_name} counter accept comment "!${cfgname}: accept tun input"`);
}
if (server_enabled === '1') {
uci.foreach(cfgname, 'server', (s) => {
if (s.enabled !== '1' || s.firewall !== '1')
return;
let proto = s.network || '{ tcp, udp }';
push(input, `meta l4proto ${proto} th dport ${s.port} counter accept comment "!${cfgname}: accept server ${s['.name']}"`);
});
}
if (!isEmpty(forward))
writefile(RUN_DIR + '/fw4_forward.nft', join('\n', forward) + '\n');
if (!isEmpty(input))
writefile(RUN_DIR + '/fw4_input.nft', join('\n', input) + '\n');
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,235 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2023 ImmortalWrt.org
*/
import { mkstemp } from 'fs';
import { urldecode_params } from 'luci.http';
/* Global variables start */
export const HP_DIR = '/etc/homeproxy';
export const RUN_DIR = '/var/run/homeproxy';
/* Global variables end */
/* Utilities start */
/* Kanged from luci-app-commands */
export function shellQuote(s) {
return `'${replace(s, "'", "'\\''")}'`;
};
export function isBinary(str) {
for (let off = 0, byte = ord(str); off < length(str); byte = ord(str, ++off))
if (byte <= 8 || (byte >= 14 && byte <= 31))
return true;
return false;
};
export function executeCommand(...args) {
let outfd = mkstemp();
let errfd = mkstemp();
const exitcode = system(`${join(' ', args)} >&${outfd.fileno()} 2>&${errfd.fileno()}`);
outfd.seek(0);
errfd.seek(0);
const stdout = outfd.read(1024 * 512) ?? '';
const stderr = errfd.read(1024 * 512) ?? '';
outfd.close();
errfd.close();
const binary = isBinary(stdout);
return {
command: join(' ', args),
stdout: binary ? null : stdout,
stderr,
exitcode,
binary
};
};
export function getTime(epoch) {
const local_time = localtime(epoch);
return replace(replace(sprintf(
'%d-%2d-%2d@%2d:%2d:%2d',
local_time.year,
local_time.mon,
local_time.mday,
local_time.hour,
local_time.min,
local_time.sec
), ' ', '0'), '@', ' ');
};
export function wGET(url, ua) {
if (!url || type(url) !== 'string')
return null;
if (!ua)
ua = 'Wget/1.21 (HomeProxy, like v2rayN)';
const output = executeCommand(`/usr/bin/wget -qO- --user-agent ${shellQuote(ua)} --timeout=10 ${shellQuote(url)}`) || {};
return trim(output.stdout);
};
/* Utilities end */
/* String helper start */
export function isEmpty(res) {
return !res || res === 'nil' || (type(res) in ['array', 'object'] && length(res) === 0);
};
export function strToBool(str) {
return (str === '1') || null;
};
export function strToInt(str) {
return !isEmpty(str) ? (int(str) || null) : null;
};
export function strToTime(str) {
if (isEmpty(str))
return null;
str = '' + str;
/* A subscription may already deliver a unit-bearing duration (e.g. sing-box
* "10s", "1m30s"); only append 's' to a bare number so we never emit "10ss". */
return match(str, /[a-zA-Z]$/) ? str : (str + 's');
};
export function removeBlankAttrs(res) {
let content;
if (type(res) === 'object') {
content = {};
map(keys(res), (k) => {
if (type(res[k]) in ['array', 'object'])
content[k] = removeBlankAttrs(res[k]);
else if (res[k] !== null && res[k] !== '')
content[k] = res[k];
});
} else if (type(res) === 'array') {
content = [];
map(res, (k, i) => {
if (type(k) in ['array', 'object'])
push(content, removeBlankAttrs(k));
else if (k !== null && k !== '')
push(content, k);
});
} else
return res;
return content;
};
export function validateHostname(hostname) {
return (match(hostname, /^[a-zA-Z0-9_]+$/) != null ||
(match(hostname, /^[a-zA-Z0-9_][a-zA-Z0-9_%-.]*[a-zA-Z0-9]$/) &&
match(hostname, /[^0-9.]/)));
};
export function validation(datatype, data) {
if (!datatype || !data)
return null;
const ret = system(`/sbin/validate_data ${shellQuote(datatype)} ${shellQuote(data)} 2>/dev/null`);
return (ret === 0);
};
/* String helper end */
/* String parser start */
export function decodeBase64Str(str) {
if (isEmpty(str))
return null;
str = trim(str);
str = replace(str, '_', '/');
str = replace(str, '-', '+');
const padding = length(str) % 4;
if (padding)
str = str + substr('====', padding);
return b64dec(str);
};
export function parseURL(url) {
if (type(url) !== 'string')
return null;
const services = {
http: '80',
https: '443'
};
const objurl = {};
objurl.href = url;
url = replace(url, /#(.+)$/, (_, val) => {
objurl.hash = val;
return '';
});
url = replace(url, /^(\w[A-Za-z0-9\+\-\.]+):/, (_, val) => {
objurl.protocol = val;
return '';
});
url = replace(url, /\?(.+)/, (_, val) => {
objurl.search = val;
objurl.searchParams = urldecode_params(val);
return '';
});
url = replace(url, /^\/\/([^\/]+)/, (_, val) => {
val = replace(val, /^([^@]+)@/, (_, val) => {
objurl.userinfo = val;
return '';
});
val = replace(val, /:(\d+)$/, (_, val) => {
objurl.port = val;
return '';
});
if (validation('ip4addr', val) ||
validation('ip6addr', replace(val, /\[|\]/g, '')) ||
validation('hostname', val))
objurl.hostname = val;
return '';
});
objurl.pathname = url || '/';
if (!objurl.protocol || !objurl.hostname)
return null;
if (objurl.userinfo) {
objurl.userinfo = replace(objurl.userinfo, /:(.+)$/, (_, val) => {
objurl.password = val;
return '';
});
if (match(objurl.userinfo, /^[A-Za-z0-9\+\-\_\.]+$/)) {
objurl.username = objurl.userinfo;
delete objurl.userinfo;
} else {
delete objurl.userinfo;
delete objurl.password;
}
};
if (!objurl.port)
objurl.port = services[objurl.protocol];
objurl.host = objurl.hostname + (objurl.port ? `:${objurl.port}` : '');
objurl.origin = `${objurl.protocol}://${objurl.host}`;
return objurl;
};
/* String parser end */
@@ -0,0 +1,89 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Headless import of proxy share-link(s) (vless://, vmess://, trojan://, ss://,
* hysteria://, hysteria2://, tuic://, …) into homeproxy `node` sections. This is
* the SSH/agent-callable equivalent of the browser-only "Import share links"
* button (handleLinkImport / parseShareLink) in the LuCI node view. It reuses the
* shared parser in node_parse.uc — the SAME parse_uri the subscription importer
* uses — so there is no second link-parsing implementation to drift out of sync.
*
* Usage: ucode import_link.uc <path-to-file-with-one-link-per-line | single-link> [label]
* Output: one JSON line, e.g.
* { "result": true, "imported": [ { "section": "…", "label": "…", "type": "vless" } ], "failed": 0 }
* or { "error": "…" } with a non-zero exit code.
*
* Imported nodes carry NO grouphash, so they are treated as user-created and are
* never removed by a subsequent subscription update.
*/
'use strict';
import { readfile } from 'fs';
import { cursor } from 'uci';
import { parse_uri } from 'node_parse';
const uciconfig = 'homeproxy';
function emit(o) { printf('%s\n', o); }
/* Dependency-free stable hash (FNV-1a, 32-bit) — md5/digest is absent on the
* OpenWrt 23.05 legacy build, so keep section naming pure-ucode like import_conf.uc. */
function strhash(s) {
let h = 2166136261;
const n = length(s);
for (let i = 0; i < n; i++) {
h = (h ^ ord(s, i)) & 0xFFFFFFFF;
h = (h * 16777619) & 0xFFFFFFFF;
}
return sprintf('%08x', h);
}
const arg = ARGV[0];
const want_label = ARGV[1];
if (!arg) {
emit({ error: 'usage: import_link.uc <file-with-links|link> [label]' });
exit(1);
}
/* arg is either a readable file (one link per line) or the link itself. */
let text = readfile(arg);
if (!text)
text = arg;
const lines = split(trim(text), '\n');
const uci = cursor();
let imported = [];
let failed = 0;
for (let line in lines) {
line = trim(line);
if (!length(line))
continue;
const config = parse_uri(line);
if (type(config) !== 'object' || !config.type) {
failed++;
continue;
}
const label = (want_label && length(lines) === 1)
? want_label
: (config.label || (config.type + '-' + (config.address || 'node')));
config.label = label;
const name = strhash(label + '|' + (config.address || '') + ':' + (config.port || ''));
uci.set(uciconfig, name, 'node');
for (let k in config)
if (config[k] !== null && config[k] !== '')
uci.set(uciconfig, name, k, config[k]);
push(imported, { section: name, label: label, type: config.type });
}
if (length(imported))
uci.commit(uciconfig);
emit({ result: length(imported) > 0, imported: imported, failed: failed });
@@ -0,0 +1,263 @@
#!/usr/bin/ucode
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Copyright (C) 2025 ImmortalWrt.org
*/
'use strict';
import { cursor } from 'uci';
import { isEmpty, parseURL } from 'homeproxy';
const uci = cursor();
const uciconfig = 'homeproxy';
uci.load(uciconfig);
const uciinfra = 'infra',
ucimigration = 'migration',
ucimain = 'config',
ucinode = 'node',
ucidns = 'dns',
ucidnsserver = 'dns_server',
ucidnsrule = 'dns_rule',
ucirouting = 'routing',
uciroutingnode = 'routing_node',
uciroutingrule = 'routing_rule',
uciserver = 'server';
/* chinadns-ng has been removed */
if (uci.get(uciconfig, uciinfra, 'china_dns_port'))
uci.delete(uciconfig, uciinfra, 'china_dns_port');
/* chinadns server now only accepts single server */
const china_dns_server = uci.get(uciconfig, ucimain, 'china_dns_server');
if (type(china_dns_server) === 'array') {
uci.set(uciconfig, ucimain, 'china_dns_server', china_dns_server[0]);
} else {
if (china_dns_server === 'wan_114')
uci.set(uciconfig, ucimain, 'china_dns_server', '114.114.114.114');
else if (match(china_dns_server, /,/))
uci.set(uciconfig, ucimain, 'china_dns_server', split(china_dns_server, ',')[0]);
}
/* github_token option has been moved to config section */
const github_token = uci.get(uciconfig, uciinfra, 'github_token');
if (github_token) {
uci.set(uciconfig, ucimain, 'github_token', github_token);
uci.delete(uciconfig, uciinfra, 'github_token')
}
/* ntp_server was introduced */
if (!uci.get(uciconfig, uciinfra, 'ntp_server'))
uci.set(uciconfig, uciinfra, 'ntp_server', 'nil');
/* tun_gso was deprecated in sb 1.11 */
if (!isEmpty(uci.get(uciconfig, uciinfra, 'tun_gso')))
uci.delete(uciconfig, uciinfra, 'tun_gso');
/* create migration section */
if (!uci.get(uciconfig, ucimigration))
uci.set(uciconfig, ucimigration, uciconfig);
/* delete old crontab command */
const migration_crontab = uci.get(uciconfig, ucimigration, 'crontab');
if (!migration_crontab) {
system('sed -i "/update_crond.sh/d" "/etc/crontabs/root" 2>"/dev/null"');
uci.set(uciconfig, ucimigration, 'crontab', '1');
}
/* log_level was introduced */
if (isEmpty(uci.get(uciconfig, ucimain, 'log_level')))
uci.set(uciconfig, ucimain, 'log_level', 'warn');
if (isEmpty(uci.get(uciconfig, uciserver, 'log_level')))
uci.set(uciconfig, uciserver, 'log_level', 'warn');
/* empty value defaults to all ports now */
if (uci.get(uciconfig, ucimain, 'routing_port') === 'all')
uci.delete(uciconfig, ucimain, 'routing_port');
/* experimental section was removed */
if (uci.get(uciconfig, 'experimental'))
uci.delete(uciconfig, 'experimental');
/* block-dns was removed from built-in dns servers */
const default_dns_server = uci.get(uciconfig, ucidns, 'default_server');
if (default_dns_server === 'block-dns') {
/* append a rule at last to block all DNS queries */
uci.set(uciconfig, '_migration_dns_final_block', ucidnsrule);
uci.set(uciconfig, '_migration_dns_final_block', 'label', 'migration_final_block_dns');
uci.set(uciconfig, '_migration_dns_final_block', 'enabled', '1');
uci.set(uciconfig, '_migration_dns_final_block', 'mode', 'default');
uci.set(uciconfig, '_migration_dns_final_block', 'action', 'reject');
uci.set(uciconfig, ucidns, 'default_server', 'default-dns');
}
const dns_server_migration = {};
/* DNS servers options */
uci.foreach(uciconfig, ucidnsserver, (cfg) => {
/* legacy format was deprecated in sb 1.12 */
if (cfg.address) {
const addr = parseURL((!match(cfg.address, /:\/\//) ? 'udp://' : '') + (validation('ip6addr', cfg.address) ? `[${cfg.address}]` : cfg.address));
/* RCode was moved into DNS rules */
if (addr.protocol === 'rcode') {
dns_server_migration[cfg['.name']] = { action: 'predefined' };
switch (addr.hostname) {
case 'success':
dns_server_migration[cfg['.name']].rcode = 'NOERROR';
break;
case 'format_error':
dns_server_migration[cfg['.name']].rcode = 'FORMERR';
break;
case 'server_failure':
dns_server_migration[cfg['.name']].rcode = 'SERVFAIL';
break;
case 'name_error':
dns_server_migration[cfg['.name']].rcode = 'NXDOMAIN';
break;
case 'not_implemented':
dns_server_migration[cfg['.name']].rcode = 'NOTIMP';
break;
case 'refused':
default:
dns_server_migration[cfg['.name']].rcode = 'REFUSED';
break;
}
uci.delete(uciconfig, cfg['.name']);
return;
}
uci.set(uciconfig, cfg['.name'], 'type', addr.protocol);
uci.set(uciconfig, cfg['.name'], 'server', addr.hostname);
uci.set(uciconfig, cfg['.name'], 'server_port', addr.port);
uci.set(uciconfig, cfg['.name'], 'path', (addr.pathname !== '/') ? addr.pathname : null);
uci.delete(uciconfig, cfg['.name'], 'address');
}
if (cfg.strategy) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'default_strategy', cfg.strategy);
dns_server_migration[cfg['.name']] = { strategy: cfg.strategy };
uci.delete(uciconfig, cfg['.name'], 'strategy');
}
if (cfg.client_subnet) {
if (cfg['.name'] === default_dns_server)
uci.set(uciconfig, ucidns, 'client_subnet', cfg.client_subnet);
if (isEmpty(dns_server_migration[cfg['.name']]))
dns_server_migration[cfg['.name']] = {};
dns_server_migration[cfg['.name']].client_subnet = cfg.client_subnet;
uci.delete(uciconfig, cfg['.name'], 'client_subnet');
}
});
/* DNS rules options */
uci.foreach(uciconfig, ucidnsrule, (cfg) => {
/* outbound was removed in sb 1.12 */
if (cfg.outbound) {
uci.delete(uciconfig, cfg['.name']);
if (!cfg.enabled)
return;
map(cfg.outbound, (outbound) => {
switch (outbound) {
case 'direct-out':
case 'block-out':
break;
case 'any-out':
uci.set(uciconfig, ucirouting, 'default_outbound_dns', cfg.server);
break;
default:
uci.set(uciconfig, cfg.outbound, 'domain_resolver', cfg.server);
break;
}
});
return;
}
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-dns was moved into action in sb 1.11 */
if (cfg.server === 'block-dns') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'server');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
/* strategy and client_subnet were moved into dns rules */
if (dns_server_migration[cfg.server]) {
if (dns_server_migration[cfg.server].strategy)
uci.set(uciconfig, cfg['.name'], 'strategy', dns_server_migration[cfg.server].strategy);
if (dns_server_migration[cfg.server].client_subnet)
uci.set(uciconfig, cfg['.name'], 'client_subnet', dns_server_migration[cfg.server].client_subnet);
if (dns_server_migration[cfg.server].rcode) {
uci.set(uciconfig, cfg['.name'], 'action', 'predefined');
uci.set(uciconfig, cfg['.name'], 'rcode', dns_server_migration[cfg.server].rcode);
uci.delete(uciconfig, cfg['.name'], 'server');
}
}
});
/* nodes options */
uci.foreach(uciconfig, ucinode, (cfg) => {
/* tls_ech_tls_disable_drs is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_tls_disable_drs))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_tls_disable_drs');
/* tls_ech_enable_pqss is useless and deprecated in sb 1.12 */
if (!isEmpty(cfg.tls_ech_enable_pqss))
uci.delete(uciconfig, cfg['.name'], 'tls_ech_enable_pqss');
/* wireguard_gso was deprecated in sb 1.11 */
if (!isEmpty(cfg.wireguard_gso))
uci.delete(uciconfig, cfg['.name'], 'wireguard_gso');
});
/* routing rules options */
uci.foreach(uciconfig, uciroutingrule, (cfg) => {
/* rule_set_ipcidr_match_source was renamed in sb 1.10 */
if (cfg.rule_set_ipcidr_match_source === '1')
uci.rename(uciconfig, cfg['.name'], 'rule_set_ipcidr_match_source', 'rule_set_ip_cidr_match_source');
/* block-out was moved into action in sb 1.11 */
if (cfg.outbound === 'block-out') {
uci.set(uciconfig, cfg['.name'], 'action', 'reject');
uci.delete(uciconfig, cfg['.name'], 'outbound');
} else if (!cfg.action) {
/* add missing 'action' field */
uci.set(uciconfig, cfg['.name'], 'action', 'route');
}
});
/* server options */
/* auto_firewall was moved into server options */
const auto_firewall = uci.get(uciconfig, uciserver, 'auto_firewall');
if (!isEmpty(auto_firewall))
uci.delete(uciconfig, uciserver, 'auto_firewall');
uci.foreach(uciconfig, uciserver, (cfg) => {
/* auto_firewall was moved into server options */
if (auto_firewall === '1')
uci.set(uciconfig, cfg['.name'], 'firewall' , '1');
/* sniff_override was deprecated in sb 1.11 */
if (!isEmpty(cfg.sniff_override))
uci.delete(uciconfig, cfg['.name'], 'sniff_override');
/* domain_strategy is now pointless without sniff override */
if (!isEmpty(cfg.domain_strategy))
uci.delete(uciconfig, cfg['.name'], 'domain_strategy');
});
if (!isEmpty(uci.changes(uciconfig)))
uci.commit(uciconfig);
@@ -0,0 +1,680 @@
/*
* SPDX-License-Identifier: GPL-2.0-only
*
* Shared share-link parser: turns a single proxy share-link (vless://, vmess://,
* trojan://, ss://, hysteria://, hysteria2://, tuic://, …) into a homeproxy node
* config object. Extracted verbatim from update_subscriptions.uc so the
* subscription importer AND the standalone link importer (import_link.uc) use one
* implementation. `log` is an injected callback (no-op by default) so callers can
* route diagnostics where they like.
*/
'use strict';
import { urldecode, urlencode, urldecode_params } from 'luci.http';
import { parseURL, decodeBase64Str, validation, isEmpty } from 'homeproxy';
import { connect } from 'ubus';
const sing_features = connect().call('luci.homeproxy', 'singbox_get_features', {}) || {};
export function parse_uri(uri, log) {
if (type(log) != 'function') log = function() {};
let config, url, params;
if (type(uri) === 'object') {
if (uri.nodetype === 'sip008') {
/* https://shadowsocks.org/guide/sip008.html */
config = {
label: uri.remarks,
type: 'shadowsocks',
address: uri.server,
port: uri.server_port,
shadowsocks_encrypt_method: uri.method,
password: uri.password,
shadowsocks_plugin: uri.plugin,
shadowsocks_plugin_opts: uri.plugin_opts
};
} else {
/* Pre-parsed sing-box JSON outbound from parse_singbox_outbound() */
config = uri;
}
} else if (type(uri) === 'string') {
uri = split(trim(uri), '://');
switch (uri[0]) {
case 'anytls':
/* https://github.com/anytls/anytls-go/blob/v0.0.8/docs/uri_scheme.md */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'anytls',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
tls: '1',
tls_sni: params.sni,
tls_insecure: (params.insecure === '1') ? '1' : '0'
};
break;
case 'http':
case 'https':
url = parseURL('http://' + uri[1]) || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'http',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'https') ? '1' : '0'
};
break;
case 'hysteria':
/* https://github.com/HyNetwork/hysteria/wiki/URI-Scheme */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic || (params.protocol && params.protocol !== 'udp')) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria',
address: url.hostname,
port: url.port,
hysteria_protocol: params.protocol || 'udp',
hysteria_auth_type: params.auth ? 'string' : null,
hysteria_auth_payload: params.auth,
hysteria_obfs_password: params.obfsParam,
hysteria_down_mbps: params.downmbps,
hysteria_up_mbps: params.upmbps,
tls: '1',
tls_insecure: (params.insecure in ['true', '1']) ? '1' : '0',
tls_sni: params.peer,
tls_alpn: params.alpn
};
break;
case 'hysteria2':
case 'hy2':
/* https://v2.hysteria.network/docs/developers/URI-Scheme/ */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'hysteria2',
address: url.hostname,
port: url.port,
password: url.username ? (
urldecode(url.username + (url.password ? (':' + url.password) : ''))
) : null,
hysteria_obfs_type: params.obfs,
hysteria_obfs_password: params['obfs-password'],
tls: '1',
tls_insecure: (params.insecure === '1') ? '1' : '0',
tls_sni: params.sni
};
break;
case 'naive':
case 'naive+http':
case 'naive+https':
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
let naive_extra_headers = null;
if (params.header) {
const hdr = split(params.header, ':', 2);
if (length(hdr) === 2) {
let hdrs = {};
hdrs[trim(hdr[0])] = trim(hdr[1]);
naive_extra_headers = sprintf('%J', hdrs);
}
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'naive',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
tls: (uri[0] === 'naive+https' || params.security === 'tls') ? '1' : '0',
tls_sni: params.sni || url.hostname,
naive_udp_over_tcp: (params.uot === '1') ? '1' : null,
naive_quic: (params.quic === '1') ? '1' : null,
naive_extra_headers: naive_extra_headers
};
break;
case 'socks':
case 'socks4':
case 'socks4a':
case 'socsk5':
case 'socks5h':
url = parseURL('http://' + uri[1]) || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'socks',
address: url.hostname,
port: url.port,
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
socks_version: (match(uri[0], /4/)) ? '4' : '5'
};
break;
case 'ss':
/* "Lovely" Shadowrocket format */
const ss_suri = split(uri[1], '#');
let ss_slabel = '';
if (length(ss_suri) <= 2) {
if (length(ss_suri) === 2)
ss_slabel = '#' + urlencode(ss_suri[1]);
if (decodeBase64Str(ss_suri[0]))
uri[1] = decodeBase64Str(ss_suri[0]) + ss_slabel;
}
/* Legacy format is not supported, it should be never appeared in modern subscriptions */
/* https://github.com/shadowsocks/shadowsocks-org/commit/78ca46cd6859a4e9475953ed34a2d301454f579e */
/* SIP002 format https://shadowsocks.org/guide/sip002.html */
url = parseURL('http://' + uri[1]) || {};
let ss_userinfo = {};
if (url.username && url.password)
/* User info encoded with URIComponent */
ss_userinfo = [url.username, urldecode(url.password)];
else if (url.username)
/* User info encoded with base64 */
ss_userinfo = split(decodeBase64Str(urldecode(url.username)), ':', 2);
let ss_plugin, ss_plugin_opts;
if (url.search && url.searchParams.plugin) {
const ss_plugin_info = split(url.searchParams.plugin, ';', 2);
ss_plugin = ss_plugin_info[0];
if (ss_plugin === 'simple-obfs')
/* Fix non-standard plugin name */
ss_plugin = 'obfs-local';
ss_plugin_opts = ss_plugin_info[1];
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: ss_userinfo[0],
password: ss_userinfo[1],
shadowsocks_plugin: ss_plugin,
shadowsocks_plugin_opts: ss_plugin_opts
};
break;
case 'shadowtls':
/* shadowtls://STLS_PASS@HOST:PORT?version=V&sni=SNI&fp=FP&method=SS_METHOD&password=SS_PASS#Label
* Represents a Shadowsocks 2022 connection wrapped in ShadowTLS transport. */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'shadowsocks',
address: url.hostname,
port: url.port,
shadowsocks_encrypt_method: params.method || '2022-blake3-aes-256-gcm',
password: params.password ? urldecode(params.password) : null,
shadowtls_enabled: '1',
shadowtls_password: url.username ? urldecode(url.username) : null,
shadowtls_version: params.version || '3',
tls_sni: params.sni || null,
tls_utls: params.fp || null
};
break;
case 'ssh':
/* Manual parse to avoid parseURL corruption on long base64 query values */
let ssh_str = trim(uri[1]);
let ssh_label = null;
const ssh_hash_idx = index(ssh_str, '#');
if (ssh_hash_idx >= 0) {
ssh_label = urldecode(substr(ssh_str, ssh_hash_idx + 1));
ssh_str = substr(ssh_str, 0, ssh_hash_idx);
}
let ssh_params = {};
const ssh_q = index(ssh_str, '?');
if (ssh_q >= 0) {
ssh_params = urldecode_params(substr(ssh_str, ssh_q + 1)) || {};
ssh_str = substr(ssh_str, 0, ssh_q);
}
ssh_str = replace(ssh_str, /\/+$/, '');
const ssh_at = index(ssh_str, '@');
let ssh_user = null, ssh_pass = null, ssh_host = null, ssh_port = null;
if (ssh_at >= 0) {
const ssh_userinfo = substr(ssh_str, 0, ssh_at);
const ssh_hostport = substr(ssh_str, ssh_at + 1);
const ssh_colon = index(ssh_userinfo, ':');
if (ssh_colon >= 0) {
ssh_user = urldecode(substr(ssh_userinfo, 0, ssh_colon));
ssh_pass = urldecode(substr(ssh_userinfo, ssh_colon + 1));
} else {
ssh_user = urldecode(ssh_userinfo);
}
const ssh_hp = split(ssh_hostport, ':');
ssh_port = pop(ssh_hp);
ssh_host = join(':', ssh_hp) || null;
}
let ssh_host_key = null;
/* Hiddify: hk=key1\n,key2\n,key3\n — urldecode_params already decodes values */
const raw_hk = ssh_params.hk || ssh_params.host_key || ssh_params.hostKey;
if (raw_hk) {
const ssh_hk_lines = filter(split(raw_hk, ','), (l) => length(trim(l)) > 0);
ssh_host_key = length(ssh_hk_lines) ? ssh_hk_lines : null;
}
config = {
label: ssh_label,
type: 'ssh',
address: ssh_host,
port: ssh_port,
username: ssh_user,
password: length(ssh_pass) ? ssh_pass : null,
/* Hiddify uses pk= (short) or private_key= ; urldecode_params already decoded */
ssh_priv_key: ssh_params.pk || ssh_params.private_key || ssh_params.privateKey || null,
ssh_priv_key_pp: length(ssh_params.passphrase) ? ssh_params.passphrase : null,
ssh_host_key: ssh_host_key,
/* Hiddify always enables udp_over_tcp for SSH; only disable if explicitly set to 0 */
ssh_udp_over_tcp: (ssh_params.uot === '0' || ssh_params.udp_over_tcp in ['0', 'false']) ? null : '1'
};
break;
case 'trojan':
/* https://p4gefau1t.github.io/trojan-go/developer/url/ */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'trojan',
address: url.hostname,
port: url.port,
password: urldecode(url.username),
transport: (params.type && params.type !== 'tcp') ? params.type : null,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: sing_features.with_utls ? params.fp : null
};
switch(params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
case 'xhttp':
config.http_path = params.path ? urldecode(params.path) : null;
config.http_host = params.host ? urldecode(params.host) : null;
config.xhttp_mode = params.mode || null;
break;
}
if (params.hiddify === '1') {
if (params.fragment) {
const fparts = split(urldecode(params.fragment), ',');
if (length(fparts) >= 2) {
config.tls_fragment = '1';
config.tls_fragment_size = fparts[0];
config.tls_fragment_sleep = fparts[1];
config.tls_fragment_type = fparts[2] || null;
}
}
if (params.allowInsecure === 'true' || params.insecure === 'true')
config.tls_insecure = '1';
}
break;
case 'tuic':
/* https://github.com/daeuniverse/dae/discussions/182 */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
if (!sing_features.with_quic) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'tuic',
address: url.hostname,
port: url.port,
uuid: url.username,
password: url.password ? urldecode(url.password) : null,
tuic_congestion_control: params.congestion_control,
tuic_udp_relay_mode: params.udp_relay_mode,
tuic_enable_zero_rtt: params.zero_rtt_handshake || null,
tuic_heartbeat: params.heartbeat || null,
tls: '1',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
};
break;
case 'vless':
/* https://github.com/XTLS/Xray-core/discussions/716 */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
/* Unsupported protocol */
if (params.type === 'kcp') {
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
return null;
} else if (params.type === 'quic' && ((params.quicSecurity && params.quicSecurity !== 'none') || !sing_features.with_quic)) {
log(sprintf('Skipping sunsupported %s node: %s.', uri[0], urldecode(url.hash) || url.hostname));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'vless',
address: url.hostname,
port: url.port,
uuid: url.username,
transport: (params.type !== 'tcp') ? params.type : null,
tls: (params.security in ['tls', 'xtls', 'reality']) ? '1' : '0',
tls_sni: params.sni,
tls_alpn: params.alpn ? split(urldecode(params.alpn), ',') : null,
tls_reality: (params.security === 'reality') ? '1' : '0',
tls_reality_public_key: params.pbk ? urldecode(params.pbk) : null,
tls_reality_short_id: params.sid,
tls_utls: sing_features.with_utls ? params.fp : null,
vless_flow: (params.security in ['tls', 'reality']) ? params.flow : null
};
switch(params.type) {
case 'grpc':
config.grpc_servicename = params.serviceName;
break;
case 'http':
case 'tcp':
if (params.type === 'http' || params.headerType === 'http') {
config.http_host = params.host ? split(urldecode(params.host), ',') : null;
config.http_path = params.path ? urldecode(params.path) : null;
}
break;
case 'httpupgrade':
config.httpupgrade_host = params.host ? urldecode(params.host) : null;
config.http_path = params.path ? urldecode(params.path) : null;
break;
case 'ws':
config.ws_host = params.host ? urldecode(params.host) : null;
config.ws_path = params.path ? urldecode(params.path) : null;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
case 'xhttp':
config.http_path = params.path ? urldecode(params.path) : null;
config.http_host = params.host ? urldecode(params.host) : null;
config.xhttp_mode = params.mode || null;
break;
}
if (params.hiddify === '1') {
if (params.fragment) {
const fparts = split(urldecode(params.fragment), ',');
if (length(fparts) >= 2) {
config.tls_fragment = '1';
config.tls_fragment_size = fparts[0];
config.tls_fragment_sleep = fparts[1];
config.tls_fragment_type = fparts[2] || null;
}
}
if (params.allowInsecure === 'true' || params.insecure === 'true')
config.tls_insecure = '1';
if (params.extra) {
try {
const extra = json(urldecode(params.extra));
if (extra.headers && length(keys(extra.headers)) > 0)
config.xhttp_headers = sprintf('%J', extra.headers);
if (extra.downloadSettings) {
const dl = extra.downloadSettings;
config.xhttp_download_server = dl.address;
config.xhttp_download_port = '' + dl.port;
if (dl.xhttpSettings) {
config.xhttp_download_path = dl.xhttpSettings.path;
config.xhttp_download_host = dl.xhttpSettings.host;
config.xhttp_download_mode = dl.xhttpSettings.mode;
}
config.xhttp_download_security = dl.security;
if (dl.security === 'reality' && dl.realitySettings) {
config.xhttp_download_sni = dl.realitySettings.serverName;
config.xhttp_download_fp = dl.realitySettings.fingerprint;
config.xhttp_download_pbk = dl.realitySettings.publicKey;
config.xhttp_download_sid = dl.realitySettings.shortId;
} else if (dl.security === 'tls' && dl.tlsSettings) {
config.xhttp_download_sni = dl.tlsSettings.serverName;
config.xhttp_download_alpn = dl.tlsSettings.alpn;
}
}
} catch(e) {}
}
}
break;
case 'vmess':
/* "Lovely" shadowrocket format */
if (match(uri, /&/)) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
/* https://github.com/2dust/v2rayN/wiki/Description-of-VMess-share-link */
try {
uri = json(decodeBase64Str(uri[1])) || {};
} catch(e) {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
}
if (uri.v != '2') {
log(sprintf('Skipping unsupported %s format.', uri[0]));
return null;
/* Unsupported protocol */
} else if (uri.net === 'kcp') {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
return null;
} else if (uri.net === 'quic' && ((uri.type && uri.type !== 'none') || uri.path || !sing_features.with_quic)) {
log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
if (!sing_features.with_quic)
log(sprintf('Please rebuild sing-box with %s support!', 'QUIC'));
return null;
}
/*
* https://www.v2fly.org/config/protocols/vmess.html#vmess-md5-%E8%AE%A4%E8%AF%81%E4%BF%A1%E6%81%AF-%E6%B7%98%E6%B1%B0%E6%9C%BA%E5%88%B6
* else if (uri.aid && int(uri.aid) !== 0) {
* log(sprintf('Skipping unsupported %s node: %s.', uri[0], uri.ps || uri.add));
* return null;
* }
*/
config = {
label: uri.ps ? urldecode(uri.ps) : null,
type: 'vmess',
address: uri.add,
port: uri.port,
uuid: uri.id,
vmess_alterid: uri.aid,
vmess_encrypt: uri.scy || 'auto',
vmess_global_padding: '1',
transport: (uri.net !== 'tcp') ? uri.net : null,
tls: (uri.tls === 'tls') ? '1' : '0',
tls_sni: uri.sni || uri.host,
tls_alpn: uri.alpn ? split(uri.alpn, ',') : null,
tls_utls: sing_features.with_utls ? uri.fp : null
};
switch (uri.net) {
case 'grpc':
config.grpc_servicename = uri.path;
break;
case 'h2':
case 'tcp':
if (uri.net === 'h2' || uri.type === 'http') {
config.transport = 'http';
config.http_host = uri.host ? split(uri.host, ',') : null;
config.http_path = uri.path;
}
break;
case 'httpupgrade':
config.httpupgrade_host = uri.host;
config.http_path = uri.path;
break;
case 'ws':
config.ws_host = uri.host;
config.ws_path = uri.path;
if (config.ws_path && match(config.ws_path, /\?ed=/)) {
config.websocket_early_data_header = 'Sec-WebSocket-Protocol';
config.websocket_early_data = split(config.ws_path, '?ed=')[1];
config.ws_path = split(config.ws_path, '?ed=')[0];
}
break;
}
break;
case 'mieru':
/* https://github.com/enfein/mieru */
url = parseURL('http://' + uri[1]) || {};
params = url.searchParams || {};
config = {
label: url.hash ? urldecode(url.hash) : null,
type: 'mieru',
address: url.hostname,
port: '0',
username: url.username ? urldecode(url.username) : null,
password: url.password ? urldecode(url.password) : null,
mieru_protocol: params.protocol || null,
mieru_port_range: params.port || null,
mieru_multiplexing: params.multiplexing || null,
mieru_handshake_mode: params['handshake-mode'] || null
};
break;
case 'wg':
case 'wireguard':
/* Manual parse: WireGuard private key may contain URL-encoded chars
* incompatible with parseURL's userinfo regex */
let wg_str = trim(uri[1]);
let wg_label = null;
const wg_hash = index(wg_str, '#');
if (wg_hash >= 0) {
wg_label = urldecode(substr(wg_str, wg_hash + 1));
wg_str = substr(wg_str, 0, wg_hash);
}
let wg_params = {};
const wg_q = index(wg_str, '?');
if (wg_q >= 0) {
wg_params = urldecode_params(substr(wg_str, wg_q + 1)) || {};
wg_str = substr(wg_str, 0, wg_q);
}
/* Strip trailing slash left by "KEY@HOST:PORT/?params" format */
wg_str = replace(wg_str, /\/+$/, '');
const wg_at = index(wg_str, '@');
let wg_priv_key = null, wg_host = null, wg_port = null;
if (wg_at >= 0) {
wg_priv_key = urldecode(substr(wg_str, 0, wg_at));
const wg_hp_parts = split(substr(wg_str, wg_at + 1), ':');
wg_port = pop(wg_hp_parts);
wg_host = join(':', wg_hp_parts) || null;
} else {
/* wg://HOST:PORT?privateKey=...&publicKey=... format (no userinfo) */
const wg_hp_parts = split(wg_str, ':');
wg_port = pop(wg_hp_parts);
wg_host = join(':', wg_hp_parts) || null;
wg_priv_key = wg_params.privateKey || wg_params.privatekey || null;
}
const wg_local_addr = wg_params.address || wg_params.ip || null;
config = {
label: wg_label,
type: 'wireguard',
address: wg_host,
port: wg_port,
wireguard_private_key: wg_priv_key,
wireguard_peer_public_key: wg_params.publicKey || wg_params.publickey || null,
wireguard_pre_shared_key: wg_params.presharedKey || wg_params.presharedkey || null,
wireguard_local_address: wg_local_addr ? split(wg_local_addr, ',') : null,
wireguard_mtu: wg_params.mtu || null,
wireguard_reserved: wg_params.reserved ? split(wg_params.reserved, ',') : null
};
break;
}
}
if (!isEmpty(config)) {
if (config.address)
config.address = replace(config.address, /\[|\]/g, '');
if (!validation('host', config.address) || (config.type !== 'mieru' && !validation('port', config.port))) {
log(sprintf('Skipping invalid %s node: %s.', config.type, config.label || 'NULL'));
return null;
} else if (!config.label)
config.label = (validation('ip6addr', config.address) ?
`[${config.address}]` : config.address) + ':' + config.port;
}
return config;
};
@@ -0,0 +1,11 @@
#!/bin/sh
# SPDX-License-Identifier: GPL-2.0-only
#
# Copyright (C) 2023 ImmortalWrt.org
SCRIPTS_DIR="/etc/homeproxy/scripts"
# Region rule-sets (geosite/geoip .srs) are remote sing-box rule_sets — the core downloads
# and refreshes them itself (update_interval). No firewall list files to cron-update anymore.
"$SCRIPTS_DIR"/update_subscriptions.uc
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,52 @@
#!/bin/sh
# Resolve the Zapret tester's fixed test hosts to one IPv4 each — ONCE, up front,
# so the full-test sweep doesn't re-hit DNS for every one of its candidates.
#
# Resolution uses the router's normal resolver: the SAME secure-DNS / direct-egress
# path production uses for these blocked domains (generate_client.uc), so the IP is
# representative for the TLS-handshake probe — a foreign resolver could hand back a
# different CDN edge and make the result meaningless.
#
# Each lookup is time-bounded (busybox has no `timeout`: bg + watchdog kill) so a
# wedged resolver can't hang the rpcd call (which otherwise surfaces in LuCI as
# "ubus call error"), and the whole pass is retried once because applying Zapret
# restarts homeproxy's DNS resolver and it's briefly unavailable right after.
#
# Output: one JSON line —
# {"ok":1,"ips":{"yt":"1.2.3.4","tg":"…","dc":"…","st":"…"}}
# {"ok":0,"error":"…"}
# tag|host — mirrors zapret_test.sh's HOSTS (far/near spread).
HOSTS="yt|redirector.googlevideo.com tg|telegram.org dc|discord.com st|www.speedtest.net"
TMP=/tmp/zapret_resolve.$$
mkdir -p "$TMP" 2>/dev/null
trap 'rm -rf "$TMP" 2>/dev/null' EXIT INT TERM
resolve_one() { # $1=host -> echoes one non-loopback IPv4 (or empty); bounded ~2s
nslookup "$1" > "$TMP/o" 2>/dev/null & np=$!
( sleep 2; kill "$np" 2>/dev/null ) >/dev/null 2>&1 & sw=$!
wait "$np" 2>/dev/null
kill "$sw" 2>/dev/null
grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' "$TMP/o" 2>/dev/null | grep -vE '^127\.' | tail -1
}
JSON=""; OK=0; n=0
while [ "$n" -lt 2 ]; do
JSON=""; ANY=""
for h in $HOSTS; do
tag=${h%%|*}; host=${h##*|}
ip=$(resolve_one "$host")
[ -n "$ip" ] && ANY=1
[ -n "$JSON" ] && JSON="$JSON,"
JSON="$JSON\"$tag\":\"$ip\""
done
[ -n "$ANY" ] && { OK=1; break; }
n=$((n+1)); sleep 1
done
if [ "$OK" = 1 ]; then
echo "{\"ok\":1,\"ips\":{$JSON}}"
else
echo '{"ok":0,"error":"could not resolve any test host (DNS not ready? wait a few seconds after Apply, then retry)"}'
fi
@@ -0,0 +1,137 @@
#!/bin/sh
# Isolated nfqws2 strategy tester.
#
# nfqws2 is a packet mangler (NFQUEUE), not a proxy, so it can't be probed
# "through" like ByeDPI's SOCKS port. Instead we queue ONLY the resolved
# test-site IPs to a TEMP NFQUEUE running the candidate strategy, probe the TLS
# handshake with curl, and tear everything down via a trap — without disturbing
# the live homeproxy queue (qnum 200) or other traffic.
#
# Usage: zapret_test.sh "<strategy opts>"
# Output: a single JSON line: {"ok":N,"total":M,"results":[{tag,label,host,ip,tls,ok,reason},...]}
#
# Pass signal = curl time_appconnect > 0 (the TLS handshake completed = the desync
# got the ClientHello past the DPI). HTTP status is irrelevant (a 404 is a pass).
STRAT="$1"
IPS_ARG="$2" # optional pre-resolved "tag=ip tag=ip …" (full-test
# passes these so 36 candidates don't each re-hit DNS)
QNUM=209 # temp queue, distinct from the live 200
DMARK=0x40000000 # nfqws2 DESYNC_MARK (its reinjected fakes)
NFQWS=/opt/zapret2/nfq2/nfqws2
LUA="--lua-init=@/opt/zapret2/lua/zapret-lib.lua --lua-init=@/opt/zapret2/lua/zapret-antidpi.lua --lua-init=@/opt/zapret2/lua/zapret-auto.lua"
TABLE=zapret_test
CMT=zapret_test # comment tag on our output_redir bypass rules
TMP=/tmp/zapret_test.$$
# tag|label|host — far/near spread (mirrors the ByeDPI tester)
HOSTS="yt|YouTube|redirector.googlevideo.com tg|Telegram|telegram.org dc|Discord|discord.com st|Speedtest|www.speedtest.net"
NFQ_PID=""
WATCH_PID=""
cleanup() {
# nfqws2 daemonizes (so $! is stale) and busybox has no pkill. Match nfqws2 by
# process NAME (never the shell/grep itself), then kill only the one on our temp
# qnum (sparing the live qnum 200 instance).
for p in $(pgrep nfqws2 2>/dev/null); do
tr '\0' ' ' < "/proc/$p/cmdline" 2>/dev/null | grep -q "qnum=$QNUM" && kill "$p" 2>/dev/null
done
[ -n "$NFQ_PID" ] && kill "$NFQ_PID" 2>/dev/null
nft delete table inet "$TABLE" 2>/dev/null
# remove our surgical bypass rules from the LIVE output_redir chain (by comment)
for hnd in $(nft -a list chain inet fw4 homeproxy_output_redir 2>/dev/null | grep "$CMT" | grep -oE 'handle [0-9]+' | grep -oE '[0-9]+'); do
nft delete rule inet fw4 homeproxy_output_redir handle "$hnd" 2>/dev/null
done
[ -n "$WATCH_PID" ] && kill "$WATCH_PID" 2>/dev/null
rm -rf "$TMP" 2>/dev/null
}
trap cleanup EXIT INT TERM
fail() { echo "{\"ok\":0,\"total\":0,\"error\":\"$1\"}"; exit 0; }
[ -x "$NFQWS" ] || fail "zapret2 not installed"
command -v curl >/dev/null 2>&1 || fail "curl not installed"
mkdir -p "$TMP" 2>/dev/null
# Watchdog: guarantee teardown within 30s even if curl/nslookup wedges, so the
# live firewall is never left modified (busybox has no `timeout` applet).
# Redirect its fds to /dev/null: otherwise the backgrounded `sleep` keeps the
# caller's stdout pipe open and rpcd/popen blocks on read() for the full 30s
# (the result is ready in ~2s — the watchdog just held the pipe).
( sleep 30; kill -TERM $$ 2>/dev/null ) >/dev/null 2>&1 &
WATCH_PID=$!
# 1. Test-host IPs — BEFORE touching the firewall, so a slow resolver never runs
# while the redirect is modified. Prefer IPs passed in via "$2" (the full-test
# sweep resolves ONCE up front via zapret_resolve.sh and reuses them for every
# candidate); fall back to resolving here for a standalone call.
IPSET=""
if [ -n "$IPS_ARG" ]; then
for kv in $IPS_ARG; do
tag=${kv%%=*}; ip=${kv#*=}
[ -n "$tag" ] && echo "$ip" > "$TMP/ip_$tag"
[ -n "$ip" ] && IPSET="$IPSET$ip,"
done
else
for h in $HOSTS; do
tag=${h%%|*}; host=${h##*|}
ip=$(nslookup "$host" 2>/dev/null | grep -oE '([0-9]{1,3}\.){3}[0-9]{1,3}' | grep -vE '^127\.' | tail -1)
echo "$ip" > "$TMP/ip_$tag"
[ -n "$ip" ] && IPSET="$IPSET$ip,"
done
fi
IPSET=$(echo "$IPSET" | sed 's/[[:space:]]//g; s/,$//')
[ -z "$IPSET" ] && fail "could not resolve any test host"
# 2. Surgically send ONLY the test IPs' TCP/443 direct (return from output_redir,
# so router-origin curl egresses direct where our temp queue can catch it).
# Everything else stays redirected. Tagged with a comment for clean teardown.
if nft list chain inet fw4 homeproxy_output_redir >/dev/null 2>&1; then
nft insert rule inet fw4 homeproxy_output_redir ip daddr "{ $IPSET }" tcp dport 443 counter return comment "\"$CMT\"" 2>/dev/null
fi
# 3. Temp table: queue test-IP TCP/443 handshakes to QNUM (exclude nfqws's own
# reinjected DMARK fakes so they aren't re-queued); notrack those fakes.
nft add table inet "$TABLE" 2>/dev/null
nft add chain inet "$TABLE" pre "{ type filter hook postrouting priority mangle; policy accept; }" 2>/dev/null
nft add rule inet "$TABLE" pre "meta mark and $DMARK == 0 ip daddr { $IPSET } tcp dport 443 ct original packets 1-12 counter queue num $QNUM bypass" 2>/dev/null
nft add chain inet "$TABLE" out "{ type filter hook output priority -150; policy accept; }" 2>/dev/null
nft add rule inet "$TABLE" out "meta mark and $DMARK != 0 notrack" 2>/dev/null
# 4. Start the candidate nfqws2 on the temp queue.
$NFQWS --qnum=$QNUM --user=daemon --fwmark=$DMARK $LUA $STRAT >/dev/null 2>&1 &
NFQ_PID=$!
sleep 1
pgrep -f "qnum=$QNUM" >/dev/null 2>&1 || fail "nfqws2 did not start - check strategy"
# 5. Probe every host in parallel: curl forced to the resolved IP (keeps SNI),
# write "time_appconnect curl_exit". Wait ONLY on the curl jobs — a bare `wait`
# would also block on the 30s watchdog child.
CPIDS=""
for h in $HOSTS; do
tag=${h%%|*}; host=${h##*|}
ip=$(cat "$TMP/ip_$tag" 2>/dev/null)
if [ -z "$ip" ]; then echo "0 6" > "$TMP/r_$tag"; continue; fi
( out=$(curl -s --resolve "${host}:443:${ip}" -o /dev/null -w '%{time_appconnect}' --connect-timeout 4 --max-time 10 "https://${host}" 2>/dev/null); echo "$out $?" > "$TMP/r_$tag" ) >/dev/null 2>&1 &
CPIDS="$CPIDS $!"
done
[ -n "$CPIDS" ] && wait $CPIDS
# 6. Build JSON.
JSON=""; OKC=0; TOT=0
for h in $HOSTS; do
tag=${h%%|*}; rest=${h#*|}; label=${rest%%|*}; host=${rest##*|}
ip=$(cat "$TMP/ip_$tag" 2>/dev/null)
raw=$(cat "$TMP/r_$tag" 2>/dev/null); tls=${raw%% *}; rc=${raw##* }
TOT=$((TOT+1))
ok=0; case "$tls" in 0|0.000000|"") ok=0;; *) ok=1;; esac
[ "$ok" = 1 ] && OKC=$((OKC+1))
reason=""
if [ "$ok" = 0 ]; then
case "$rc" in 6) reason=dns;; 7) reason=refused;; 28) reason=timeout;; 35|51|53|56|58|59|60) reason=tls;; *) reason=fail;; esac
fi
[ -n "$JSON" ] && JSON="$JSON,"
JSON="$JSON{\"tag\":\"$tag\",\"label\":\"$label\",\"host\":\"$host\",\"ip\":\"$ip\",\"tls\":\"$tls\",\"ok\":$ok,\"reason\":\"$reason\"}"
done
echo "{\"ok\":$OKC,\"total\":$TOT,\"results\":[$JSON]}"