mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-11-01 16:44:23 +08:00
🎉 Sync 2026-09-27 21:03:26
This commit is contained in:
1 parent
f4559bbed1
commit
065763f7fd
673 files changed
+261285
-34160
No files matched your search
@@ -1,15 +0,0 @@
|
|||||||
# Router files MUST use LF line endings. CRLF breaks nft (firewall ruleset is
|
|
||||||
# rejected wholesale), POSIX shell (init.d), and can confuse ucode parsing.
|
|
||||||
# Force LF on checkout regardless of the developer's core.autocrlf setting.
|
|
||||||
|
|
||||||
*.ut text eol=lf
|
|
||||||
*.uc text eol=lf
|
|
||||||
*.nft text eol=lf
|
|
||||||
*.sh text eol=lf
|
|
||||||
*.js text eol=lf
|
|
||||||
*.json text eol=lf
|
|
||||||
|
|
||||||
# Extensionless router scripts
|
|
||||||
root/etc/init.d/* text eol=lf
|
|
||||||
root/usr/share/rpcd/ucode/* text eol=lf
|
|
||||||
root/etc/uci-defaults/* text eol=lf
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
# SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
#
|
|
||||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
|
||||||
# Copyright (C) 2024-2026 1andrevich <1andrevich.recede274@passmail.net>
|
|
||||||
|
|
||||||
include $(TOPDIR)/rules.mk
|
|
||||||
|
|
||||||
LUCI_TITLE:=Re:HomeProxy - multi-core proxy platform (fork of ImmortalWrt HomeProxy)
|
|
||||||
LUCI_PKGARCH:=all
|
|
||||||
LUCI_DEPENDS:= \
|
|
||||||
+firewall4 \
|
|
||||||
+ucode-mod-digest
|
|
||||||
|
|
||||||
PKG_NAME:=luci-app-re-homeproxy
|
|
||||||
PKG_VERSION:=1
|
|
||||||
PKG_RELEASE:=1
|
|
||||||
PKG_MAINTAINER:=1andrevich <1andrevich.recede274@passmail.net>
|
|
||||||
PKG_LICENSE:=GPL-2.0-only
|
|
||||||
PKG_LICENSE_FILES:=LICENSE
|
|
||||||
|
|
||||||
define Package/luci-app-re-homeproxy/conffiles
|
|
||||||
/etc/config/homeproxy
|
|
||||||
/etc/homeproxy/certs/
|
|
||||||
/etc/homeproxy/ruleset/
|
|
||||||
/etc/homeproxy/resources/direct_list.txt
|
|
||||||
/etc/homeproxy/resources/proxy_list.txt
|
|
||||||
endef
|
|
||||||
|
|
||||||
define Package/luci-app-re-homeproxy/postinst
|
|
||||||
#!/bin/sh
|
|
||||||
# Full restart, NOT kill -HUP: SIGHUP only reloads ACLs/session, so rpcd keeps the
|
|
||||||
# OLD ucode method set from /usr/share/rpcd/ucode/ until restarted. On every update
|
|
||||||
# that shipped a new/changed method (e.g. zapret_resolve_hosts) the method would be
|
|
||||||
# missing/stale until a manual `rpcd restart` — surfacing as "DNS not ready",
|
|
||||||
# yellow "?" in diagnostics, "Unknown method", etc. A restart reloads methods + ACLs.
|
|
||||||
# Detached + redirected and after a short delay so it (a) lets the install finish
|
|
||||||
# first, and (b) doesn't block when the upgrade is driven THROUGH rpcd (LuCI Software
|
|
||||||
# page): rpcd's popen() would otherwise wait on a child holding stdout open.
|
|
||||||
#
|
|
||||||
# MUST use setsid, not a bare `( … ) &`: a plain background subshell stays in the
|
|
||||||
# postinst's process group, so when opkg/apk tears the install down it reaps the
|
|
||||||
# still-sleeping child and the restart NEVER fires (methods stay stale → "DNS not
|
|
||||||
# ready" / "Method not found" until a manual `rpcd restart`). setsid puts the
|
|
||||||
# restarter in its own session, reparented to init, so it survives both the install
|
|
||||||
# teardown AND rpcd's own stop. Fall back to the plain form only where setsid is
|
|
||||||
# absent (rare; util-linux setsid ships on stock OpenWrt).
|
|
||||||
[ -n "$$IPKG_INSTROOT" ] && exit 0
|
|
||||||
if command -v setsid >/dev/null 2>&1; then
|
|
||||||
setsid sh -c 'sleep 3; /etc/init.d/rpcd restart' >/dev/null 2>&1 &
|
|
||||||
else
|
|
||||||
( sleep 3; /etc/init.d/rpcd restart ) >/dev/null 2>&1 &
|
|
||||||
fi
|
|
||||||
exit 0
|
|
||||||
endef
|
|
||||||
|
|
||||||
include $(TOPDIR)/feeds/luci/luci.mk
|
|
||||||
|
|
||||||
# call BuildPackage - OpenWrt buildroot signature
|
|
||||||
@@ -1,196 +0,0 @@
|
|||||||
[**Русский 🇷🇺**](README_ru.md) / [**English**](README.md)
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/one_andrevich"><img src="https://img.shields.io/badge/Telegram-Join-2CA5E0?style=flat-square&logo=telegram&logoColor=white" alt="Telegram"></a>
|
|
||||||
<a href="https://ko-fi.com/D1D11SQNQD"><img src="https://img.shields.io/badge/Ko--fi-Support-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi"></a>
|
|
||||||
<a href="https://nowpayments.io/donation?api_key=decbeb76-30f8-4c6d-ba40-2d2dec7fd888"><img src="https://img.shields.io/badge/Crypto-Donate-2EBE74?style=flat-square&logo=bitcoin&logoColor=white" alt="Crypto donate"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
# Re:HomeProxy
|
|
||||||
|
|
||||||
A modern multi-core proxy platform powered by [hiddify-core](https://github.com/hiddify/hiddify-core) and [sing-box-extended](https://github.com/shtorm-7/sing-box-extended).
|
|
||||||
A fork of [ImmortalWrt HomeProxy](https://github.com/immortalwrt/homeproxy).
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Re:HomeProxy is a feature-rich proxy management system, a fresh take on ImmortalWrt's HomeProxy. It runs on a choice of cores ([hiddify-core](https://github.com/hiddify/hiddify-core) or [sing-box-extended](https://github.com/shtorm-7/sing-box-extended)), adds a built-in DPI-bypass based on [Zapret2](https://github.com/bol-van/zapret2) and [ByeDPI](https://github.com/hufrea/byedpi) for un-throttling sites without a VPN, ready-made Russia routing rules, and a one-click core installer — all from the LuCI web interface.
|
|
||||||
|
|
||||||
## Key Features
|
|
||||||
|
|
||||||
- **Multi-core engine** — run on **hiddify-core** or **sing-box-extended**, your choice per device. The built-in **Core Management** page installs and updates the core for you and automatically picks the right build for your available storage (with a compact build for tight-storage devices).
|
|
||||||
- **Wide protocol support** — Naive, Mieru, Hysteria, SOCKS, Shadowsocks, ShadowTLS, Trojan, VLESS (XHTTP), VMess, WireGuard, **AmneziaWG / WARP** (sing-box-extended), SSH and more.
|
|
||||||
- **Two built-in DPI-bypass engines** — un-throttle and unblock sites (e.g. YouTube, Discord) **without any VPN subscription**:
|
|
||||||
- **ByeDPI** ([hufrea/byedpi](https://github.com/hufrea/byedpi)) — a SOCKS-level desync proxy, with 47 ready-made strategy presets and a multi-site **strategy tester** that shows which setting actually works on your ISP.
|
|
||||||
- **Zapret 2** ([bol-van/zapret2](https://github.com/bol-van/zapret2), nfqws2) — a packet-level NFQUEUE desync that mangles the handshake in-place. Selected per routing rule (e.g. send only YouTube/Discord through it), with curated presets, optional Discord-voice desync, and its own scoped tester.
|
|
||||||
- **URLTest auto-selection** — automatically routes through the fastest reachable node and fails over when one goes down.
|
|
||||||
- **Russia routing rules** — one-click RU Proxy Rules (Russia Inside, Re:Filter) with curated domain/IP lists, so only blocked destinations go through the proxy.
|
|
||||||
- **Subscription support** — import nodes from subscription links (sing-box JSON / Hiddify, base64 / plain share-links, and Xray/V2Ray JSON configs) and update them on demand.
|
|
||||||
- **Diagnostics** — a built-in page to check core/system health, inspect ports, and generate a shareable report.
|
|
||||||
- **Modern web interface** — clean, responsive LuCI UI with node management, ACL traffic routing, and NFT rule control.
|
|
||||||
|
|
||||||
|
|
||||||
## Prerequisites
|
|
||||||
|
|
||||||
- OpenWRT / ImmortalWrt 24.10 or higher (opkg)
|
|
||||||
- OpenWRT / ImmortalWrt 25.12 or higher (apk)
|
|
||||||
|
|
||||||
Optionally legacy build for 23.05 is available in Releases
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
*~40 MB of free space recommended. Tight on storage? Install the LuCI app first, then use its **Core & Tools** tab (Services → Re:HomeProxy → Core & Tools) to install a core — it auto-picks a build that fits, including a compact build for small memory devices.*
|
|
||||||
|
|
||||||
### Quick install (one-liner)
|
|
||||||
|
|
||||||
Installs the LuCI app, then interactively walks you through the proxy core and optional ByeDPI / Zapret. Works on APK (25.12+), opkg (24.10) and 23.05 legacy. Run over SSH on the router:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -qO- https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Behind a blocked/throttled GitHub, pass a mirror (note: the env var goes on `sh`, not `wget`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -qO- https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh | GH_MIRROR=https://your.mirror sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Prefer to do it by hand? Follow the per-version steps below.
|
|
||||||
|
|
||||||
### OpenWRT 25.12+ (APK)
|
|
||||||
|
|
||||||
#### 1. Install *luci-app-re-homeproxy* package
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/homeproxy-hiddify.pub https://github.com/1andrevich/homeproxy-hiddify/releases/latest/download/homeproxy-hiddify.pub
|
|
||||||
cp /tmp/homeproxy-hiddify.pub /etc/apk/keys/
|
|
||||||
wget -O /tmp/luci-app-re-homeproxy.apk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-app-re-homeproxy[^"]*\.apk' | head -1)"
|
|
||||||
apk add /tmp/luci-app-re-homeproxy.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
Once the key is in `/etc/apk/keys/` it is trusted permanently — no flag needed for future updates.
|
|
||||||
|
|
||||||
#### 2. Install components from the **Core & Tools** tab
|
|
||||||
|
|
||||||
Open **Services → Re:HomeProxy → Core & Tools** and install what you need — the installer auto-picks a build that fits your storage:
|
|
||||||
|
|
||||||
- **Proxy core** *(required, pick one)* — [hiddify-core](https://github.com/hiddify/hiddify-core) (default) or [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) (adds AmneziaWG / WARP and the widest protocol set). See **[Core Management](../../wiki/Core-Management-en)**.
|
|
||||||
- **ByeDPI** *(optional)* — SOCKS-level DPI bypass that un-throttles sites without a VPN, with 40 presets and a built-in strategy tester. See **[ByeDPI](../../wiki/ByeDPI-en)**.
|
|
||||||
- **Zapret 2** *(optional)* — packet-level (nfqws2) DPI bypass selected per routing rule, with curated presets and optional Discord-voice desync. See **[Zapret](../../wiki/Zapret-en)**.
|
|
||||||
|
|
||||||
### OpenWRT 24.10 (opkg)
|
|
||||||
|
|
||||||
#### 1. Install *luci-app-re-homeproxy* package
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/luci-app-re-homeproxy.ipk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-app-re-homeproxy[^"]*\.ipk' | head -1)"
|
|
||||||
opkg install /tmp/luci-app-re-homeproxy.ipk
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Install components from the **Core & Tools** tab
|
|
||||||
|
|
||||||
Open **Services → Re:HomeProxy → Core & Tools** and install what you need — the installer auto-picks a build that fits your storage:
|
|
||||||
|
|
||||||
- **Proxy core** *(required, pick one)* — [hiddify-core](https://github.com/hiddify/hiddify-core) (default) or [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) (adds AmneziaWG / WARP and the widest protocol set). See **[Core Management](../../wiki/Core-Management-en)**.
|
|
||||||
- **ByeDPI** *(optional)* — SOCKS-level DPI bypass that un-throttles sites without a VPN, with 40 presets and a built-in strategy tester. See **[ByeDPI](../../wiki/ByeDPI-en)**.
|
|
||||||
- **Zapret 2** *(optional)* — packet-level (nfqws2) DPI bypass selected per routing rule, with curated presets and optional Discord-voice desync. See **[Zapret](../../wiki/Zapret-en)**.
|
|
||||||
|
|
||||||
### Manual install over SSH (alternative to the Core & Tools tab)
|
|
||||||
|
|
||||||
The **Core & Tools** tab is recommended — it auto-detects your hardware and picks a build that fits. To install the same components by hand over SSH, first detect your architecture and package format:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
. /etc/openwrt_release; ARCH="$DISTRIB_ARCH"
|
|
||||||
command -v apk >/dev/null && EXT=apk || EXT=ipk
|
|
||||||
echo "$ARCH / $EXT"
|
|
||||||
```
|
|
||||||
|
|
||||||
The hiddify-core and ByeDPI packages are signed with the `homeproxy-hiddify.pub` key trusted during the app install above; if you skipped that, add `--allow-untrusted` to `apk add`.
|
|
||||||
|
|
||||||
**Proxy core — pick one** (plus its required kernel modules):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add kmod-nft-tproxy kmod-tun; else opkg install kmod-nft-tproxy kmod-tun; fi
|
|
||||||
|
|
||||||
# hiddify-core (default; for a compact build replace 'latest/download' with 'download/upx')
|
|
||||||
wget -O /tmp/hiddify-core.$EXT "https://github.com/1andrevich/hiddify-core/releases/latest/download/hiddify-core_${ARCH}.${EXT}"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/hiddify-core.apk; else opkg install --force-reinstall /tmp/hiddify-core.ipk; fi
|
|
||||||
|
|
||||||
# ...OR sing-box-extended (AmneziaWG / WARP, widest protocol set; unsigned)
|
|
||||||
URL=$(wget -qO- 'https://api.github.com/repos/shtorm-7/sing-box-extended/releases/latest' | grep -o "https://github\.com/[^\"]*${ARCH}[^\"]*\.${EXT}" | head -1)
|
|
||||||
wget -O /tmp/sing-box-extended.$EXT "$URL"
|
|
||||||
if [ "$EXT" = apk ]; then apk add --allow-untrusted /tmp/sing-box-extended.apk; else opkg install /tmp/sing-box-extended.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**ByeDPI** *(optional)* — needs `curl` (its built-in strategy tester uses it):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add curl; else opkg install curl; fi
|
|
||||||
URL=$(wget -qO- 'https://api.github.com/repos/1andrevich/ByeDPI-OpenWrt/releases/latest' | grep -o "https://github\.com/[^\"]*byedpi_[^\"]*${ARCH}\.${EXT}" | head -1)
|
|
||||||
wget -O /tmp/byedpi.$EXT "$URL"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/byedpi.apk; else opkg install /tmp/byedpi.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Zapret 2** *(optional)* — needs the NFQUEUE kernel module:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add kmod-nft-queue; else opkg install kmod-nft-queue; fi
|
|
||||||
[ "$EXT" = apk ] && wget -O /etc/apk/keys/zapret2-1andrevich.pub "https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2-1andrevich.pub"
|
|
||||||
wget -O /tmp/zapret2.$EXT "https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2_${ARCH}.${EXT}"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/zapret2.apk; else opkg install /tmp/zapret2.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional
|
|
||||||
|
|
||||||
Installation of [Russian Language Pack](https://github.com/1andrevich/homeproxy-hiddify/blob/master/README_ru.md#3-%D1%83%D1%81%D1%82%D0%B0%D0%BD%D0%BE%D0%B2%D0%BA%D0%B0-%D1%8F%D0%B7%D1%8B%D0%BA%D0%BE%D0%B2%D0%BE%D0%B3%D0%BE-%D0%BF%D0%B0%D0%BA%D0%B5%D1%82%D0%B0-ru-1)
|
|
||||||
|
|
||||||
If using "Custom JSON" — see the **[Custom JSON Config](../../wiki/Custom-JSON-Config-en)** wiki page for full details.
|
|
||||||
|
|
||||||
### 4. Start the service
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/homeproxy start
|
|
||||||
```
|
|
||||||
|
|
||||||
The service will auto-start on boot. Monitor logs at **Services → Re:HomeProxy → Core & Tools**.
|
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
Full guides live in the **[Wiki](../../wiki/Home)**:
|
|
||||||
|
|
||||||
- **[Getting Started](../../wiki/Getting-Started-en)** — from a fresh install to a working connection, step by step
|
|
||||||
- **[Core Management](../../wiki/Core-Management-en)** — hiddify-core vs sing-box-extended, the smart installer, storage and the compact build
|
|
||||||
- **[Supported Protocols](../../wiki/Supported-Protocols-en)** — every protocol, transport, and the build tags each needs
|
|
||||||
- **[Subscriptions & Node Import](../../wiki/Subscriptions-en)** — share links, .conf, Amnezia `vpn://` (AmneziaWG/Xray), subscriptions, base64
|
|
||||||
- **[Routing & Access Control](../../wiki/Routing-and-Access-Control-en)** — routing modes, RU Proxy Rules, per-device access control
|
|
||||||
- **[Server Settings](../../wiki/Server-Settings-en)** — run the router as a proxy server (inbounds, types, TLS/ACME)
|
|
||||||
- **[DNS & Diagnostics](../../wiki/DNS-and-Diagnostics-en)** — clean vs secure DNS, IPv6 leaks, and the Diagnostics page
|
|
||||||
- **[ByeDPI](../../wiki/ByeDPI-en)** — SOCKS-level DPI bypass, strategy presets and the tester
|
|
||||||
- **[Zapret](../../wiki/Zapret-en)** — packet-level (nfqws2) DPI bypass, presets, Discord-voice and the tester
|
|
||||||
- **[Custom Routing](../../wiki/Custom-Routing-en)** — UI routing nodes + rules (match by domain/IP/port/protocol/process)
|
|
||||||
- **[Custom JSON Config](../../wiki/Custom-JSON-Config-en)** — raw hiddify-core config routing mode
|
|
||||||
- **[Troubleshooting](../../wiki/Troubleshooting-en)** — common errors and fixes
|
|
||||||
|
|
||||||
## Credits & Acknowledgements
|
|
||||||
|
|
||||||
Re:HomeProxy stands on the work of many upstream projects. The LuCI app is GPL-licensed; the cores and bypass engines are fetched at install time from their own releases and remain under their own licenses.
|
|
||||||
|
|
||||||
**Base & cores**
|
|
||||||
- [ImmortalWrt HomeProxy](https://github.com/immortalwrt/homeproxy) — the original LuCI app this is a fork of
|
|
||||||
- [hiddify-core](https://github.com/hiddify/hiddify-core) — default proxy core (a sing-box fork by the Hiddify team)
|
|
||||||
- [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) — alternative core with extra build tags (AmneziaWG/WARP, widest protocol set)
|
|
||||||
- [sing-box](https://sing-box.sagernet.org) — the upstream engine both cores derive from
|
|
||||||
|
|
||||||
**DPI-bypass engines**
|
|
||||||
- [hufrea/byedpi](https://github.com/hufrea/byedpi) — the ByeDPI (`ciadpi`) desync engine; OpenWrt packages by [1andrevich/ByeDPI-OpenWrt](https://github.com/1andrevich/ByeDPI-OpenWrt)
|
|
||||||
- [bol-van/zapret2](https://github.com/bol-van/zapret2) — the Zapret / nfqws2 / blockcheck2 packet desync engine; OpenWrt packages by [1andrevich/zapret2-openwrt](https://github.com/1andrevich/zapret2-openwrt); some strategy presets adapted from [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube) (MIT)
|
|
||||||
|
|
||||||
**Protocols** — implemented by the cores above (see [Supported Protocols](../../wiki/Supported-Protocols-en)):
|
|
||||||
|
|
||||||
Naive, Mieru, Hysteria/Hysteria2, TUIC, SOCKS, Shadowsocks/Shadowsocks 2022, ShadowTLS, AnyTLS, Trojan, VLESS (Reality, XHTTP), VMess, WireGuard, AmneziaWG/WARP, SSH.
|
|
||||||
|
|
||||||
**Routing lists**
|
|
||||||
- [Re:Filter](https://github.com/1andrevich/re-filter) — RKN-registry domain + IP blocklist
|
|
||||||
- [itdoginfo/allow-domains](https://github.com/itdoginfo/allow-domains) — "Russia Inside" and the per-service routing lists (YouTube, Telegram, Discord, Meta, etc.)
|
|
||||||
- [itdoginfo](https://github.com/itdoginfo) — HODCA and other curated lists by itdoginfo
|
|
||||||
|
|
||||||
All trademarks and service names are the property of their respective owners and are referenced nominatively to identify the traffic each rule or list affects.
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
[**Русский 🇷🇺**](README_ru.md) / [**English**](README.md)
|
|
||||||
|
|
||||||
<p align="center">
|
|
||||||
<a href="https://t.me/one_andrevich"><img src="https://img.shields.io/badge/Telegram-Join-2CA5E0?style=flat-square&logo=telegram&logoColor=white" alt="Telegram"></a>
|
|
||||||
<a href="https://ko-fi.com/D1D11SQNQD"><img src="https://img.shields.io/badge/Ko--fi-Support-FF5E5B?style=flat-square&logo=ko-fi&logoColor=white" alt="Ko-fi"></a>
|
|
||||||
<a href="https://nowpayments.io/donation?api_key=decbeb76-30f8-4c6d-ba40-2d2dec7fd888"><img src="https://img.shields.io/badge/Crypto-Donate-2EBE74?style=flat-square&logo=bitcoin&logoColor=white" alt="Crypto donate"></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
# Re:HomeProxy
|
|
||||||
|
|
||||||
Современная многоядерная прокси-платформа на основе [hiddify-core](https://github.com/hiddify/hiddify-core) и [sing-box-extended](https://github.com/shtorm-7/sing-box-extended).
|
|
||||||
Форк [ImmortalWrt HomeProxy](https://github.com/immortalwrt/homeproxy).
|
|
||||||
|
|
||||||
## Обзор
|
|
||||||
|
|
||||||
Re:HomeProxy — многофункциональная система управления прокси, новый взгляд на HomeProxy от ImmortalWrt. Работает на выбор ядра ([hiddify-core](https://github.com/hiddify/hiddify-core) или [sing-box-extended](https://github.com/shtorm-7/sing-box-extended)), включает встроенный обход DPI на основе [Zapret2](https://github.com/bol-van/zapret2) и [ByeDPI](https://github.com/hufrea/byedpi) для разблокировки сайтов без VPN, готовые правила маршрутизации для России и установщик ядра в один клик — всё из веб-интерфейса LuCI.
|
|
||||||
|
|
||||||
## Ключевые возможности
|
|
||||||
|
|
||||||
- **Многоядерный движок** — работа на **hiddify-core** или **sing-box-extended** на ваш выбор. Встроенная страница **Управление ядром** сама установит и обновит ядро и автоматически подберёт подходящую сборку под свободное место (включая компактную сборку для устройств с малым объёмом памяти).
|
|
||||||
- **Широкая поддержка протоколов** — Naive, Mieru, Hysteria, SOCKS, Shadowsocks, ShadowTLS, Trojan, VLESS (XHTTP), VMess, WireGuard, **AmneziaWG / WARP** (sing-box-extended), SSH и другие.
|
|
||||||
- **Два встроенных движка обхода DPI** — разблокировка сайтов и снятие троттлинга (например, YouTube, Discord) **без какой-либо VPN-подписки**:
|
|
||||||
- **ByeDPI** ([hufrea/byedpi](https://github.com/hufrea/byedpi)) — рассинхронизирующий прокси на уровне SOCKS, 47 готовых пресетов стратегий и **тестер стратегий** по нескольким сайтам, который показывает, какая настройка реально работает у вашего провайдера.
|
|
||||||
- **Zapret 2** ([bol-van/zapret2](https://github.com/bol-van/zapret2), nfqws2) — рассинхронизация на уровне пакетов через NFQUEUE, искажает рукопожатие «на лету». Назначается в правилах маршрутизации (например, отправить через него только YouTube/Discord), с подобранными пресетами, опциональным обходом для голоса Discord и собственным тестером.
|
|
||||||
- **Автовыбор через URLTest** — автоматически направляет трафик через самый быстрый доступный узел и переключается при сбое.
|
|
||||||
- **Правила маршрутизации для России** — RU Proxy Rules в один клик (Russia Inside, Re:Filter) с готовыми списками доменов/IP, чтобы через прокси шли только заблокированные адреса.
|
|
||||||
- **Поддержка подписок** — импорт узлов по ссылкам подписок (sing-box JSON / Hiddify, base64 / обычные share-ссылки, а также JSON-конфиги Xray/V2Ray) и обновление по требованию.
|
|
||||||
- **Диагностика** — встроенная страница для проверки состояния ядра/системы, просмотра портов и формирования отчёта.
|
|
||||||
- **Современный веб-интерфейс** — чистый адаптивный интерфейс LuCI с управлением узлами, маршрутизацией ACL и NFT-правилами.
|
|
||||||
|
|
||||||
|
|
||||||
## Требования
|
|
||||||
|
|
||||||
- OpenWRT / ImmortalWrt 24.10 или выше (opkg)
|
|
||||||
- OpenWRT / ImmortalWrt 25.12 или выше (apk)
|
|
||||||
|
|
||||||
Опционально в разделе Releases доступна legacy-сборка для 23.05
|
|
||||||
|
|
||||||
## Установка
|
|
||||||
|
|
||||||
*Рекомендуется ~40 Мб свободного места. Мало места? Сначала установите пакет LuCI, затем на вкладке **Ядро и службы** (Службы → Re:HomeProxy → Ядро и службы) установите ядро — оно само подберёт подходящую сборку для устройств с малым объёмом памяти.*
|
|
||||||
|
|
||||||
### Быстрая установка (одной строкой)
|
|
||||||
|
|
||||||
Ставит LuCI-приложение, затем интерактивно проводит по выбору ядра прокси и опциональных ByeDPI / Zapret. Работает на APK (25.12+), opkg (24.10) и 23.05 legacy. Выполните по SSH на роутере:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -qO- https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh | sh
|
|
||||||
```
|
|
||||||
|
|
||||||
При заблокированном/замедленном GitHub можно указать зеркало (важно: переменная ставится перед `sh`, а не перед `wget`):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -qO- https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh | GH_MIRROR=https://your.mirror sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Хотите вручную? Шаги по версиям — ниже.
|
|
||||||
|
|
||||||
### OpenWRT 25.12+ (APK)
|
|
||||||
|
|
||||||
#### 1. Установка пакета Re:HomeProxy
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/homeproxy-hiddify.pub https://github.com/1andrevich/homeproxy-hiddify/releases/latest/download/homeproxy-hiddify.pub
|
|
||||||
cp /tmp/homeproxy-hiddify.pub /etc/apk/keys/
|
|
||||||
wget -O /tmp/luci-app-re-homeproxy.apk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-app-re-homeproxy[^"]*\.apk' | head -1)"
|
|
||||||
apk add /tmp/luci-app-re-homeproxy.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
После того как ключ окажется в `/etc/apk/keys/`, он будет доверенным постоянно — флаг указывать при последующих обновлениях не нужно.
|
|
||||||
|
|
||||||
#### 2. Установка компонентов на вкладке **Ядро и службы**
|
|
||||||
|
|
||||||
Откройте **Службы → Re:HomeProxy → Ядро и службы** и установите нужное — установщик сам подберёт сборку под свободное место:
|
|
||||||
|
|
||||||
- **Ядро прокси** *(обязательно, выберите одно)* — [hiddify-core](https://github.com/hiddify/hiddify-core) (по умолчанию) или [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) (добавляет AmneziaWG / WARP и самый широкий набор протоколов). См. **[Управление ядром](../../wiki/Core-Management-ru)**.
|
|
||||||
- **ByeDPI** *(опционально)* — обход DPI на уровне SOCKS, снимает троттлинг без VPN, 47 пресетов и встроенный тестер стратегий. См. **[ByeDPI](../../wiki/ByeDPI-ru)**.
|
|
||||||
- **Zapret 2** *(опционально)* — обход DPI на уровне пакетов (nfqws2), назначается в правилах маршрутизации, с подобранными пресетами и опциональным обходом для голоса Discord. См. **[Zapret](../../wiki/Zapret-ru)**.
|
|
||||||
|
|
||||||
#### 3. Установка языкового пакета RU
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/luci-i18n-homeproxy-ru.apk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-i18n-homeproxy-ru[^"]*\.apk' | head -1)"
|
|
||||||
apk add /tmp/luci-i18n-homeproxy-ru.apk
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### OpenWRT 24.10 (opkg)
|
|
||||||
|
|
||||||
#### 1. Установка пакета Re:HomeProxy
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/luci-app-re-homeproxy.ipk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-app-re-homeproxy[^"]*\.ipk' | head -1)"
|
|
||||||
opkg install /tmp/luci-app-re-homeproxy.ipk
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Установка компонентов на вкладке **Ядро и службы**
|
|
||||||
|
|
||||||
Откройте **Службы → Re:HomeProxy → Ядро и службы** и установите нужное — установщик сам подберёт сборку под свободное место:
|
|
||||||
|
|
||||||
- **Ядро прокси** *(обязательно, выберите одно)* — [hiddify-core](https://github.com/hiddify/hiddify-core) (по умолчанию) или [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) (добавляет AmneziaWG / WARP и самый широкий набор протоколов). См. **[Управление ядром](../../wiki/Core-Management-ru)**.
|
|
||||||
- **ByeDPI** *(опционально)* — обход DPI на уровне SOCKS, снимает троттлинг без VPN, 47 пресетов и встроенный тестер стратегий. См. **[ByeDPI](../../wiki/ByeDPI-ru)**.
|
|
||||||
- **Zapret 2** *(опционально)* — обход DPI на уровне пакетов (nfqws2), назначается в правилах маршрутизации, с подобранными пресетами и опциональным обходом для голоса Discord. См. **[Zapret](../../wiki/Zapret-ru)**.
|
|
||||||
|
|
||||||
#### 3. Установка языкового пакета RU
|
|
||||||
|
|
||||||
```sh
|
|
||||||
wget -O /tmp/luci-i18n-homeproxy-ru.ipk "$(wget -qO- 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' | grep -o 'https://github\.com/[^"]*luci-i18n-homeproxy-ru[^"]*\.ipk' | head -1)"
|
|
||||||
opkg install /tmp/luci-i18n-homeproxy-ru.ipk
|
|
||||||
```
|
|
||||||
|
|
||||||
### Ручная установка по SSH (вместо вкладки «Ядро и службы»)
|
|
||||||
|
|
||||||
Вкладка **«Ядро и службы»** предпочтительна — она сама определяет железо и подбирает подходящую сборку. Чтобы поставить то же самое вручную по SSH, сначала определите архитектуру и формат пакетов:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
. /etc/openwrt_release; ARCH="$DISTRIB_ARCH"
|
|
||||||
command -v apk >/dev/null && EXT=apk || EXT=ipk
|
|
||||||
echo "$ARCH / $EXT"
|
|
||||||
```
|
|
||||||
|
|
||||||
Пакеты hiddify-core и ByeDPI подписаны ключом `homeproxy-hiddify.pub`, который добавляется в доверенные при установке приложения выше; если вы пропустили тот шаг — добавьте `--allow-untrusted` к `apk add`.
|
|
||||||
|
|
||||||
**Ядро прокси — выберите одно** (плюс необходимые модули ядра):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add kmod-nft-tproxy kmod-tun; else opkg install kmod-nft-tproxy kmod-tun; fi
|
|
||||||
|
|
||||||
# hiddify-core (по умолчанию; для компактной сборки замените 'latest/download' на 'download/upx')
|
|
||||||
wget -O /tmp/hiddify-core.$EXT "https://github.com/1andrevich/hiddify-core/releases/latest/download/hiddify-core_${ARCH}.${EXT}"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/hiddify-core.apk; else opkg install --force-reinstall /tmp/hiddify-core.ipk; fi
|
|
||||||
|
|
||||||
# ...или sing-box-extended (AmneziaWG / WARP, самый широкий набор протоколов; без подписи)
|
|
||||||
URL=$(wget -qO- 'https://api.github.com/repos/shtorm-7/sing-box-extended/releases/latest' | grep -o "https://github\.com/[^\"]*${ARCH}[^\"]*\.${EXT}" | head -1)
|
|
||||||
wget -O /tmp/sing-box-extended.$EXT "$URL"
|
|
||||||
if [ "$EXT" = apk ]; then apk add --allow-untrusted /tmp/sing-box-extended.apk; else opkg install /tmp/sing-box-extended.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**ByeDPI** *(опционально)* — нужен `curl` (его использует встроенный тестер стратегий):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add curl; else opkg install curl; fi
|
|
||||||
URL=$(wget -qO- 'https://api.github.com/repos/1andrevich/ByeDPI-OpenWrt/releases/latest' | grep -o "https://github\.com/[^\"]*byedpi_[^\"]*${ARCH}\.${EXT}" | head -1)
|
|
||||||
wget -O /tmp/byedpi.$EXT "$URL"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/byedpi.apk; else opkg install /tmp/byedpi.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
**Zapret 2** *(опционально)* — нужен модуль ядра NFQUEUE:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
if [ "$EXT" = apk ]; then apk add kmod-nft-queue; else opkg install kmod-nft-queue; fi
|
|
||||||
[ "$EXT" = apk ] && wget -O /etc/apk/keys/zapret2-1andrevich.pub "https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2-1andrevich.pub"
|
|
||||||
wget -O /tmp/zapret2.$EXT "https://github.com/1andrevich/zapret2-openwrt/releases/latest/download/zapret2_${ARCH}.${EXT}"
|
|
||||||
if [ "$EXT" = apk ]; then apk add /tmp/zapret2.apk; else opkg install /tmp/zapret2.ipk; fi
|
|
||||||
```
|
|
||||||
|
|
||||||
### Дополнительно. Режим «Пользовательский JSON»
|
|
||||||
|
|
||||||
Подробная документация доступна на странице вики **[Пользовательский JSON конфиг](../../wiki/Custom-JSON-Config-ru)**.
|
|
||||||
|
|
||||||
### 4. Запуск службы
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/homeproxy start
|
|
||||||
```
|
|
||||||
|
|
||||||
Служба запускается автоматически при загрузке системы. Логи доступны в разделе **Службы → Re:HomeProxy → Ядро и службы**.
|
|
||||||
|
|
||||||
## Документация
|
|
||||||
|
|
||||||
Полные руководства — в **[вики](../../wiki/Home)**:
|
|
||||||
|
|
||||||
- **[Первая настройка](../../wiki/Getting-Started-ru)** — от свежей установки до рабочего соединения, по шагам
|
|
||||||
- **[Управление ядром](../../wiki/Core-Management-ru)** — hiddify-core или sing-box-extended, умный установщик, память и компактная сборка
|
|
||||||
- **[Поддерживаемые протоколы](../../wiki/Supported-Protocols-ru)** — все протоколы, транспорты и нужные им build-теги
|
|
||||||
- **[Подписки и импорт узлов](../../wiki/Subscriptions-ru)** — share-ссылки, .conf, Amnezia `vpn://` (AmneziaWG/Xray), подписки, base64
|
|
||||||
- **[Маршрутизация и контроль доступа](../../wiki/Routing-and-Access-Control-ru)** — режимы маршрутизации, RU Proxy Rules, пер-девайс контроль доступа
|
|
||||||
- **[Настройки сервера](../../wiki/Server-Settings-ru)** — роутер как прокси-сервер (inbound-ы, типы, TLS/ACME)
|
|
||||||
- **[DNS и диагностика](../../wiki/DNS-and-Diagnostics-ru)** — чистый и защищённый DNS, утечки IPv6 и страница диагностики
|
|
||||||
- **[ByeDPI](../../wiki/ByeDPI-ru)** — обход DPI на уровне SOCKS, пресеты стратегий и тестер
|
|
||||||
- **[Zapret](../../wiki/Zapret-ru)** — обход DPI на уровне пакетов (nfqws2), пресеты, голос Discord и тестер
|
|
||||||
- **[Пользовательская маршрутизация](../../wiki/Custom-Routing-ru)** — узлы и правила маршрутизации в UI (матч по домену/IP/порту/протоколу/процессу)
|
|
||||||
- **[Пользовательский JSON конфиг](../../wiki/Custom-JSON-Config-ru)** — режим маршрутизации с «сырым» конфигом hiddify-core
|
|
||||||
- **[Устранение неполадок](../../wiki/Troubleshooting-ru)** — типичные ошибки и их решение
|
|
||||||
|
|
||||||
## Благодарности и используемые проекты
|
|
||||||
|
|
||||||
Re:HomeProxy опирается на работу множества вышестоящих проектов. Приложение LuCI распространяется под GPL; ядра и движки обхода DPI скачиваются при установке из их собственных релизов и остаются под своими лицензиями.
|
|
||||||
|
|
||||||
**Основа и ядра**
|
|
||||||
- [ImmortalWrt HomeProxy](https://github.com/immortalwrt/homeproxy) — исходное приложение LuCI, форком которого является проект
|
|
||||||
- [hiddify-core](https://github.com/hiddify/hiddify-core) — ядро прокси по умолчанию (форк sing-box от команды Hiddify)
|
|
||||||
- [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) — альтернативное ядро с дополнительными build-тегами (AmneziaWG/WARP, самый широкий набор протоколов)
|
|
||||||
- [sing-box](https://sing-box.sagernet.org) — вышестоящий движок, на котором основаны оба ядра
|
|
||||||
|
|
||||||
**Движки обхода DPI**
|
|
||||||
- [hufrea/byedpi](https://github.com/hufrea/byedpi) — движок рассинхронизации ByeDPI (`ciadpi`); пакеты для OpenWrt — [1andrevich/ByeDPI-OpenWrt](https://github.com/1andrevich/ByeDPI-OpenWrt)
|
|
||||||
- [bol-van/zapret2](https://github.com/bol-van/zapret2) — движок рассинхронизации пакетов Zapret / nfqws2 / blockcheck2; пакеты для OpenWrt — [1andrevich/zapret2-openwrt](https://github.com/1andrevich/zapret2-openwrt); часть пресетов адаптирована из [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube) (MIT)
|
|
||||||
|
|
||||||
**Протоколы** — реализованы ядрами выше (см. [Поддерживаемые протоколы](../../wiki/Supported-Protocols-ru)):
|
|
||||||
|
|
||||||
Naive, Mieru, Hysteria/Hysteria2, TUIC, SOCKS, Shadowsocks/Shadowsocks 2022, ShadowTLS, AnyTLS, Trojan, VLESS (Reality, XHTTP), VMess, WireGuard, AmneziaWG/WARP, SSH.
|
|
||||||
|
|
||||||
**Списки маршрутизации**
|
|
||||||
- [Re:Filter](https://github.com/1andrevich/re-filter) — список доменов + IP по реестру РКН
|
|
||||||
- [itdoginfo/allow-domains](https://github.com/itdoginfo/allow-domains) — «Russia Inside» и сервисные списки (YouTube, Telegram, Discord, Meta и др.)
|
|
||||||
- [itdoginfo](https://github.com/itdoginfo) — HODCA и другие подобранные списки от itdoginfo
|
|
||||||
|
|
||||||
Все товарные знаки и названия сервисов принадлежат их владельцам и упоминаются номинативно — для обозначения трафика, к которому относится правило или список.
|
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
*
|
|
||||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
'require baseclass';
|
|
||||||
'require form';
|
|
||||||
'require fs';
|
|
||||||
'require rpc';
|
|
||||||
'require uci';
|
|
||||||
'require ui';
|
|
||||||
|
|
||||||
return baseclass.extend({
|
|
||||||
dns_strategy: {
|
|
||||||
'': _('Default'),
|
|
||||||
'prefer_ipv4': _('Prefer IPv4'),
|
|
||||||
'prefer_ipv6': _('Prefer IPv6'),
|
|
||||||
'ipv4_only': _('IPv4 only'),
|
|
||||||
'ipv6_only': _('IPv6 only')
|
|
||||||
},
|
|
||||||
|
|
||||||
shadowsocks_encrypt_length: {
|
|
||||||
/* AEAD */
|
|
||||||
'aes-128-gcm': 0,
|
|
||||||
'aes-192-gcm': 0,
|
|
||||||
'aes-256-gcm': 0,
|
|
||||||
'chacha20-ietf-poly1305': 0,
|
|
||||||
'xchacha20-ietf-poly1305': 0,
|
|
||||||
/* AEAD 2022 */
|
|
||||||
'2022-blake3-aes-128-gcm': 16,
|
|
||||||
'2022-blake3-aes-256-gcm': 32,
|
|
||||||
'2022-blake3-chacha20-poly1305': 32
|
|
||||||
},
|
|
||||||
|
|
||||||
shadowsocks_encrypt_methods: [
|
|
||||||
/* Stream */
|
|
||||||
'none',
|
|
||||||
/* AEAD */
|
|
||||||
'aes-128-gcm',
|
|
||||||
'aes-192-gcm',
|
|
||||||
'aes-256-gcm',
|
|
||||||
'chacha20-ietf-poly1305',
|
|
||||||
'xchacha20-ietf-poly1305',
|
|
||||||
/* AEAD 2022 */
|
|
||||||
'2022-blake3-aes-128-gcm',
|
|
||||||
'2022-blake3-aes-256-gcm',
|
|
||||||
'2022-blake3-chacha20-poly1305'
|
|
||||||
],
|
|
||||||
|
|
||||||
tls_cipher_suites: [
|
|
||||||
'TLS_RSA_WITH_AES_128_CBC_SHA',
|
|
||||||
'TLS_RSA_WITH_AES_256_CBC_SHA',
|
|
||||||
'TLS_RSA_WITH_AES_128_GCM_SHA256',
|
|
||||||
'TLS_RSA_WITH_AES_256_GCM_SHA384',
|
|
||||||
'TLS_AES_128_GCM_SHA256',
|
|
||||||
'TLS_AES_256_GCM_SHA384',
|
|
||||||
'TLS_CHACHA20_POLY1305_SHA256',
|
|
||||||
'TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA',
|
|
||||||
'TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA',
|
|
||||||
'TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA',
|
|
||||||
'TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA',
|
|
||||||
'TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256',
|
|
||||||
'TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384',
|
|
||||||
'TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256',
|
|
||||||
'TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384',
|
|
||||||
'TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256',
|
|
||||||
'TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256'
|
|
||||||
],
|
|
||||||
|
|
||||||
tls_versions: [
|
|
||||||
'1.0',
|
|
||||||
'1.1',
|
|
||||||
'1.2',
|
|
||||||
'1.3'
|
|
||||||
],
|
|
||||||
|
|
||||||
CBIStaticList: form.DynamicList.extend({
|
|
||||||
__name__: 'CBI.StaticList',
|
|
||||||
|
|
||||||
renderWidget: function(/* ... */) {
|
|
||||||
let dl = form.DynamicList.prototype.renderWidget.apply(this, arguments);
|
|
||||||
dl.querySelector('.add-item ul > li[data-value="-"]')?.remove();
|
|
||||||
return dl;
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
|
|
||||||
calcStringMD5(e) {
|
|
||||||
/* Thanks to https://stackoverflow.com/a/41602636 */
|
|
||||||
let h = (a, b) => {
|
|
||||||
let c, d, e, f, g;
|
|
||||||
c = a & 2147483648;
|
|
||||||
d = b & 2147483648;
|
|
||||||
e = a & 1073741824;
|
|
||||||
f = b & 1073741824;
|
|
||||||
g = (a & 1073741823) + (b & 1073741823);
|
|
||||||
return e & f ? g ^ 2147483648 ^ c ^ d : e | f ? g & 1073741824 ? g ^ 3221225472 ^ c ^ d : g ^ 1073741824 ^ c ^ d : g ^ c ^ d;
|
|
||||||
}, k = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & c | ~b & d, e), g))) << f | a >>> 32 - f, b),
|
|
||||||
l = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b & d | c & ~d, e), g))) << f | a >>> 32 - f, b),
|
|
||||||
m = (a, b, c, d, e, f, g) => h((a = h(a, h(h(b ^ c ^ d, e), g))) << f | a >>> 32 - f, b),
|
|
||||||
n = (a, b, c, d, e, f, g) => h((a = h(a, h(h(c ^ (b | ~d), e), g))) << f | a >>> 32 - f, b),
|
|
||||||
p = a => { let b = '', d = ''; for (let c = 0; c <= 3; c++) d = a >>> 8 * c & 255, d = '0' + d.toString(16), b += d.substr(d.length - 2, 2); return b; };
|
|
||||||
|
|
||||||
let f = [], q, r, s, t, a, b, c, d;
|
|
||||||
e = (() => {
|
|
||||||
e = e.replace(/\r\n/g, '\n');
|
|
||||||
let b = '';
|
|
||||||
for (let d = 0; d < e.length; d++) {
|
|
||||||
let c = e.charCodeAt(d);
|
|
||||||
b += c < 128 ? String.fromCharCode(c) : c < 2048 ? String.fromCharCode(c >> 6 | 192) + String.fromCharCode(c & 63 | 128) :
|
|
||||||
String.fromCharCode(c >> 12 | 224) + String.fromCharCode(c >> 6 & 63 | 128) + String.fromCharCode(c & 63 | 128);
|
|
||||||
}
|
|
||||||
return b;
|
|
||||||
})();
|
|
||||||
f = (() => {
|
|
||||||
let c = e.length, a = c + 8, d = 16 * ((a - a % 64) / 64 + 1), b = Array(d - 1), f = 0, g = 0;
|
|
||||||
for (; g < c;) a = (g - g % 4) / 4, f = g % 4 * 8, b[a] |= e.charCodeAt(g) << f, g++;
|
|
||||||
a = (g - g % 4) / 4, b[a] |= 128 << g % 4 * 8, b[d - 2] = c << 3, b[d - 1] = c >>> 29;
|
|
||||||
return b;
|
|
||||||
})();
|
|
||||||
|
|
||||||
a = 1732584193, b = 4023233417, c = 2562383102, d = 271733878;
|
|
||||||
for (e = 0; e < f.length; e += 16) {
|
|
||||||
q = a, r = b, s = c, t = d;
|
|
||||||
a = k(a, b, c, d, f[e + 0], 7, 3614090360), d = k(d, a, b, c, f[e + 1], 12, 3905402710),
|
|
||||||
c = k(c, d, a, b, f[e + 2], 17, 606105819), b = k(b, c, d, a, f[e + 3], 22, 3250441966),
|
|
||||||
a = k(a, b, c, d, f[e + 4], 7, 4118548399), d = k(d, a, b, c, f[e + 5], 12, 1200080426),
|
|
||||||
c = k(c, d, a, b, f[e + 6], 17, 2821735955), b = k(b, c, d, a, f[e + 7], 22, 4249261313),
|
|
||||||
a = k(a, b, c, d, f[e + 8], 7, 1770035416), d = k(d, a, b, c, f[e + 9], 12, 2336552879),
|
|
||||||
c = k(c, d, a, b, f[e + 10], 17, 4294925233), b = k(b, c, d, a, f[e + 11], 22, 2304563134),
|
|
||||||
a = k(a, b, c, d, f[e + 12], 7, 1804603682), d = k(d, a, b, c, f[e + 13], 12, 4254626195),
|
|
||||||
c = k(c, d, a, b, f[e + 14], 17, 2792965006), b = k(b, c, d, a, f[e + 15], 22, 1236535329),
|
|
||||||
a = l(a, b, c, d, f[e + 1], 5, 4129170786), d = l(d, a, b, c, f[e + 6], 9, 3225465664),
|
|
||||||
c = l(c, d, a, b, f[e + 11], 14, 643717713), b = l(b, c, d, a, f[e + 0], 20, 3921069994),
|
|
||||||
a = l(a, b, c, d, f[e + 5], 5, 3593408605), d = l(d, a, b, c, f[e + 10], 9, 38016083),
|
|
||||||
c = l(c, d, a, b, f[e + 15], 14, 3634488961), b = l(b, c, d, a, f[e + 4], 20, 3889429448),
|
|
||||||
a = l(a, b, c, d, f[e + 9], 5, 568446438), d = l(d, a, b, c, f[e + 14], 9, 3275163606),
|
|
||||||
c = l(c, d, a, b, f[e + 3], 14, 4107603335), b = l(b, c, d, a, f[e + 8], 20, 1163531501),
|
|
||||||
a = l(a, b, c, d, f[e + 13], 5, 2850285829), d = l(d, a, b, c, f[e + 2], 9, 4243563512),
|
|
||||||
c = l(c, d, a, b, f[e + 7], 14, 1735328473), b = l(b, c, d, a, f[e + 12], 20, 2368359562),
|
|
||||||
a = m(a, b, c, d, f[e + 5], 4, 4294588738), d = m(d, a, b, c, f[e + 8], 11, 2272392833),
|
|
||||||
c = m(c, d, a, b, f[e + 11], 16, 1839030562), b = m(b, c, d, a, f[e + 14], 23, 4259657740),
|
|
||||||
a = m(a, b, c, d, f[e + 1], 4, 2763975236), d = m(d, a, b, c, f[e + 4], 11, 1272893353),
|
|
||||||
c = m(c, d, a, b, f[e + 7], 16, 4139469664), b = m(b, c, d, a, f[e + 10], 23, 3200236656),
|
|
||||||
a = m(a, b, c, d, f[e + 13], 4, 681279174), d = m(d, a, b, c, f[e + 0], 11, 3936430074),
|
|
||||||
c = m(c, d, a, b, f[e + 3], 16, 3572445317), b = m(b, c, d, a, f[e + 6], 23, 76029189),
|
|
||||||
a = m(a, b, c, d, f[e + 9], 4, 3654602809), d = m(d, a, b, c, f[e + 12], 11, 3873151461),
|
|
||||||
c = m(c, d, a, b, f[e + 15], 16, 530742520), b = m(b, c, d, a, f[e + 2], 23, 3299628645),
|
|
||||||
a = n(a, b, c, d, f[e + 0], 6, 4096336452), d = n(d, a, b, c, f[e + 7], 10, 1126891415),
|
|
||||||
c = n(c, d, a, b, f[e + 14], 15, 2878612391), b = n(b, c, d, a, f[e + 5], 21, 4237533241),
|
|
||||||
a = n(a, b, c, d, f[e + 12], 6, 1700485571), d = n(d, a, b, c, f[e + 3], 10, 2399980690),
|
|
||||||
c = n(c, d, a, b, f[e + 10], 15, 4293915773), b = n(b, c, d, a, f[e + 1], 21, 2240044497),
|
|
||||||
a = n(a, b, c, d, f[e + 8], 6, 1873313359), d = n(d, a, b, c, f[e + 15], 10, 4264355552),
|
|
||||||
c = n(c, d, a, b, f[e + 6], 15, 2734768916), b = n(b, c, d, a, f[e + 13], 21, 1309151649),
|
|
||||||
a = n(a, b, c, d, f[e + 4], 6, 4149444226), d = n(d, a, b, c, f[e + 11], 10, 3174756917),
|
|
||||||
c = n(c, d, a, b, f[e + 2], 15, 718787259), b = n(b, c, d, a, f[e + 9], 21, 3951481745),
|
|
||||||
a = h(a, q), b = h(b, r), c = h(c, s), d = h(d, t);
|
|
||||||
}
|
|
||||||
return (p(a) + p(b) + p(c) + p(d)).toLowerCase();
|
|
||||||
},
|
|
||||||
|
|
||||||
decodeBase64Str(str) {
|
|
||||||
if (!str)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
/* Thanks to luci-app-ssr-plus */
|
|
||||||
str = str.replace(/-/g, '+').replace(/_/g, '/');
|
|
||||||
let padding = (4 - str.length % 4) % 4;
|
|
||||||
if (padding)
|
|
||||||
str = str + Array(padding + 1).join('=');
|
|
||||||
|
|
||||||
return decodeURIComponent(Array.prototype.map.call(atob(str), (c) =>
|
|
||||||
'%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2)
|
|
||||||
).join(''));
|
|
||||||
},
|
|
||||||
|
|
||||||
getBuiltinFeatures() {
|
|
||||||
const callGetSingBoxFeatures = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'singbox_get_features',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
return L.resolveDefault(callGetSingBoxFeatures(), {}).then((features) => {
|
|
||||||
/* If RPC failed or returned no core_type, fall back to core.info file */
|
|
||||||
if (features.core_type)
|
|
||||||
return features;
|
|
||||||
return L.resolveDefault(fs.read_direct('/var/run/homeproxy/core.info', 'text'), null)
|
|
||||||
.then((text) => {
|
|
||||||
if (text) {
|
|
||||||
try {
|
|
||||||
const info = JSON.parse(text);
|
|
||||||
features.core_type = info.core_type || features.core_type;
|
|
||||||
features.version = info.version || features.version;
|
|
||||||
} catch(e) {}
|
|
||||||
}
|
|
||||||
return features;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
generateRand(type, length) {
|
|
||||||
let byteArr;
|
|
||||||
if (['base64', 'hex'].includes(type))
|
|
||||||
byteArr = crypto.getRandomValues(new Uint8Array(length));
|
|
||||||
switch (type) {
|
|
||||||
case 'base64':
|
|
||||||
/* Thanks to https://stackoverflow.com/questions/9267899 */
|
|
||||||
return btoa(String.fromCharCode.apply(null, byteArr));
|
|
||||||
case 'hex':
|
|
||||||
return Array.from(byteArr, (byte) =>
|
|
||||||
(byte & 255).toString(16).padStart(2, '0')
|
|
||||||
).join('');
|
|
||||||
case 'uuid':
|
|
||||||
/* Thanks to https://stackoverflow.com/a/2117523 */
|
|
||||||
return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, (c) =>
|
|
||||||
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
loadDefaultLabel(uciconfig, ucisection) {
|
|
||||||
let label = uci.get(uciconfig, ucisection, 'label');
|
|
||||||
if (label) {
|
|
||||||
return label;
|
|
||||||
} else {
|
|
||||||
uci.set(uciconfig, ucisection, 'label', ucisection);
|
|
||||||
return ucisection;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
loadModalTitle(title, addtitle, uciconfig, ucisection) {
|
|
||||||
let label = uci.get(uciconfig, ucisection, 'label');
|
|
||||||
return label ? title + ' » ' + label : addtitle;
|
|
||||||
},
|
|
||||||
|
|
||||||
renderSectionAdd(section, extra_class) {
|
|
||||||
let el = form.GridSection.prototype.renderSectionAdd.apply(section, [ extra_class ]),
|
|
||||||
nameEl = el.querySelector('.cbi-section-create-name');
|
|
||||||
ui.addValidator(nameEl, 'uciname', true, (v) => {
|
|
||||||
let button = el.querySelector('.cbi-section-create > .cbi-button-add');
|
|
||||||
let uciconfig = section.uciconfig || section.map.config;
|
|
||||||
|
|
||||||
if (!v) {
|
|
||||||
button.disabled = true;
|
|
||||||
return true;
|
|
||||||
} else if (uci.get(uciconfig, v)) {
|
|
||||||
button.disabled = true;
|
|
||||||
return _('Expecting: %s').format(_('unique UCI identifier'));
|
|
||||||
} else {
|
|
||||||
button.disabled = null;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}, 'blur', 'keyup');
|
|
||||||
|
|
||||||
return el;
|
|
||||||
},
|
|
||||||
|
|
||||||
uploadCertificate(_option, type, filename, ev) {
|
|
||||||
const callWriteCertificate = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'certificate_write',
|
|
||||||
params: ['filename'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
return ui.uploadFile('/tmp/homeproxy_certificate.tmp', ev.target)
|
|
||||||
.then(L.bind((_btn, res) => {
|
|
||||||
return L.resolveDefault(callWriteCertificate(filename), {}).then((ret) => {
|
|
||||||
if (ret.result === true)
|
|
||||||
ui.addNotification(null, E('p', _('Your %s was successfully uploaded. Size: %sB.').format(type, res.size)));
|
|
||||||
else
|
|
||||||
ui.addNotification(null, E('p', _('Failed to upload %s, error: %s.').format(type, ret.error)));
|
|
||||||
});
|
|
||||||
}, this, ev.target))
|
|
||||||
.catch((e) => { ui.addNotification(null, E('p', e.message)) });
|
|
||||||
},
|
|
||||||
|
|
||||||
validateBase64Key(length, section_id, value) {
|
|
||||||
/* Thanks to luci-proto-wireguard */
|
|
||||||
if (section_id && value)
|
|
||||||
if (value.length !== length || !value.match(/^(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/) || value[length-1] !== '=')
|
|
||||||
return _('Expecting: %s').format(_('valid base64 key with %d characters').format(length));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
validateCertificatePath(section_id, value) {
|
|
||||||
if (section_id && value)
|
|
||||||
if (!value.match(/^(\/etc\/homeproxy\/certs\/|\/etc\/acme\/|\/etc\/ssl\/).+$/))
|
|
||||||
return _('Expecting: %s').format(_('/etc/homeproxy/certs/..., /etc/acme/..., /etc/ssl/...'));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
validatePortRange(section_id, value) {
|
|
||||||
if (section_id && value) {
|
|
||||||
value = value.match(/^(\d+)?\:(\d+)?$/);
|
|
||||||
if (value && (value[1] || value[2])) {
|
|
||||||
if (!value[1])
|
|
||||||
value[1] = 0;
|
|
||||||
else if (!value[2])
|
|
||||||
value[2] = 65535;
|
|
||||||
|
|
||||||
if (value[1] < value[2] && value[2] <= 65535)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return _('Expecting: %s').format( _('valid port range (port1:port2)'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
validateUniqueValue(uciconfig, ucisection, ucioption, section_id, value) {
|
|
||||||
if (section_id) {
|
|
||||||
if (!value)
|
|
||||||
return _('Expecting: %s').format(_('non-empty value'));
|
|
||||||
if (ucioption === 'node' && value in ['urltest', 'main-out', 'direct-out', 'block-out'])
|
|
||||||
return true;
|
|
||||||
|
|
||||||
let duplicate = false;
|
|
||||||
uci.sections(uciconfig, ucisection, (res) => {
|
|
||||||
if (res['.name'] !== section_id)
|
|
||||||
if (res[ucioption] === value)
|
|
||||||
duplicate = true
|
|
||||||
});
|
|
||||||
if (duplicate)
|
|
||||||
return _('Expecting: %s').format(_('unique value'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
|
|
||||||
validateUUID(section_id, value) {
|
|
||||||
if (section_id) {
|
|
||||||
if (!value)
|
|
||||||
return _('Expecting: %s').format(_('non-empty value'));
|
|
||||||
else if (value.match('^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') === null)
|
|
||||||
return _('Expecting: %s').format(_('valid uuid'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,571 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
*
|
|
||||||
* Copyright (C) 2023-2025 ImmortalWrt.org
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
'require dom';
|
|
||||||
'require homeproxy as hp';
|
|
||||||
'require poll';
|
|
||||||
'require rpc';
|
|
||||||
'require uci';
|
|
||||||
'require ui';
|
|
||||||
'require view';
|
|
||||||
|
|
||||||
const css = '\
|
|
||||||
.diag-section { margin-bottom: 1.5em; }\
|
|
||||||
.diag-row { display:flex; align-items:baseline; margin:.35em 0; gap:.6em; }\
|
|
||||||
.diag-label { min-width:11em; color:#666; font-size:.9em; flex-shrink:0; }\
|
|
||||||
.diag-ok { color: #2a2; font-weight: bold; }\
|
|
||||||
.diag-fail { color: #c33; font-weight: bold; }\
|
|
||||||
.diag-warn { color: #c80; font-weight: bold; }\
|
|
||||||
.diag-gray { color: #888; }\
|
|
||||||
.diag-pre { font-family:monospace; font-size:.82em; background:#f5f5f5; border:1px solid #ddd;\
|
|
||||||
padding:.5em .8em; white-space:pre-wrap; word-break:break-all; border-radius:3px;\
|
|
||||||
margin:.3em 0 0; max-height:14em; overflow-y:auto; }\
|
|
||||||
.diag-input { padding:.25em .4em; border:1px solid #ccc; border-radius:3px;\
|
|
||||||
font-size:.9em; width:14em; }\
|
|
||||||
.diag-btn { margin-right:.4em; }\
|
|
||||||
.diag-port-line { font-family:monospace; font-size:.82em; margin:.1em 0; }\
|
|
||||||
#diag-report-area { width:100%; height:22em; font-family:monospace; font-size:.8em;\
|
|
||||||
white-space:pre; overflow:auto; background:#f5f5f5; border:1px solid #ddd; padding:.5em; }\
|
|
||||||
';
|
|
||||||
|
|
||||||
/* ── RPC declarations ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
const callCoreCheck = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_core_check',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callConfigCheck = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_config_check',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callDnsRu = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_dns_ru',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callNftables = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_nftables',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
const callReport = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_report',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callServiceRestart = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'diag_service_restart',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callConnCheck = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'connection_check',
|
|
||||||
params: ['site'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callIpInfo = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'clash_ip_info',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callActiveNode = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'clash_active_node',
|
|
||||||
params: ['tag'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
/* Resolve a sing-box outbound tag (cfg-<section>-out) to its UCI label. */
|
|
||||||
function resolveTag(tag) {
|
|
||||||
const m = tag && tag.match(/^cfg-(.+)-out$/);
|
|
||||||
if (m) {
|
|
||||||
const label = uci.get('homeproxy', m[1], 'label');
|
|
||||||
if (label) return label;
|
|
||||||
}
|
|
||||||
/* Fixed aggregate tags → name the configured node behind them. */
|
|
||||||
const specials = { 'main-out': 'main_node', 'main-udp-out': 'main_udp_node' };
|
|
||||||
if (specials[tag]) {
|
|
||||||
const v = uci.get('homeproxy', 'config', specials[tag]);
|
|
||||||
if (v === 'byedpi-out') return 'ByeDPI';
|
|
||||||
if (v === 'zapret-out') return 'Zapret';
|
|
||||||
if (v && !['urltest', 'same', 'nil', ''].includes(v)) {
|
|
||||||
const label = uci.get('homeproxy', v, 'label');
|
|
||||||
if (label) return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── DOM helpers ──────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function statusBadge(ok, text) {
|
|
||||||
/* ucode serialises booleans as integers 0/1 — use loose checks so both
|
|
||||||
JS booleans and integers work. null/undefined → "uncertain" (orange ?). */
|
|
||||||
const isOk = !!ok;
|
|
||||||
const isWarn = ok == null; /* catches null AND undefined */
|
|
||||||
const icon = isOk ? '✓ ' : isWarn ? '? ' : '✗ ';
|
|
||||||
const cls = isOk ? 'diag-ok' : isWarn ? 'diag-warn' : 'diag-fail';
|
|
||||||
const label = text || (isOk ? _('OK') : isWarn ? '?' : _('FAIL'));
|
|
||||||
return E('strong', { 'class': cls }, icon + label);
|
|
||||||
}
|
|
||||||
|
|
||||||
function row(label, value) {
|
|
||||||
return E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('span', { 'class': 'diag-label' }, label),
|
|
||||||
E('span', {}, value)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function pre(text) {
|
|
||||||
return E('div', { 'class': 'diag-pre' }, text || _('(empty)'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionCard(title, id, rows) {
|
|
||||||
return E('div', { 'class': 'cbi-section diag-section' }, [
|
|
||||||
E('h3', {}, title),
|
|
||||||
E('div', { 'id': id }, rows)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function spinner(el, label) {
|
|
||||||
dom.content(el, E('em', { 'class': 'diag-gray' }, label || _('Testing…')));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Section builders ─────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
function buildConnectivitySection(view, coreType) {
|
|
||||||
const items = []; /* manual checks; the section's run() fires them together */
|
|
||||||
|
|
||||||
function siteRow(label, site) {
|
|
||||||
const resultEl = E('strong', { 'class': 'diag-gray' }, _('unchecked'));
|
|
||||||
function run() {
|
|
||||||
dom.content(resultEl, E('em', { 'class': 'diag-gray' }, _('Testing…')));
|
|
||||||
return L.resolveDefault(callConnCheck(site), {}).then(function(ret) {
|
|
||||||
dom.content(resultEl, statusBadge(!!ret.result, ret.result ? _('passed') : _('failed')));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push(run);
|
|
||||||
const btn = E('button', { 'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run) }, [ _('Check') ]);
|
|
||||||
return E('div', { 'class': 'diag-row' }, [ E('span', { 'class': 'diag-label' }, label), btn, resultEl ]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Exit IP / geo — only hiddify-core's Clash API reports it. */
|
|
||||||
function ipRow(label, type) {
|
|
||||||
const resultEl = E('strong', { 'class': 'diag-gray' }, _('unchecked'));
|
|
||||||
function run() {
|
|
||||||
dom.content(resultEl, E('em', { 'class': 'diag-gray' }, _('Testing…')));
|
|
||||||
return L.resolveDefault(callIpInfo(), {}).then(function(ret) {
|
|
||||||
if (ret.error) { dom.content(resultEl, E('span', { 'class': 'diag-fail' }, ret.error)); return; }
|
|
||||||
const entry = ret[type];
|
|
||||||
if (!entry || !entry.ip) { dom.content(resultEl, E('span', { 'class': 'diag-gray' }, _('No data'))); return; }
|
|
||||||
const meta = [entry.country, entry.org].filter(Boolean).join(', ');
|
|
||||||
const delay = (entry.delay && entry.delay !== 65535) ? ' — ' + entry.delay + ' ms' : '';
|
|
||||||
const node = (type === 'proxy' && entry.node) ? resolveTag(entry.node) + ': ' : '';
|
|
||||||
dom.content(resultEl, E('strong', { 'class': 'diag-ok' },
|
|
||||||
node + entry.ip + (meta ? ' (' + meta + ')' : '') + delay));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
items.push(run);
|
|
||||||
const btn = E('button', { 'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run) }, [ _('Check') ]);
|
|
||||||
return E('div', { 'class': 'diag-row' }, [ E('span', { 'class': 'diag-label' }, label), btn, resultEl ]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Active node — live status (which node sing-box has selected), auto-updating;
|
|
||||||
* sing-box can't report exit IP, so this stands in for the IP rows. */
|
|
||||||
/* Live active-node row. `tag` selects which outbound group to resolve:
|
|
||||||
undefined → the main (TCP) node via GLOBAL; 'main-udp-out' → the dedicated UDP node. */
|
|
||||||
function activeNodeRow(labelText, tag) {
|
|
||||||
const resultEl = E('span', { 'class': 'diag-gray' }, '—');
|
|
||||||
poll.add(L.bind(function() {
|
|
||||||
return L.resolveDefault(callActiveNode(tag), {}).then(function(ret) {
|
|
||||||
if (ret && !ret.error && ret.node) {
|
|
||||||
const type = ret.type ? ' (' + ret.type + ')' : '';
|
|
||||||
/* Same 4-colour scheme as the status/client pages: 65535 ms is the
|
|
||||||
URLTest timeout sentinel (confirmed dead → red); >=3000 ms is
|
|
||||||
working-but-slow (orange); a real low latency is green; no delay
|
|
||||||
is unmeasured (gray, no number). */
|
|
||||||
let cls, dStr = '';
|
|
||||||
if (ret.delay === 65535) { cls = 'diag-fail'; dStr = ' — ' + _('timeout'); }
|
|
||||||
else if (ret.delay >= 3000) { cls = 'diag-warn'; dStr = ' — ' + ret.delay + ' ms'; }
|
|
||||||
else if (ret.delay) { cls = 'diag-ok'; dStr = ' — ' + ret.delay + ' ms'; }
|
|
||||||
else cls = 'diag-gray';
|
|
||||||
dom.content(resultEl, E('strong', { 'class': cls }, resolveTag(ret.node) + type + dStr));
|
|
||||||
} else {
|
|
||||||
dom.content(resultEl, E('span', { 'class': 'diag-gray' }, _('No active node')));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
return E('div', { 'class': 'diag-row' }, [ E('span', { 'class': 'diag-label' }, labelText), resultEl ]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const rows = [
|
|
||||||
siteRow(_('Baidu'), 'baidu'),
|
|
||||||
siteRow(_('Google'), 'google'),
|
|
||||||
siteRow(_('YouTube'), 'youtube'),
|
|
||||||
siteRow(_('Yandex'), 'yandex'),
|
|
||||||
siteRow(_('Speedtest'), 'speedtest')
|
|
||||||
];
|
|
||||||
|
|
||||||
/* hiddify → exit-IP rows; sing-box → live Active Node (it can't report exit IP). */
|
|
||||||
if (coreType === 'hiddify') {
|
|
||||||
rows.push(ipRow(_('Direct IP'), 'direct'));
|
|
||||||
rows.push(ipRow(_('Proxy IP'), 'proxy'));
|
|
||||||
} else if (coreType === 'singbox') {
|
|
||||||
rows.push(activeNodeRow(_('Active Node')));
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dedicated UDP node — only the modes that expose "Main UDP node" in the UI set it
|
|
||||||
(Global + the bypass_cn/bypass_ir reverse modes; proxy_banned_ru/custom don't, and
|
|
||||||
'redirect' proxy mode has no UDP path). Show which node currently carries UDP. */
|
|
||||||
const udpNode = uci.get('homeproxy', 'config', 'main_udp_node');
|
|
||||||
const proxyMode = uci.get('homeproxy', 'config', 'proxy_mode');
|
|
||||||
if (udpNode && !['nil', 'same', ''].includes(udpNode) && proxyMode !== 'redirect')
|
|
||||||
rows.push(activeNodeRow(_('Active UDP Node'), 'main-udp-out'));
|
|
||||||
|
|
||||||
return {
|
|
||||||
el: sectionCard(_('Connectivity'), 'diag-connectivity', rows),
|
|
||||||
run: function() { return Promise.all(items.map(function(fn) { return fn(); })); }
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCoreSection(view) {
|
|
||||||
const resultsEl = E('div', {});
|
|
||||||
const restartMsgEl = E('span', { 'class': 'diag-gray', 'style': 'font-size:.9em' });
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
spinner(resultsEl, _('Checking core…'));
|
|
||||||
return L.resolveDefault(callCoreCheck(), {}).then(function(ret) {
|
|
||||||
if (!ret || ret.error) {
|
|
||||||
dom.content(resultsEl, E('span', { 'class': 'diag-fail' }, ret ? ret.error : _('RPC error')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const portLines = (ret.listen_ports && ret.listen_ports.length)
|
|
||||||
? ret.listen_ports.map(function(l) { return E('div', { 'class': 'diag-port-line' }, l); })
|
|
||||||
: [ E('em', { 'class': 'diag-gray' }, _('none detected')) ];
|
|
||||||
|
|
||||||
dom.content(resultsEl, [
|
|
||||||
row(_('Hiddify-core'), statusBadge(ret.hiddify_installed, ret.hiddify_installed ? _('installed') : _('not found'))),
|
|
||||||
row(_('sing-box'), statusBadge(ret.singbox_installed, ret.singbox_installed ? _('installed') : _('not found'))),
|
|
||||||
row(_('ByeDPI'), statusBadge(ret.byedpi_installed, ret.byedpi_installed ? _('installed') : _('not found'))),
|
|
||||||
row(_('Zapret 2'), statusBadge(ret.zapret_installed, ret.zapret_installed ? _('installed') : _('not found'))),
|
|
||||||
ret.binary ? row(_('Active binary'), E('code', {}, ret.binary)) : null,
|
|
||||||
ret.version ? row(_('Version'), E('span', { 'class': 'diag-pre', 'style': 'max-height:5em' }, ret.version)) : null,
|
|
||||||
row(_('Running'), statusBadge(ret.running, ret.running ? _('yes') + (ret.pid ? ' (PID ' + ret.pid + ')' : '') : _('no'))),
|
|
||||||
ret.byedpi_installed ? row(_('ByeDPI running'), statusBadge(ret.byedpi_running, ret.byedpi_running ? _('yes') + (ret.byedpi_pid ? ' (PID ' + ret.byedpi_pid + ')' : '') : _('no'))) : null,
|
|
||||||
ret.zapret_installed ? row(_('Zapret 2 running'), statusBadge(ret.zapret_running, ret.zapret_running ? _('yes') + (ret.zapret_pid ? ' (PID ' + ret.zapret_pid + ')' : '') : _('no'))) : null,
|
|
||||||
row(_('Listening ports'), E('div', {}, portLines))
|
|
||||||
].filter(Boolean));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function restart() {
|
|
||||||
dom.content(restartMsgEl, E('em', { 'class': 'diag-gray' }, _('Restarting… (may take ~5 s)')));
|
|
||||||
return L.resolveDefault(callServiceRestart(), {}).then(function(ret) {
|
|
||||||
if (ret && ret.result) {
|
|
||||||
dom.content(restartMsgEl, statusBadge(true, _('Service restarted')));
|
|
||||||
/* Refresh core status after restart */
|
|
||||||
return run();
|
|
||||||
} else {
|
|
||||||
dom.content(restartMsgEl, statusBadge(false,
|
|
||||||
_('Restart failed') + (ret && ret.exit_code != null ? ' (exit ' + ret.exit_code + ')' : '')));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
el: sectionCard(_('Core & System'), 'diag-core', [
|
|
||||||
E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run)
|
|
||||||
}, _('Check')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-negative diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, restart)
|
|
||||||
}, _('Restart Service')),
|
|
||||||
restartMsgEl
|
|
||||||
]),
|
|
||||||
resultsEl
|
|
||||||
]),
|
|
||||||
run: run
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildConfigSection(view) {
|
|
||||||
const resultsEl = E('div', {});
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
spinner(resultsEl, _('Checking config…'));
|
|
||||||
return L.resolveDefault(callConfigCheck(), {}).then(function(ret) {
|
|
||||||
if (!ret || ret.error) {
|
|
||||||
dom.content(resultsEl, E('span', { 'class': 'diag-fail' }, ret ? ret.error : _('RPC error')));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.content(resultsEl, [
|
|
||||||
row(_('Valid'), statusBadge(ret.valid)),
|
|
||||||
ret.size_bytes != null ? row(_('Size'), (ret.size_bytes / 1024).toFixed(1) + ' KB') : null,
|
|
||||||
ret.stats ? row(_('Inbounds'), String(ret.stats.inbounds)) : null,
|
|
||||||
ret.stats ? row(_('Outbounds'), String(ret.stats.outbounds)) : null,
|
|
||||||
ret.stats ? row(_('Rules'), String(ret.stats.rules)) : null,
|
|
||||||
ret.stats ? row(_('DNS servers'), String(ret.stats.dns_servers)) : null,
|
|
||||||
ret.check_output ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, _('Check output')),
|
|
||||||
pre(ret.check_output)
|
|
||||||
]) : null
|
|
||||||
].filter(Boolean));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
el: sectionCard(_('Configuration'), 'diag-config', [
|
|
||||||
E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run)
|
|
||||||
}, _('Check'))
|
|
||||||
]),
|
|
||||||
resultsEl
|
|
||||||
]),
|
|
||||||
run: run
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDnsSection(view) {
|
|
||||||
const resultsEl = E('div', {});
|
|
||||||
|
|
||||||
const cardEl = sectionCard(_('DNS Tests'), 'diag-dns', [
|
|
||||||
E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run)
|
|
||||||
}, _('Test'))
|
|
||||||
]),
|
|
||||||
resultsEl
|
|
||||||
]);
|
|
||||||
cardEl.style.display = 'none';
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
spinner(resultsEl, _('Testing DNS…'));
|
|
||||||
return L.resolveDefault(callDnsRu(), {}).then(function(ret) {
|
|
||||||
if (ret.skip) {
|
|
||||||
cardEl.style.display = 'none';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
cardEl.style.display = '';
|
|
||||||
if (ret.error) {
|
|
||||||
dom.content(resultsEl, E('span', { 'class': 'diag-fail' }, ret.error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const regionDomain = ret.region_domain || '?';
|
|
||||||
dom.content(resultsEl, [
|
|
||||||
E('div', { 'style': 'font-weight:bold; margin:.4em 0 .2em' },
|
|
||||||
(ret.region_label ? ret.region_label + ' ' : '') + _('DNS (direct)')),
|
|
||||||
row(_('Server'), E('code', {}, ret.region_server || '?')),
|
|
||||||
row(regionDomain, statusBadge(ret.region_ok,
|
|
||||||
ret.region_ok ? _('Resolved') : _('No answer — check server address'))),
|
|
||||||
ret.region_output ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, 'nslookup ' + regionDomain),
|
|
||||||
pre(ret.region_output)
|
|
||||||
]) : null,
|
|
||||||
|
|
||||||
E('div', { 'style': 'font-weight:bold; margin:.8em 0 .2em' }, _('Secure DNS')),
|
|
||||||
ret.secure_server ? row(_('Server'), E('code', {}, ret.secure_server)) : null,
|
|
||||||
row(_('Bootstrap'), E('code', {}, ret.bootstrap || '?')),
|
|
||||||
row('andrevi.ch', statusBadge(ret.secure_ok,
|
|
||||||
ret.secure_ok ? _('Resolved via proxy') : _('No answer — check proxy and proxy list'))),
|
|
||||||
ret.secure_output ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, 'nslookup andrevi.ch'),
|
|
||||||
pre(ret.secure_output)
|
|
||||||
]) : null
|
|
||||||
].filter(Boolean));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { el: cardEl, run: run };
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildNftSection(view) {
|
|
||||||
const resultsEl = E('div', {});
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
spinner(resultsEl, _('Reading nftables…'));
|
|
||||||
return L.resolveDefault(callNftables(), {}).then(function(ret) {
|
|
||||||
if (ret.error) {
|
|
||||||
dom.content(resultsEl, E('span', { 'class': 'diag-fail' }, ret.error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
dom.content(resultsEl, [
|
|
||||||
row(_('Re:HomeProxy chains'), statusBadge(ret.nft_present,
|
|
||||||
ret.nft_present ? _('found') : _('not found — firewall rules may be missing'))),
|
|
||||||
ret.nft_rules ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, _('Matched lines in fw4')),
|
|
||||||
pre(ret.nft_rules)
|
|
||||||
]) : null,
|
|
||||||
ret.udp_tproxy ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, _('UDP TPROXY counters (UDP into HomeProxy)')),
|
|
||||||
pre(ret.udp_tproxy)
|
|
||||||
]) : null,
|
|
||||||
ret.uci_firewall ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, _('UCI firewall settings')),
|
|
||||||
pre(ret.uci_firewall)
|
|
||||||
]) : null,
|
|
||||||
ret.zapret_enabled ? row(_('Zapret (nfqws2)'), statusBadge(ret.zapret_running,
|
|
||||||
ret.zapret_running ? _('running') : _('enabled, but nfqws2 not running'))) : null,
|
|
||||||
(ret.zapret_enabled && ret.zapret_queue) ? E('div', {}, [
|
|
||||||
E('div', { 'class': 'diag-label' }, _('Zapret NFQUEUE counters (mark 110 → queue)')),
|
|
||||||
pre(ret.zapret_queue)
|
|
||||||
]) : null
|
|
||||||
].filter(Boolean));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
el: sectionCard(_('Network Intercept'), 'diag-nft', [
|
|
||||||
E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run)
|
|
||||||
}, _('Check'))
|
|
||||||
]),
|
|
||||||
resultsEl
|
|
||||||
]),
|
|
||||||
run: run
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function buildReportSection(view) {
|
|
||||||
const textArea = E('textarea', {
|
|
||||||
'id': 'diag-report-area',
|
|
||||||
'readonly': true,
|
|
||||||
'placeholder': _('Click Generate to collect diagnostics…')
|
|
||||||
});
|
|
||||||
|
|
||||||
function run() {
|
|
||||||
textArea.value = _('Collecting diagnostics — this may take a few seconds…');
|
|
||||||
return L.resolveDefault(callReport(), {}).then(function(ret) {
|
|
||||||
textArea.value = ret.report || _('(empty)');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function copy() {
|
|
||||||
textArea.select();
|
|
||||||
document.execCommand('copy');
|
|
||||||
}
|
|
||||||
|
|
||||||
function download() {
|
|
||||||
const text = textArea.value;
|
|
||||||
if (!text || text === _('Click Generate to collect diagnostics…')) return;
|
|
||||||
const now = new Date();
|
|
||||||
const pad = (n) => String(n).padStart(2, '0');
|
|
||||||
const stamp = now.getFullYear() + '-' + pad(now.getMonth()+1) + '-' + pad(now.getDate()) +
|
|
||||||
'_' + pad(now.getHours()) + '-' + pad(now.getMinutes()) + '-' + pad(now.getSeconds());
|
|
||||||
const blob = new Blob([text], { type: 'text/plain' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = E('a', { href: url, download: 'homeproxy-diagnostics-' + stamp + '.txt' });
|
|
||||||
document.body.appendChild(a);
|
|
||||||
a.click();
|
|
||||||
document.body.removeChild(a);
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
el: sectionCard(_('Diagnostics Report'), 'diag-report', [
|
|
||||||
E('div', { 'class': 'diag-row' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, run)
|
|
||||||
}, _('Generate')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, copy)
|
|
||||||
}, _('Copy')),
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button diag-btn',
|
|
||||||
'click': ui.createHandlerFn(view, download)
|
|
||||||
}, _('Download')),
|
|
||||||
E('em', { 'class': 'diag-gray', 'style': 'font-size:.85em' },
|
|
||||||
_('Sensitive values (passwords, keys, UUIDs) are redacted.'))
|
|
||||||
]),
|
|
||||||
textArea
|
|
||||||
])
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── View ─────────────────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
load: function() {
|
|
||||||
return Promise.all([
|
|
||||||
L.resolveDefault(hp.getBuiltinFeatures(), {}),
|
|
||||||
uci.load('homeproxy')
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
render: function(data) {
|
|
||||||
const features = (data && data[0]) || {};
|
|
||||||
document.head.appendChild(E('style', {}, [ css ]));
|
|
||||||
|
|
||||||
const core = buildCoreSection(this);
|
|
||||||
const config = buildConfigSection(this);
|
|
||||||
const dns = buildDnsSection(this);
|
|
||||||
const nft = buildNftSection(this);
|
|
||||||
const conn = buildConnectivitySection(this, features.core_type || null);
|
|
||||||
const report = buildReportSection(this);
|
|
||||||
|
|
||||||
dns.run();
|
|
||||||
|
|
||||||
const runAll = ui.createHandlerFn(this, function() {
|
|
||||||
return Promise.all([
|
|
||||||
core.run(),
|
|
||||||
config.run(),
|
|
||||||
nft.run(),
|
|
||||||
conn.run()
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
return E('div', { 'class': 'cbi-map' }, [
|
|
||||||
E('h2', {}, _('Re:HomeProxy Diagnostics')),
|
|
||||||
E('div', { 'class': 'cbi-section', 'style': 'padding-bottom:.5em' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action important',
|
|
||||||
'click': runAll
|
|
||||||
}, _('Run All Tests')),
|
|
||||||
E('em', { 'class': 'diag-gray', 'style': 'margin-left:.8em; font-size:.9em' },
|
|
||||||
_('Runs Core, Config, Network, and Connectivity checks simultaneously.'))
|
|
||||||
]),
|
|
||||||
core.el,
|
|
||||||
config.el,
|
|
||||||
dns.el,
|
|
||||||
nft.el,
|
|
||||||
conn.el,
|
|
||||||
report.el
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSaveApply: null,
|
|
||||||
handleSave: null,
|
|
||||||
handleReset: null
|
|
||||||
});
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,926 +0,0 @@
|
|||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
*
|
|
||||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
'require dom';
|
|
||||||
'require form';
|
|
||||||
'require fs';
|
|
||||||
'require homeproxy as hp';
|
|
||||||
'require poll';
|
|
||||||
'require rpc';
|
|
||||||
'require uci';
|
|
||||||
'require ui';
|
|
||||||
'require view';
|
|
||||||
|
|
||||||
/* Thanks to luci-app-aria2 */
|
|
||||||
const css = ' \
|
|
||||||
#log_textarea { \
|
|
||||||
padding: 10px; \
|
|
||||||
text-align: left; \
|
|
||||||
} \
|
|
||||||
#log_textarea pre { \
|
|
||||||
padding: .5rem; \
|
|
||||||
word-break: break-all; \
|
|
||||||
margin: 0; \
|
|
||||||
} \
|
|
||||||
.description { \
|
|
||||||
background-color: #33ccff; \
|
|
||||||
}';
|
|
||||||
|
|
||||||
const hp_dir = '/var/run/homeproxy';
|
|
||||||
|
|
||||||
/* Map a sing-box outbound tag to its human-readable UCI label */
|
|
||||||
function resolveTag(tag) {
|
|
||||||
const m = tag && tag.match(/^cfg-(.+)-out$/);
|
|
||||||
if (m) {
|
|
||||||
const label = uci.get('homeproxy', m[1], 'label');
|
|
||||||
if (label) return label;
|
|
||||||
}
|
|
||||||
/* Fixed tags: look up the backing UCI section via the 'main' config section */
|
|
||||||
const specials = { 'main-out': 'main_node', 'main-udp-out': 'main_udp_node' };
|
|
||||||
if (specials[tag]) {
|
|
||||||
const section = uci.get('homeproxy', 'main', specials[tag]);
|
|
||||||
if (section) {
|
|
||||||
const label = uci.get('homeproxy', section, 'label');
|
|
||||||
if (label) return label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tag;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getIPInfo(o, type) {
|
|
||||||
const callIPInfo = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'clash_ip_info',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const resultEl = E('strong', { 'style': 'color:gray' }, _('unchecked'));
|
|
||||||
|
|
||||||
const formatIPInfo = (entry, nodeTag) => {
|
|
||||||
if (!entry)
|
|
||||||
return E('strong', { 'style': 'color:red' }, _('No data'));
|
|
||||||
|
|
||||||
const lines = [];
|
|
||||||
if (nodeTag)
|
|
||||||
lines.push(E('span', {}, [ _('Node') + ': ', E('strong', {}, resolveTag(nodeTag)) ]));
|
|
||||||
if (entry.ip) {
|
|
||||||
const delayStr = (entry.delay && entry.delay !== 65535) ? ` — ${entry.delay} ms` : '';
|
|
||||||
const meta = [entry.country, entry.org].filter(Boolean).join(', ');
|
|
||||||
const label = entry.ip + (meta ? ` (${meta})` : '') + delayStr;
|
|
||||||
lines.push(E('span', {}, [ 'IP: ', E('strong', { 'style': 'color:green' }, label) ]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return E('span', {}, lines.map((l) => E('div', {}, [ l ])));
|
|
||||||
};
|
|
||||||
|
|
||||||
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action',
|
|
||||||
'click': ui.createHandlerFn(this, () => {
|
|
||||||
return L.resolveDefault(callIPInfo(), {}).then((ret) => {
|
|
||||||
const el = o.default.firstElementChild.nextElementSibling;
|
|
||||||
if (ret.error) {
|
|
||||||
dom.content(el, E('span', { 'style': 'color:red' }, ret.error));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const entry = ret[type];
|
|
||||||
dom.content(el, formatIPInfo(entry, type === 'proxy' ? entry?.node : null));
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, [ _('Check') ]),
|
|
||||||
' ',
|
|
||||||
resultEl
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getConnStat(o, site) {
|
|
||||||
const callConnStat = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'connection_check',
|
|
||||||
params: ['site'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action',
|
|
||||||
'click': ui.createHandlerFn(this, () => {
|
|
||||||
return L.resolveDefault(callConnStat(site), {}).then((ret) => {
|
|
||||||
let ele = o.default.firstElementChild.nextElementSibling;
|
|
||||||
if (ret.result) {
|
|
||||||
ele.style.setProperty('color', 'green');
|
|
||||||
ele.innerHTML = _('passed');
|
|
||||||
} else {
|
|
||||||
ele.style.setProperty('color', 'red');
|
|
||||||
ele.innerHTML = _('failed');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
}, [ _('Check') ]),
|
|
||||||
' ',
|
|
||||||
E('strong', { 'style': 'color:gray' }, _('unchecked')),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRuntimeLog(o, name, _option_index, section_id, _in_table) {
|
|
||||||
const filename = o.option.split('_')[1];
|
|
||||||
|
|
||||||
let section, log_level_el;
|
|
||||||
switch (filename) {
|
|
||||||
case 'homeproxy':
|
|
||||||
section = null;
|
|
||||||
break;
|
|
||||||
case 'hiddify-c':
|
|
||||||
section = 'config';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (section) {
|
|
||||||
const selected = uci.get('homeproxy', section, 'log_level') || 'warn';
|
|
||||||
const choices = {
|
|
||||||
trace: _('Trace'),
|
|
||||||
debug: _('Debug'),
|
|
||||||
info: _('Info'),
|
|
||||||
warn: _('Warn'),
|
|
||||||
error: _('Error'),
|
|
||||||
fatal: _('Fatal'),
|
|
||||||
panic: _('Panic')
|
|
||||||
};
|
|
||||||
|
|
||||||
log_level_el = E('select', {
|
|
||||||
'id': o.cbid(section_id),
|
|
||||||
'class': 'cbi-input-select',
|
|
||||||
'style': 'margin-left: 4px; width: 6em;',
|
|
||||||
'change': ui.createHandlerFn(this, (ev) => {
|
|
||||||
uci.set('homeproxy', section, 'log_level', ev.target.value);
|
|
||||||
return o.map.save(null, true).then(() => {
|
|
||||||
ui.changes.apply(true);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(choices).forEach((v) => {
|
|
||||||
log_level_el.appendChild(E('option', {
|
|
||||||
'value': v,
|
|
||||||
'selected': (v === selected) ? '' : null
|
|
||||||
}, [ choices[v] ]));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const callLogClean = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'log_clean',
|
|
||||||
params: ['type'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const log_textarea = E('div', { 'id': 'log_textarea' },
|
|
||||||
E('img', {
|
|
||||||
'src': L.resource('icons/loading.svg'),
|
|
||||||
'alt': _('Loading'),
|
|
||||||
'style': 'vertical-align:middle'
|
|
||||||
}, _('Collecting data...'))
|
|
||||||
);
|
|
||||||
|
|
||||||
let log;
|
|
||||||
poll.add(L.bind(() => {
|
|
||||||
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
|
|
||||||
.then((res) => {
|
|
||||||
log = E('pre', { 'wrap': 'pre' }, [
|
|
||||||
res.trim() || _('Log is empty.')
|
|
||||||
]);
|
|
||||||
|
|
||||||
dom.content(log_textarea, log);
|
|
||||||
}).catch((err) => {
|
|
||||||
if (err.toString().includes('NotFoundError'))
|
|
||||||
log = E('pre', { 'wrap': 'pre' }, [
|
|
||||||
_('Log file does not exist.')
|
|
||||||
]);
|
|
||||||
else
|
|
||||||
log = E('pre', { 'wrap': 'pre' }, [
|
|
||||||
_('Unknown error: %s').format(err)
|
|
||||||
]);
|
|
||||||
|
|
||||||
dom.content(log_textarea, log);
|
|
||||||
});
|
|
||||||
}));
|
|
||||||
|
|
||||||
return E([
|
|
||||||
E('style', [ css ]),
|
|
||||||
E('div', {'class': 'cbi-map'}, [
|
|
||||||
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
|
|
||||||
_('%s log').format(name),
|
|
||||||
log_level_el || '',
|
|
||||||
E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action',
|
|
||||||
'style': 'margin-left: 4px;',
|
|
||||||
'click': ui.createHandlerFn(this, () => {
|
|
||||||
return L.resolveDefault(callLogClean(filename), {});
|
|
||||||
})
|
|
||||||
}, [ _('Clean log') ])
|
|
||||||
]),
|
|
||||||
E('div', {'class': 'cbi-section'}, [
|
|
||||||
log_textarea,
|
|
||||||
E('div', {'style': 'text-align:right'},
|
|
||||||
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
|
|
||||||
)
|
|
||||||
])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CORE_MGMT = '/usr/share/homeproxy/scripts/core_mgmt.uc';
|
|
||||||
|
|
||||||
const callCurlStatus = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'curl_status',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callCurlInstall = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'curl_install',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callCurlRemove = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'curl_remove',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callByeDPIStatus = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'byedpi_status',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callByeDPIPrepareInstall = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'byedpi_prepare_install',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callByeDPIInstallPkg = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'byedpi_install_pkg',
|
|
||||||
params: ['tmp_path', 'pkg_manager'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callByeDPIRemove = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'byedpi_remove',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callZapretStatus = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'zapret_status',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callZapretPrepareInstall = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'zapret_prepare_install',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callZapretInstallPkg = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'zapret_install_pkg',
|
|
||||||
params: ['tmp_path', 'pkg_manager'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const callZapretRemove = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'zapret_remove',
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildByeDPICard(byedpi, isMainNode) {
|
|
||||||
let installed = byedpi?.installed || false;
|
|
||||||
let version = byedpi?.version || null;
|
|
||||||
const running = byedpi?.running || false;
|
|
||||||
const pkgMgr = byedpi?.pkg_manager || null;
|
|
||||||
const canInstall = !!pkgMgr;
|
|
||||||
|
|
||||||
const statusEl = E('strong', {
|
|
||||||
style: installed ? 'color:green' : 'color:gray'
|
|
||||||
}, installed ? (version ? 'v' + version : _('Installed')) : _('Not installed'));
|
|
||||||
|
|
||||||
const runEl = E('span', {
|
|
||||||
style: 'margin-left:6px; font-size:0.9em; color:' + (running ? 'green' : 'gray')
|
|
||||||
}, running ? _('running') : _('stopped'));
|
|
||||||
|
|
||||||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em' }, '');
|
|
||||||
const setMsg = (txt, color) => { msgEl.textContent = txt; msgEl.style.color = color || 'gray'; };
|
|
||||||
|
|
||||||
const installBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-action',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !canInstall || null,
|
|
||||||
title: canInstall ? '' : _('No supported package manager detected'),
|
|
||||||
click: async function() {
|
|
||||||
const prevInstalled = installed;
|
|
||||||
const prevVersion = version;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
statusEl.textContent = _('Installing...');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
|
|
||||||
const fail = (msg) => {
|
|
||||||
installed = prevInstalled;
|
|
||||||
version = prevVersion;
|
|
||||||
statusEl.textContent = installed ? (version ? 'v' + version : _('Installed')) : _('Not installed');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = !installed;
|
|
||||||
setMsg(msg, 'red');
|
|
||||||
};
|
|
||||||
|
|
||||||
setMsg(_('Checking requirements...'), 'gray');
|
|
||||||
const prep = await L.resolveDefault(callByeDPIPrepareInstall(), {});
|
|
||||||
if (prep.error) return fail(prep.error);
|
|
||||||
|
|
||||||
setMsg(_('Downloading...'), 'gray');
|
|
||||||
const dl = await L.resolveDefault(callCoreDownload(prep.dl_url, prep.tmp_path), {});
|
|
||||||
if (!dl.result) return fail(dl.error || _('Download failed'));
|
|
||||||
|
|
||||||
setMsg(_('Installing package...'), 'gray');
|
|
||||||
const inst = await L.resolveDefault(callByeDPIInstallPkg(prep.tmp_path, prep.pkg_manager), {});
|
|
||||||
if (!inst.result) return fail(inst.error || _('Installation failed'));
|
|
||||||
|
|
||||||
const fresh = await L.resolveDefault(callByeDPIStatus(), {});
|
|
||||||
installed = fresh.installed || false;
|
|
||||||
version = fresh.version || null;
|
|
||||||
statusEl.textContent = installed ? (version ? 'v' + version : _('Installed')) : _('Unknown');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.textContent = _('Update');
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(_('Installed successfully'), 'green');
|
|
||||||
}
|
|
||||||
}, [ installed ? _('Update') : _('Install') ]);
|
|
||||||
|
|
||||||
const removeBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-negative',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !installed || isMainNode || null,
|
|
||||||
title: isMainNode ? _('Cannot remove: ByeDPI is selected as Main Node. Change Main Node first.') : '',
|
|
||||||
click: async function() {
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
setMsg(_('Removing...'), 'gray');
|
|
||||||
const ret = await L.resolveDefault(callByeDPIRemove(), {});
|
|
||||||
installBtn.disabled = false;
|
|
||||||
if (ret.result) {
|
|
||||||
installed = false;
|
|
||||||
version = null;
|
|
||||||
statusEl.textContent = _('Not installed');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
installBtn.textContent = _('Install');
|
|
||||||
setMsg(_('Removed successfully'), 'green');
|
|
||||||
} else {
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(ret.error || _('Removal failed'), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Remove') ]);
|
|
||||||
|
|
||||||
return E('div', { style: 'margin-bottom:12px; padding:8px 10px; border:1px solid #ddd; border-radius:4px' }, [
|
|
||||||
E('div', { style: 'display:flex; align-items:center; flex-wrap:wrap; gap:6px' }, [
|
|
||||||
E('strong', {}, 'ciadpi (ByeDPI)'),
|
|
||||||
statusEl,
|
|
||||||
runEl,
|
|
||||||
installBtn,
|
|
||||||
removeBtn,
|
|
||||||
msgEl
|
|
||||||
]),
|
|
||||||
E('div', { style: 'margin-top:4px; font-size:0.9em; color:#666' },
|
|
||||||
_('Local SOCKS5 DPI bypass proxy by <a href="https://github.com/hufrea/byedpi" target="_blank">hufrea</a>. ' +
|
|
||||||
'Packages by <a href="https://github.com/1andrevich/ByeDPI-OpenWrt" target="_blank">1andrevich/ByeDPI-OpenWrt</a>. ' +
|
|
||||||
'Configure in the Client → ByeDPI tab.'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildZapretCard(zapret) {
|
|
||||||
let installed = zapret?.installed || false;
|
|
||||||
let version = zapret?.version || null;
|
|
||||||
const running = zapret?.running || false;
|
|
||||||
const pkgMgr = zapret?.pkg_manager || null;
|
|
||||||
let kmodOk = (zapret?.kmod_ok != null) ? zapret.kmod_ok : true;
|
|
||||||
const canInstall = !!pkgMgr;
|
|
||||||
|
|
||||||
const statusEl = E('strong', {
|
|
||||||
style: installed ? 'color:green' : 'color:gray'
|
|
||||||
}, installed ? (version ? 'v' + version : _('Installed')) : _('Not installed'));
|
|
||||||
|
|
||||||
const runEl = E('span', {
|
|
||||||
style: 'margin-left:6px; font-size:0.9em; color:' + (running ? 'green' : 'gray')
|
|
||||||
}, running ? _('running') : _('stopped'));
|
|
||||||
|
|
||||||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em' }, '');
|
|
||||||
const setMsg = (txt, color) => { msgEl.textContent = txt; msgEl.style.color = color || 'gray'; };
|
|
||||||
|
|
||||||
const installBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-action',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !canInstall || null,
|
|
||||||
title: canInstall ? '' : _('No supported package manager detected'),
|
|
||||||
click: async function() {
|
|
||||||
const prevInstalled = installed;
|
|
||||||
const prevVersion = version;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
statusEl.textContent = _('Installing...');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
|
|
||||||
const fail = (msg) => {
|
|
||||||
installed = prevInstalled;
|
|
||||||
version = prevVersion;
|
|
||||||
statusEl.textContent = installed ? (version ? 'v' + version : _('Installed')) : _('Not installed');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = !installed;
|
|
||||||
setMsg(msg, 'red');
|
|
||||||
};
|
|
||||||
|
|
||||||
setMsg(_('Checking requirements...'), 'gray');
|
|
||||||
const prep = await L.resolveDefault(callZapretPrepareInstall(), {});
|
|
||||||
if (prep.error) return fail(prep.error);
|
|
||||||
|
|
||||||
setMsg(_('Downloading...'), 'gray');
|
|
||||||
const dl = await L.resolveDefault(callCoreDownload(prep.dl_url, prep.tmp_path), {});
|
|
||||||
if (!dl.result) return fail(dl.error || _('Download failed'));
|
|
||||||
|
|
||||||
setMsg(_('Installing package...'), 'gray');
|
|
||||||
const inst = await L.resolveDefault(callZapretInstallPkg(prep.tmp_path, prep.pkg_manager), {});
|
|
||||||
if (!inst.result) return fail(inst.error || _('Installation failed'));
|
|
||||||
|
|
||||||
const fresh = await L.resolveDefault(callZapretStatus(), {});
|
|
||||||
installed = fresh.installed || false;
|
|
||||||
version = fresh.version || null;
|
|
||||||
kmodOk = (fresh.kmod_ok != null) ? fresh.kmod_ok : true;
|
|
||||||
statusEl.textContent = installed ? (version ? 'v' + version : _('Installed')) : _('Unknown');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.textContent = _('Update');
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
if (installed && !kmodOk)
|
|
||||||
setMsg(_('Installed, but kmod-nft-queue is missing — Zapret cannot intercept traffic without it.'), 'red');
|
|
||||||
else
|
|
||||||
setMsg(_('Installed successfully'), 'green');
|
|
||||||
}
|
|
||||||
}, [ installed ? _('Update') : _('Install') ]);
|
|
||||||
|
|
||||||
const removeBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-negative',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !installed || null,
|
|
||||||
click: async function() {
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
setMsg(_('Removing...'), 'gray');
|
|
||||||
const ret = await L.resolveDefault(callZapretRemove(), {});
|
|
||||||
installBtn.disabled = false;
|
|
||||||
if (ret.result) {
|
|
||||||
installed = false;
|
|
||||||
version = null;
|
|
||||||
statusEl.textContent = _('Not installed');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
installBtn.textContent = _('Install');
|
|
||||||
setMsg(_('Removed successfully'), 'green');
|
|
||||||
} else {
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(ret.error || _('Removal failed'), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Remove') ]);
|
|
||||||
|
|
||||||
/* nfqws2's NFQUEUE rule needs kmod-nft-queue; warn up-front if it's missing. */
|
|
||||||
if (installed && !kmodOk)
|
|
||||||
setMsg(_('Warning: kmod-nft-queue is not installed — Zapret cannot intercept traffic without it.'), 'red');
|
|
||||||
|
|
||||||
return E('div', { style: 'margin-bottom:12px; padding:8px 10px; border:1px solid #ddd; border-radius:4px' }, [
|
|
||||||
E('div', { style: 'display:flex; align-items:center; flex-wrap:wrap; gap:6px' }, [
|
|
||||||
E('strong', {}, 'nfqws2 (Zapret 2)'),
|
|
||||||
statusEl,
|
|
||||||
runEl,
|
|
||||||
installBtn,
|
|
||||||
removeBtn,
|
|
||||||
msgEl
|
|
||||||
]),
|
|
||||||
E('div', { style: 'margin-top:4px; font-size:0.9em; color:#666' },
|
|
||||||
_('Packet-level (NFQUEUE) DPI bypass by <a href="https://github.com/bol-van/zapret2" target="_blank">bol-van</a> (nfqws2). ' +
|
|
||||||
'Packages by <a href="https://github.com/1andrevich/zapret2-openwrt" target="_blank">1andrevich/zapret2-openwrt</a>. ' +
|
|
||||||
'Configure in the Node Settings → Zapret tab.'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCurlCard(curl) {
|
|
||||||
let installed = curl?.installed || false;
|
|
||||||
const pkgMgr = curl?.pkg_manager || null;
|
|
||||||
const canInstall = !!pkgMgr;
|
|
||||||
|
|
||||||
const statusEl = E('strong', {
|
|
||||||
style: installed ? 'color:green' : 'color:gray'
|
|
||||||
}, installed ? _('Installed') : _('Not installed'));
|
|
||||||
|
|
||||||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em' }, '');
|
|
||||||
const setMsg = (txt, color) => { msgEl.textContent = txt; msgEl.style.color = color || 'gray'; };
|
|
||||||
|
|
||||||
const installBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-action',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !canInstall || null,
|
|
||||||
title: canInstall ? '' : _('No supported package manager detected'),
|
|
||||||
click: async function() {
|
|
||||||
installBtn.disabled = true;
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
statusEl.textContent = _('Installing...');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
setMsg('', 'gray');
|
|
||||||
const ret = await L.resolveDefault(callCurlInstall(), {});
|
|
||||||
if (ret.result) {
|
|
||||||
installed = true;
|
|
||||||
statusEl.textContent = _('Installed');
|
|
||||||
statusEl.style.color = 'green';
|
|
||||||
installBtn.textContent = _('Reinstall');
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(_('Installed successfully'), 'green');
|
|
||||||
} else {
|
|
||||||
statusEl.textContent = _('Not installed');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
installBtn.disabled = false;
|
|
||||||
setMsg(ret.error || _('Installation failed'), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Install') ]);
|
|
||||||
|
|
||||||
const removeBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-negative',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !installed || null,
|
|
||||||
click: async function() {
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
setMsg(_('Removing...'), 'gray');
|
|
||||||
const ret = await L.resolveDefault(callCurlRemove(), {});
|
|
||||||
installBtn.disabled = false;
|
|
||||||
if (ret.result) {
|
|
||||||
installed = false;
|
|
||||||
statusEl.textContent = _('Not installed');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
installBtn.textContent = _('Install');
|
|
||||||
setMsg(_('Removed successfully'), 'green');
|
|
||||||
} else {
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(ret.error || _('Removal failed'), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Remove') ]);
|
|
||||||
|
|
||||||
return E('div', { style: 'margin-bottom:12px; padding:8px 10px; border:1px solid #ddd; border-radius:4px' }, [
|
|
||||||
E('div', { style: 'display:flex; align-items:center; flex-wrap:wrap; gap:6px' }, [
|
|
||||||
E('strong', {}, 'curl'),
|
|
||||||
statusEl,
|
|
||||||
installBtn,
|
|
||||||
removeBtn,
|
|
||||||
msgEl
|
|
||||||
]),
|
|
||||||
E('div', { style: 'margin-top:4px; font-size:0.9em; color:#666' },
|
|
||||||
_('Enables real HTTP-based ByeDPI strategy testing. Required for the "Test all strategies" feature in Client → ByeDPI tab.'))
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreInfo() {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'info'], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreCheckRemote(core) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'check_remote', core], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCorePrepare(core, variant) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'prepare_install', core, variant || ''], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreDownload(url, tmpPath) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'download_pkg', url, tmpPath], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreInstallPkg(core, tmpPath, pkgManager) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'install_pkg', core, tmpPath, pkgManager], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreInstallKmods(pkgManager) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'install_kmods', pkgManager], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function callCoreRemove(core) {
|
|
||||||
return fs.exec_direct('/usr/bin/ucode', [CORE_MGMT, 'remove', core], 'json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildCoreCard(core, coreInfo) {
|
|
||||||
const isHiddify = core === 'hiddify';
|
|
||||||
const name = isHiddify ? 'hiddify-core' : 'sing-box-extended';
|
|
||||||
const pkgMgr = coreInfo.pkg_manager;
|
|
||||||
const coreData = (isHiddify ? coreInfo.hiddify : coreInfo.singbox) || {};
|
|
||||||
const canInstall = !!pkgMgr;
|
|
||||||
|
|
||||||
const desc = isHiddify
|
|
||||||
? _('hiddify-core with sing-box syntax compatibility. Supports Hiddify App protocols and advanced features. Best compatibility with Hiddify Manager protocols. Does not support AmneziaWG.')
|
|
||||||
: _('Extended sing-box with additional protocols including AmneziaWG and TrustTunnel support. Created by shtorm-7.');
|
|
||||||
|
|
||||||
let installed = coreData.installed || false;
|
|
||||||
let version = coreData.version || null;
|
|
||||||
|
|
||||||
const statusEl = E('strong', {
|
|
||||||
style: installed ? 'color:green' : 'color:gray'
|
|
||||||
}, installed ? 'v' + version : _('Not installed'));
|
|
||||||
|
|
||||||
const msgEl = E('span', { style: 'margin-left:8px; font-size:0.9em' }, '');
|
|
||||||
const setMsg = (txt, color) => { msgEl.textContent = txt; msgEl.style.color = color || 'gray'; };
|
|
||||||
|
|
||||||
const remoteEl = E('span', { style: 'font-size:0.9em; color:gray' }, '');
|
|
||||||
|
|
||||||
const checkBtn = E('button', {
|
|
||||||
class: 'btn cbi-button',
|
|
||||||
click: async function() {
|
|
||||||
checkBtn.disabled = true;
|
|
||||||
remoteEl.textContent = _('Checking...');
|
|
||||||
remoteEl.style.color = 'gray';
|
|
||||||
const ret = await L.resolveDefault(callCoreCheckRemote(core), {});
|
|
||||||
checkBtn.disabled = false;
|
|
||||||
if (ret.error) {
|
|
||||||
remoteEl.textContent = ret.error;
|
|
||||||
remoteEl.style.color = 'red';
|
|
||||||
} else {
|
|
||||||
remoteEl.textContent = _('Latest') + ': v' + ret.version;
|
|
||||||
remoteEl.style.color = installed && version === ret.version ? 'green' : 'darkorange';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Check update') ]);
|
|
||||||
|
|
||||||
/* One smart Install: the backend auto-picks the build that fits this device's flash
|
|
||||||
* (and RAM for the compact one) — the user never sees "UPX" or has to choose. */
|
|
||||||
const doInstall = async () => {
|
|
||||||
const prevInstalled = installed;
|
|
||||||
const prevVersion = version;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
statusEl.textContent = _('Installing...');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
|
|
||||||
const fail = (msg) => {
|
|
||||||
installed = prevInstalled;
|
|
||||||
version = prevVersion;
|
|
||||||
statusEl.textContent = installed ? 'v' + (version || '?') : _('Not installed');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = !installed;
|
|
||||||
setMsg(msg, 'red');
|
|
||||||
};
|
|
||||||
|
|
||||||
setMsg(_('Checking requirements...'), 'gray');
|
|
||||||
const prep = await L.resolveDefault(callCorePrepare(core, ''), {});
|
|
||||||
if (prep.error) return fail(prep.error);
|
|
||||||
|
|
||||||
const compact = (prep.variant === 'upx');
|
|
||||||
setMsg(prep.note || _('Downloading...'), prep.note ? 'darkorange' : 'gray');
|
|
||||||
const dl = await L.resolveDefault(callCoreDownload(prep.dl_url, prep.tmp_path), {});
|
|
||||||
if (!dl.result) return fail(dl.error || _('Download failed'));
|
|
||||||
|
|
||||||
setMsg(_('Installing package...'), 'gray');
|
|
||||||
const inst = await L.resolveDefault(callCoreInstallPkg(core, prep.tmp_path, prep.pkg_manager), {});
|
|
||||||
if (!inst.result) return fail(inst.error || _('Installation failed'));
|
|
||||||
|
|
||||||
setMsg(_('Installing kernel modules...'), 'gray');
|
|
||||||
await L.resolveDefault(callCoreInstallKmods(prep.pkg_manager), {});
|
|
||||||
|
|
||||||
const fresh = await L.resolveDefault(callCoreInfo(), {});
|
|
||||||
const fd = (isHiddify ? fresh.hiddify : fresh.singbox) || {};
|
|
||||||
installed = fd.installed || false;
|
|
||||||
version = fd.version || null;
|
|
||||||
statusEl.textContent = installed ? 'v' + version : _('Unknown');
|
|
||||||
statusEl.style.color = installed ? 'green' : 'gray';
|
|
||||||
installBtn.textContent = _('Update');
|
|
||||||
installBtn.disabled = false;
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(compact ? _('Installed successfully (compact build)') : _('Installed successfully'), 'green');
|
|
||||||
};
|
|
||||||
|
|
||||||
const installBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-action',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !canInstall || null,
|
|
||||||
title: canInstall ? '' : _('No supported package manager detected'),
|
|
||||||
click: function() { return doInstall(); }
|
|
||||||
}, [ installed ? _('Update') : _('Install') ]);
|
|
||||||
|
|
||||||
const removeBtn = E('button', {
|
|
||||||
class: 'btn cbi-button cbi-button-negative',
|
|
||||||
style: 'margin-left:4px',
|
|
||||||
disabled: !installed || null,
|
|
||||||
click: async function() {
|
|
||||||
removeBtn.disabled = true;
|
|
||||||
installBtn.disabled = true;
|
|
||||||
setMsg(_('Removing...'), 'gray');
|
|
||||||
|
|
||||||
const ret = await L.resolveDefault(callCoreRemove(core), {});
|
|
||||||
|
|
||||||
installBtn.disabled = false;
|
|
||||||
if (ret.result) {
|
|
||||||
installed = false;
|
|
||||||
version = null;
|
|
||||||
statusEl.textContent = _('Not installed');
|
|
||||||
statusEl.style.color = 'gray';
|
|
||||||
installBtn.textContent = _('Install');
|
|
||||||
setMsg(_('Removed successfully'), 'green');
|
|
||||||
} else {
|
|
||||||
removeBtn.disabled = false;
|
|
||||||
setMsg(ret.error || _('Removal failed'), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Remove') ]);
|
|
||||||
|
|
||||||
return E('div', { style: 'margin-bottom:12px; padding:8px 10px; border:1px solid #ddd; border-radius:4px' }, [
|
|
||||||
E('div', { style: 'display:flex; align-items:center; flex-wrap:wrap; gap:6px' }, [
|
|
||||||
E('strong', {}, name),
|
|
||||||
statusEl,
|
|
||||||
checkBtn,
|
|
||||||
remoteEl,
|
|
||||||
installBtn,
|
|
||||||
removeBtn,
|
|
||||||
msgEl
|
|
||||||
]),
|
|
||||||
E('div', { style: 'margin-top:4px; font-size:0.9em; color:#666' }, desc)
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return view.extend({
|
|
||||||
load() {
|
|
||||||
return Promise.all([
|
|
||||||
hp.getBuiltinFeatures(),
|
|
||||||
L.resolveDefault(callCoreInfo(), {}),
|
|
||||||
uci.load('homeproxy'),
|
|
||||||
L.resolveDefault(callByeDPIStatus(), {}),
|
|
||||||
L.resolveDefault(callCurlStatus(), {}),
|
|
||||||
L.resolveDefault(callZapretStatus(), {})
|
|
||||||
]);
|
|
||||||
},
|
|
||||||
|
|
||||||
render([features, coreInfo, _uci, byedpiStatus, curlStatus, zapretStatus]) {
|
|
||||||
const routingMode = uci.get('homeproxy', 'config', 'routing_mode') || '';
|
|
||||||
const isRuMode = routingMode === 'proxy_banned_ru';
|
|
||||||
let m, s, o;
|
|
||||||
|
|
||||||
m = new form.Map('homeproxy');
|
|
||||||
|
|
||||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
|
|
||||||
s.anonymous = true;
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_active_core', _('Active core'));
|
|
||||||
const coreName = features.core_type === 'hiddify' ? 'hiddify-core' :
|
|
||||||
features.core_type === 'singbox' ? 'sing-box' : null;
|
|
||||||
const coreVer = features.version ? ' v' + features.version : '';
|
|
||||||
const coreCustomSuffix = features.core_custom ? ' (custom)' : '';
|
|
||||||
|
|
||||||
if (!features.core_type) {
|
|
||||||
const callDetectCustomCore = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'detect_custom_core',
|
|
||||||
params: ['path'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedPath = uci.get('homeproxy', 'config', 'custom_core_path') || '';
|
|
||||||
const pathInput = E('input', {
|
|
||||||
'type': 'text',
|
|
||||||
'class': 'cbi-input-text',
|
|
||||||
'value': savedPath,
|
|
||||||
'placeholder': '/path/to/hiddify-core',
|
|
||||||
'style': 'width:260px; margin-right:4px'
|
|
||||||
});
|
|
||||||
const detectMsg = E('span', { 'style': 'margin-left:8px; font-size:0.9em; color:gray' }, '');
|
|
||||||
const detectBtn = E('button', {
|
|
||||||
'class': 'btn cbi-button cbi-button-action',
|
|
||||||
'click': async function() {
|
|
||||||
const path = pathInput.value.trim();
|
|
||||||
if (!path) return;
|
|
||||||
detectBtn.disabled = true;
|
|
||||||
detectMsg.textContent = _('Detecting...');
|
|
||||||
detectMsg.style.color = 'gray';
|
|
||||||
const ret = await L.resolveDefault(callDetectCustomCore(path), {});
|
|
||||||
detectBtn.disabled = false;
|
|
||||||
if (ret.result) {
|
|
||||||
const typeName = ret.type === 'hiddify' ? 'hiddify-core' : 'sing-box';
|
|
||||||
detectMsg.textContent = _('Detected') + ': ' + typeName + (ret.version ? ' v' + ret.version : '') + ' — ' + _('reload page to apply');
|
|
||||||
detectMsg.style.color = 'green';
|
|
||||||
} else {
|
|
||||||
detectMsg.textContent = ret.error || _('Detection failed');
|
|
||||||
detectMsg.style.color = 'red';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ _('Detect') ]);
|
|
||||||
|
|
||||||
o.default = E('div', {}, [
|
|
||||||
E('strong', { 'style': 'color:red' }, _('No core installed')),
|
|
||||||
E('details', { 'style': 'margin-top:6px' }, [
|
|
||||||
E('summary', { 'style': 'cursor:pointer; color:#666; font-size:0.9em' }, _('I have a custom core path')),
|
|
||||||
E('div', { 'style': 'margin-top:6px' }, [ pathInput, detectBtn, detectMsg ])
|
|
||||||
])
|
|
||||||
]);
|
|
||||||
} else {
|
|
||||||
o.default = E('strong', { 'style': 'color:green' }, coreName + coreVer + coreCustomSuffix);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Region rule-sets (geosite/geoip .srs) are versioned and refreshed by the core itself,
|
|
||||||
* so there are no local list-version files to display here. */
|
|
||||||
|
|
||||||
|
|
||||||
if (!isRuMode) {
|
|
||||||
o = s.option(form.Value, 'github_token', _('GitHub token'));
|
|
||||||
o.description = _('Used to check for ByeDPI and Zapret updates. Without a token, GitHub limits anonymous requests to 60/hour. Create at github.com → Settings → Developer settings → Personal access tokens (no scopes needed).');
|
|
||||||
o.password = true;
|
|
||||||
o.renderWidget = function() {
|
|
||||||
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
|
||||||
|
|
||||||
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
|
||||||
'class': 'cbi-button cbi-button-apply',
|
|
||||||
'title': _('Save'),
|
|
||||||
'click': ui.createHandlerFn(this, () => {
|
|
||||||
return this.map.save(null, true).then(() => {
|
|
||||||
ui.changes.apply(true);
|
|
||||||
});
|
|
||||||
}, this.option)
|
|
||||||
}, [ _('Save') ]));
|
|
||||||
|
|
||||||
return node;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Core management'));
|
|
||||||
s.anonymous = true;
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_core_env');
|
|
||||||
const tmpMB = coreInfo.tmp_free_kb != null ? Math.round(coreInfo.tmp_free_kb / 1024) : '?';
|
|
||||||
const overlayMB = coreInfo.overlay_free_kb != null ? Math.round(coreInfo.overlay_free_kb / 1024) : '?';
|
|
||||||
o.default = E('div', { style: 'font-size:0.9em; color:#555; padding:2px 0 6px' }, [
|
|
||||||
_('Package manager') + ': ',
|
|
||||||
E('strong', {}, coreInfo.pkg_manager || _('none detected')),
|
|
||||||
E('span', { style: 'margin:0 8px' }, '|'),
|
|
||||||
_('Architecture') + ': ',
|
|
||||||
E('strong', {}, coreInfo.arch || '?'),
|
|
||||||
E('span', { style: 'margin:0 8px' }, '|'),
|
|
||||||
_('Free /tmp') + ': ',
|
|
||||||
E('strong', {
|
|
||||||
style: (coreInfo.tmp_free_kb != null && coreInfo.tmp_free_kb < 30720) ? 'color:red' : 'color:green'
|
|
||||||
}, tmpMB + ' MB'),
|
|
||||||
E('span', { style: 'margin:0 8px' }, '|'),
|
|
||||||
_('Free overlay') + ': ',
|
|
||||||
E('strong', {
|
|
||||||
style: (coreInfo.overlay_free_kb != null && coreInfo.overlay_free_kb < 30720) ? 'color:red' : 'color:green'
|
|
||||||
}, overlayMB + ' MB')
|
|
||||||
]);
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_core_hiddify');
|
|
||||||
o.default = buildCoreCard('hiddify', coreInfo);
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_core_singbox');
|
|
||||||
o.default = buildCoreCard('singbox', coreInfo);
|
|
||||||
|
|
||||||
s = m.section(form.NamedSection, 'config', 'homeproxy', _('AntiDPI'));
|
|
||||||
s.anonymous = true;
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_byedpi_card');
|
|
||||||
o.default = buildByeDPICard(byedpiStatus, uci.get('homeproxy', 'config', 'main_node') === 'byedpi-out');
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_curl_card', _('ByeDPI strategy tester'));
|
|
||||||
o.default = buildCurlCard(curlStatus);
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_zapret_card');
|
|
||||||
o.default = buildZapretCard(zapretStatus);
|
|
||||||
|
|
||||||
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
|
||||||
s.anonymous = true;
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_homeproxy_logview');
|
|
||||||
o.render = L.bind(getRuntimeLog, this, o, _('Re:HomeProxy'));
|
|
||||||
|
|
||||||
o = s.option(form.DummyValue, '_hiddify-c_logview');
|
|
||||||
o.render = L.bind(getRuntimeLog, this, o, _('core client'));
|
|
||||||
|
|
||||||
return m.render();
|
|
||||||
},
|
|
||||||
|
|
||||||
handleSaveApply: null,
|
|
||||||
handleSave: null,
|
|
||||||
handleReset: null
|
|
||||||
});
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
# Установщик Re:HomeProxy для OpenWrt (в одном скрипте: APK / opkg / 23.05 legacy)
|
|
||||||
# https://github.com/1andrevich/homeproxy-hiddify
|
|
||||||
#
|
|
||||||
# Вручную ставится только LuCI-приложение + ключ подписи, а ядро, ByeDPI и Zapret
|
|
||||||
# устанавливаются через собственный бэкенд приложения (core_mgmt.uc + rpcd-объект
|
|
||||||
# luci.homeproxy) — поэтому определение архитектуры, компактные сборки под малую
|
|
||||||
# флеш-память, проверка подписи и резерв через зеркало GitHub работают той же
|
|
||||||
# проверенной логикой, что и графический интерфейс.
|
|
||||||
#
|
|
||||||
# Установка (одной строкой — ввод читается из /dev/tty, пайп остаётся интерактивным):
|
|
||||||
# wget -qO- https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh | sh
|
|
||||||
# Либо в два шага:
|
|
||||||
# wget -O /tmp/install.sh https://raw.githubusercontent.com/1andrevich/homeproxy-hiddify/master/install.sh
|
|
||||||
# sh /tmp/install.sh
|
|
||||||
#
|
|
||||||
# При заблокированном/замедленном GitHub можно указать зеркало:
|
|
||||||
# GH_MIRROR=https://my.mirror sh install.sh
|
|
||||||
# (зеркало также пишется в uci, чтобы делегированные загрузки тоже его использовали).
|
|
||||||
# Внимание: `sh <(wget -O- ...)` на OpenWrt НЕ работает — в busybox ash нет
|
|
||||||
# process substitution; используйте форму с пайпом выше.
|
|
||||||
|
|
||||||
G='\033[0;32m'; R='\033[0;31m'; Y='\033[0;33m'; C='\033[0;36m'; N='\033[0m'
|
|
||||||
ok() { echo -e "${G}$1${N}"; }
|
|
||||||
info() { echo -e "${C}$1${N}"; }
|
|
||||||
warn() { echo -e "${Y}$1${N}"; }
|
|
||||||
die() { echo -e "${R}$1${N}"; exit 1; }
|
|
||||||
ask() { printf "${C}%s${N} " "$1"; read -r REPLY </dev/tty 2>/dev/null || REPLY=""; }
|
|
||||||
is_yes() { case "$1" in y|Y|yes|YES|да|Да|д|Д) return 0;; *) return 1;; esac; }
|
|
||||||
|
|
||||||
# --- Разбор JSON (на устройстве нет jq): достать строковое поле / проверить result:true
|
|
||||||
jget() { printf '%s\n' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -1; }
|
|
||||||
jtrue() { printf '%s' "$1" | grep -qE "\"result\"[[:space:]]*:[[:space:]]*true"; }
|
|
||||||
jerr() { printf '%s\n' "$1" | grep -qE "\"error\""; }
|
|
||||||
jfalse(){ printf '%s' "$1" | grep -qE "\"$2\"[[:space:]]*:[[:space:]]*false"; }
|
|
||||||
|
|
||||||
# --- Загрузка «сначала GitHub, при сбое — зеркало» для первого хопа (app + ключ),
|
|
||||||
# пока ещё нет собственного gh_fetch приложения. dl <url> <файл>
|
|
||||||
dl() {
|
|
||||||
wget -qO "$2" --timeout=20 "$1" 2>/dev/null && [ -s "$2" ] && return 0
|
|
||||||
if [ -n "$GH_MIRROR" ]; then
|
|
||||||
m=$(echo "$1" | sed "s#https://github.com#${GH_MIRROR}#")
|
|
||||||
wget -qO "$2" --timeout=20 "$m" 2>/dev/null && [ -s "$2" ] && return 0
|
|
||||||
fi
|
|
||||||
return 1
|
|
||||||
}
|
|
||||||
api() { wget -qO- --timeout=20 "$1" 2>/dev/null; } # GitHub API (без зеркала)
|
|
||||||
|
|
||||||
echo
|
|
||||||
ok "===== Установщик Re:HomeProxy ====="
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------- 0. окружение
|
|
||||||
[ "$(id -u)" = 0 ] || die "Запустите от root."
|
|
||||||
[ -r /etc/openwrt_release ] || die "Это не OpenWrt (нет /etc/openwrt_release)."
|
|
||||||
. /etc/openwrt_release 2>/dev/null
|
|
||||||
ARCH="$DISTRIB_ARCH"; VER="$DISTRIB_RELEASE"
|
|
||||||
[ -n "$ARCH" ] || die "Не удалось определить архитектуру пакетов (DISTRIB_ARCH)."
|
|
||||||
if command -v apk >/dev/null 2>&1; then PM=apk; EXT=apk
|
|
||||||
elif command -v opkg >/dev/null 2>&1; then PM=opkg; EXT=ipk
|
|
||||||
else die "Не найден поддерживаемый менеджер пакетов (apk/opkg)."; fi
|
|
||||||
case "$VER" in
|
|
||||||
23.05*) LEGACY=1 ;;
|
|
||||||
24.10*|25.*|*SNAPSHOT*) LEGACY=0 ;;
|
|
||||||
22.*|21.*|19.*) die "OpenWrt $VER слишком старая — нужна 23.05 или новее." ;;
|
|
||||||
*) LEGACY=0; warn "Непроверенная версия OpenWrt $VER — продолжаю." ;;
|
|
||||||
esac
|
|
||||||
SUFFIX="_all"; [ "$LEGACY" = 1 ] && SUFFIX="_all-legacy"
|
|
||||||
info "Версия: OpenWrt $VER | Архитектура: $ARCH | Менеджер пакетов: $PM | legacy=$LEGACY"
|
|
||||||
|
|
||||||
# ----------------------------------------------------------- 1. LuCI-приложение + ключ
|
|
||||||
ok "[1/5] Устанавливаю LuCI-приложение Re:HomeProxy..."
|
|
||||||
if [ "$PM" = apk ]; then
|
|
||||||
if [ ! -f /etc/apk/keys/homeproxy-hiddify.pub ]; then
|
|
||||||
dl "https://github.com/1andrevich/homeproxy-hiddify/releases/latest/download/homeproxy-hiddify.pub" /tmp/hp.pub \
|
|
||||||
&& cp /tmp/hp.pub /etc/apk/keys/ && rm -f /tmp/hp.pub && ok " ключ подписи добавлен в доверенные" \
|
|
||||||
|| warn " не удалось скачать ключ подписи — поставлю без проверки подписи"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
APPURL=$(api 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' \
|
|
||||||
| grep -o "https://github\.com/[^\"]*luci-app-re-homeproxy[^\"]*${SUFFIX}\.${EXT}" | head -1)
|
|
||||||
[ -n "$APPURL" ] || die "Не нашёл пакет luci-app-re-homeproxy${SUFFIX}.${EXT} (GitHub заблокирован? попробуйте GH_MIRROR=...)."
|
|
||||||
dl "$APPURL" /tmp/app.$EXT || die "Не удалось скачать приложение (попробуйте GH_MIRROR=...)."
|
|
||||||
if [ "$PM" = apk ]; then
|
|
||||||
apk add /tmp/app.$EXT 2>/dev/null || apk add --allow-untrusted /tmp/app.$EXT || die "apk add завершился ошибкой."
|
|
||||||
else
|
|
||||||
opkg update >/dev/null 2>&1; opkg install /tmp/app.$EXT || die "opkg install завершился ошибкой."
|
|
||||||
fi
|
|
||||||
rm -f /tmp/app.$EXT
|
|
||||||
ok " приложение установлено."
|
|
||||||
|
|
||||||
# Русский язык интерфейса (по умолчанию — да)
|
|
||||||
ask " Установить пакет русского языка? [Y/n] (по умолчанию Y):"
|
|
||||||
case "$REPLY" in
|
|
||||||
n|N|no|NO|нет|Нет|н|Н) ;; # отказ — пропускаем
|
|
||||||
*)
|
|
||||||
info " ставлю русский язык LuCI..."
|
|
||||||
# базовый перевод интерфейса LuCI (из фида, best-effort)
|
|
||||||
if [ "$PM" = apk ]; then apk add luci-i18n-base-ru >/dev/null 2>&1; else opkg install luci-i18n-base-ru >/dev/null 2>&1; fi
|
|
||||||
# перевод самого приложения (из релиза homeproxy)
|
|
||||||
LURL=$(api 'https://api.github.com/repos/1andrevich/homeproxy-hiddify/releases' \
|
|
||||||
| grep -o "https://github\.com/[^\"]*luci-i18n-homeproxy-ru[^\"]*\.${EXT}" | head -1)
|
|
||||||
if [ -n "$LURL" ] && dl "$LURL" /tmp/i18n.$EXT; then
|
|
||||||
if [ "$PM" = apk ]; then apk add /tmp/i18n.$EXT 2>/dev/null || apk add --allow-untrusted /tmp/i18n.$EXT; \
|
|
||||||
else opkg install /tmp/i18n.$EXT; fi
|
|
||||||
else warn " перевод приложения не найден — пропускаю"; fi
|
|
||||||
rm -f /tmp/i18n.$EXT
|
|
||||||
uci set luci.main.lang=ru; uci commit luci
|
|
||||||
ok " русский язык установлен" ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# Прописываем зеркало в бэкенд, чтобы делегированные загрузки тоже его использовали
|
|
||||||
if [ -n "$GH_MIRROR" ]; then uci set homeproxy.config.github_mirror="$GH_MIRROR"; uci commit homeproxy; fi
|
|
||||||
|
|
||||||
# rpcd нужно перезапустить, чтобы появились ubus-методы приложения (установка ByeDPI/Zapret)
|
|
||||||
/etc/init.d/rpcd restart >/dev/null 2>&1; sleep 2
|
|
||||||
CM=/usr/share/homeproxy/scripts/core_mgmt.uc
|
|
||||||
[ -f "$CM" ] || die "core_mgmt.uc не найден после установки — прерываю."
|
|
||||||
|
|
||||||
# ------------------------------------------------------- 2. ядро прокси (обязательно)
|
|
||||||
ok "[2/5] Ядро прокси (обязательно — выберите одно)"
|
|
||||||
echo " 1) hiddify-core (по умолчанию; на малой флеш-памяти выберет компактную сборку)"
|
|
||||||
echo " 2) sing-box-extended (AmneziaWG / WARP, самый широкий набор протоколов)"
|
|
||||||
ask " Выбор [1/2] (по умолчанию 1):"
|
|
||||||
case "$REPLY" in 2) CORE=singbox ;; *) CORE=hiddify ;; esac
|
|
||||||
|
|
||||||
PREP=$(ucode "$CM" prepare_install "$CORE")
|
|
||||||
jerr "$PREP" && die " подготовка ядра не удалась: $(jget "$PREP" error)"
|
|
||||||
DLURL=$(jget "$PREP" dl_url); TMP=$(jget "$PREP" tmp_path); PMG=$(jget "$PREP" pkg_manager)
|
|
||||||
[ -n "$DLURL" ] && [ -n "$TMP" ] && [ -n "$PMG" ] || die " подготовка ядра не вернула данные для загрузки."
|
|
||||||
info " скачиваю $CORE..."
|
|
||||||
jtrue "$(ucode "$CM" download_pkg "$DLURL" "$TMP")" || die " не удалось скачать ядро (попробуйте GH_MIRROR=...)."
|
|
||||||
jtrue "$(ucode "$CM" install_pkg "$CORE" "$TMP" "$PMG")" || die " установка ядра не удалась."
|
|
||||||
jtrue "$(ucode "$CM" install_kmods "$PMG")" || warn " не удалось поставить kmod — без kmod-nft-tproxy/kmod-tun прокси не будет маршрутизировать."
|
|
||||||
ok " $CORE установлен."
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- 3. Zapret (опционально)
|
|
||||||
ask "[3/5] Установить Zapret 2 (обход DPI на уровне пакетов — видео/QUIC, звонки)? [y/N]:"
|
|
||||||
if is_yes "$REPLY"; then
|
|
||||||
info " ставлю модуль ядра NFQUEUE..."
|
|
||||||
if [ "$PM" = apk ]; then apk add kmod-nft-queue >/dev/null 2>&1; else opkg install kmod-nft-queue >/dev/null 2>&1; fi
|
|
||||||
ZP=$(ubus call luci.homeproxy zapret_prepare_install 2>/dev/null)
|
|
||||||
if [ -z "$ZP" ] || jerr "$ZP"; then warn " не удалось подготовить Zapret — пропускаю. ($(jget "$ZP" error))"
|
|
||||||
else
|
|
||||||
ZURL=$(jget "$ZP" dl_url); ZTMP=$(jget "$ZP" tmp_path); ZPMG=$(jget "$ZP" pkg_manager)
|
|
||||||
if [ -n "$ZURL" ] && dl "$ZURL" "$ZTMP"; then
|
|
||||||
RES=$(ubus call luci.homeproxy zapret_install_pkg "{\"tmp_path\":\"$ZTMP\",\"pkg_manager\":\"$ZPMG\"}" 2>/dev/null)
|
|
||||||
if jtrue "$RES"; then
|
|
||||||
ok " Zapret установлен."
|
|
||||||
ZST=$(ubus call luci.homeproxy zapret_status 2>/dev/null)
|
|
||||||
if jfalse "$ZST" kmod_ok; then
|
|
||||||
warn " модуль NFQUEUE не загружен — Zapret не включаю (иначе сломается firewall). Включите вручную после kmod-nft-queue."
|
|
||||||
else
|
|
||||||
uci set homeproxy.config.zapret_enabled=1; uci commit homeproxy; ok " Zapret включён."
|
|
||||||
fi
|
|
||||||
else warn " установка Zapret не удалась."; fi
|
|
||||||
else warn " не удалось скачать Zapret — пропускаю."; fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# --------------------------------------------------------------- 4. ByeDPI (опционально)
|
|
||||||
ask "[4/5] Установить ByeDPI (обход DPI на уровне SOCKS, нужен curl)? [y/N]:"
|
|
||||||
if is_yes "$REPLY"; then
|
|
||||||
info " ставлю curl (его использует тестер стратегий ByeDPI)..."
|
|
||||||
if [ "$PM" = apk ]; then apk add curl >/dev/null 2>&1; else opkg install curl >/dev/null 2>&1; fi
|
|
||||||
BP=$(ubus call luci.homeproxy byedpi_prepare_install 2>/dev/null)
|
|
||||||
if [ -z "$BP" ] || jerr "$BP"; then warn " не удалось подготовить ByeDPI — пропускаю. ($(jget "$BP" error))"
|
|
||||||
else
|
|
||||||
BURL=$(jget "$BP" dl_url); BTMP=$(jget "$BP" tmp_path); BPMG=$(jget "$BP" pkg_manager)
|
|
||||||
if [ -n "$BURL" ] && dl "$BURL" "$BTMP"; then
|
|
||||||
RES=$(ubus call luci.homeproxy byedpi_install_pkg "{\"tmp_path\":\"$BTMP\",\"pkg_manager\":\"$BPMG\"}" 2>/dev/null)
|
|
||||||
if jtrue "$RES"; then
|
|
||||||
ok " ByeDPI установлен."
|
|
||||||
uci set homeproxy.config.byedpi_enabled=1; uci commit homeproxy; ok " ByeDPI включён."
|
|
||||||
else warn " установка ByeDPI не удалась."; fi
|
|
||||||
else warn " не удалось скачать ByeDPI — пропускаю."; fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ------------------------------------------------- 5. режим маршрутизации (пресет)
|
|
||||||
ask "[5/5] Настроить режим «Россия — раздельное туннелирование» (Re:filter через основной сервер)? [Д/н] (по умолчанию да):"
|
|
||||||
case "$REPLY" in
|
|
||||||
n|N|no|NO|нет|Нет|н|Н) ;; # отказ — оставляем как есть
|
|
||||||
*)
|
|
||||||
CURMODE=$(uci -q get homeproxy.config.routing_mode)
|
|
||||||
if [ -z "$CURMODE" ]; then
|
|
||||||
uci set homeproxy.config.routing_mode=proxy_banned_ru
|
|
||||||
uci set homeproxy.config.proxy_calls=1
|
|
||||||
uci set homeproxy.config.no_proxy_torrents=1
|
|
||||||
uci set homeproxy.config.ipv6_support=0
|
|
||||||
uci commit homeproxy
|
|
||||||
CURMODE=proxy_banned_ru
|
|
||||||
fi
|
|
||||||
# Re:filter через основной пул — только если режим РФ и правил ещё нет
|
|
||||||
if [ "$CURMODE" = proxy_banned_ru ] && ! uci show homeproxy 2>/dev/null | grep -q "=proxy_ru_rule$"; then
|
|
||||||
SEC=$(uci add homeproxy proxy_ru_rule)
|
|
||||||
uci set "homeproxy.$SEC.source=refilter"
|
|
||||||
uci set "homeproxy.$SEC.node=main-out"
|
|
||||||
uci set "homeproxy.$SEC.enabled=1"
|
|
||||||
uci commit homeproxy
|
|
||||||
ok " режим настроен: Re:filter через основной сервер"
|
|
||||||
else
|
|
||||||
info " режим/правила уже заданы — не меняю"
|
|
||||||
fi ;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------- 6. финал
|
|
||||||
/etc/init.d/homeproxy enable >/dev/null 2>&1
|
|
||||||
/etc/init.d/homeproxy start >/dev/null 2>&1
|
|
||||||
|
|
||||||
# LAN-адрес роутера для прямой ссылки на страницу LuCI (обрезаем /маску на 25.12+)
|
|
||||||
LANIP=$(uci -q get network.lan.ipaddr | cut -d/ -f1)
|
|
||||||
[ -n "$LANIP" ] || LANIP=$(ip -4 addr show br-lan 2>/dev/null | sed -n 's#.*inet \([0-9.]*\).*#\1#p' | head -1)
|
|
||||||
[ -n "$LANIP" ] || LANIP="192.168.1.1"
|
|
||||||
|
|
||||||
echo
|
|
||||||
ok "===== Готово ====="
|
|
||||||
info "Откройте Re:HomeProxy в браузере:"
|
|
||||||
URL="http://$LANIP/cgi-bin/luci/admin/services/homeproxy"
|
|
||||||
# OSC 8: кликабельная ссылка в поддерживающих терминалах; в остальных просто виден URL
|
|
||||||
printf '\033[0;36m \033]8;;%s\033\\%s\033]8;;\033\\\033[0m\n' "$URL" "$URL"
|
|
||||||
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
@@ -1,32 +0,0 @@
|
|||||||
{
|
|
||||||
"bounding": [
|
|
||||||
"CAP_NET_ADMIN",
|
|
||||||
"CAP_NET_BIND_SERVICE",
|
|
||||||
"CAP_NET_RAW",
|
|
||||||
"CAP_SYS_PTRACE"
|
|
||||||
],
|
|
||||||
"effective": [
|
|
||||||
"CAP_NET_ADMIN",
|
|
||||||
"CAP_NET_BIND_SERVICE",
|
|
||||||
"CAP_NET_RAW",
|
|
||||||
"CAP_SYS_PTRACE"
|
|
||||||
],
|
|
||||||
"ambient": [
|
|
||||||
"CAP_NET_ADMIN",
|
|
||||||
"CAP_NET_BIND_SERVICE",
|
|
||||||
"CAP_NET_RAW",
|
|
||||||
"CAP_SYS_PTRACE"
|
|
||||||
],
|
|
||||||
"permitted": [
|
|
||||||
"CAP_NET_ADMIN",
|
|
||||||
"CAP_NET_BIND_SERVICE",
|
|
||||||
"CAP_NET_RAW",
|
|
||||||
"CAP_SYS_PTRACE"
|
|
||||||
],
|
|
||||||
"inheritable": [
|
|
||||||
"CAP_NET_ADMIN",
|
|
||||||
"CAP_NET_BIND_SERVICE",
|
|
||||||
"CAP_NET_RAW",
|
|
||||||
"CAP_SYS_PTRACE"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
|
|
||||||
config homeproxy 'infra'
|
|
||||||
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
|
|
||||||
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
|
|
||||||
option mixed_port '5330'
|
|
||||||
option redirect_port '5331'
|
|
||||||
option tproxy_port '5332'
|
|
||||||
option dns_port '5333'
|
|
||||||
option dns_redirect '1'
|
|
||||||
option ntp_server 'nil'
|
|
||||||
option sniff_override '1'
|
|
||||||
option udp_timeout ''
|
|
||||||
option tun_name 'singtun0'
|
|
||||||
option tun_addr4 '172.19.0.1/30'
|
|
||||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
|
||||||
option tun_mtu '9000'
|
|
||||||
option table_mark '100'
|
|
||||||
option self_mark '100'
|
|
||||||
option tproxy_mark '101'
|
|
||||||
option tun_mark '102'
|
|
||||||
|
|
||||||
config homeproxy 'migration'
|
|
||||||
option crontab '1'
|
|
||||||
|
|
||||||
config homeproxy 'config'
|
|
||||||
option main_node 'nil'
|
|
||||||
option main_udp_node 'same'
|
|
||||||
option dns_server '8.8.8.8'
|
|
||||||
option china_dns_server '223.5.5.5'
|
|
||||||
option routing_mode 'proxy_banned_ru'
|
|
||||||
option routing_port 'all'
|
|
||||||
option proxy_mode 'redirect_tproxy'
|
|
||||||
option ipv6_support '0'
|
|
||||||
option proxy_calls '1'
|
|
||||||
option no_proxy_torrents '1'
|
|
||||||
option github_token ''
|
|
||||||
option log_level 'warn'
|
|
||||||
|
|
||||||
config homeproxy 'control'
|
|
||||||
option lan_proxy_mode 'disabled'
|
|
||||||
|
|
||||||
config homeproxy 'routing'
|
|
||||||
option sniff_override '1'
|
|
||||||
option default_outbound 'direct-out'
|
|
||||||
option default_outbound_dns 'default-dns'
|
|
||||||
|
|
||||||
config homeproxy 'dns'
|
|
||||||
option dns_strategy 'prefer_ipv4'
|
|
||||||
option default_server 'local-dns'
|
|
||||||
option disable_cache '0'
|
|
||||||
option disable_cache_expire '0'
|
|
||||||
|
|
||||||
config homeproxy 'subscription'
|
|
||||||
option auto_update '0'
|
|
||||||
option allow_insecure '0'
|
|
||||||
option packet_encoding 'xudp'
|
|
||||||
option update_via_proxy '0'
|
|
||||||
option filter_nodes 'blacklist'
|
|
||||||
list filter_keywords '重置|到期|过期|剩余|套餐'
|
|
||||||
list filter_keywords 'Expiration|Remaining'
|
|
||||||
|
|
||||||
config homeproxy 'server'
|
|
||||||
option enabled '0'
|
|
||||||
option log_level 'warn'
|
|
||||||
|
|
||||||
@@ -1,600 +0,0 @@
|
|||||||
#!/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 %}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
#!/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
@@ -1,235 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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 */
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
#!/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 });
|
|
||||||
@@ -1,680 +0,0 @@
|
|||||||
/*
|
|
||||||
* 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;
|
|
||||||
};
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
#!/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
@@ -1,52 +0,0 @@
|
|||||||
#!/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
|
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
#!/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]}"
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
{
|
|
||||||
"version": 1,
|
|
||||||
"candidates": [
|
|
||||||
{
|
|
||||||
"name": "Default (fake + multidisorder)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Multisplit",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multisplit:pos=1,midsld",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake repeats (x6)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:repeats=6 --lua-desync=multidisorder:pos=1,midsld",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake only",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "YouTube (Google seqovl + fake-QUIC)",
|
|
||||||
"args": "--blob=tls_google:@/opt/zapret2/files/fake/tls_clienthello_www_google_com.bin --blob=quic_google:@/opt/zapret2/files/fake/quic_initial_www_google_com.bin --filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=2,sniext+1:seqovl=679:seqovl_pattern=tls_google --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=quic_google:repeats=6",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Discord (TLS seqovl + STUN voice)",
|
|
||||||
"args": "--blob=tls_google:@/opt/zapret2/files/fake/tls_clienthello_www_google_com.bin --blob=stun_fake:@/opt/zapret2/files/fake/stun.bin --filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=1,sniext+1:seqovl=336:seqovl_pattern=tls_google --new --filter-udp=443 --filter-l7=stun,discord --lua-desync=fake:blob=stun_fake:repeats=6",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "YouTube + Discord (combined)",
|
|
||||||
"args": "--blob=tls_google:@/opt/zapret2/files/fake/tls_clienthello_www_google_com.bin --blob=quic_google:@/opt/zapret2/files/fake/quic_initial_www_google_com.bin --blob=stun_fake:@/opt/zapret2/files/fake/stun.bin --filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=2,sniext+1:seqovl=679:seqovl_pattern=tls_google --new --filter-udp=443 --filter-l7=quic --payload=quic_initial --lua-desync=fake:blob=quic_google:repeats=6 --new --filter-udp=443 --filter-l7=stun,discord --lua-desync=fake:blob=stun_fake:repeats=6",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS auto (rnd SNI)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_seq=-10000:tls_mod=rnd,dupsid:repeats=11 --lua-desync=multidisorder:pos=1,midsld",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake + multisplit seqovl",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_seq=-10000:repeats=8 --lua-desync=multisplit:pos=1:seqovl=679:seqovl_pattern=fake_default_tls",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Hostfakesplit",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=hostfakesplit:host=www.google.com:tcp_ts=-1000:repeats=4",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Syndata + multidisorder",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=syndata --lua-desync=multidisorder:pos=1,midsld",
|
|
||||||
"group": "recommended"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Split @2",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=2",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Split @1,midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=1,midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Split @sniext+1",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=sniext+1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Split @midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Split multi-pos (7pt)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=1,sniext+1,host+1,midsld-2,midsld,midsld+2,endhost-1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disorder @1",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disorder @sniext+1",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=sniext+1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Disorder @midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Seqovl split @10",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=10:seqovl=1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Seqovl split @10,sniext+1",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:pos=10,sniext+1:seqovl=1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Seqovl disorder @2",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=2:seqovl=1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Seqovl disorder @midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multidisorder:pos=midsld:seqovl=1",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Tcpseg seqovl + drop",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=tcpseg:pos=0,-1:seqovl=1 --lua-desync=drop",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS + badsum",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:badsum",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS + bad-seq",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_seq=-3000",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS + bad-ack/ts",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_ack=-66000:tcp_ts_up",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS + bad-ts",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_ts=-1000",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake TLS + SYN flag",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_flags_set=SYN",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake zero-payload + md5",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=0x00000000:tcp_md5",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake md5 + split @2",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5 --lua-desync=multisplit:pos=2",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake badsum + disorder @midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:badsum --lua-desync=multidisorder:pos=midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake bad-seq + disorder @1,midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_seq=-3000 --lua-desync=multidisorder:pos=1,midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Fake-in-split @2 (nodrop)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=multisplit:blob=fake_default_tls:tcp_md5:pos=2:nodrop",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Wssize + split @1,midsld",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=wssize:wsize=1:scale=6 --lua-desync=multisplit:pos=1,midsld",
|
|
||||||
"group": "auto"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "Syndata (TLS marker)",
|
|
||||||
"args": "--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=syndata:blob=0x1603",
|
|
||||||
"group": "auto"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
#!/bin/sh /etc/rc.common
|
|
||||||
# SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
#
|
|
||||||
# Copyright (C) 2022-2023 ImmortalWrt.org
|
|
||||||
|
|
||||||
USE_PROCD=1
|
|
||||||
|
|
||||||
START=99
|
|
||||||
STOP=10
|
|
||||||
|
|
||||||
CONF="homeproxy"
|
|
||||||
PROG_HIDDIFY="/usr/bin/hiddify-core"
|
|
||||||
PROG_SINGBOX="/usr/bin/sing-box"
|
|
||||||
|
|
||||||
HP_DIR="/etc/homeproxy"
|
|
||||||
RUN_DIR="/var/run/homeproxy"
|
|
||||||
LOG_PATH="$RUN_DIR/homeproxy.log"
|
|
||||||
|
|
||||||
# we don't know which is the default server, just take the first one
|
|
||||||
DNSMASQ_UCI_CONFIG="$(uci -q show "dhcp.@dnsmasq[0]" | awk 'NR==1 {split($0, conf, /[.=]/); print conf[2]}')"
|
|
||||||
if [ -f "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG" ]; then
|
|
||||||
DNSMASQ_DIR="$(awk -F '=' '/^conf-dir=/ {print $2}' "/tmp/etc/dnsmasq.conf.$DNSMASQ_UCI_CONFIG")/dnsmasq-homeproxy.d"
|
|
||||||
else
|
|
||||||
DNSMASQ_DIR="/tmp/dnsmasq.d/dnsmasq-homeproxy.d"
|
|
||||||
fi
|
|
||||||
|
|
||||||
log() {
|
|
||||||
echo -e "$(date "+%Y-%m-%d %H:%M:%S") [DAEMON] $*" >> "$LOG_PATH"
|
|
||||||
}
|
|
||||||
|
|
||||||
start_service() {
|
|
||||||
mkdir -p "$RUN_DIR"
|
|
||||||
|
|
||||||
# Self-heal a missing/empty config: /etc/config/homeproxy is a conffile, so a manual
|
|
||||||
# delete (or a wiped overlay) leaves the service with nothing and it won't regenerate.
|
|
||||||
# Restore defaults from the packaged backup. Only when there are no homeproxy sections,
|
|
||||||
# so a populated (even if odd) user config is never overwritten.
|
|
||||||
if ! grep -q "config homeproxy" "/etc/config/$CONF" 2>/dev/null; then
|
|
||||||
if [ -f "/usr/share/homeproxy/homeproxy.default" ]; then
|
|
||||||
cp "/usr/share/homeproxy/homeproxy.default" "/etc/config/$CONF"
|
|
||||||
log "config /etc/config/$CONF was missing/empty — restored defaults."
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
config_load "$CONF"
|
|
||||||
|
|
||||||
local preferred_core
|
|
||||||
config_get preferred_core "config" "preferred_core" "auto"
|
|
||||||
|
|
||||||
# Try the preferred core first; fall back to whichever is installed
|
|
||||||
case "$preferred_core" in
|
|
||||||
hiddify)
|
|
||||||
if [ -x "$PROG_HIDDIFY" ]; then
|
|
||||||
PROG="$PROG_HIDDIFY"; RUN_CMD="srun"; CORE_TYPE="hiddify"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
singbox)
|
|
||||||
if [ -x "$PROG_SINGBOX" ]; then
|
|
||||||
PROG="$PROG_SINGBOX"; RUN_CMD="run"; CORE_TYPE="singbox"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
|
|
||||||
if [ -z "$PROG" ]; then
|
|
||||||
if [ -x "$PROG_HIDDIFY" ]; then
|
|
||||||
PROG="$PROG_HIDDIFY"; RUN_CMD="srun"; CORE_TYPE="hiddify"
|
|
||||||
elif [ -x "$PROG_SINGBOX" ]; then
|
|
||||||
PROG="$PROG_SINGBOX"; RUN_CMD="run"; CORE_TYPE="singbox"
|
|
||||||
else
|
|
||||||
log "Error: No supported core found. Install hiddify-core or sing-box first."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
local routing_mode proxy_mode
|
|
||||||
config_get routing_mode "config" "routing_mode" "proxy_banned_ru"
|
|
||||||
config_get proxy_mode "config" "proxy_mode" "redirect_tproxy"
|
|
||||||
|
|
||||||
# Write core info for LuCI
|
|
||||||
local core_version
|
|
||||||
core_version="$("$PROG" version 2>/dev/null | head -1 | awk '{print $3}')"
|
|
||||||
printf '{"core_type":"%s","version":"%s"}\n' "$CORE_TYPE" "$core_version" > "$RUN_DIR/core.info"
|
|
||||||
|
|
||||||
# Generate or copy client config
|
|
||||||
if [ "$routing_mode" = "custom_json" ]; then
|
|
||||||
if [ -e "$HP_DIR/hiddify-c.json" ]; then
|
|
||||||
cp "$HP_DIR/hiddify-c.json" "$RUN_DIR/hiddify-c.json"
|
|
||||||
elif [ ! -e "$RUN_DIR/hiddify-c.json" ]; then
|
|
||||||
log "Error: client config not found. Place your config at $HP_DIR/hiddify-c.json"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
rm -f "$RUN_DIR/hiddify-c.json"
|
|
||||||
ucode "$HP_DIR/scripts/generate_client.uc"
|
|
||||||
if [ ! -e "$RUN_DIR/hiddify-c.json" ]; then
|
|
||||||
log "Error: failed to generate client config."
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# DNSMasq rules
|
|
||||||
local ipv6_support dns_port
|
|
||||||
config_get_bool ipv6_support "config" "ipv6_support" "0"
|
|
||||||
config_get dns_port "infra" "dns_port" "5333"
|
|
||||||
mkdir -p "$DNSMASQ_DIR"
|
|
||||||
echo -e "conf-dir=$DNSMASQ_DIR" > "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf"
|
|
||||||
case "$routing_mode" in
|
|
||||||
"bypass_cn"|"bypass_ir"|"custom"|"custom_json"|"global"|"proxy_banned_ru")
|
|
||||||
# All modes do DNS inside sing-box; dnsmasq just redirects to its inbound.
|
|
||||||
cat <<-EOF >> "$DNSMASQ_DIR/redirect-dns.conf"
|
|
||||||
no-poll
|
|
||||||
no-resolv
|
|
||||||
server=127.0.0.1#$dns_port
|
|
||||||
EOF
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
|
|
||||||
|
|
||||||
# Setup tproxy routing table
|
|
||||||
local table_mark tproxy_mark
|
|
||||||
config_get table_mark "infra" "table_mark" "100"
|
|
||||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
|
||||||
|
|
||||||
ip rule add fwmark "$tproxy_mark" table "$table_mark"
|
|
||||||
ip route add local 0.0.0.0/0 dev lo table "$table_mark"
|
|
||||||
|
|
||||||
if [ "$ipv6_support" -eq "1" ]; then
|
|
||||||
ip -6 rule add fwmark "$tproxy_mark" table "$table_mark"
|
|
||||||
ip -6 route add local ::/0 dev lo table "$table_mark"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# hiddify-core (client)
|
|
||||||
procd_open_instance "hiddify-c"
|
|
||||||
|
|
||||||
procd_set_param command "$PROG"
|
|
||||||
procd_append_param command $RUN_CMD --config "$RUN_DIR/hiddify-c.json"
|
|
||||||
|
|
||||||
# QUIC-GO GSO is broken on kernel 6.6 currently
|
|
||||||
uname -r | grep -Eq "^6\.6" && procd_set_param env "QUIC_GO_DISABLE_GSO"="true"
|
|
||||||
|
|
||||||
procd_set_param limits core="unlimited"
|
|
||||||
procd_set_param limits nofile="1000000 1000000"
|
|
||||||
procd_set_param stderr 1
|
|
||||||
procd_set_param respawn
|
|
||||||
|
|
||||||
procd_close_instance
|
|
||||||
|
|
||||||
# log-cleaner
|
|
||||||
procd_open_instance "log-cleaner"
|
|
||||||
procd_set_param command "$HP_DIR/scripts/clean_log.sh"
|
|
||||||
procd_set_param respawn
|
|
||||||
procd_close_instance
|
|
||||||
|
|
||||||
echo > "$RUN_DIR/hiddify-c.log"
|
|
||||||
|
|
||||||
# Setup firewall
|
|
||||||
# Strip CR: a CRLF-tainted template (e.g. transferred from Windows) would otherwise
|
|
||||||
# emit CRLF into the nft file, which nft rejects wholesale — silently dropping every
|
|
||||||
# homeproxy chain since fw4 loads all-or-nothing.
|
|
||||||
ucode "$HP_DIR/scripts/firewall_pre.uc"
|
|
||||||
utpl -S "$HP_DIR/scripts/firewall_post.ut" | tr -d '\r' > "$RUN_DIR/fw4_post.nft"
|
|
||||||
fw4 reload >"/dev/null" 2>&1
|
|
||||||
|
|
||||||
# ByeDPI
|
|
||||||
local byedpi_enabled byedpi_cmd_opts byedpi_block_quic byedpi_gid
|
|
||||||
config_get_bool byedpi_enabled "config" "byedpi_enabled" "0"
|
|
||||||
if [ "$byedpi_enabled" = "1" ] && [ -x "/usr/bin/ciadpi" ]; then
|
|
||||||
config_get byedpi_cmd_opts "config" "byedpi_cmd_opts" "--disorder 1"
|
|
||||||
config_get byedpi_gid "infra" "byedpi_gid" "8181"
|
|
||||||
|
|
||||||
/etc/init.d/ciadpi stop 2>/dev/null; /etc/init.d/ciadpi disable 2>/dev/null; true
|
|
||||||
|
|
||||||
# ByeDPI runs as root (needed for TCP_MD5SIG and other desync) but under a dedicated
|
|
||||||
# group, so its egress can be excluded from the proxy redirect by socket group (skgid).
|
|
||||||
# ciadpi has no fwmark option, and without this exclusion its outbound connections
|
|
||||||
# loop back through sing-box (conntrack table full / packet flood).
|
|
||||||
if ! grep -q "^byedpi:" /etc/group; then
|
|
||||||
echo "byedpi:x:${byedpi_gid}:" >> /etc/group
|
|
||||||
fi
|
|
||||||
|
|
||||||
procd_open_instance "byedpi"
|
|
||||||
procd_set_param command /usr/bin/ciadpi -i 127.0.0.1 -p 5335
|
|
||||||
# Quote-aware split so an argument value containing spaces survives intact —
|
|
||||||
# e.g. an inline hostlist -H:"host1 host2 host3" must stay a single argument,
|
|
||||||
# not be torn into separate tokens by plain word-splitting. eval re-parses the
|
|
||||||
# quoting; the string is the admin's own root-owned UCI option, so this is
|
|
||||||
# argument parsing, not a trust boundary. The leading -- protects against an
|
|
||||||
# option-like first token.
|
|
||||||
eval "set -- $byedpi_cmd_opts"
|
|
||||||
for _arg in "$@"; do
|
|
||||||
procd_append_param command "$_arg"
|
|
||||||
done
|
|
||||||
procd_set_param group "byedpi"
|
|
||||||
procd_set_param respawn
|
|
||||||
procd_close_instance
|
|
||||||
|
|
||||||
config_get_bool byedpi_block_quic "config" "byedpi_block_quic" "0"
|
|
||||||
if [ "$byedpi_block_quic" = "1" ]; then
|
|
||||||
nft 'add chain inet fw4 homeproxy_quic { type filter hook prerouting priority -150; policy accept; }' 2>/dev/null || true
|
|
||||||
nft 'add rule inet fw4 homeproxy_quic ip daddr { 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } return' 2>/dev/null || true
|
|
||||||
nft 'add rule inet fw4 homeproxy_quic udp dport 443 drop' 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Zapret (nfqws2): HomeProxy runs its OWN nfqws2 on a dedicated NFQUEUE. Which flows
|
|
||||||
# reach the queue is decided upstream by sing-box (zapret-out routing_mark) + nft
|
|
||||||
# (firewall_post.ut), so nfqws2 runs WITHOUT hostlists — it desyncs whatever it's fed.
|
|
||||||
# The zapret2 package only supplies the binary + lua; its own service/firewall stays
|
|
||||||
# disabled so it can't fight us for the queue or install conflicting nft rules.
|
|
||||||
local zapret_enabled zapret_cmd_opts zapret_qnum zapret_default_opts
|
|
||||||
config_get_bool zapret_enabled "config" "zapret_enabled" "0"
|
|
||||||
if [ "$zapret_enabled" = "1" ] && [ -x "/opt/zapret2/nfq2/nfqws2" ]; then
|
|
||||||
config_get zapret_qnum "config" "zapret_qnum" "200"
|
|
||||||
# Default strategy = zapret2's shipped recipe minus hostlists, TCP http+tls only.
|
|
||||||
# The strategy is the single source of truth: if it has a --filter-udp profile,
|
|
||||||
# firewall_post.ut queues QUIC to it automatically; if not, QUIC is left alone.
|
|
||||||
zapret_default_opts="--filter-tcp=80 --filter-l7=http --payload=http_req --lua-desync=fake:blob=fake_default_http:tcp_md5 --lua-desync=multisplit:pos=method+2 --new --filter-tcp=443 --filter-l7=tls --payload=tls_client_hello --lua-desync=fake:blob=fake_default_tls:tcp_md5:tcp_seq=-10000 --lua-desync=multidisorder:pos=1,midsld"
|
|
||||||
config_get zapret_cmd_opts "config" "zapret_cmd_opts" "$zapret_default_opts"
|
|
||||||
|
|
||||||
/etc/init.d/zapret2 stop 2>/dev/null; /etc/init.d/zapret2 disable 2>/dev/null; true
|
|
||||||
|
|
||||||
procd_open_instance "zapret"
|
|
||||||
# Fixed args: drop privileges to daemon, tag reinjected fakes with DESYNC_MARK
|
|
||||||
# 0x40000000 (firewall_post.ut lets that mark escape the redirect and not be
|
|
||||||
# re-queued), and load the lua desync library (nfqws2 reads the shipped .gz directly).
|
|
||||||
procd_set_param command /opt/zapret2/nfq2/nfqws2 \
|
|
||||||
--qnum="$zapret_qnum" \
|
|
||||||
--user=daemon \
|
|
||||||
--fwmark=0x40000000 \
|
|
||||||
--lua-init=@/opt/zapret2/lua/zapret-lib.lua \
|
|
||||||
--lua-init=@/opt/zapret2/lua/zapret-antidpi.lua \
|
|
||||||
--lua-init=@/opt/zapret2/lua/zapret-auto.lua
|
|
||||||
# Strategy opts: quote-aware split so a value with spaces survives intact. The string
|
|
||||||
# is the admin's own root-owned UCI option (argument parsing, not a trust boundary);
|
|
||||||
# leading -- guards an option-like first token. Same rationale as ByeDPI above.
|
|
||||||
eval "set -- $zapret_cmd_opts"
|
|
||||||
for _arg in "$@"; do
|
|
||||||
procd_append_param command "$_arg"
|
|
||||||
done
|
|
||||||
procd_set_param respawn
|
|
||||||
procd_close_instance
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Subscription auto-update cron. This install half was dropped in the init rewrite
|
|
||||||
# (commit 4f3511f) while the stop_service cleanup below was kept, leaving the UI
|
|
||||||
# "Auto update" checkbox wired to nothing. Restored verbatim from upstream: when
|
|
||||||
# enabled, run update_crond.sh daily at the chosen hour (refreshes subscriptions and,
|
|
||||||
# in bypass_mainland_china mode, the China resources). The #${CONF}_autosetup marker
|
|
||||||
# lets stop_service remove only our line without touching user cron jobs.
|
|
||||||
local auto_update auto_update_time
|
|
||||||
config_get_bool auto_update "subscription" "auto_update" "0"
|
|
||||||
if [ "$auto_update" = "1" ]; then
|
|
||||||
config_get auto_update_time "subscription" "auto_update_time" "2"
|
|
||||||
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
|
|
||||||
echo -e "0 $auto_update_time * * * $HP_DIR/scripts/update_crond.sh #${CONF}_autosetup" >> "/etc/crontabs/root"
|
|
||||||
/etc/init.d/cron restart >"/dev/null" 2>&1
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "$(basename $PROG) started."
|
|
||||||
}
|
|
||||||
|
|
||||||
stop_service() {
|
|
||||||
sed -i "/#${CONF}_autosetup/d" "/etc/crontabs/root" 2>"/dev/null"
|
|
||||||
/etc/init.d/cron restart >"/dev/null" 2>&1
|
|
||||||
|
|
||||||
# Load config
|
|
||||||
config_load "$CONF"
|
|
||||||
local table_mark tproxy_mark tun_name
|
|
||||||
config_get table_mark "infra" "table_mark" "100"
|
|
||||||
config_get tproxy_mark "infra" "tproxy_mark" "101"
|
|
||||||
config_get tun_name "infra" "tun_name" "singtun0"
|
|
||||||
|
|
||||||
# Tproxy
|
|
||||||
ip rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
|
|
||||||
ip route del local 0.0.0.0/0 dev lo table "$table_mark" 2>"/dev/null"
|
|
||||||
ip -6 rule del fwmark "$tproxy_mark" table "$table_mark" 2>"/dev/null"
|
|
||||||
ip -6 route del local ::/0 dev lo table "$table_mark" 2>"/dev/null"
|
|
||||||
|
|
||||||
# Nftables rules
|
|
||||||
for i in "homeproxy_dstnat_redir" "homeproxy_output_redir" \
|
|
||||||
"homeproxy_redirect" "homeproxy_redirect_proxy" \
|
|
||||||
"homeproxy_redirect_proxy_port" "homeproxy_redirect_lanac" \
|
|
||||||
"homeproxy_mangle_prerouting" "homeproxy_mangle_output" \
|
|
||||||
"homeproxy_mangle_tproxy" "homeproxy_mangle_tproxy_port" \
|
|
||||||
"homeproxy_mangle_tproxy_lanac" "homeproxy_mangle_mark" \
|
|
||||||
"homeproxy_mangle_tun" "homeproxy_mangle_tun_mark" \
|
|
||||||
"homeproxy_quic"; do
|
|
||||||
nft flush chain inet fw4 "$i"
|
|
||||||
nft delete chain inet fw4 "$i"
|
|
||||||
done 2>"/dev/null"
|
|
||||||
for i in "homeproxy_local_addr_v4" "homeproxy_local_addr_v6" \
|
|
||||||
"homeproxy_wan_proxy_addr_v4" "homeproxy_wan_proxy_addr_v6" \
|
|
||||||
"homeproxy_wan_direct_addr_v4" "homeproxy_wan_direct_addr_v6" \
|
|
||||||
"homeproxy_routing_port"; do
|
|
||||||
nft flush set inet fw4 "$i"
|
|
||||||
nft delete set inet fw4 "$i"
|
|
||||||
done 2>"/dev/null"
|
|
||||||
echo 2>"/dev/null" > "$RUN_DIR/fw4_forward.nft"
|
|
||||||
echo 2>"/dev/null" > "$RUN_DIR/fw4_input.nft"
|
|
||||||
echo 2>"/dev/null" > "$RUN_DIR/fw4_post.nft"
|
|
||||||
fw4 reload >"/dev/null" 2>&1
|
|
||||||
|
|
||||||
# Remove DNS hijack
|
|
||||||
rm -rf "$DNSMASQ_DIR/../dnsmasq-homeproxy.conf" "$DNSMASQ_DIR"
|
|
||||||
/etc/init.d/dnsmasq restart >"/dev/null" 2>&1
|
|
||||||
|
|
||||||
# Only remove the log; hiddify-c.json is manually placed by the user
|
|
||||||
rm -f "$RUN_DIR/hiddify-c.log"
|
|
||||||
|
|
||||||
log "Service stopped."
|
|
||||||
}
|
|
||||||
|
|
||||||
service_stopped() {
|
|
||||||
config_load "$CONF"
|
|
||||||
local tun_name
|
|
||||||
config_get tun_name "infra" "tun_name" "singtun0"
|
|
||||||
|
|
||||||
ip link set "$tun_name" down 2>"/dev/null"
|
|
||||||
ip tuntap del mode tun name "$tun_name" 2>"/dev/null"
|
|
||||||
}
|
|
||||||
|
|
||||||
reload_service() {
|
|
||||||
log "Reloading service..."
|
|
||||||
|
|
||||||
stop
|
|
||||||
start
|
|
||||||
}
|
|
||||||
|
|
||||||
service_triggers() {
|
|
||||||
procd_add_reload_trigger "$CONF"
|
|
||||||
procd_add_interface_trigger "interface.*.up" wan /etc/init.d/$CONF reload
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
|
|
||||||
config homeproxy 'infra'
|
|
||||||
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
|
|
||||||
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
|
|
||||||
option mixed_port '5330'
|
|
||||||
option redirect_port '5331'
|
|
||||||
option tproxy_port '5332'
|
|
||||||
option dns_port '5333'
|
|
||||||
option dns_redirect '1'
|
|
||||||
option ntp_server 'nil'
|
|
||||||
option sniff_override '1'
|
|
||||||
option udp_timeout ''
|
|
||||||
option tun_name 'singtun0'
|
|
||||||
option tun_addr4 '172.19.0.1/30'
|
|
||||||
option tun_addr6 'fdfe:dcba:9876::1/126'
|
|
||||||
option tun_mtu '9000'
|
|
||||||
option table_mark '100'
|
|
||||||
option self_mark '100'
|
|
||||||
option tproxy_mark '101'
|
|
||||||
option tun_mark '102'
|
|
||||||
|
|
||||||
config homeproxy 'migration'
|
|
||||||
option crontab '1'
|
|
||||||
|
|
||||||
config homeproxy 'config'
|
|
||||||
option main_node 'nil'
|
|
||||||
option main_udp_node 'same'
|
|
||||||
option dns_server '8.8.8.8'
|
|
||||||
option china_dns_server '223.5.5.5'
|
|
||||||
option routing_mode 'proxy_banned_ru'
|
|
||||||
option routing_port 'all'
|
|
||||||
option proxy_mode 'redirect_tproxy'
|
|
||||||
option ipv6_support '0'
|
|
||||||
option proxy_calls '1'
|
|
||||||
option no_proxy_torrents '1'
|
|
||||||
option github_token ''
|
|
||||||
option log_level 'warn'
|
|
||||||
|
|
||||||
config homeproxy 'control'
|
|
||||||
option lan_proxy_mode 'disabled'
|
|
||||||
|
|
||||||
config homeproxy 'routing'
|
|
||||||
option sniff_override '1'
|
|
||||||
option default_outbound 'direct-out'
|
|
||||||
option default_outbound_dns 'default-dns'
|
|
||||||
|
|
||||||
config homeproxy 'dns'
|
|
||||||
option dns_strategy 'prefer_ipv4'
|
|
||||||
option default_server 'local-dns'
|
|
||||||
option disable_cache '0'
|
|
||||||
option disable_cache_expire '0'
|
|
||||||
|
|
||||||
config homeproxy 'subscription'
|
|
||||||
option auto_update '0'
|
|
||||||
option allow_insecure '0'
|
|
||||||
option packet_encoding 'xudp'
|
|
||||||
option update_via_proxy '0'
|
|
||||||
option filter_nodes 'blacklist'
|
|
||||||
list filter_keywords '重置|到期|过期|剩余|套餐'
|
|
||||||
list filter_keywords 'Expiration|Remaining'
|
|
||||||
|
|
||||||
config homeproxy 'server'
|
|
||||||
option enabled '0'
|
|
||||||
option log_level 'warn'
|
|
||||||
|
|
||||||
@@ -1,353 +0,0 @@
|
|||||||
#!/usr/bin/ucode
|
|
||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
*
|
|
||||||
* Copyright (C) 2022-2025 ImmortalWrt.org
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import { access, popen, readfile } from 'fs';
|
|
||||||
|
|
||||||
function shellquote(s) {
|
|
||||||
return `'${replace(s, "'", "'\\''")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optional GitHub mirror (set by a provisioning tool or the user as
|
|
||||||
* uci homeproxy.config.github_mirror). Used ONLY as a FALLBACK: every fetch tries
|
|
||||||
* GitHub first and only swaps to the mirror if GitHub fails — so a healthy GitHub is
|
|
||||||
* never bypassed, but a blocked/failed one still gets the file via the mirror. */
|
|
||||||
function gh_mirror_base() {
|
|
||||||
let base = null;
|
|
||||||
const fd = popen('uci -q get homeproxy.config.github_mirror 2>/dev/null');
|
|
||||||
if (fd) { base = trim(fd.read('all')); fd.close(); }
|
|
||||||
return (base && length(base)) ? replace(base, /\/+$/, '') : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function gh_mirror_of(url, base) {
|
|
||||||
const m = match(url, /^https:\/\/github\.com(\/[^\/]+\/[^\/]+\/releases\/.+)$/);
|
|
||||||
return m ? `${base}${m[1]}` : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* GitHub-first, mirror-FALLBACK download. Returns wget exit code (0 = success). */
|
|
||||||
function gh_fetch(url, dest, timeout_ms) {
|
|
||||||
let rc = system(`wget -qO ${shellquote(dest)} --timeout=15 ${shellquote(url)} 2>/dev/null`, timeout_ms);
|
|
||||||
if (rc !== 0) {
|
|
||||||
const base = gh_mirror_base();
|
|
||||||
const mu = base ? gh_mirror_of(url, base) : null;
|
|
||||||
if (mu)
|
|
||||||
rc = system(`wget -qO ${shellquote(dest)} --timeout=15 ${shellquote(mu)} 2>/dev/null`, timeout_ms);
|
|
||||||
}
|
|
||||||
return rc;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detect_pkg_manager() {
|
|
||||||
for (let p in ['/usr/bin/apk', '/sbin/apk', '/usr/sbin/apk'])
|
|
||||||
if (access(p)) return 'apk';
|
|
||||||
for (let p in ['/bin/opkg', '/usr/bin/opkg'])
|
|
||||||
if (access(p)) return 'opkg';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detect_arch() {
|
|
||||||
const os_rel = readfile('/etc/os-release') || '';
|
|
||||||
const m = match(os_rel, /OPENWRT_ARCH="([^"]+)"/) ||
|
|
||||||
match(os_rel, /OPENWRT_ARCH=([^\n]+)/);
|
|
||||||
return m ? trim(m[1]) : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function free_kb(path) {
|
|
||||||
const fd = popen(`df -k ${path} 2>/dev/null | awk 'NR==2{print $4}'`);
|
|
||||||
if (!fd) return 0;
|
|
||||||
const v = int(trim(fd.read('all'))); fd.close();
|
|
||||||
return v || 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function free_ram_kb() {
|
|
||||||
const m = match(readfile('/proc/meminfo') || '', /MemAvailable:\s+([0-9]+)/);
|
|
||||||
return m ? int(m[1]) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Does the overlay filesystem transparently compress? jffs2/ubifs do (~3x on a Go binary),
|
|
||||||
* ext4/f2fs do not. This decides how much flash the FULL binary actually occupies. */
|
|
||||||
function overlay_compresses() {
|
|
||||||
const fd = popen("mount 2>/dev/null | awk '$3==\"/overlay\"{print $5; exit}'");
|
|
||||||
let t = '';
|
|
||||||
if (fd) { t = trim(fd.read('all')); fd.close(); }
|
|
||||||
return (t in ['jffs2', 'ubifs']);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Footprint thresholds (KB). The UPX/compact build is already compressed (~20 MB on ANY fs)
|
|
||||||
* but decompresses the whole binary into RAM at every launch (trades flash for RAM). The full
|
|
||||||
* Go binary is ~76 MB raw → ~76 MB on ext4/f2fs, but only ~26 MB on a compressing overlay. */
|
|
||||||
const FULL_OVERLAY_RAW_KB = 81920; /* ~80 MB free for the full binary on an uncompressed overlay */
|
|
||||||
const FULL_OVERLAY_COMP_KB = 32768; /* ~32 MB on a compressing overlay (binary stores ~3x smaller) */
|
|
||||||
const COMPACT_OVERLAY_KB = 25600; /* ~25 MB for the compact (UPX) build — fs-independent */
|
|
||||||
const COMPACT_RAM_KB = 98304; /* ~96 MB free RAM to decompress + run the compact build */
|
|
||||||
|
|
||||||
const action = ARGV[0];
|
|
||||||
let result;
|
|
||||||
|
|
||||||
if (action === 'info') {
|
|
||||||
const pkg_manager = detect_pkg_manager();
|
|
||||||
const arch = detect_arch();
|
|
||||||
|
|
||||||
const tmp_free_kb = free_kb('/tmp');
|
|
||||||
let overlay_free_kb = free_kb('/overlay');
|
|
||||||
if (!overlay_free_kb) overlay_free_kb = free_kb('/');
|
|
||||||
const ram_free_kb = free_ram_kb();
|
|
||||||
|
|
||||||
const hiddify_installed = !!access('/usr/bin/hiddify-core');
|
|
||||||
let hiddify_version = null;
|
|
||||||
if (hiddify_installed) {
|
|
||||||
const fd = popen('/usr/bin/hiddify-core version 2>/dev/null');
|
|
||||||
if (fd) {
|
|
||||||
const out = fd.read('all'); fd.close();
|
|
||||||
const m = match(out, /version v?(\S+)/);
|
|
||||||
if (m) hiddify_version = m[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const singbox_installed = !!access('/usr/bin/sing-box');
|
|
||||||
let singbox_version = null;
|
|
||||||
let singbox_extended = false;
|
|
||||||
if (singbox_installed) {
|
|
||||||
const fd = popen('/usr/bin/sing-box version 2>/dev/null');
|
|
||||||
if (fd) {
|
|
||||||
const out = fd.read('all'); fd.close();
|
|
||||||
const m = match(out, /version v?(\S+)/);
|
|
||||||
if (m) singbox_version = m[1];
|
|
||||||
singbox_extended = !!match(out, /amneziawg|with_amnezia/);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
result = {
|
|
||||||
pkg_manager, arch, tmp_free_kb, overlay_free_kb, ram_free_kb,
|
|
||||||
hiddify: { installed: hiddify_installed, version: hiddify_version },
|
|
||||||
singbox: { installed: singbox_installed, version: singbox_version, extended: singbox_extended }
|
|
||||||
};
|
|
||||||
|
|
||||||
} else if (action === 'check_remote') {
|
|
||||||
const core = ARGV[1];
|
|
||||||
if (!(core in ['hiddify', 'singbox'])) {
|
|
||||||
result = { error: 'illegal core' };
|
|
||||||
} else {
|
|
||||||
const api_url = core === 'hiddify'
|
|
||||||
? 'https://api.github.com/repos/1andrevich/hiddify-core/releases/latest'
|
|
||||||
: 'https://api.github.com/repos/shtorm-7/sing-box-extended/releases/latest';
|
|
||||||
|
|
||||||
const fd = popen('wget -qO- --timeout=10 ' + shellquote(api_url) + ' 2>/dev/null');
|
|
||||||
if (!fd) {
|
|
||||||
result = { error: 'wget failed' };
|
|
||||||
} else {
|
|
||||||
const raw = trim(fd.read('all')); fd.close();
|
|
||||||
if (!length(raw)) {
|
|
||||||
result = { error: 'no response from GitHub API' };
|
|
||||||
} else {
|
|
||||||
let data;
|
|
||||||
try { data = json(raw); } catch(e) { data = null; }
|
|
||||||
if (!data)
|
|
||||||
result = { error: 'invalid JSON from GitHub API' };
|
|
||||||
else if (!data?.tag_name)
|
|
||||||
result = { error: 'could not read tag_name from response' };
|
|
||||||
else
|
|
||||||
result = { tag: data.tag_name, version: replace(data.tag_name, /^v/, '') };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (action === 'prepare_install') {
|
|
||||||
const core = ARGV[1];
|
|
||||||
const variant = ARGV[2] || ''; /* 'upx' = the compressed hiddify-core build (small flash, decompresses into RAM at launch) */
|
|
||||||
if (!(core in ['hiddify', 'singbox'])) {
|
|
||||||
result = { error: 'illegal core' };
|
|
||||||
} else {
|
|
||||||
const pkg_manager = detect_pkg_manager();
|
|
||||||
if (!pkg_manager) {
|
|
||||||
result = { error: 'no supported package manager found (apk or opkg)' };
|
|
||||||
} else {
|
|
||||||
const arch = detect_arch();
|
|
||||||
if (!arch || !match(arch, /^[a-zA-Z0-9_-]+$/)) {
|
|
||||||
result = { error: 'could not detect device architecture' };
|
|
||||||
} else {
|
|
||||||
const tmp_free_kb = free_kb('/tmp');
|
|
||||||
if (tmp_free_kb < 30720) {
|
|
||||||
result = { error: `not enough /tmp space: ${tmp_free_kb} KB free, need 30 MB` };
|
|
||||||
} else {
|
|
||||||
let overlay_free_kb = free_kb('/overlay');
|
|
||||||
if (!overlay_free_kb) overlay_free_kb = free_kb('/');
|
|
||||||
const ram_free_kb = free_ram_kb();
|
|
||||||
const ext = pkg_manager === 'apk' ? '.apk' : '.ipk';
|
|
||||||
/* full-binary footprint depends on whether the overlay compresses */
|
|
||||||
const full_need = overlay_compresses() ? FULL_OVERLAY_COMP_KB : FULL_OVERLAY_RAW_KB;
|
|
||||||
|
|
||||||
if (core === 'hiddify') {
|
|
||||||
/* Auto-pick the build from free overlay (+ free RAM for the compact one).
|
|
||||||
* ARGV[2] ('standard'|'upx') is an advanced override; size-checked either way.
|
|
||||||
* This is the guardrail for the "full binary truncates on a small overlay →
|
|
||||||
* SIGBUS" trap: never offer a build the device can't actually hold/run. */
|
|
||||||
let chosen = variant;
|
|
||||||
let note = null;
|
|
||||||
/* UPX-compressed binaries are crash-prone (SIGILL on startup) on these
|
|
||||||
* soft-float MIPS targets, so never AUTO-pick the compact build there —
|
|
||||||
* fall back to standard, or error cleanly. An explicit variant='upx'
|
|
||||||
* override is still honored (advanced users). Non-MIPS arches (incl.
|
|
||||||
* aarch64 like MT7622) are unaffected. */
|
|
||||||
const mips_no_auto_upx = (arch === 'mipsel_24kc' || arch === 'mips_24kc');
|
|
||||||
if (chosen !== 'standard' && chosen !== 'upx') {
|
|
||||||
if (overlay_free_kb >= full_need)
|
|
||||||
chosen = 'standard';
|
|
||||||
else if (mips_no_auto_upx)
|
|
||||||
chosen = null; /* don't squeeze a crash-prone compact build onto tight-flash MIPS */
|
|
||||||
else if (overlay_free_kb >= COMPACT_OVERLAY_KB && ram_free_kb >= COMPACT_RAM_KB) {
|
|
||||||
chosen = 'upx';
|
|
||||||
note = 'Limited storage — installing the compact build (decompresses into RAM at launch).';
|
|
||||||
} else
|
|
||||||
chosen = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!chosen) {
|
|
||||||
result = mips_no_auto_upx
|
|
||||||
? { error: `Not enough overlay for the full build: ${overlay_free_kb} KB free, need ~${full_need} KB. The compact build is not offered on MIPS (UPX is unreliable there). Free up space, or bake the core into a custom firmware image.` }
|
|
||||||
: (overlay_free_kb >= COMPACT_OVERLAY_KB)
|
|
||||||
? { error: `Device too constrained: ${overlay_free_kb} KB free overlay (full build needs ~${full_need}) and only ${ram_free_kb} KB free RAM (compact build needs ~${COMPACT_RAM_KB}). Use sing-box-extended instead.` }
|
|
||||||
: { error: `Not enough storage: ${overlay_free_kb} KB free overlay, need at least ~${COMPACT_OVERLAY_KB} KB (~25 MB).` };
|
|
||||||
} else {
|
|
||||||
const need = (chosen === 'upx') ? COMPACT_OVERLAY_KB : full_need;
|
|
||||||
if (overlay_free_kb < need) {
|
|
||||||
result = { error: `Not enough overlay space for the ${chosen === 'upx' ? 'compact' : 'full'} build: ${overlay_free_kb} KB free, need ~${need} KB.` };
|
|
||||||
} else {
|
|
||||||
const hp_path = (chosen === 'upx') ? 'download/upx' : 'latest/download';
|
|
||||||
result = {
|
|
||||||
pkg_manager, arch, variant: chosen, note,
|
|
||||||
dl_url: `https://github.com/1andrevich/hiddify-core/releases/${hp_path}/hiddify-core_${arch}${ext}`,
|
|
||||||
tmp_path: `/tmp/hiddify-core${ext}`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (overlay_free_kb < full_need) {
|
|
||||||
result = { error: `Not enough overlay space: ${overlay_free_kb} KB free, sing-box-extended needs ~${full_need} KB.` };
|
|
||||||
} else {
|
|
||||||
const api_fd = popen('wget -qO- --timeout=10 https://api.github.com/repos/shtorm-7/sing-box-extended/releases/latest 2>/dev/null');
|
|
||||||
if (!api_fd) {
|
|
||||||
result = { error: 'failed to contact GitHub API' };
|
|
||||||
} else {
|
|
||||||
const api_raw = trim(api_fd.read('all')); api_fd.close();
|
|
||||||
let api_data;
|
|
||||||
try { api_data = json(api_raw); } catch(e) { api_data = null; }
|
|
||||||
if (!api_data?.tag_name) {
|
|
||||||
result = { error: 'could not determine latest version from GitHub' };
|
|
||||||
} else {
|
|
||||||
let dl_url = null;
|
|
||||||
for (let asset in (api_data?.assets || [])) {
|
|
||||||
const n = asset?.name || '';
|
|
||||||
if (!match(n, /openwrt/)) continue;
|
|
||||||
if (length(split(n, arch)) < 2) continue;
|
|
||||||
if (ext === '.apk' && !match(n, /\.apk$/)) continue;
|
|
||||||
if (ext === '.ipk' && !match(n, /\.ipk$/)) continue;
|
|
||||||
dl_url = asset?.browser_download_url;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!dl_url)
|
|
||||||
result = { error: `no package found for arch ${arch} in latest release` };
|
|
||||||
else
|
|
||||||
result = { pkg_manager, arch, dl_url, tmp_path: `/tmp/sing-box-extended${ext}` };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (action === 'download_pkg') {
|
|
||||||
/* UI calls (url, tmp_path). Also tolerate an optional leading core arg
|
|
||||||
* (download_pkg <core> <url> <tmp_path>) so callers that mirror the
|
|
||||||
* prepare_install/install_pkg core-first signature don't silently fail. */
|
|
||||||
let url = ARGV[1];
|
|
||||||
let tmp_path = ARGV[2];
|
|
||||||
if (ARGV[3] && (ARGV[1] in ['hiddify', 'singbox'])) {
|
|
||||||
url = ARGV[2];
|
|
||||||
tmp_path = ARGV[3];
|
|
||||||
}
|
|
||||||
if (!url || !tmp_path) {
|
|
||||||
result = { result: false, error: 'missing arguments' };
|
|
||||||
} else {
|
|
||||||
const exit_code = gh_fetch(url, tmp_path, 300000); /* GitHub first, mirror fallback */
|
|
||||||
result = exit_code === 0 ? { result: true } : { result: false, error: 'download failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (action === 'install_pkg') {
|
|
||||||
const core = ARGV[1];
|
|
||||||
const tmp_path = ARGV[2];
|
|
||||||
const pkg_manager = ARGV[3];
|
|
||||||
if (!(core in ['hiddify', 'singbox']) || !tmp_path || !pkg_manager) {
|
|
||||||
result = { result: false, error: 'invalid arguments' };
|
|
||||||
} else {
|
|
||||||
let exit_code;
|
|
||||||
if (core === 'hiddify' && pkg_manager === 'apk') {
|
|
||||||
/* Signing key: skip the fetch if already present (a provisioning tool may pre-place
|
|
||||||
* it); else GitHub-first, mirror-FALLBACK via gh_fetch, best-effort (a
|
|
||||||
* throttled/blocked GitHub must NOT fail the install — the package already
|
|
||||||
* arrived over HTTPS). Install trusted if the key is there, else untrusted. */
|
|
||||||
if (!access('/etc/apk/keys/homeproxy-hiddify.pub')) {
|
|
||||||
if (gh_fetch('https://github.com/1andrevich/homeproxy-hiddify/releases/latest/download/homeproxy-hiddify.pub', '/tmp/homeproxy-hiddify.pub', 20000) === 0)
|
|
||||||
system('[ -s /tmp/homeproxy-hiddify.pub ] && cp /tmp/homeproxy-hiddify.pub /etc/apk/keys/ 2>/dev/null; rm -f /tmp/homeproxy-hiddify.pub');
|
|
||||||
}
|
|
||||||
if (access('/etc/apk/keys/homeproxy-hiddify.pub'))
|
|
||||||
exit_code = system(`apk add ${shellquote(tmp_path)} >/dev/null 2>&1; RC=$?; rm -f ${shellquote(tmp_path)}; exit $RC`, 120000);
|
|
||||||
else
|
|
||||||
exit_code = system(`apk add --allow-untrusted ${shellquote(tmp_path)} >/dev/null 2>&1; RC=$?; rm -f ${shellquote(tmp_path)}; exit $RC`, 120000);
|
|
||||||
} else if (pkg_manager === 'apk') {
|
|
||||||
/* sing-box-extended has no signing key — allow-untrusted is unavoidable */
|
|
||||||
exit_code = system(
|
|
||||||
`{ apk add --allow-untrusted ${shellquote(tmp_path)}; } >/dev/null 2>&1` +
|
|
||||||
`; RC=$?; rm -f ${shellquote(tmp_path)}; exit $RC`,
|
|
||||||
120000
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
exit_code = system(
|
|
||||||
`{ opkg install --force-reinstall ${shellquote(tmp_path)}; } >/dev/null 2>&1` +
|
|
||||||
`; RC=$?; rm -f ${shellquote(tmp_path)}; exit $RC`,
|
|
||||||
120000
|
|
||||||
);
|
|
||||||
}
|
|
||||||
result = exit_code === 0 ? { result: true } : { result: false, error: 'package installation failed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (action === 'install_kmods') {
|
|
||||||
const pkg_manager = ARGV[1] || detect_pkg_manager();
|
|
||||||
let exit_code = 1;
|
|
||||||
/* No --no-cache: it forces apk to refetch the package index over the network
|
|
||||||
* (slow / >60s on poor uplinks, and the index is usually already cached from the
|
|
||||||
* app install). The kmods are tiny and resolve from the local index in seconds. */
|
|
||||||
if (pkg_manager === 'apk')
|
|
||||||
exit_code = system('apk add kmod-nft-tproxy kmod-tun >/dev/null 2>&1', 120000);
|
|
||||||
else if (pkg_manager === 'opkg')
|
|
||||||
exit_code = system('opkg install kmod-nft-tproxy kmod-tun >/dev/null 2>&1', 60000);
|
|
||||||
result = (exit_code === 0)
|
|
||||||
? { result: true }
|
|
||||||
: { result: false, error: `kmod install failed (pkg_manager=${pkg_manager || 'none'})` };
|
|
||||||
|
|
||||||
} else if (action === 'remove') {
|
|
||||||
const core = ARGV[1];
|
|
||||||
if (!(core in ['hiddify', 'singbox'])) {
|
|
||||||
result = { result: false, error: 'illegal core' };
|
|
||||||
} else {
|
|
||||||
const pkg_manager = detect_pkg_manager();
|
|
||||||
if (!pkg_manager) {
|
|
||||||
result = { result: false, error: 'no supported package manager found' };
|
|
||||||
} else {
|
|
||||||
const pkg_name = core === 'hiddify' ? 'hiddify-core' : 'sing-box-extended';
|
|
||||||
const exit_code = pkg_manager === 'apk'
|
|
||||||
? system(`apk del ${pkg_name} >/dev/null 2>&1`, 30000)
|
|
||||||
: system(`opkg remove ${pkg_name} >/dev/null 2>&1`, 30000);
|
|
||||||
result = { result: (exit_code === 0) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} else {
|
|
||||||
result = { error: `unknown action: ${action}` };
|
|
||||||
}
|
|
||||||
|
|
||||||
printf('%s\n', result);
|
|
||||||
@@ -1,120 +0,0 @@
|
|||||||
#!/usr/bin/ucode
|
|
||||||
/*
|
|
||||||
* SPDX-License-Identifier: GPL-2.0-only
|
|
||||||
*
|
|
||||||
* Headless import of a WireGuard / AmneziaWG `.conf` file into a homeproxy
|
|
||||||
* `node` section. This is the SSH/agent-callable equivalent of the browser-only
|
|
||||||
* `parseWireGuardConf()` + "Import .conf" button in the LuCI node view — kept
|
|
||||||
* field-for-field in sync with it.
|
|
||||||
*
|
|
||||||
* Usage: ucode import_conf.uc <path-to-.conf> [label]
|
|
||||||
* Output: one JSON line, e.g. { "result": true, "section": "awg…", "label": "…", "type": "amneziawg" }
|
|
||||||
* or { "error": "…" } with a non-zero exit code.
|
|
||||||
*/
|
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import { readfile } from 'fs';
|
|
||||||
import { cursor } from 'uci';
|
|
||||||
|
|
||||||
const uciconfig = 'homeproxy';
|
|
||||||
|
|
||||||
function emit(o) { printf('%s\n', o); }
|
|
||||||
|
|
||||||
/* Dependency-free stable hash (FNV-1a, 32-bit) for an idempotent section name.
|
|
||||||
* Deliberately NOT using `digest`/md5: ucode-mod-digest is absent on OpenWrt 23.05
|
|
||||||
* (the legacy build strips it), and that target's build patch only rewrites
|
|
||||||
* update_subscriptions.uc — not this script. Pure ucode works everywhere. */
|
|
||||||
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 path = ARGV[0];
|
|
||||||
const want_label = ARGV[1];
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
emit({ error: 'usage: import_conf.uc <path-to-.conf> [label]' });
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = readfile(path);
|
|
||||||
if (!text) {
|
|
||||||
emit({ error: `cannot read file: ${path}` });
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- parse (mirror of parseWireGuardConf in node.js) ---- */
|
|
||||||
const rawlines = split(text, '\n');
|
|
||||||
let section = null;
|
|
||||||
let iface = {}, peer = {};
|
|
||||||
|
|
||||||
for (let i = 0; i < length(rawlines); i++) {
|
|
||||||
let line = trim(rawlines[i]);
|
|
||||||
if (!length(line) || substr(line, 0, 1) === '#')
|
|
||||||
continue;
|
|
||||||
if (line === '[Interface]') { section = 'interface'; continue; }
|
|
||||||
if (line === '[Peer]') { section = 'peer'; continue; }
|
|
||||||
let eq = index(line, '=');
|
|
||||||
if (eq < 0)
|
|
||||||
continue;
|
|
||||||
let key = trim(substr(line, 0, eq));
|
|
||||||
let val = trim(substr(line, eq + 1));
|
|
||||||
if (section === 'interface') iface[key] = val;
|
|
||||||
else if (section === 'peer') peer[key] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!iface.PrivateKey || !peer.PublicKey || !peer.Endpoint) {
|
|
||||||
emit({ error: 'not a valid WireGuard/AmneziaWG .conf (missing PrivateKey / PublicKey / Endpoint)' });
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* split Endpoint host:port on the LAST colon (IPv6 literal safe) */
|
|
||||||
const ep = peer.Endpoint;
|
|
||||||
const lc = rindex(ep, ':');
|
|
||||||
let host = (lc >= 0) ? substr(ep, 0, lc) : ep;
|
|
||||||
const port = (lc >= 0) ? substr(ep, lc + 1) : '';
|
|
||||||
host = replace(host, '[', '');
|
|
||||||
host = replace(host, ']', '');
|
|
||||||
|
|
||||||
const isAWG = !!(iface.Jc || iface.Jmin || iface.Jmax || iface.H1);
|
|
||||||
|
|
||||||
let node = {
|
|
||||||
label: want_label || (isAWG ? 'AmneziaWG' : 'WireGuard'),
|
|
||||||
type: isAWG ? 'amneziawg' : 'wireguard',
|
|
||||||
address: host,
|
|
||||||
port: port,
|
|
||||||
wireguard_private_key: iface.PrivateKey,
|
|
||||||
wireguard_peer_public_key: peer.PublicKey
|
|
||||||
};
|
|
||||||
if (peer.PresharedKey) node.wireguard_pre_shared_key = peer.PresharedKey;
|
|
||||||
if (iface.Address) node.wireguard_local_address = map(split(iface.Address, ','), (a) => trim(a));
|
|
||||||
if (iface.MTU) node.wireguard_mtu = iface.MTU;
|
|
||||||
|
|
||||||
if (isAWG) {
|
|
||||||
const awg = {
|
|
||||||
amnezia_jc: iface.Jc, amnezia_jmin: iface.Jmin, amnezia_jmax: iface.Jmax,
|
|
||||||
amnezia_s1: iface.S1, amnezia_s2: iface.S2, amnezia_s3: iface.S3, amnezia_s4: iface.S4,
|
|
||||||
amnezia_h1: iface.H1, amnezia_h2: iface.H2, amnezia_h3: iface.H3, amnezia_h4: iface.H4,
|
|
||||||
amnezia_i1: iface.I1, amnezia_i2: iface.I2, amnezia_i3: iface.I3, amnezia_i4: iface.I4, amnezia_i5: iface.I5
|
|
||||||
};
|
|
||||||
for (let k in awg)
|
|
||||||
if (awg[k] != null && awg[k] !== '')
|
|
||||||
node[k] = awg[k];
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ---- write the node section (idempotent: same file → same section name) ---- */
|
|
||||||
const uci = cursor();
|
|
||||||
uci.load(uciconfig);
|
|
||||||
|
|
||||||
const sid = 'awg' + strhash(text);
|
|
||||||
uci.set(uciconfig, sid, 'node');
|
|
||||||
for (let k in node)
|
|
||||||
uci.set(uciconfig, sid, k, node[k]);
|
|
||||||
uci.commit(uciconfig);
|
|
||||||
|
|
||||||
emit({ result: true, section: sid, label: node.label, type: node.type });
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
{
|
|
||||||
"luci-app-re-homeproxy": {
|
|
||||||
"description": "Grant access to homeproxy configuration",
|
|
||||||
"read": {
|
|
||||||
"file": {
|
|
||||||
"/etc/homeproxy/scripts/update_subscriptions.uc": [ "exec" ],
|
|
||||||
"/usr/bin/ucode": [ "exec" ],
|
|
||||||
"/etc/homeproxy/zapret_candidates.json": [ "read" ],
|
|
||||||
"/var/run/homeproxy/homeproxy.log": [ "read" ],
|
|
||||||
"/var/run/homeproxy/hiddify-c.log": [ "read" ]
|
|
||||||
},
|
|
||||||
"ubus": {
|
|
||||||
"service": [ "list" ],
|
|
||||||
"luci.homeproxy": [ "*" ]
|
|
||||||
},
|
|
||||||
"uci": [ "homeproxy" ]
|
|
||||||
},
|
|
||||||
"write": {
|
|
||||||
"file": {
|
|
||||||
"/tmp/homeproxy_certificate.tmp": [ "write" ]
|
|
||||||
},
|
|
||||||
"uci": [ "homeproxy" ]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -1,102 +0,0 @@
|
|||||||
🇬🇧 [English](ByeDPI-en) | 🇷🇺 [Русский](ByeDPI-ru)
|
|
||||||
|
|
||||||
# ByeDPI
|
|
||||||
|
|
||||||
**ByeDPI** is a built-in DPI-bypass that can un-throttle and unblock sites **without any VPN subscription**. Re:HomeProxy bundles the [ByeDPI](https://github.com/hufrea/byedpi) engine (`ciadpi`) and manages it for you.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it is — and what it is not
|
|
||||||
|
|
||||||
ByeDPI runs a tiny local proxy that **desyncs the TLS handshake** of your connections (splitting, reordering, fake packets, OOB data, etc.) so that your ISP's Deep Packet Inspection can no longer recognise — and therefore can no longer throttle or block — the traffic.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| ✅ **Un-throttles DPI-filtered sites** | The common case for YouTube and similar services that load slowly or buffer endlessly under DPI throttling. |
|
|
||||||
| ✅ **No subscription, no server** | It's a local trick — you don't need a VPN node, account, or any remote server. |
|
|
||||||
| ❌ **Does not encrypt or hide traffic** | Your ISP still sees *which* sites you visit; ByeDPI only changes *how* the first packets look. It is **not a VPN**. |
|
|
||||||
| ❌ **Cannot reach fully IP-blocked sites** | If a site is blocked by IP/DNS (not just throttled), only a real proxy/VPN node can reach it. |
|
|
||||||
| ❌ **Not a tunnel** | ByeDPI carries your traffic but does not resolve DNS like a proxy — see *DNS* below. |
|
|
||||||
|
|
||||||
Think of ByeDPI as a complement to a VPN node (offload throttled sites like YouTube to ByeDPI to save VPN bandwidth), or as a standalone tool when the only problem is throttling.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enabling ByeDPI
|
|
||||||
|
|
||||||
1. Go to **Services → Re:HomeProxy → Node Settings** and open the **ByeDPI** section.
|
|
||||||
2. If `ciadpi` is not installed yet, use the **Install** button — Re:HomeProxy fetches the right `byedpi` package for your architecture.
|
|
||||||
3. Tick **Enable**, pick a strategy (see below), and **Save & Apply**.
|
|
||||||
|
|
||||||
That's all that's needed for the engine. Re:HomeProxy starts `ciadpi` for you and prevents it from looping back through the proxy automatically — you do not need to touch the firewall or process settings.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategies and presets
|
|
||||||
|
|
||||||
A "strategy" is the set of command-line options ByeDPI uses to mangle the handshake. There is no universal best strategy — **what works depends on your ISP's DPI**. Re:HomeProxy ships **47 ready-made presets** grouped by technique (named by group letter, e.g. `C3`, `I1`):
|
|
||||||
|
|
||||||
| Group | Examples | Idea |
|
|
||||||
|-------|----------|------|
|
|
||||||
| Disorder | `--disorder 1`, `--disorder 1+s` | Reorder the first segments so DPI can't reassemble the SNI |
|
|
||||||
| Fake TTL | `--fake -1 --ttl 6…15` | Inject a fake packet that dies (low TTL) before reaching the server |
|
|
||||||
| Fake MD5 | `--fake -1 --md5sig` | Fake packet carrying an invalid TCP MD5 signature |
|
|
||||||
| TLS record | `--tlsrec 1+s` | Split at the TLS record layer |
|
|
||||||
| OOB / DisOOB | `--oob 1+s`, `--disoob 1+s` | Out-of-band byte tricks at the SNI |
|
|
||||||
| Split | `--split 1+s` | Split the ClientHello at the SNI |
|
|
||||||
| HTTP mix | `--mod-http hcsmix,…` | Case/format mixing for plain-HTTP hosts |
|
|
||||||
| **Adaptive** | `--auto=ssl_err`, `--auto=torst` | **Self-correcting** — retries per connection on TLS error / reset |
|
|
||||||
| Fake SNI/TLS | `--fake-sni`, `--fake-tls-mod` | Send a decoy SNI / TLS fingerprint |
|
|
||||||
| Aggressive combos | split + OOB + disorder together | When single techniques aren't enough |
|
|
||||||
|
|
||||||
You can also pick **Custom** and type your own option string.
|
|
||||||
|
|
||||||
### Recommended starting point: preset I1 (adaptive)
|
|
||||||
|
|
||||||
**Preset I1 — "Auto SSL Error Fallback"** (`--fake -1 --ttl 8 --auto=ssl_err --fake -1 --ttl 5`) is the safest default, **especially for YouTube**.
|
|
||||||
|
|
||||||
Why adaptive matters: a **fixed** fake-TTL strategy (the Fake-TTL group, presets C1–C5) works for *far* servers but corrupts the handshake on *near* CDN/edge servers — e.g. some YouTube video segments served from a local Google cache. A fixed strategy that fails on near servers not only stalls the page, it can produce a storm of failed-handshake retries that **fills the router's connection-tracking table and freezes SSH/LuCI**. The adaptive `--auto=ssl_err` strategy retries per-connection when it sees a TLS error, so it self-corrects across near *and* far destinations.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The strategy tester
|
|
||||||
|
|
||||||
Because the right strategy is ISP-specific, the ByeDPI section includes a **tester**:
|
|
||||||
|
|
||||||
- **Test all strategies** — probes four reference sites in parallel and shows a result per site as dots:
|
|
||||||
- **YouTube** and **Telegram** — *far* servers
|
|
||||||
- **Discord** and **Speedtest.net** — *near* servers
|
|
||||||
- `●●●●` = all four reachable with that strategy; `●●○○` = far sites work but near sites fail (a sign the strategy is destination-sensitive, like a fixed fake-TTL).
|
|
||||||
- **Test current** — tests only the strategy currently in the command field and shows full site names with ✓ / ✗.
|
|
||||||
|
|
||||||
Pick a strategy that scores **all-green (`●●●●`)** for your network. If several do, prefer an adaptive one (I1/I2).
|
|
||||||
|
|
||||||
> The tester runs ByeDPI on a separate temporary port, so it does not disturb live traffic.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing traffic through ByeDPI
|
|
||||||
|
|
||||||
ByeDPI appears as a selectable node (**ByeDPI**) once enabled. Two common setups:
|
|
||||||
|
|
||||||
- **Alongside a VPN node (recommended):** in `proxy_banned_ru` mode, open **RU Proxy Rules** and set a *specific list's* node to **ByeDPI** — e.g. route YouTube through ByeDPI (saving VPN bandwidth) while everything else uses your VPN.
|
|
||||||
- **Standalone (no VPN):** set ByeDPI as the **main node**. All proxied traffic then goes through the local desync.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
ByeDPI is a desync, not a tunnel, so DNS does **not** go through it. When ByeDPI carries traffic, Re:HomeProxy resolves names **directly** (your configured Secure DNS detours direct-out for ByeDPI). Trying to push DoH/DoT or UDP DNS *through* ByeDPI fails — the handshake mangling corrupts the encrypted DNS session. This is handled automatically; there is nothing to configure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Likely cause / fix |
|
|
||||||
|---------|--------------------|
|
|
||||||
| A site still loads slowly | The strategy isn't right for your ISP — run **Test all strategies** and pick an all-green one. |
|
|
||||||
| YouTube buffers on a fixed fake-TTL preset | Switch to an adaptive preset (**I1/I2**); fixed TTL stalls on near Google cache nodes. |
|
|
||||||
| Router/LuCI/SSH becomes laggy | A destination-sensitive strategy is causing retry storms — switch to an adaptive preset. |
|
|
||||||
| A site is unreachable, not just slow | It may be IP-blocked, not throttled — ByeDPI can't help; use a proxy/VPN node for it. |
|
|
||||||
|
|
||||||
See also: [Zapret](Zapret-en) · [Core Management](Core-Management-en) · [Supported Protocols](Supported-Protocols-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
🇬🇧 [English](ByeDPI-en) | 🇷🇺 [Русский](ByeDPI-ru)
|
|
||||||
|
|
||||||
# ByeDPI
|
|
||||||
|
|
||||||
**ByeDPI** — встроенный обход DPI, который позволяет разблокировать сайты и снять троттлинг **без какой-либо VPN-подписки**. Re:HomeProxy включает движок [ByeDPI](https://github.com/hufrea/byedpi) (`ciadpi`) и управляет им за вас.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что это — и чем оно не является
|
|
||||||
|
|
||||||
ByeDPI запускает крошечный локальный прокси, который **рассинхронизирует TLS-рукопожатие** ваших соединений (разбиение, переупорядочивание, фейковые пакеты, OOB-данные и т. п.), чтобы система DPI провайдера больше не распознавала трафик — а значит, не могла его замедлять или блокировать.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| ✅ **Снимает троттлинг сайтов под DPI** | Типичный случай — YouTube и подобные сервисы, которые медленно грузятся или бесконечно буферизуются из-за DPI-троттлинга. |
|
|
||||||
| ✅ **Без подписки и сервера** | Это локальный трюк — не нужен VPN-узел, аккаунт или удалённый сервер. |
|
|
||||||
| ❌ **Не шифрует и не скрывает трафик** | Провайдер по-прежнему видит, *какие* сайты вы посещаете; ByeDPI меняет только то, *как выглядят* первые пакеты. Это **не VPN**. |
|
|
||||||
| ❌ **Не открывает сайты, заблокированные по IP** | Если сайт блокируется по IP/DNS (а не просто троттлится), достучаться до него может только настоящий прокси/VPN-узел. |
|
|
||||||
| ❌ **Не туннель** | ByeDPI переносит трафик, но не резолвит DNS как прокси — см. раздел *DNS*. |
|
|
||||||
|
|
||||||
Рассматривайте ByeDPI как дополнение к VPN-узлу (перенаправьте троттлящиеся сайты вроде YouTube на ByeDPI, чтобы экономить трафик VPN) либо как самостоятельный инструмент, когда проблема только в троттлинге.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Включение ByeDPI
|
|
||||||
|
|
||||||
1. Откройте **Сервисы → Re:HomeProxy → Настройки узлов** и раздел **ByeDPI**.
|
|
||||||
2. Если `ciadpi` ещё не установлен, нажмите **Установить** — Re:HomeProxy скачает подходящий пакет `byedpi` для вашей архитектуры.
|
|
||||||
3. Отметьте **Включить**, выберите стратегию (см. ниже) и нажмите **Сохранить и применить**.
|
|
||||||
|
|
||||||
Этого достаточно. Re:HomeProxy сам запускает `ciadpi` и автоматически предотвращает зацикливание его трафика через прокси — настраивать межсетевой экран или процесс вручную не нужно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Стратегии и пресеты
|
|
||||||
|
|
||||||
«Стратегия» — это набор параметров командной строки, которыми ByeDPI искажает рукопожатие. Универсально лучшей стратегии нет — **что сработает, зависит от DPI вашего провайдера**. Re:HomeProxy включает **47 готовых пресетов**, сгруппированных по технике (именуются по букве группы, например `C3`, `I1`):
|
|
||||||
|
|
||||||
| Группа | Примеры | Идея |
|
|
||||||
|--------|---------|------|
|
|
||||||
| Disorder | `--disorder 1`, `--disorder 1+s` | Переупорядочить первые сегменты, чтобы DPI не собрал SNI |
|
|
||||||
| Fake TTL | `--fake -1 --ttl 6…15` | Вставить фейковый пакет, «умирающий» (низкий TTL) до сервера |
|
|
||||||
| Fake MD5 | `--fake -1 --md5sig` | Фейковый пакет с неверной TCP MD5-подписью |
|
|
||||||
| TLS record | `--tlsrec 1+s` | Разбиение на уровне TLS-записи |
|
|
||||||
| OOB / DisOOB | `--oob 1+s`, `--disoob 1+s` | Трюки с внеполосным байтом у SNI |
|
|
||||||
| Split | `--split 1+s` | Разбить ClientHello по SNI |
|
|
||||||
| HTTP mix | `--mod-http hcsmix,…` | Смешивание регистра/формата для обычного HTTP |
|
|
||||||
| **Адаптивные** | `--auto=ssl_err`, `--auto=torst` | **Самокорректирующиеся** — повторяют попытку на каждом соединении при TLS-ошибке/сбросе |
|
|
||||||
| Fake SNI/TLS | `--fake-sni`, `--fake-tls-mod` | Отправить ложный SNI / TLS-отпечаток |
|
|
||||||
| Агрессивные комбо | split + OOB + disorder вместе | Когда одиночных техник недостаточно |
|
|
||||||
|
|
||||||
Также можно выбрать **Custom** и ввести свою строку параметров.
|
|
||||||
|
|
||||||
### Рекомендуемая отправная точка: пресет I1 (адаптивный)
|
|
||||||
|
|
||||||
**Пресет I1 — «Auto SSL Error Fallback»** (`--fake -1 --ttl 8 --auto=ssl_err --fake -1 --ttl 5`) — самый безопасный вариант по умолчанию, **особенно для YouTube**.
|
|
||||||
|
|
||||||
Почему важна адаптивность: **фиксированная** стратегия fake-TTL (группа Fake TTL, пресеты C1–C5) работает для *далёких* серверов, но портит рукопожатие на *близких* CDN/edge-серверах — например, на сегментах видео YouTube из локального кэша Google. Фиксированная стратегия, дающая сбой на близких серверах, не только подвешивает страницу, но и может породить шторм повторов неудачных рукопожатий, который **переполняет таблицу отслеживания соединений роутера и подвешивает SSH/LuCI**. Адаптивная `--auto=ssl_err` повторяет попытку на каждом соединении при TLS-ошибке и потому сама подстраивается под близкие *и* далёкие адреса.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Тестер стратегий
|
|
||||||
|
|
||||||
Поскольку нужная стратегия зависит от провайдера, в разделе ByeDPI есть **тестер**:
|
|
||||||
|
|
||||||
- **Проверить все стратегии** — параллельно опрашивает четыре эталонных сайта и показывает результат по каждому точками:
|
|
||||||
- **YouTube** и **Telegram** — *далёкие* серверы
|
|
||||||
- **Discord** и **Speedtest.net** — *близкие* серверы
|
|
||||||
- `●●●●` = все четыре доступны с этой стратегией; `●●○○` = далёкие работают, близкие — нет (признак того, что стратегия чувствительна к адресу, как фиксированный fake-TTL).
|
|
||||||
- **Проверить текущую стратегию** — проверяет только стратегию из поля команды и показывает полные имена сайтов с ✓ / ✗.
|
|
||||||
|
|
||||||
Выбирайте стратегию с результатом **«всё зелёное» (`●●●●`)** для вашей сети. Если таких несколько — предпочтите адаптивную (I1/I2).
|
|
||||||
|
|
||||||
> Тестер запускает ByeDPI на отдельном временном порту, поэтому не мешает «живому» трафику.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Маршрутизация трафика через ByeDPI
|
|
||||||
|
|
||||||
После включения ByeDPI появляется как выбираемый узел (**ByeDPI**). Две частые схемы:
|
|
||||||
|
|
||||||
- **Вместе с VPN-узлом (рекомендуется):** в режиме `proxy_banned_ru` откройте **RU Proxy Rules** и задайте для *конкретного списка* узел **ByeDPI** — например, направьте YouTube через ByeDPI (экономя трафик VPN), а остальное оставьте на VPN.
|
|
||||||
- **Самостоятельно (без VPN):** назначьте ByeDPI **основным узлом**. Тогда весь проксируемый трафик идёт через локальную рассинхронизацию.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
ByeDPI — это рассинхронизация, а не туннель, поэтому DNS через него **не** идёт. Когда ByeDPI несёт трафик, Re:HomeProxy резолвит имена **напрямую** (настроенный Secure DNS для ByeDPI идёт через direct-out). Попытка пропустить DoH/DoT или UDP DNS *через* ByeDPI проваливается — искажение рукопожатия ломает зашифрованную DNS-сессию. Это обрабатывается автоматически, настраивать ничего не нужно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Устранение неполадок
|
|
||||||
|
|
||||||
| Симптом | Вероятная причина / решение |
|
|
||||||
|---------|------------------------------|
|
|
||||||
| Сайт всё ещё грузится медленно | Стратегия не подходит вашему провайдеру — запустите **Проверить все стратегии** и выберите полностью зелёную. |
|
|
||||||
| YouTube буферизуется на фиксированном пресете fake-TTL | Переключитесь на адаптивный пресет (**I1/I2**); фиксированный TTL подвешивает близкие узлы кэша Google. |
|
|
||||||
| Роутер/LuCI/SSH начинают тормозить | Стратегия, чувствительная к адресу, вызывает шторм повторов — переключитесь на адаптивный пресет. |
|
|
||||||
| Сайт недоступен, а не просто медленный | Возможно, он заблокирован по IP, а не троттлится — ByeDPI не поможет; используйте для него прокси/VPN-узел. |
|
|
||||||
|
|
||||||
См. также: [Zapret](Zapret-ru) · [Управление ядром](Core-Management-ru) · [Поддерживаемые протоколы](Supported-Protocols-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
🇬🇧 [English](Core-Management-en) | 🇷🇺 [Русский](Core-Management-ru)
|
|
||||||
|
|
||||||
# Core Management
|
|
||||||
|
|
||||||
Re:HomeProxy is **multi-core**: the LuCI app is the interface, and a separate **core** binary does the actual proxying. You install, update, and switch cores from **Services → Re:HomeProxy → Core & Tools → Core management** — no SSH required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Choosing a core
|
|
||||||
|
|
||||||
| | **hiddify-core** (default) | **sing-box-extended** |
|
|
||||||
|---|---|---|
|
|
||||||
| Engine | Fork of sing-box by the Hiddify team | Fork of sing-box with extra build tags |
|
|
||||||
| Footprint | Lighter; a **compact build** exists for small devices | Larger (~26 MB installed) |
|
|
||||||
| Protocols | Hiddify-app protocols, TLS fragment, XHTTP, Mieru, etc. | The widest protocol set… |
|
|
||||||
| **AmneziaWG / WARP** | ❌ **Not supported** | ✅ **Supported** |
|
|
||||||
|
|
||||||
**Rule of thumb:** if you need **AmneziaWG/WARP**, or want the broadest protocol coverage and have ~40 MB free, choose **sing-box-extended**. Otherwise **hiddify-core** is the lighter default and is the only one with a compact build for tight-storage routers.
|
|
||||||
|
|
||||||
Which protocols appear in the node editor depends on the core you install — see [Supported Protocols](Supported-Protocols-en).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installing — one smart button
|
|
||||||
|
|
||||||
Core Management has a single **Install** button per core. It does **not** ask you to choose between "standard" and "compressed" builds — instead it inspects your device and picks a build that actually fits:
|
|
||||||
|
|
||||||
1. It reads the free space on `/overlay` (persistent flash) and your free RAM.
|
|
||||||
2. For hiddify-core: if the full build fits → it installs the **standard** build. If storage is tight but there's enough RAM → it installs the **compact** build and tells you so.
|
|
||||||
3. If neither can fit → it stops with a clear message instead of installing something broken.
|
|
||||||
|
|
||||||
### Why the guardrail matters
|
|
||||||
|
|
||||||
A core binary that is **larger than the free overlay** gets **truncated** as it's written, and then crashes with a **"bus error" (SIGBUS)** the moment it's launched — a confusing failure that looks like a corrupt download. The installer's size check exists specifically to prevent this: it never offers a build the device can't hold.
|
|
||||||
|
|
||||||
### The compact build
|
|
||||||
|
|
||||||
The compact (UPX-compressed) build of hiddify-core is much smaller on flash, but it **decompresses into RAM each time it launches** — trading flash space for memory. The installer only picks it when there's enough free RAM, and shows a note like *"Limited storage — installing the compact build (decompresses into RAM at launch)."* On most routers this is invisible in day-to-day use.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Storage at a glance
|
|
||||||
|
|
||||||
| Free on `/overlay` | What installs |
|
|
||||||
|--------------------|---------------|
|
|
||||||
| ~40 MB+ | hiddify-core or sing-box-extended (full build) |
|
|
||||||
| ~25–40 MB | hiddify-core (compact build, if RAM allows) |
|
|
||||||
| < ~25 MB | Not enough — free space, or bake the core into a custom firmware image |
|
|
||||||
|
|
||||||
> On a **compressing** overlay (jffs2/ubifs) the full build needs less free space than the raw figure, because the filesystem compresses it. The installer accounts for this automatically — trust its check over the table above.
|
|
||||||
|
|
||||||
Tight on flash but building your own image? Large cores fit comfortably when **baked into the SquashFS** root at image-build time (the SquashFS root is compressed and read-only), rather than installed into the writable overlay.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Switching cores and updating
|
|
||||||
|
|
||||||
- **Update:** press **Install** again — it fetches the latest release and reinstalls.
|
|
||||||
- **Switch cores:** install the other core; if both are present, Re:HomeProxy uses your **Preferred core** setting (on **Client → Routing Settings**). The config generator and the service honour the same preference, so the generated config always matches the core that will run it.
|
|
||||||
- **Custom / external core:** instead of the managed install you can point Re:HomeProxy at a **self-provided core binary** (a build you compiled, or a version not in Releases) — it detects and uses it. Advanced setups only.
|
|
||||||
- **Version / status:** the **Core management** section shows the installed core and version. If it shows all `?`, the backend (rpcd) is stale — restart it (`/etc/init.d/rpcd restart`) and reload the page.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resources & updates
|
|
||||||
|
|
||||||
The **Core & Tools** tab also has a **Resources management** section for the data files routing depends on — the GeoIP / Geosite databases and the RU rule-sets:
|
|
||||||
|
|
||||||
- **Check update** fetches the latest rule-set and geo data on demand.
|
|
||||||
- The RU routing lists also **self-refresh** on a schedule, so in normal use you don't need to touch this.
|
|
||||||
- **Subscriptions** update separately — on demand from the Subscriptions tab, or automatically on a cron; see [Subscriptions & Node Import](Subscriptions-en).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Kernel modules
|
|
||||||
|
|
||||||
The proxy needs `kmod-nft-tproxy` and `kmod-tun` for transparent routing. The installer pulls these in; if routing doesn't work after a fresh install, confirm they're present.
|
|
||||||
|
|
||||||
See also: [ByeDPI](ByeDPI-en) · [Zapret](Zapret-en) · [Supported Protocols](Supported-Protocols-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
🇬🇧 [English](Core-Management-en) | 🇷🇺 [Русский](Core-Management-ru)
|
|
||||||
|
|
||||||
# Управление ядром
|
|
||||||
|
|
||||||
Re:HomeProxy **многоядерный**: приложение LuCI — это интерфейс, а само проксирование выполняет отдельный бинарник-**ядро**. Установка, обновление и переключение ядер выполняются на вкладке **Сервисы → Re:HomeProxy → Ядро и службы → Управление ядром** — без SSH.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Выбор ядра
|
|
||||||
|
|
||||||
| | **hiddify-core** (по умолчанию) | **sing-box-extended** |
|
|
||||||
|---|---|---|
|
|
||||||
| Движок | Форк sing-box от команды Hiddify | Форк sing-box с доп. флагами сборки |
|
|
||||||
| Размер | Легче; есть **компактная сборка** для слабых устройств | Крупнее (~26 МБ установленного) |
|
|
||||||
| Протоколы | Протоколы Hiddify-app, TLS fragment, XHTTP, Mieru и др. | Самый широкий набор протоколов… |
|
|
||||||
| **AmneziaWG / WARP** | ❌ **Не поддерживается** | ✅ **Поддерживается** |
|
|
||||||
|
|
||||||
**Правило:** если нужен **AmneziaWG/WARP** или хочется максимально широкий набор протоколов и есть ~40 МБ свободного места — выбирайте **sing-box-extended**. Иначе **hiddify-core** — более лёгкий вариант по умолчанию и единственный с компактной сборкой для роутеров с малым объёмом памяти.
|
|
||||||
|
|
||||||
Какие протоколы появятся в редакторе узлов — зависит от установленного ядра; см. [Поддерживаемые протоколы](Supported-Protocols-ru).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Установка — одна умная кнопка
|
|
||||||
|
|
||||||
На странице «Управление ядром» для каждого ядра есть единственная кнопка **Установить**. Она **не** спрашивает, какую сборку — «стандартную» или «сжатую» — выбрать. Вместо этого она проверяет устройство и подбирает сборку, которая действительно поместится:
|
|
||||||
|
|
||||||
1. Считывает свободное место на `/overlay` (постоянная флеш-память) и свободную ОЗУ.
|
|
||||||
2. Для hiddify-core: если полная сборка помещается → ставит **стандартную**. Если места мало, но ОЗУ хватает → ставит **компактную** и сообщает об этом.
|
|
||||||
3. Если не помещается ни одна → останавливается с понятным сообщением, а не ставит заведомо нерабочее.
|
|
||||||
|
|
||||||
### Зачем нужна эта защита
|
|
||||||
|
|
||||||
Бинарник ядра, который **больше свободного overlay**, при записи **обрезается** и затем падает с **«bus error» (SIGBUS)** при запуске — сбивающая с толку ошибка, похожая на повреждённую загрузку. Проверка размера в установщике существует именно для этого: он никогда не предложит сборку, которая не помещается на устройстве.
|
|
||||||
|
|
||||||
### Компактная сборка
|
|
||||||
|
|
||||||
Компактная (сжатая UPX) сборка hiddify-core занимает гораздо меньше места на флеше, но **распаковывается в ОЗУ при каждом запуске** — обмен места на флеше на память. Установщик выбирает её только при достаточном объёме свободной ОЗУ и показывает примечание вроде *«Мало места — устанавливается компактная сборка (распаковывается в ОЗУ при запуске)»*. На большинстве роутеров в повседневной работе это незаметно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Память — кратко
|
|
||||||
|
|
||||||
| Свободно на `/overlay` | Что устанавливается |
|
|
||||||
|------------------------|---------------------|
|
|
||||||
| ~40 МБ+ | hiddify-core или sing-box-extended (полная сборка) |
|
|
||||||
| ~25–40 МБ | hiddify-core (компактная сборка, если хватает ОЗУ) |
|
|
||||||
| < ~25 МБ | Недостаточно — освободите место или запеките ядро в собственный образ прошивки |
|
|
||||||
|
|
||||||
> На **сжимающем** overlay (jffs2/ubifs) полной сборке нужно меньше свободного места, чем сырая цифра, потому что ФС её сжимает. Установщик учитывает это автоматически — доверяйте его проверке, а не таблице выше.
|
|
||||||
|
|
||||||
Мало флеша, но собираете свой образ? Крупные ядра свободно помещаются, если **запечь их в SquashFS**-корень на этапе сборки образа (корень SquashFS сжат и доступен только для чтения), а не ставить в записываемый overlay.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Переключение ядер и обновление
|
|
||||||
|
|
||||||
- **Обновление:** нажмите **Установить** снова — будет загружен и переустановлен последний релиз.
|
|
||||||
- **Переключение ядер:** установите другое ядро; если присутствуют оба, Re:HomeProxy использует настройку **Предпочтительное ядро** (на вкладке **Настройки клиента → Настройки маршрутизации**). Генератор конфигурации и служба учитывают ту же настройку, поэтому сгенерированный конфиг всегда соответствует ядру, которое его запустит.
|
|
||||||
- **Своё / внешнее ядро:** вместо управляемой установки можно указать Re:HomeProxy на **собственный бинарник ядра** (свою сборку или версию, которой нет в Releases) — он его обнаружит и использует. Только для продвинутых сценариев.
|
|
||||||
- **Версия / статус:** раздел **Управление ядром** показывает установленное ядро и версию. Если везде `?`, значит бэкенд (rpcd) устарел — перезапустите его (`/etc/init.d/rpcd restart`) и перезагрузите страницу.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ресурсы и обновления
|
|
||||||
|
|
||||||
На вкладке **Ядро и службы** также есть раздел **Управление ресурсами** для файлов данных, от которых зависит маршрутизация — баз GeoIP / Geosite и RU-списков:
|
|
||||||
|
|
||||||
- **Проверить обновление** скачивает свежие списки и гео-данные по запросу.
|
|
||||||
- RU-списки маршрутизации к тому же **сами обновляются** по расписанию, так что в обычной работе трогать это не нужно.
|
|
||||||
- **Подписки** обновляются отдельно — по запросу со вкладки «Подписки» или автоматически по cron; см. [Подписки и импорт узлов](Subscriptions-ru).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Модули ядра
|
|
||||||
|
|
||||||
Для прозрачной маршрутизации прокси нужны `kmod-nft-tproxy` и `kmod-tun`. Установщик подтягивает их; если после свежей установки маршрутизация не работает — проверьте, что они присутствуют.
|
|
||||||
|
|
||||||
См. также: [ByeDPI](ByeDPI-ru) · [Zapret](Zapret-ru) · [Поддерживаемые протоколы](Supported-Protocols-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,155 +0,0 @@
|
|||||||
🇬🇧 [English](Custom-JSON-Config-en) | 🇷🇺 [Русский](Custom-JSON-Config-ru)
|
|
||||||
|
|
||||||
# Custom JSON Config
|
|
||||||
|
|
||||||
**Custom JSON** is a routing mode that lets you bypass Re:HomeProxy's UCI-based configuration entirely and supply a raw [sing-box](https://sing-box.sagernet.org) / hiddify-core JSON config directly. It is intended for advanced users who need fine-grained control that the web UI does not expose.
|
|
||||||
|
|
||||||
To enable it, go to **Re:HomeProxy → Client → Routing Settings** and set **Routing Mode** to **Custom JSON**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Config Structure
|
|
||||||
|
|
||||||
A hiddify-core config is a JSON object. The main top-level sections are:
|
|
||||||
|
|
||||||
| Section | Purpose |
|
|
||||||
|---------|---------|
|
|
||||||
| `log` | Logging level and output path |
|
|
||||||
| `dns` | DNS servers, rules, and strategy |
|
|
||||||
| `inbounds` | How traffic enters the proxy (tproxy, tun, socks, http, etc.) |
|
|
||||||
| `outbounds` | Where traffic exits (direct, proxy servers, chains) |
|
|
||||||
| `route` | Rules that map inbound traffic to outbounds |
|
|
||||||
| `experimental` | Cache database, clash API, and other experimental features |
|
|
||||||
|
|
||||||
A minimal working config needs at least one inbound, at least two outbounds (`proxy` + `direct`), and route rules to decide which traffic goes where.
|
|
||||||
|
|
||||||
**Full reference:** [sing-box Configuration](https://sing-box.sagernet.org/configuration/)
|
|
||||||
|
|
||||||
Specific sections:
|
|
||||||
- [DNS](https://sing-box.sagernet.org/configuration/dns/)
|
|
||||||
- [Inbounds](https://sing-box.sagernet.org/configuration/inbound/)
|
|
||||||
- [Outbounds](https://sing-box.sagernet.org/configuration/outbound/)
|
|
||||||
- [Route](https://sing-box.sagernet.org/configuration/route/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## hiddify-core vs. sing-box
|
|
||||||
|
|
||||||
hiddify-core is a fork of sing-box and is largely config-compatible. The key differences:
|
|
||||||
|
|
||||||
- **Additional protocols** — hiddify-core may support protocols not yet upstream in sing-box, or support them earlier. This includes AnyTLS and extended options for some existing protocols. Check [Supported Protocols](Supported-Protocols-en) for what your installed build includes.
|
|
||||||
- **Hiddify-specific extensions** — some extra fields and options exist for hiddify's own features. These are documented in the [HiddifyCli guide](https://hiddify.com/app/HiddifyCli-guide/#run-config-or-subscription-link-in-hiddifycli-with-hiddifyapp-settings).
|
|
||||||
- **Version differences** — hiddify-core may be ahead of or behind a specific sing-box release. If a sing-box feature is missing, check the hiddify-core release notes.
|
|
||||||
|
|
||||||
When writing configs, start from the sing-box documentation and refer to the hiddify-core guide for anything that does not behave as expected.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Example Skeleton
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"log": {
|
|
||||||
"level": "info"
|
|
||||||
},
|
|
||||||
"dns": {
|
|
||||||
"servers": [
|
|
||||||
{ "tag": "remote", "address": "tls://1.1.1.1" },
|
|
||||||
{ "tag": "local", "address": "223.5.5.5", "detour": "direct" }
|
|
||||||
],
|
|
||||||
"rules": [
|
|
||||||
{ "geosite": "cn", "server": "local" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inbounds": [
|
|
||||||
{
|
|
||||||
"type": "tproxy",
|
|
||||||
"tag": "tproxy-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5332
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outbounds": [
|
|
||||||
{
|
|
||||||
"type": "vless",
|
|
||||||
"tag": "proxy",
|
|
||||||
"server": "your.server.example",
|
|
||||||
"server_port": 443
|
|
||||||
},
|
|
||||||
{ "type": "direct", "tag": "direct" },
|
|
||||||
{ "type": "block", "tag": "block" },
|
|
||||||
{ "type": "dns", "tag": "dns-out" }
|
|
||||||
],
|
|
||||||
"route": {
|
|
||||||
"rules": [
|
|
||||||
{ "protocol": "dns", "outbound": "dns-out" },
|
|
||||||
{ "geosite": "cn", "outbound": "direct" },
|
|
||||||
{ "geoip": "cn", "outbound": "direct" }
|
|
||||||
],
|
|
||||||
"final": "proxy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This is illustrative only — adjust inbound type/port, outbound settings, and routing rules to match your setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- **Validate before saving.** An invalid JSON config will silently fail to apply. Use a JSON validator before pasting — syntax errors will not be reported in the UI. After saving, check the log (see below) to confirm the config loaded correctly.
|
|
||||||
- **Logs.** If the config applies but traffic does not work, check the core log in **Re:HomeProxy → Core & Tools** or via SSH: `tail -f /var/run/homeproxy/hiddify-c.log`.
|
|
||||||
- **Inbound ports.** Re:HomeProxy's firewall rules expect specific ports. If you change inbound ports in a custom config, the nftables redirect rules will not match and traffic will not reach your inbound. The default inbound configuration used by Re:HomeProxy is:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"inbounds": [
|
|
||||||
{
|
|
||||||
"type": "direct",
|
|
||||||
"tag": "dns-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5333
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "mixed",
|
|
||||||
"tag": "mixed-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5330,
|
|
||||||
"udp_timeout": "300s",
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true,
|
|
||||||
"set_system_proxy": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redirect",
|
|
||||||
"tag": "redirect-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5331,
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tproxy",
|
|
||||||
"tag": "tproxy-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5332,
|
|
||||||
"network": "udp",
|
|
||||||
"udp_timeout": "300s",
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
If you need to change these ports, update them both in your custom JSON and in `/etc/config/homeproxy` (the `proxy_port`, `redirect_port`, `tproxy_port`, and `dns_port` options) so the firewall rules stay in sync.
|
|
||||||
- **`default_mark`.** This is **required** in the `route` section to prevent tproxy routing loops. Without it, hiddify-core's own outbound traffic gets intercepted by the nftables tproxy rules and sent back to the proxy, causing a loop. The value must match `self_mark` in `/etc/config/homeproxy` (default: `100`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"route": {
|
|
||||||
"default_mark": 100,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If you change this value, update `self_mark` in `/etc/config/homeproxy` to match.
|
|
||||||
|
|
||||||
- **Outbound tags.** Route rules reference outbound tags by name — keep them consistent.
|
|
||||||
@@ -1,166 +0,0 @@
|
|||||||
🇬🇧 [English](Custom-JSON-Config-en) | 🇷🇺 [Русский](Custom-JSON-Config-ru)
|
|
||||||
|
|
||||||
# Пользовательский JSON конфиг
|
|
||||||
|
|
||||||
**Пользовательский JSON** — режим маршрутизации, позволяющий полностью обойти UCI-конфигурацию Re:HomeProxy и использовать «сырой» JSON-конфиг формата [sing-box](https://sing-box.sagernet.org) / hiddify-core напрямую. Предназначен для опытных пользователей, которым нужен тонкий контроль, недоступный через веб-интерфейс.
|
|
||||||
|
|
||||||
Чтобы включить режим: **Re:HomeProxy → Клиент → Настройки маршрутизации** → установить **Режим маршрутизации** в значение **Пользовательский JSON**.
|
|
||||||
|
|
||||||
Файл конфигурации размещается по пути `/etc/homeproxy/hiddify-c.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Структура конфига
|
|
||||||
|
|
||||||
Конфиг hiddify-core — это JSON-объект. Основные секции верхнего уровня:
|
|
||||||
|
|
||||||
| Секция | Назначение |
|
|
||||||
|--------|-----------|
|
|
||||||
| `log` | Уровень логирования и путь к файлу лога |
|
|
||||||
| `dns` | DNS-серверы, правила и стратегия разрешения |
|
|
||||||
| `inbounds` | Как трафик попадает в прокси (tproxy, tun, socks, http и др.) |
|
|
||||||
| `outbounds` | Куда трафик выходит (прямо, через прокси-серверы, цепочки) |
|
|
||||||
| `route` | Правила сопоставления входящего трафика с исходящими |
|
|
||||||
| `experimental` | Кэш, Clash API и другие экспериментальные возможности |
|
|
||||||
|
|
||||||
Минимальный рабочий конфиг требует как минимум одного inbound, двух outbound (`proxy` + `direct`) и правил маршрутизации.
|
|
||||||
|
|
||||||
**Полная документация:** [Конфигурация sing-box](https://sing-box.sagernet.org/configuration/)
|
|
||||||
|
|
||||||
Отдельные разделы:
|
|
||||||
- [DNS](https://sing-box.sagernet.org/configuration/dns/)
|
|
||||||
- [Inbounds](https://sing-box.sagernet.org/configuration/inbound/)
|
|
||||||
- [Outbounds](https://sing-box.sagernet.org/configuration/outbound/)
|
|
||||||
- [Route](https://sing-box.sagernet.org/configuration/route/)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## hiddify-core и sing-box
|
|
||||||
|
|
||||||
hiddify-core — форк sing-box, в целом совместимый по формату конфига. Ключевые отличия:
|
|
||||||
|
|
||||||
- **Дополнительные протоколы** — hiddify-core поддерживает протоколы, которых нет в upstream sing-box или которые появились в нём позже. Это AnyTLS, расширенные опции для некоторых существующих протоколов. Полный список — на странице [Поддерживаемые протоколы](Supported-Protocols-ru).
|
|
||||||
- **Расширения hiddify** — некоторые дополнительные поля и опции специфичны для hiddify. Они задокументированы в [руководстве HiddifyCli](https://hiddify.com/app/HiddifyCli-guide/#run-config-or-subscription-link-in-hiddifycli-with-hiddifyapp-settings).
|
|
||||||
- **Различия версий** — hiddify-core может опережать или отставать от конкретного релиза sing-box. Если функция sing-box не работает — проверьте release notes hiddify-core.
|
|
||||||
|
|
||||||
При написании конфигов отталкивайтесь от документации sing-box, обращаясь к руководству hiddify-core там, где поведение отличается.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Пример скелета конфига
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"log": {
|
|
||||||
"disabled": false,
|
|
||||||
"level": "warn",
|
|
||||||
"output": "/var/run/homeproxy/hiddify-c.log",
|
|
||||||
"timestamp": true
|
|
||||||
},
|
|
||||||
"dns": {
|
|
||||||
"servers": [
|
|
||||||
{ "tag": "remote", "address": "tls://1.1.1.1" },
|
|
||||||
{ "tag": "local", "address": "223.5.5.5", "detour": "direct" }
|
|
||||||
],
|
|
||||||
"rules": [
|
|
||||||
{ "geosite": "cn", "server": "local" }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"inbounds": [
|
|
||||||
{
|
|
||||||
"type": "tproxy",
|
|
||||||
"tag": "tproxy-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5332,
|
|
||||||
"network": "udp",
|
|
||||||
"sniff": true
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"outbounds": [
|
|
||||||
{
|
|
||||||
"type": "vless",
|
|
||||||
"tag": "proxy",
|
|
||||||
"server": "your.server.example",
|
|
||||||
"server_port": 443
|
|
||||||
},
|
|
||||||
{ "type": "direct", "tag": "direct" },
|
|
||||||
{ "type": "block", "tag": "block" },
|
|
||||||
{ "type": "dns", "tag": "dns-out" }
|
|
||||||
],
|
|
||||||
"route": {
|
|
||||||
"default_mark": 100,
|
|
||||||
"rules": [
|
|
||||||
{ "protocol": "dns", "outbound": "dns-out" },
|
|
||||||
{ "geosite": "cn", "outbound": "direct" },
|
|
||||||
{ "geoip": "cn", "outbound": "direct" }
|
|
||||||
],
|
|
||||||
"final": "proxy"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Это только иллюстрация — адаптируйте тип/порт inbound, настройки outbound и правила маршрутизации под свою конфигурацию.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Советы
|
|
||||||
|
|
||||||
- **Проверяйте перед сохранением.** Некорректный JSON будет молча проигнорирован. Используйте JSON-валидатор перед вставкой — ошибки синтаксиса не отображаются в интерфейсе. После сохранения проверьте лог (см. ниже), чтобы убедиться, что конфиг загрузился корректно.
|
|
||||||
|
|
||||||
- **Логи.** Если конфиг применился, но трафик не работает — проверьте лог ядра в **Re:HomeProxy → Ядро и службы** или через SSH: `tail -f /var/run/homeproxy/hiddify-c.log`.
|
|
||||||
|
|
||||||
- **Порты входящих соединений.** Правила файрвола Re:HomeProxy ожидают конкретные порты. Если вы меняете порты inbound в пользовательском JSON, правила nftables перестанут совпадать и трафик не достигнет inbound. Стандартная конфигурация inbounds, используемая Re:HomeProxy:
|
|
||||||
|
|
||||||
```json
|
|
||||||
"inbounds": [
|
|
||||||
{
|
|
||||||
"type": "direct",
|
|
||||||
"tag": "dns-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5333
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "mixed",
|
|
||||||
"tag": "mixed-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5330,
|
|
||||||
"udp_timeout": "300s",
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true,
|
|
||||||
"set_system_proxy": false
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "redirect",
|
|
||||||
"tag": "redirect-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5331,
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "tproxy",
|
|
||||||
"tag": "tproxy-in",
|
|
||||||
"listen": "::",
|
|
||||||
"listen_port": 5332,
|
|
||||||
"network": "udp",
|
|
||||||
"udp_timeout": "300s",
|
|
||||||
"sniff": true,
|
|
||||||
"sniff_override_destination": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
Если нужно изменить порты — обновите их и в пользовательском JSON, и в `/etc/config/homeproxy` (опции `mixed_port`, `redirect_port`, `tproxy_port`, `dns_port`), чтобы правила файрвола оставались согласованными.
|
|
||||||
|
|
||||||
- **`default_mark`.** **Обязательное** поле в секции `route` для предотвращения петель маршрутизации tproxy. Без него собственный исходящий трафик hiddify-core перехватывается правилами nftables и снова направляется в прокси. Значение должно совпадать с `self_mark` в `/etc/config/homeproxy` (по умолчанию: `100`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
"route": {
|
|
||||||
"default_mark": 100,
|
|
||||||
...
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Если вы меняете это значение — обновите `self_mark` в `/etc/config/homeproxy` соответственно.
|
|
||||||
|
|
||||||
- **Теги outbound.** Правила маршрутизации ссылаются на outbound по имени тега — следите за их согласованностью.
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
🇬🇧 [English](Custom-Routing-en) | 🇷🇺 [Русский](Custom-Routing-ru)
|
|
||||||
|
|
||||||
# Custom Routing
|
|
||||||
|
|
||||||
**Custom routing** is the manual middle ground between the ready-made [routing modes](Routing-and-Access-Control-en) and a hand-written [Custom JSON](Custom-JSON-Config-en) config. You build your own **routing nodes** (where traffic can exit) and **routing rules** (which traffic goes where) in the LuCI UI, without writing JSON.
|
|
||||||
|
|
||||||
Set it on **Client → Routing Settings → Routing mode → Custom routing**. Two extra tabs appear: **Routing Nodes** and **Routing Rules**.
|
|
||||||
|
|
||||||
> You don't have to switch the whole router to Custom routing: in **Russia (Proxy Banned)** mode, ticking **Advanced custom rules** 👨💻 reveals these same two tabs so you can layer custom rules *on top of* the RU presets. See [Routing & Access Control](Routing-and-Access-Control-en).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing Nodes
|
|
||||||
|
|
||||||
A **routing node** is a named outbound that rules can target. Each can be:
|
|
||||||
|
|
||||||
- a specific **node**, or **URLTest** (auto-pick the fastest from a pool),
|
|
||||||
- a **chain** — via the **Outbound** field, send this node's traffic *through* another routing node,
|
|
||||||
- with its own **Domain resolver** (including the Russia 🔓 / Secure 🔒 servers in RU mode) and **Domain strategy** (IPv4-only, prefer IPv6, etc.).
|
|
||||||
|
|
||||||
This is what lets you do things like "this rule → node A, but resolve its domains with the secure DNS" or build a multi-hop chain.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing Rules
|
|
||||||
|
|
||||||
Each **routing rule** matches some traffic and sends it to a routing node (or **Direct**). The editor groups the match fields into tabs:
|
|
||||||
|
|
||||||
| Tab | Matches on |
|
|
||||||
|-----|-----------|
|
|
||||||
| **Other fields** | **Protocol** (sniffed — BitTorrent, DNS, QUIC, TLS, HTTP, STUN, SSH, RDP, DTLS…), **IP version**, network, rule-sets |
|
|
||||||
| **Host/IP fields** | `domain` / `domain_suffix` / `domain_keyword` / `domain_regex`, destination & source **IP CIDR** |
|
|
||||||
| **Port fields** | destination / source **port** and port ranges |
|
|
||||||
| **Process fields** | **process name / path** (for traffic originating on the router itself) |
|
|
||||||
|
|
||||||
The **Mode** field shows the matching logic — within a category the entries are OR-ed, and the categories are AND-ed together:
|
|
||||||
|
|
||||||
```
|
|
||||||
(domain || domain_suffix || domain_keyword || domain_regex || ip_cidr || ip_is_private) &&
|
|
||||||
(port || port_range) &&
|
|
||||||
(source_ip_cidr || source_ip_is_private) &&
|
|
||||||
(source_port || source_port_range) &&
|
|
||||||
(other fields)
|
|
||||||
```
|
|
||||||
|
|
||||||
Rules are evaluated top to bottom (the list is sortable) and the first match wins. The fields map one-to-one to sing-box's route rule — for exactly what each accepts, see the upstream reference:
|
|
||||||
|
|
||||||
- [Route](https://sing-box.sagernet.org/configuration/route/) — how routing is structured
|
|
||||||
- [Route Rule](https://sing-box.sagernet.org/configuration/route/rule/) — every match field
|
|
||||||
- [Sniff](https://sing-box.sagernet.org/configuration/route/sniff/) — **Protocol** matching needs sniffing enabled (it is, by default)
|
|
||||||
|
|
||||||
> **Protocol matching needs sniffing.** Matching by `quic`, `tls`, `bittorrent`, etc. relies on the inbound sniffing the first packets — Re:HomeProxy enables this on its inbounds, so it works out of the box.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Custom routing vs Custom JSON
|
|
||||||
|
|
||||||
| | **Custom routing** | **[Custom JSON](Custom-JSON-Config-en)** |
|
|
||||||
|---|---|---|
|
|
||||||
| How | UI: Routing Nodes + Routing Rules | Raw sing-box / hiddify-core JSON |
|
|
||||||
| Good for | Per-app/site/IP rules without writing JSON | Full control, fields the UI doesn't expose |
|
|
||||||
| Validation | UI-guided | You must validate the JSON yourself |
|
|
||||||
|
|
||||||
Start with Custom routing; drop to Custom JSON only when you need something the rule editor can't express.
|
|
||||||
|
|
||||||
See also: [Routing & Access Control](Routing-and-Access-Control-en) · [Custom JSON Config](Custom-JSON-Config-en) · [Supported Protocols](Supported-Protocols-en) · [DNS & Diagnostics](DNS-and-Diagnostics-en)
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
🇬🇧 [English](Custom-Routing-en) | 🇷🇺 [Русский](Custom-Routing-ru)
|
|
||||||
|
|
||||||
# Пользовательская маршрутизация
|
|
||||||
|
|
||||||
**Пользовательская маршрутизация** — это ручная золотая середина между готовыми [режимами маршрутизации](Routing-and-Access-Control-ru) и конфигом [Пользовательский JSON](Custom-JSON-Config-ru), написанным руками. Вы сами собираете **узлы маршрутизации** (откуда может выходить трафик) и **правила маршрутизации** (какой трафик куда идёт) в интерфейсе LuCI, не написав ни строчки JSON.
|
|
||||||
|
|
||||||
Включается в **Настройки клиента → Настройки маршрутизации → Режим маршрутизации → Пользовательская маршрутизация**. Появляются две вкладки: **Правила узлов** и **Расширенные правила**.
|
|
||||||
|
|
||||||
> Переключать на пользовательскую маршрутизацию весь роутер необязательно: в режиме **Россия (Обход Блокировок)** галочка **Расширенные правила** 👨💻 открывает те же две вкладки, чтобы добавлять свои правила *поверх* RU-пресетов. См. [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Правила узлов
|
|
||||||
|
|
||||||
**Узел маршрутизации** — это именованный outbound, на который могут указывать правила. Каждый может быть:
|
|
||||||
|
|
||||||
- конкретным **узлом** или **URLTest** (авто-выбор самого быстрого из пула),
|
|
||||||
- **цепочкой** — через поле **outbound** направить трафик этого узла *через* другой узел маршрутизации,
|
|
||||||
- со своим **Резолвером доменов** (включая серверы Россия 🔓 / Защищённый 🔒 в режиме РФ) и **Стратегией доменов** (только IPv4, предпочитать IPv6 и т. п.).
|
|
||||||
|
|
||||||
Именно это позволяет делать вещи вроде «это правило → узел A, но домены резолвить защищённым DNS» или строить многошаговую цепочку.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Расширенные правила
|
|
||||||
|
|
||||||
Каждое **правило маршрутизации** сопоставляет какой-то трафик и отправляет его на узел маршрутизации (или **Напрямую**). Поля сопоставления в редакторе сгруппированы по вкладкам:
|
|
||||||
|
|
||||||
| Вкладка | Сопоставление по |
|
|
||||||
|---------|------------------|
|
|
||||||
| **Прочие поля** | **Протокол** (по сниффингу — BitTorrent, DNS, QUIC, TLS, HTTP, STUN, SSH, RDP, DTLS…), **Версия IP**, сеть, rule-set-ы |
|
|
||||||
| **Поля хоста/IP** | `domain` / `domain_suffix` / `domain_keyword` / `domain_regex`, **IP CIDR** назначения и источника |
|
|
||||||
| **Поля портов** | **порт** и диапазоны портов назначения / источника |
|
|
||||||
| **Поля процесса** | **имя / путь процесса** (для трафика, исходящего с самого роутера) |
|
|
||||||
|
|
||||||
Поле **Режим** показывает логику сопоставления — внутри категории записи объединяются по ИЛИ, а категории — по И:
|
|
||||||
|
|
||||||
```
|
|
||||||
(domain || domain_suffix || domain_keyword || domain_regex || ip_cidr || ip_is_private) &&
|
|
||||||
(port || port_range) &&
|
|
||||||
(source_ip_cidr || source_ip_is_private) &&
|
|
||||||
(source_port || source_port_range) &&
|
|
||||||
(прочие поля)
|
|
||||||
```
|
|
||||||
|
|
||||||
Правила проверяются сверху вниз (список сортируемый), срабатывает первое совпавшее. Поля один-в-один соответствуют route-правилу sing-box — что именно принимает каждое поле, см. в официальном справочнике:
|
|
||||||
|
|
||||||
- [Route](https://sing-box.sagernet.org/configuration/route/) — как устроена маршрутизация
|
|
||||||
- [Route Rule](https://sing-box.sagernet.org/configuration/route/rule/) — все поля сопоставления
|
|
||||||
- [Sniff](https://sing-box.sagernet.org/configuration/route/sniff/) — сопоставление по **Протоколу** требует включённого сниффинга (он включён по умолчанию)
|
|
||||||
|
|
||||||
> **Сопоставление по протоколу требует сниффинга.** Матч по `quic`, `tls`, `bittorrent` и т. п. опирается на анализ первых пакетов на inbound-е — Re:HomeProxy включает это на своих inbound-ах, так что работает из коробки.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Пользовательская маршрутизация vs Пользовательский JSON
|
|
||||||
|
|
||||||
| | **Пользовательская маршрутизация** | **[Пользовательский JSON](Custom-JSON-Config-ru)** |
|
|
||||||
|---|---|---|
|
|
||||||
| Как | Интерфейс: Правила узлов + Расширенные правила | «Сырой» JSON sing-box / hiddify-core |
|
|
||||||
| Для чего | Правила по приложению/сайту/IP без написания JSON | Полный контроль, поля, которых нет в интерфейсе |
|
|
||||||
| Проверка | С подсказками интерфейса | JSON нужно валидировать самому |
|
|
||||||
|
|
||||||
Начинайте с пользовательской маршрутизации; переходите на Пользовательский JSON, только когда нужно что-то, что редактор правил выразить не может.
|
|
||||||
|
|
||||||
См. также: [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru) · [Пользовательский JSON конфиг](Custom-JSON-Config-ru) · [Поддерживаемые протоколы](Supported-Protocols-ru) · [DNS и диагностика](DNS-and-Diagnostics-ru)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
🇬🇧 [English](DNS-and-Diagnostics-en) | 🇷🇺 [Русский](DNS-and-Diagnostics-ru)
|
|
||||||
|
|
||||||
# DNS & Diagnostics
|
|
||||||
|
|
||||||
Two related topics: how Re:HomeProxy resolves names (and why that matters for leaks), and how to verify everything works.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
DNS is configured on **Client → Routing**. Which fields appear depends on the routing mode.
|
|
||||||
|
|
||||||
### Russia mode (`proxy_banned_ru`) — two resolvers
|
|
||||||
|
|
||||||
Russia mode splits DNS into a **clean** resolver and a **secure** resolver so that lookups go to the right place:
|
|
||||||
|
|
||||||
| Field | Purpose | Default |
|
|
||||||
|-------|---------|---------|
|
|
||||||
| **Russia DNS server 🔓** | Resolves **Russian** domains **directly**, without the proxy. Plain UDP DNS. | Yandex `77.88.8.8` (also SkyDNS, Comss.one, Cloudflare, Google) |
|
|
||||||
| **Secure DNS server 🔒** | Resolves **blocked** domains **through the proxy** using encrypted DNS (DoH/DoT), so your ISP can't see which blocked sites you look up. | Cloudflare DoH `https://cloudflare-dns.com/dns-query` (also Quad9, AdGuard, Google; DoT variants) |
|
|
||||||
|
|
||||||
This split matters: resolving a blocked domain over plain ISP DNS would either fail or leak the lookup, so blocked domains use encrypted DNS via the proxy, while ordinary Russian domains resolve fast and direct.
|
|
||||||
|
|
||||||
> **ByeDPI note:** when a node is ByeDPI, secure DNS is automatically detoured **direct** (not through ByeDPI), because the desync corrupts DoH/DoT. This is handled for you — see [ByeDPI](ByeDPI-en).
|
|
||||||
|
|
||||||
### Other modes
|
|
||||||
|
|
||||||
- **DNS server** — the general resolver (supports UDP/TCP/DoH/DoQ/DoT). Default `8.8.8.8`. Or `WAN DNS` to read it from the interface.
|
|
||||||
- **China DNS server** — used in *Bypass mainland China* mode for Chinese domains (default Aliyun `223.5.5.5`).
|
|
||||||
|
|
||||||
### Advanced: DNS Servers & DNS Rules
|
|
||||||
|
|
||||||
The **DNS Settings**, **DNS Servers**, and **DNS Rules** tabs (under Node Settings) let you define custom resolvers and per-domain DNS routing, including the **domain strategy** (IPv4-only, prefer IPv6, etc.). Most users never need these.
|
|
||||||
|
|
||||||
### IPv6 and leaks
|
|
||||||
|
|
||||||
In Russia mode, **IPv6 support is off by default on purpose**: the RU routing lists contain **no IPv6 CIDRs**, so any IPv6 traffic would bypass *both* the proxy and the rules — a silent leak. If your WAN has IPv6 and you want everything controlled, the safe choice is to **leave IPv6 disabled** rather than enabling `ipv6_support`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Diagnostics
|
|
||||||
|
|
||||||
The **Diagnostics** page (Services → Re:HomeProxy → Diagnostics) runs live checks. Each card has its own **Check** button.
|
|
||||||
|
|
||||||
| Card | What it shows |
|
|
||||||
|------|---------------|
|
|
||||||
| **Connectivity** | Reachability of test sites. On **hiddify-core** it also shows your **Direct IP** vs **Proxy IP** (the two should differ when traffic is actually proxied); on **sing-box-extended** it shows the live **Active Node** instead, because sing-box can't report the exit IP. |
|
|
||||||
| **Core & System** | Whether the core (hiddify-core / sing-box-extended) and ByeDPI are installed, the active binary, version, whether they're running (with PID), and listening ports. Includes a **Restart Service** button. |
|
|
||||||
| **Configuration** | Whether the generated core config is **valid**, its size, and counts of inbounds / outbounds / rules. |
|
|
||||||
| **DNS Tests** | Checks that DNS resolution works through the configured resolvers. |
|
|
||||||
| **Network Intercept** | The nft/firewall interception state — UCI firewall settings and, when Zapret is on, the **Zapret NFQUEUE counters** (mark 110 → queue) so you can confirm packets are reaching nfqws2. |
|
|
||||||
| **Diagnostics Report** | **Generate** a shareable plain-text report bundling the above — paste it when asking for help. |
|
|
||||||
|
|
||||||
### Reading the results
|
|
||||||
|
|
||||||
- **Direct IP == Proxy IP** (hiddify-core) → traffic isn't being proxied (check main node / routing mode / access control). On sing-box-extended, confirm the **Active Node** row shows your chosen node instead.
|
|
||||||
- **Core not running** → use **Restart Service**; if status shows all `?`, the rpcd backend is stale (`/etc/init.d/rpcd restart`, wait ~2 s).
|
|
||||||
- **Config invalid** → a node or rule is malformed; the report names the error.
|
|
||||||
- **Zapret counters not incrementing** → the strategy/queue isn't catching traffic — see [Zapret](Zapret-en).
|
|
||||||
|
|
||||||
See also: [Getting Started](Getting-Started-en) · [Routing & Access Control](Routing-and-Access-Control-en) · [ByeDPI](ByeDPI-en) · [Zapret](Zapret-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
🇬🇧 [English](DNS-and-Diagnostics-en) | 🇷🇺 [Русский](DNS-and-Diagnostics-ru)
|
|
||||||
|
|
||||||
# DNS и диагностика
|
|
||||||
|
|
||||||
Две связанные темы: как Re:HomeProxy резолвит имена (и почему это важно для утечек) и как проверить, что всё работает.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DNS
|
|
||||||
|
|
||||||
DNS настраивается в **Настройки клиента → Настройки маршрутизации**. Какие поля видны, зависит от режима маршрутизации.
|
|
||||||
|
|
||||||
### Режим России (`proxy_banned_ru`) — два резолвера
|
|
||||||
|
|
||||||
Режим России разделяет DNS на **чистый** и **защищённый** резолверы, чтобы запросы уходили туда, куда нужно:
|
|
||||||
|
|
||||||
| Поле | Назначение | По умолчанию |
|
|
||||||
|------|------------|--------------|
|
|
||||||
| **DNS-сервер России 🔓** | Резолвит **российские** домены **напрямую**, без прокси. Обычный UDP DNS. | Yandex `77.88.8.8` (также SkyDNS, Comss.one, Cloudflare, Google) |
|
|
||||||
| **Защищённый DNS-сервер 🔒** | Резолвит **заблокированные** домены **через прокси** по шифрованному DNS (DoH/DoT), чтобы провайдер не видел, какие заблокированные сайты вы ищете. | Cloudflare DoH `https://cloudflare-dns.com/dns-query` (также Quad9, AdGuard, Google; варианты DoT) |
|
|
||||||
|
|
||||||
Это разделение важно: резолв заблокированного домена через обычный DNS провайдера либо не сработает, либо приведёт к утечке запроса, поэтому заблокированные домены идут по шифрованному DNS через прокси, а обычные российские — быстро и напрямую.
|
|
||||||
|
|
||||||
> **Про ByeDPI:** когда узел — ByeDPI, защищённый DNS автоматически уводится **напрямую** (не через ByeDPI), так как рассинхрон ломает DoH/DoT. Это делается за вас — см. [ByeDPI](ByeDPI-ru).
|
|
||||||
|
|
||||||
### Другие режимы
|
|
||||||
|
|
||||||
- **DNS-сервер** — общий резолвер (поддерживает UDP/TCP/DoH/DoQ/DoT). По умолчанию `8.8.8.8`. Или `WAN DNS`, чтобы взять его с интерфейса.
|
|
||||||
- **DNS-сервер для Китая** — используется в режиме *Китай - Direct* для китайских доменов (по умолчанию Aliyun `223.5.5.5`).
|
|
||||||
|
|
||||||
### Продвинутое: DNS-серверы и DNS-правила
|
|
||||||
|
|
||||||
Вкладки **Настройки DNS**, **DNS-серверы** и **DNS-правила** (в Настройках узлов) позволяют задать свои резолверы и пер-доменную маршрутизацию DNS, включая **стратегию доменов** (только IPv4, предпочитать IPv6 и т. п.). Большинству пользователей это не нужно.
|
|
||||||
|
|
||||||
### IPv6 и утечки
|
|
||||||
|
|
||||||
В режиме России **поддержка IPv6 по умолчанию выключена намеренно**: RU-списки маршрутизации **не содержат IPv6-подсетей**, поэтому любой IPv6-трафик обошёл бы *и* прокси, *и* правила — тихая утечка. Если на WAN есть IPv6 и вы хотите всё контролировать, безопаснее **оставить IPv6 выключенным**, а не включать `ipv6_support`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Диагностика
|
|
||||||
|
|
||||||
Страница **Диагностика** (Сервисы → Re:HomeProxy → Диагностика) запускает живые проверки. У каждой карточки своя кнопка **Проверить**.
|
|
||||||
|
|
||||||
| Карточка | Что показывает |
|
|
||||||
|----------|----------------|
|
|
||||||
| **Проверка соединения** | Доступность тестовых сайтов. На **hiddify-core** также показывает ваш **IP провайдера** против **IP прокси** (при реальном проксировании они должны различаться); на **sing-box-extended** вместо этого показывает **Активный узел**, так как sing-box не может сообщить исходящий IP. |
|
|
||||||
| **Ядро и система** | Установлены ли ядро (hiddify-core / sing-box-extended) и ByeDPI, активный бинарник, версия, запущены ли они (с PID) и слушаемые порты. Есть кнопка **Перезапустить службу**. |
|
|
||||||
| **Конфигурация** | Валиден ли сгенерированный конфиг ядра, его размер и счётчики inbound / outbound / правил. |
|
|
||||||
| **Тест DNS** | Проверяет, что резолв DNS работает через настроенные резолверы. |
|
|
||||||
| **Перехват трафика** | Состояние перехвата nft/firewall — настройки firewall в UCI и, при включённом Zapret, **счётчики Zapret NFQUEUE** (метка 110 → очередь), чтобы убедиться, что пакеты доходят до nfqws2. |
|
|
||||||
| **Отчёт диагностики** | **Сгенерировать** текстовый отчёт, объединяющий всё выше — приложите его при обращении за помощью. |
|
|
||||||
|
|
||||||
### Как читать результаты
|
|
||||||
|
|
||||||
- **IP провайдера == IP прокси** (hiddify-core) → трафик не проксируется (проверьте основной узел / режим маршрутизации / контроль доступа). На sing-box-extended убедитесь, что в строке **Активный узел** показан выбранный узел.
|
|
||||||
- **Ядро не запущено** → нажмите **Перезапустить службу**; если статус весь в `?`, бэкенд rpcd устарел (`/etc/init.d/rpcd restart`, подождите ~2 с).
|
|
||||||
- **Конфиг невалиден** → узел или правило некорректны; отчёт назовёт ошибку.
|
|
||||||
- **Счётчики Zapret не растут** → стратегия/очередь не ловит трафик — см. [Zapret](Zapret-ru).
|
|
||||||
|
|
||||||
См. также: [Первая настройка](Getting-Started-ru) · [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru) · [ByeDPI](ByeDPI-ru) · [Zapret](Zapret-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
🇬🇧 [English](Getting-Started-en) | 🇷🇺 [Русский](Getting-Started-ru)
|
|
||||||
|
|
||||||
# Getting Started
|
|
||||||
|
|
||||||
This page walks you from a fresh install to a working connection. It assumes Re:HomeProxy is already installed (see the [README](../../blob/master/README.md) for package installation).
|
|
||||||
|
|
||||||
> Everything below is done in the LuCI web UI: **Services → Re:HomeProxy**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 1 — Install a core
|
|
||||||
|
|
||||||
The LuCI app is only the interface; a separate **core** binary does the proxying. Open **Core & Tools → Core management** and install one:
|
|
||||||
|
|
||||||
- **hiddify-core** (default) — lighter, has a compact build for small routers.
|
|
||||||
- **sing-box-extended** — needed for AmneziaWG/WARP and the widest protocol set.
|
|
||||||
|
|
||||||
The installer auto-picks a build that fits your storage. Full details: **[Core Management](Core-Management-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 2 — Add at least one node
|
|
||||||
|
|
||||||
Open **Node Settings → Nodes**. A "node" is a proxy server you connect through. Add one by:
|
|
||||||
|
|
||||||
- **Import share links** — paste `vless://`, `vmess://`, `ss://`, `trojan://`, `vpn://` (Amnezia), Hysteria, etc.
|
|
||||||
- **Import .conf** — a WireGuard / AmneziaWG config file.
|
|
||||||
- **Subscription URL** — let the router pull and refresh a list of nodes automatically.
|
|
||||||
- **Manual** — fill the fields by hand.
|
|
||||||
|
|
||||||
All import methods and formats: **[Subscriptions & Node Import](Subscriptions-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 3 — Select the main node
|
|
||||||
|
|
||||||
Back on the **Client** page, set **Main node** to the node you added — or choose **URLTest** to let Re:HomeProxy automatically pick and fail over to the fastest reachable node.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 4 — Choose a routing mode
|
|
||||||
|
|
||||||
On the **Client → Routing** tab, pick **Routing mode**. The default depends on your LuCI language:
|
|
||||||
|
|
||||||
- **Russia (Proxy Banned)** — `proxy_banned_ru`, the default for Russian installs: everything goes **direct** except the destinations you add to **RU Proxy Rules** (blocked/throttled sites), which go through the proxy.
|
|
||||||
- **Global** — everything through the proxy.
|
|
||||||
- **GFWList / Bypass mainland China / Only proxy mainland China** — China-oriented presets.
|
|
||||||
- **Custom routing / Custom JSON** — full manual control.
|
|
||||||
|
|
||||||
For Russia, leave it on **Russia (Proxy Banned)** and open the **RU Proxy Rules** tab to enable the lists you need (Russia Inside, Re:Filter, YouTube, Discord…). Full reference: **[Routing & Access Control](Routing-and-Access-Control-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 5 — Check the basic transport settings
|
|
||||||
|
|
||||||
Still on the **Routing** tab, the defaults are sensible:
|
|
||||||
|
|
||||||
- **Proxy mode** — `Redirect TCP + TProxy UDP` by default (TUN options appear if `kmod-tun` is installed).
|
|
||||||
- **IPv6 support** — **off** by default in Russia mode on purpose: the RU lists have no IPv6 CIDRs, so v6 traffic would bypass both the proxy and the rules. Leave it off unless you know you need it. See **[DNS & Diagnostics](DNS-and-Diagnostics-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 6 — Save, apply, start
|
|
||||||
|
|
||||||
Press **Save & Apply**. Then start the service (it also auto-starts on boot):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/homeproxy start
|
|
||||||
```
|
|
||||||
|
|
||||||
Watch the log at **Core & Tools**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Step 7 — Verify it works
|
|
||||||
|
|
||||||
Open the **Diagnostics** page and run the checks:
|
|
||||||
|
|
||||||
- **Connectivity** — confirms reachability of test sites. On **hiddify-core** it shows your **Direct IP** vs **Proxy IP** (they should differ when traffic is proxied); on **sing-box-extended** it shows the live **Active Node** instead (sing-box can't report the exit IP).
|
|
||||||
- **Core & System** — confirms the core is installed and running.
|
|
||||||
- **Configuration** — confirms the generated config is valid.
|
|
||||||
|
|
||||||
If something is off, see **[Troubleshooting](Troubleshooting-en)** and **[DNS & Diagnostics](DNS-and-Diagnostics-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Optional — un-throttle sites without a VPN node
|
|
||||||
|
|
||||||
If a site (e.g. YouTube, Discord) is merely *throttled* rather than IP-blocked, you can offload it to a built-in DPI-bypass instead of spending VPN bandwidth:
|
|
||||||
|
|
||||||
- **[ByeDPI](ByeDPI-en)** — a SOCKS-level desync.
|
|
||||||
- **[Zapret](Zapret-en)** — a packet-level (nfqws2) desync.
|
|
||||||
|
|
||||||
Both can be selected per RU Proxy Rule (e.g. route only YouTube through ByeDPI/Zapret while the rest uses your VPN).
|
|
||||||
|
|
||||||
See also: [Core Management](Core-Management-en) · [Routing & Access Control](Routing-and-Access-Control-en) · [Subscriptions & Node Import](Subscriptions-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,96 +0,0 @@
|
|||||||
🇬🇧 [English](Getting-Started-en) | 🇷🇺 [Русский](Getting-Started-ru)
|
|
||||||
|
|
||||||
# Первая настройка
|
|
||||||
|
|
||||||
Эта страница проведёт вас от свежей установки до рабочего соединения. Предполагается, что Re:HomeProxy уже установлен (установку пакетов см. в [README](../../blob/master/README_ru.md)).
|
|
||||||
|
|
||||||
> Всё ниже делается в веб-интерфейсе LuCI: **Сервисы → Re:HomeProxy**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 1 — Установите ядро
|
|
||||||
|
|
||||||
Приложение LuCI — это только интерфейс; проксированием занимается отдельный бинарник **ядра**. Откройте **Ядро и службы → Управление ядром** и установите одно из них:
|
|
||||||
|
|
||||||
- **hiddify-core** (по умолчанию) — легче, есть компактная сборка для маленьких роутеров.
|
|
||||||
- **sing-box-extended** — нужно для AmneziaWG/WARP и самого широкого набора протоколов.
|
|
||||||
|
|
||||||
Установщик сам подберёт сборку под ваше хранилище. Подробно: **[Управление ядром](Core-Management-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 2 — Добавьте хотя бы один узел
|
|
||||||
|
|
||||||
Откройте **Настройки узлов → Узлы**. «Узел» — это прокси-сервер, через который вы подключаетесь. Добавить можно:
|
|
||||||
|
|
||||||
- **Импорт share-ссылок** — вставьте `vless://`, `vmess://`, `ss://`, `trojan://`, `vpn://` (Amnezia), Hysteria и т. п.
|
|
||||||
- **Импорт .conf** — файл конфигурации WireGuard / AmneziaWG.
|
|
||||||
- **Ссылка подписки** — роутер сам скачает и будет обновлять список узлов.
|
|
||||||
- **Вручную** — заполнить поля самостоятельно.
|
|
||||||
|
|
||||||
Все способы и форматы импорта: **[Подписки и импорт узлов](Subscriptions-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 3 — Выберите основной узел
|
|
||||||
|
|
||||||
На странице **Настройки клиента** задайте **Основной узел** = добавленный узел — или выберите **URLTest**, чтобы Re:HomeProxy автоматически выбирал самый быстрый доступный узел и переключался при сбоях.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 4 — Выберите режим маршрутизации
|
|
||||||
|
|
||||||
На вкладке **Настройки клиента → Настройки маршрутизации** выберите **Режим маршрутизации**. Значение по умолчанию зависит от языка LuCI:
|
|
||||||
|
|
||||||
- **Россия (Обход Блокировок)** — `proxy_banned_ru`, по умолчанию для русских установок: всё идёт **напрямую**, кроме адресов, добавленных в **Правила прокси (РФ)** (заблокированные/троттлящиеся сайты) — они идут через прокси.
|
|
||||||
- **Глобальный** — весь трафик через прокси.
|
|
||||||
- **Китай - GFWList / Китай - Direct / Китай - Proxy** — пресеты для Китая.
|
|
||||||
- **Пользовательская маршрутизация / Пользовательский JSON** — полный ручной контроль.
|
|
||||||
|
|
||||||
Для России оставьте **Россия (Обход Блокировок)** и откройте вкладку **Правила прокси (РФ)**, чтобы включить нужные списки (Russia Inside, Re:Filter, YouTube, Discord…). Полный справочник: **[Маршрутизация и контроль доступа](Routing-and-Access-Control-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 5 — Проверьте базовые настройки транспорта
|
|
||||||
|
|
||||||
На той же вкладке **Настройки маршрутизации** значения по умолчанию разумны:
|
|
||||||
|
|
||||||
- **Режим прокси** — по умолчанию `Redirect TCP + TProxy UDP` (опции TUN появляются при установленном `kmod-tun`).
|
|
||||||
- **Поддержка IPv6** — **выключена** по умолчанию в режиме России намеренно: в RU-списках нет IPv6-подсетей, поэтому v6-трафик обошёл бы и прокси, и правила. Оставьте выключенной, если не уверены, что она нужна. См. **[DNS и диагностика](DNS-and-Diagnostics-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 6 — Сохраните, примените, запустите
|
|
||||||
|
|
||||||
Нажмите **Сохранить и применить**. Затем запустите службу (она также стартует при загрузке):
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/homeproxy start
|
|
||||||
```
|
|
||||||
|
|
||||||
Логи — в разделе **Ядро и службы**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Шаг 7 — Проверьте, что всё работает
|
|
||||||
|
|
||||||
Откройте страницу **Диагностика** и запустите проверки:
|
|
||||||
|
|
||||||
- **Проверка соединения** — проверяет доступность тестовых сайтов. На **hiddify-core** показывает ваш **IP провайдера** против **IP прокси** (при проксировании они должны различаться); на **sing-box-extended** вместо этого показывает **Активный узел** (sing-box не может сообщить исходящий IP).
|
|
||||||
- **Ядро и система** — подтверждает, что ядро установлено и запущено.
|
|
||||||
- **Конфигурация** — подтверждает, что сгенерированный конфиг валиден.
|
|
||||||
|
|
||||||
Если что-то не так — см. **[Устранение неполадок](Troubleshooting-ru)** и **[DNS и диагностика](DNS-and-Diagnostics-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## По желанию — обойти замедление без VPN
|
|
||||||
|
|
||||||
Если сайт (например, YouTube, Discord) просто *замедляется*, а не заблокирован по IP, его можно отдать встроенному обходу DPI вместо расхода трафика VPN:
|
|
||||||
|
|
||||||
- **[ByeDPI](ByeDPI-ru)** — рассинхронизация на уровне SOCKS.
|
|
||||||
- **[Zapret](Zapret-ru)** — рассинхронизация на уровне пакетов (nfqws2).
|
|
||||||
|
|
||||||
Оба выбираются для конкретного правила Правила прокси (РФ) (например, направить через ByeDPI/Zapret только YouTube, а остальное — через VPN).
|
|
||||||
|
|
||||||
См. также: [Управление ядром](Core-Management-ru) · [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru) · [Подписки и импорт узлов](Subscriptions-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Re:HomeProxy Wiki
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🇬🇧 English
|
|
||||||
|
|
||||||
| Page | Description |
|
|
||||||
|------|-------------|
|
|
||||||
| [Getting Started](Getting-Started-en) | From a fresh install to a working connection, step by step |
|
|
||||||
| [Core Management](Core-Management-en) | Choosing hiddify-core vs sing-box-extended, the smart installer, storage and the compact build |
|
|
||||||
| [Supported Protocols](Supported-Protocols-en) | All supported proxy protocols, build requirements, transport options |
|
|
||||||
| [Subscriptions & Node Import](Subscriptions-en) | Share links, .conf, Amnezia vpn:// (AmneziaWG/Xray), subscriptions, base64 |
|
|
||||||
| [Routing & Access Control](Routing-and-Access-Control-en) | Routing modes, RU Proxy Rules, per-device access control |
|
|
||||||
| [Server Settings](Server-Settings-en) | Run the router as a proxy server — inbounds, types, TLS/ACME |
|
|
||||||
| [DNS & Diagnostics](DNS-and-Diagnostics-en) | Clean vs secure DNS, IPv6 leaks, and the Diagnostics page |
|
|
||||||
| [ByeDPI](ByeDPI-en) | SOCKS-level DPI-bypass — un-throttle sites without a VPN, strategy presets and the tester |
|
|
||||||
| [Zapret](Zapret-en) | Packet-level (nfqws2) DPI-bypass — per-rule desync, presets, Discord voice and the tester |
|
|
||||||
| [Custom Routing](Custom-Routing-en) | UI routing nodes + rules — match by domain/IP/port/protocol/process |
|
|
||||||
| [Custom JSON Config](Custom-JSON-Config-en) | Using the Custom JSON routing mode with raw hiddify-core config |
|
|
||||||
| [Troubleshooting](Troubleshooting-en) | Common errors and how to fix them |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🇷🇺 Русский
|
|
||||||
|
|
||||||
| Страница | Описание |
|
|
||||||
|----------|----------|
|
|
||||||
| [Первая настройка](Getting-Started-ru) | От свежей установки до рабочего соединения, по шагам |
|
|
||||||
| [Управление ядром](Core-Management-ru) | Выбор hiddify-core или sing-box-extended, умный установщик, память и компактная сборка |
|
|
||||||
| [Поддерживаемые протоколы](Supported-Protocols-ru) | Все поддерживаемые протоколы, требования к сборке, варианты транспорта |
|
|
||||||
| [Подписки и импорт узлов](Subscriptions-ru) | Share-ссылки, .conf, Amnezia vpn:// (AmneziaWG/Xray), подписки, base64 |
|
|
||||||
| [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru) | Режимы маршрутизации, RU Proxy Rules, пер-девайс контроль доступа |
|
|
||||||
| [Настройки сервера](Server-Settings-ru) | Роутер как прокси-сервер — inbound-ы, типы, TLS/ACME |
|
|
||||||
| [DNS и диагностика](DNS-and-Diagnostics-ru) | Чистый и защищённый DNS, утечки IPv6 и страница диагностики |
|
|
||||||
| [ByeDPI](ByeDPI-ru) | Обход DPI на уровне SOCKS — разблокировка сайтов без VPN, пресеты стратегий и тестер |
|
|
||||||
| [Zapret](Zapret-ru) | Обход DPI на уровне пакетов (nfqws2) — рассинхронизация по правилам, пресеты, голос Discord и тестер |
|
|
||||||
| [Пользовательская маршрутизация](Custom-Routing-ru) | Узлы и правила маршрутизации в UI — матч по домену/IP/порту/протоколу/процессу |
|
|
||||||
| [Пользовательский JSON конфиг](Custom-JSON-Config-ru) | Использование режима «Пользовательский JSON» с конфигом hiddify-core |
|
|
||||||
| [Устранение неполадок](Troubleshooting-ru) | Типичные ошибки и способы их устранения |
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
🇬🇧 [English](Routing-and-Access-Control-en) | 🇷🇺 [Русский](Routing-and-Access-Control-ru)
|
|
||||||
|
|
||||||
# Routing & Access Control
|
|
||||||
|
|
||||||
Re:HomeProxy decides **what** traffic is proxied (routing mode + rules) and **which devices** are affected (access control). This page covers both.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing modes
|
|
||||||
|
|
||||||
Set on **Client → Routing → Routing mode**:
|
|
||||||
|
|
||||||
| Mode | What it does |
|
|
||||||
|------|--------------|
|
|
||||||
| **Russia (Proxy Banned)** (`proxy_banned_ru`) | Default for RU. Everything is **direct** except destinations added to **RU Proxy Rules**, which go through the proxy. |
|
|
||||||
| **Global** | All traffic through the proxy. |
|
|
||||||
| **GFWList** | Proxies a built-in list of commonly blocked (GFW) domains. |
|
|
||||||
| **Bypass mainland China** | Everything proxied except mainland-China destinations (direct). |
|
|
||||||
| **Only proxy mainland China** | Only mainland-China destinations proxied. |
|
|
||||||
| **Custom routing** | Manual routing nodes + rules (advanced). |
|
|
||||||
| **Custom JSON** | Hand-written hiddify-core config — see [Custom JSON Config](Custom-JSON-Config-en). |
|
|
||||||
|
|
||||||
**Routing ports** — restrict which destination ports are proxied (e.g. *Common ports only* to keep P2P traffic direct).
|
|
||||||
|
|
||||||
**Proxy mode** — how traffic is intercepted: `Redirect TCP`, `Redirect TCP + TProxy UDP` (default), `Redirect TCP + Tun UDP`, or full `Tun TCP/UDP` (TUN modes need `kmod-tun`).
|
|
||||||
|
|
||||||
### Main node and a separate UDP node
|
|
||||||
|
|
||||||
On the **Routing Settings** tab, **Main node** sets where traffic exits (a specific node, or **URLTest** to auto-pick the fastest with its own pool, test interval and tolerance).
|
|
||||||
|
|
||||||
By default UDP follows the main node. Set **Main UDP node** to send **UDP/QUIC out a different node** than TCP — useful when your main node handles UDP poorly (or not at all), or when you want QUIC/voice on a node that's better at it. It has its own URLTest pool too. Leave it on *Same as main node* if you don't need the split.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Russia (Proxy Banned) — the full UI
|
|
||||||
|
|
||||||
`proxy_banned_ru` is the default RU mode: **everything is direct except what you explicitly proxy.** When it is selected, the **Routing** tab gains the fields below and an extra **RU Proxy Rules** tab appears.
|
|
||||||
|
|
||||||
### Routing tab (in this mode)
|
|
||||||
|
|
||||||
| Field | What it does |
|
|
||||||
|-------|--------------|
|
|
||||||
| **Russia DNS server** 🔓 | Resolves Russian/normal domains **directly** (not through the proxy). Default *Yandex (77.88.8.8)*; also SkyDNS, Comss.one, and plain-UDP Cloudflare/Google. |
|
|
||||||
| **Secure DNS server** 🔒 | Resolves **blocked** domains **through the proxy** over encrypted DNS (DoH/DoT), so your ISP can't see those lookups. Default *Cloudflare DoH*; also Quad9 / AdGuard / Google (DoH and DoT). See [DNS & Diagnostics](DNS-and-Diagnostics-en). |
|
|
||||||
| **Proxy calls** 📞 | Route VoIP call ports (WhatsApp, Telegram, FaceTime…) through the proxy. Off by default. |
|
|
||||||
| **Do not proxify torrents** 🧲 | Force BitTorrent traffic (protocol + common ports) direct. Off by default. |
|
|
||||||
| **Advanced custom rules** 👨💻 | Reveal the **Routing Nodes** and **Routing Rules** tabs (see below). Off by default. |
|
|
||||||
| **Routing ports** | Limit which destination ports are proxied (e.g. *Common ports only* keeps P2P direct). |
|
|
||||||
| **Proxy mode** | How traffic is intercepted — see [Routing modes](#routing-modes) above. |
|
|
||||||
|
|
||||||
### RU Proxy Rules tab
|
|
||||||
|
|
||||||
This is where you choose *what* to proxy. **The default route is Direct** — only the rules you add here are proxied. Each rule = a **Source list** ⤵️ + a **Node** 🔗 to send it through, applied with automatic priority:
|
|
||||||
|
|
||||||
1. **Smaller service lists** first — YouTube, Twitter/X, TikTok, Telegram, Discord, Roblox, Meta (Facebook/Instagram), Google AI, Google Play, anime, HDRezka, international news, adult, GeoBlock, HODCA, and cloud/CDN ranges (Cloudflare, CloudFront, OVH, Hetzner, DigitalOcean).
|
|
||||||
2. **Russia Inside** (1000+ domains, by itdoginfo) — the in-Russia must-have set.
|
|
||||||
3. **Re:Filter** (60000+ domains + 25000+ IPs) — the Roskomnadzor blocklist.
|
|
||||||
|
|
||||||
The lists are community-maintained and **self-refresh** on the router; you only pick which to enable. (Adding the same source twice is flagged — only the first rule takes effect.)
|
|
||||||
|
|
||||||
**Per-rule Node** 🔗 can be:
|
|
||||||
|
|
||||||
- **Same as main node** — use your main proxy.
|
|
||||||
- **Separate URLTest** — auto-select among a chosen set of nodes, with its own **URLTest nodes**, **Test interval** (default 180 s) and **Test tolerance** (default 150 ms).
|
|
||||||
- **A specific node**.
|
|
||||||
- **ByeDPI** or **Zapret** — send that list through a DPI-bypass instead of a VPN node (e.g. YouTube via [ByeDPI](ByeDPI-en) / [Zapret](Zapret-en) to save VPN bandwidth).
|
|
||||||
|
|
||||||
### Advanced custom rules (optional)
|
|
||||||
|
|
||||||
Enabling **Advanced custom rules** 👨💻 layers two extra tabs on top of the RU presets:
|
|
||||||
|
|
||||||
- **Routing Nodes** — define named outbounds (a node, a URLTest group, or a chained **Outbound**), each with its own **Domain resolver** (including the Russia 🔓 / Secure 🔒 servers) and **Domain strategy**.
|
|
||||||
- **Routing Rules** — match traffic by domain / IP / port / protocol and send it to one of those routing nodes.
|
|
||||||
|
|
||||||
Full reference for both tabs (and the standalone **Custom routing** mode): **[Custom Routing](Custom-Routing-en)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Access Control — which devices are affected
|
|
||||||
|
|
||||||
**Client → Access Control** controls *which LAN devices* the proxy applies to. There are several **independent** controls — they combine, they are not one dropdown:
|
|
||||||
|
|
||||||
### LAN IP Policy
|
|
||||||
|
|
||||||
- **Proxy mode for devices** — the global gate:
|
|
||||||
- **Disable** — no per-device restriction (all LAN devices follow the routing rules).
|
|
||||||
- **Proxy listed only** — only the listed IPs/MACs are proxied.
|
|
||||||
- **Proxy all except listed** — everyone is proxied except the listed IPs/MACs (which go direct).
|
|
||||||
- **Gaming mode** *(independent list)* — for the selected devices, **only TCP** is proxied (UDP/game traffic stays direct for lower latency).
|
|
||||||
- **Global proxy** *(independent list)* — for the selected devices, **all** traffic goes through the proxy regardless of the routing rules.
|
|
||||||
|
|
||||||
Each of these is its own IP/MAC list, so you can mix them per device.
|
|
||||||
|
|
||||||
### WAN IP Policy
|
|
||||||
|
|
||||||
Force specific **destination** IPs/CIDRs to be proxied or direct, regardless of mode.
|
|
||||||
|
|
||||||
### Proxy / Direct Domain Lists
|
|
||||||
|
|
||||||
Free-form domain lists that are always proxied or always direct — useful for one-off destinations not covered by the RU lists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- In Russia mode, start with **Russia Inside + Re:Filter** enabled; add per-service lists only if a specific site still misbehaves.
|
|
||||||
- If a site is *throttled* (slow) rather than *blocked* (unreachable), route it through [ByeDPI](ByeDPI-en) or [Zapret](Zapret-en) instead of the VPN.
|
|
||||||
- Use the [Diagnostics](DNS-and-Diagnostics-en) page to confirm traffic is actually being proxied — **Direct IP vs Proxy IP** on hiddify-core, or the **Active Node** row on sing-box-extended.
|
|
||||||
|
|
||||||
See also: [Getting Started](Getting-Started-en) · [Subscriptions & Node Import](Subscriptions-en) · [DNS & Diagnostics](DNS-and-Diagnostics-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,110 +0,0 @@
|
|||||||
🇬🇧 [English](Routing-and-Access-Control-en) | 🇷🇺 [Русский](Routing-and-Access-Control-ru)
|
|
||||||
|
|
||||||
# Маршрутизация и контроль доступа
|
|
||||||
|
|
||||||
Re:HomeProxy решает, **какой** трафик проксировать (режим маршрутизации + правила) и **каких устройств** это касается (контроль доступа). Эта страница — про оба аспекта.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Режимы маршрутизации
|
|
||||||
|
|
||||||
Задаётся в **Настройки клиента → Настройки маршрутизации → Режим маршрутизации**:
|
|
||||||
|
|
||||||
| Режим | Что делает |
|
|
||||||
|-------|------------|
|
|
||||||
| **Россия (Обход Блокировок)** (`proxy_banned_ru`) | По умолчанию для РФ. Всё идёт **напрямую**, кроме адресов в **Правила прокси (РФ)** — они через прокси. |
|
|
||||||
| **Глобальный** | Весь трафик через прокси. |
|
|
||||||
| **Китай - GFWList** | Проксирует встроенный список часто блокируемых (GFW) доменов. |
|
|
||||||
| **Китай - Direct** | Всё через прокси, кроме адресов материкового Китая (напрямую). |
|
|
||||||
| **Китай - Proxy** | Через прокси только адреса материкового Китая. |
|
|
||||||
| **Пользовательская маршрутизация** | Ручные узлы и правила маршрутизации (для продвинутых). |
|
|
||||||
| **Пользовательский JSON** | Конфиг hiddify-core вручную — см. [Пользовательский JSON конфиг](Custom-JSON-Config-ru). |
|
|
||||||
|
|
||||||
**Порты маршрутизации** — ограничить, какие порты назначения проксируются (например, *Только обычные порты*, чтобы P2P-трафик шёл напрямую).
|
|
||||||
|
|
||||||
**Режим прокси** — как перехватывается трафик: `Redirect TCP`, `Redirect TCP + TProxy UDP` (по умолчанию), `Redirect TCP + Tun UDP` или полный `Tun TCP/UDP` (режимы TUN требуют `kmod-tun`).
|
|
||||||
|
|
||||||
### Основной узел и отдельный UDP-узел
|
|
||||||
|
|
||||||
На вкладке **Настройки маршрутизации** поле **Основной узел** задаёт, через какой узел уходит трафик (конкретный узел или **URLTest** для авто-выбора самого быстрого, со своим пулом, интервалом и допуском теста).
|
|
||||||
|
|
||||||
По умолчанию UDP идёт через основной узел. Поле **Основной UDP-узел** позволяет отправлять **UDP/QUIC через другой узел**, чем TCP — полезно, если основной узел плохо (или вообще не) работает с UDP, или если хочется отдать QUIC/голос узлу, который с этим справляется лучше. У него тоже свой пул URLTest. Оставьте *Как основной узел*, если разделение не нужно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Россия (Обход Блокировок) — полный интерфейс
|
|
||||||
|
|
||||||
`proxy_banned_ru` — режим РФ по умолчанию: **весь трафик идёт напрямую, кроме того, что вы явно проксируете.** При его выборе на вкладке **Маршрутизация** появляются поля ниже, а также отдельная вкладка **Правила прокси (РФ)**.
|
|
||||||
|
|
||||||
### Вкладка «Маршрутизация» (в этом режиме)
|
|
||||||
|
|
||||||
| Поле | Что делает |
|
|
||||||
|------|------------|
|
|
||||||
| **DNS-сервер России** 🔓 | Разрешает российские/обычные домены **напрямую** (не через прокси). По умолчанию *Yandex (77.88.8.8)*; также SkyDNS, Comss.one и обычный UDP Cloudflare/Google. |
|
|
||||||
| **Защищённый DNS-сервер** 🔒 | Разрешает **заблокированные** домены **через прокси** по шифрованному DNS (DoH/DoT), чтобы провайдер не видел эти запросы. По умолчанию *Cloudflare DoH*; также Quad9 / AdGuard / Google (DoH и DoT). См. [DNS и диагностика](DNS-and-Diagnostics-ru). |
|
|
||||||
| **Проксировать звонки** 📞 | Направить порты VoIP-звонков (WhatsApp, Telegram, FaceTime…) через прокси. По умолчанию выключено. |
|
|
||||||
| **Не проксировать торренты** 🧲 | Заставить трафик BitTorrent (протокол + типичные порты) идти напрямую. По умолчанию выключено. |
|
|
||||||
| **Расширенные правила** 👨💻 | Открывает вкладки **Правила узлов** и **Расширенные правила** (см. ниже). По умолчанию выключено. |
|
|
||||||
| **Порты маршрутизации** | Ограничить, какие порты назначения проксируются (например, *только типичные порты* оставляет P2P напрямую). |
|
|
||||||
| **Режим прокси** | Как перехватывается трафик — см. [Режимы маршрутизации](#режимы-маршрутизации) выше. |
|
|
||||||
|
|
||||||
### Вкладка «Правила прокси (РФ)»
|
|
||||||
|
|
||||||
Здесь выбирается, *что* проксировать. **Маршрут по умолчанию — Напрямую**, проксируется только то, что вы добавите. Каждое правило = **список-источник** ⤵️ + **узел** 🔗, через который его отправлять. Правила применяются с автоматическим приоритетом:
|
|
||||||
|
|
||||||
1. Сначала **небольшие сервисные списки** — YouTube, Twitter/X, TikTok, Telegram, Discord, Roblox, Meta (Facebook/Instagram), Google AI, Google Play, аниме, HDRezka, международные новости, 18+, GeoBlock, HODCA и облачные/CDN-диапазоны (Cloudflare, CloudFront, OVH, Hetzner, DigitalOcean).
|
|
||||||
2. **Russia Inside** (1000+ доменов, от itdoginfo) — обязательный набор для РФ.
|
|
||||||
3. **Re:Filter** (60000+ доменов + 25000+ IP) — список блокировок Роскомнадзора.
|
|
||||||
|
|
||||||
Сами списки поддерживаются сообществом и **автообновляются** на роутере; вы только выбираете, какие включить. (Повторное добавление того же источника отмечается — сработает только первое правило.)
|
|
||||||
|
|
||||||
**Узел** 🔗 для каждого правила может быть:
|
|
||||||
|
|
||||||
- **Как основной узел** — ваш основной прокси.
|
|
||||||
- **Отдельный URLTest** — автовыбор среди заданного набора узлов, со своими **узлами URLTest**, **интервалом тестирования** (по умолчанию 180 с) и **допуском теста** (по умолчанию 150 мс).
|
|
||||||
- **Конкретный узел**.
|
|
||||||
- **ByeDPI** или **Zapret** — отправить список через обход DPI вместо VPN-узла (например, YouTube через [ByeDPI](ByeDPI-ru) / [Zapret](Zapret-ru), чтобы экономить трафик VPN).
|
|
||||||
|
|
||||||
### Расширенные правила (опционально)
|
|
||||||
|
|
||||||
Включение **Расширенных правил** 👨💻 добавляет две вкладки поверх RU-пресетов:
|
|
||||||
|
|
||||||
- **Правила узлов** — задают именованные исходящие (узел, группа URLTest или цепочка через **Исходящий**), у каждого свой **Резолвер доменов** (включая серверы Россия 🔓 / Защищённый 🔒) и **Стратегия доменов**.
|
|
||||||
- **Расширенные правила** — сопоставляют трафик по домену / IP / порту / протоколу и направляют его на один из этих узлов.
|
|
||||||
|
|
||||||
Полный справочник по обеим вкладкам (и по самостоятельному режиму **Пользовательская маршрутизация**): **[Пользовательская маршрутизация](Custom-Routing-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Контроль доступа — каких устройств это касается
|
|
||||||
|
|
||||||
**Настройки клиента → Контроль доступа** определяет, *к каким устройствам LAN* применяется прокси. Здесь несколько **независимых** механизмов — они комбинируются, это не один список:
|
|
||||||
|
|
||||||
### Политика IP для LAN
|
|
||||||
|
|
||||||
- **Режим проксирования устройств** — глобальный вентиль:
|
|
||||||
- **Отключить** — без пер-девайс ограничений (все устройства следуют правилам маршрутизации).
|
|
||||||
- **Проксировать только указанные** — проксируются только перечисленные IP/MAC.
|
|
||||||
- **Проксировать всё кроме указанных** — проксируются все, кроме перечисленных IP/MAC (те идут напрямую).
|
|
||||||
- **Игровой режим** *(независимый список)* — для выбранных устройств проксируется **только TCP** (UDP/игровой трафик идёт напрямую для меньшей задержки).
|
|
||||||
- **Глобальный прокси** *(независимый список)* — для выбранных устройств **весь** трафик идёт через прокси, независимо от правил маршрутизации.
|
|
||||||
|
|
||||||
Каждый из этих механизмов — свой список IP/MAC, так что их можно комбинировать по устройствам.
|
|
||||||
|
|
||||||
### Политика WAN IP
|
|
||||||
|
|
||||||
Принудительно проксировать или направлять напрямую конкретные **IP/подсети назначения**, независимо от режима.
|
|
||||||
|
|
||||||
### Списки доменов (прокси / напрямую)
|
|
||||||
|
|
||||||
Свободные списки доменов, которые всегда проксируются или всегда идут напрямую — удобно для разовых адресов, не покрытых RU-списками.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Советы
|
|
||||||
|
|
||||||
- В режиме России начните с включённых **Russia Inside + Re:Filter**; добавляйте сервисные списки, только если конкретный сайт всё ещё работает плохо.
|
|
||||||
- Если сайт *троттлится* (медленный), а не *заблокирован* (недоступен), направьте его через [ByeDPI](ByeDPI-ru) или [Zapret](Zapret-ru) вместо VPN.
|
|
||||||
- Используйте страницу [Диагностика](DNS-and-Diagnostics-ru), чтобы убедиться, что трафик реально проксируется — **IP провайдера против IP прокси** на hiddify-core или строка **Активный узел** на sing-box-extended.
|
|
||||||
|
|
||||||
См. также: [Первая настройка](Getting-Started-ru) · [Подписки и импорт узлов](Subscriptions-ru) · [DNS и диагностика](DNS-and-Diagnostics-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
🇬🇧 [English](Server-Settings-en) | 🇷🇺 [Русский](Server-Settings-ru)
|
|
||||||
|
|
||||||
# Server Settings
|
|
||||||
|
|
||||||
Most of Re:HomeProxy is a **client** — the router dials *out* to proxy nodes. **Server Settings** is the reverse: it runs **inbounds** on the router so other devices can connect *in*. Use it to turn the router into your own endpoint (e.g. a home server you reach from a phone while away), or to expose a local SOCKS/HTTP proxy to the LAN.
|
|
||||||
|
|
||||||
Open it at **Services → Re:HomeProxy → Server Settings**. It is independent of client mode — run either, both, or neither. A fresh install has **no inbounds**; you add them here.
|
|
||||||
|
|
||||||
> ⚠️ An inbound opens a listening port. Turn on **Firewall** (below) only for inbounds you want reachable from the Internet, and always protect them with a strong password and/or TLS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Global
|
|
||||||
|
|
||||||
A single **Enable** switch turns the server subsystem on or off. Each inbound below also has its own enable toggle.
|
|
||||||
|
|
||||||
## Inbounds (Server settings)
|
|
||||||
|
|
||||||
Add one row per listener. The common fields:
|
|
||||||
|
|
||||||
| Field | Meaning |
|
|
||||||
|-------|---------|
|
|
||||||
| **Label** | A unique name for the inbound |
|
|
||||||
| **Enable** | Turn this inbound on/off |
|
|
||||||
| **Firewall** | *Allow access from the Internet* — off = reachable from LAN only, on = the port is opened on WAN |
|
|
||||||
| **Type** | The inbound protocol (see below) |
|
|
||||||
| **Listen address / Listen port** | Bind address (default `::`) and a unique port |
|
|
||||||
| **Username / Password** | Where the protocol uses credentials |
|
|
||||||
|
|
||||||
Per-protocol fields (encryption method, TLS, transport, multiplex…) appear in the edit dialog **after you pick a Type**. They mirror sing-box's inbound options one-to-one — rather than repeat them here, see the **[sing-box inbound reference](https://sing-box.sagernet.org/configuration/inbound/)** for each field, and the per-type pages (e.g. [VLESS](https://sing-box.sagernet.org/configuration/inbound/vless/), [Trojan](https://sing-box.sagernet.org/configuration/inbound/trojan/), [Hysteria2](https://sing-box.sagernet.org/configuration/inbound/hysteria2/)).
|
|
||||||
|
|
||||||
### Inbound types
|
|
||||||
|
|
||||||
**Always available:** AnyTLS, HTTP, **Mixed** (HTTP + SOCKS on one port), Shadowsocks, SOCKS, Trojan, VLESS, VMess.
|
|
||||||
|
|
||||||
Core/build-dependent (the dropdown only shows them if your core supports them — check tags as in [Supported Protocols](Supported-Protocols-en)):
|
|
||||||
|
|
||||||
- **Hysteria, Hysteria2, NaïveProxy, TUIC** — require a `with_quic` build.
|
|
||||||
- **MTProxy** — sing-box-extended only.
|
|
||||||
|
|
||||||
## TLS & certificates
|
|
||||||
|
|
||||||
TLS-capable inbounds expose the usual controls (min/max version, cipher suites) plus either **your own certificate** or, on a `with_acme` build, **ACME** auto-issuance (Let's Encrypt / ZeroSSL, via HTTP-01 or DNS-01 challenge). These follow sing-box field-for-field — see **[sing-box TLS](https://sing-box.sagernet.org/configuration/shared/tls/)** and its [ACME section](https://sing-box.sagernet.org/configuration/shared/tls/#acme).
|
|
||||||
|
|
||||||
## Tips
|
|
||||||
|
|
||||||
- Quickest personal endpoint: **Mixed** + a strong password — point a client's SOCKS/HTTP proxy at the router's IP and port.
|
|
||||||
- Public, blocking-resistant endpoint: a TLS type (VLESS / Trojan / Hysteria2) with ACME **DNS-01**, so you don't have to expose port 80.
|
|
||||||
- Leave **Firewall** off for an inbound you only use from inside the LAN.
|
|
||||||
|
|
||||||
See also: [Supported Protocols](Supported-Protocols-en) · [Core Management](Core-Management-en) · [Getting Started](Getting-Started-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
🇬🇧 [English](Server-Settings-en) | 🇷🇺 [Русский](Server-Settings-ru)
|
|
||||||
|
|
||||||
# Настройки сервера
|
|
||||||
|
|
||||||
Бо́льшая часть Re:HomeProxy — это **клиент**: роутер сам подключается *наружу* к прокси-узлам. **Настройки сервера** — наоборот: роутер поднимает **входящие соединения (inbounds)**, чтобы другие устройства подключались *к нему*. Пригодится, чтобы сделать из роутера собственную точку входа (например, домашний сервер, к которому вы подключаетесь с телефона в поездке) или открыть локальный SOCKS/HTTP-прокси в LAN.
|
|
||||||
|
|
||||||
Открывается в **Сервисы → Re:HomeProxy → Настройки сервера**. Не зависит от клиентского режима — можно использовать любой из них, оба или ни одного. На свежей установке inbound-ов **нет** — вы добавляете их здесь.
|
|
||||||
|
|
||||||
> ⚠️ Inbound открывает слушающий порт. Включайте **Брандмауэр** (ниже) только для тех inbound-ов, которые должны быть доступны из интернета, и всегда защищайте их надёжным паролем и/или TLS.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Глобально
|
|
||||||
|
|
||||||
Один переключатель **Включить** включает или выключает серверную подсистему. У каждого inbound-а ниже есть свой собственный переключатель.
|
|
||||||
|
|
||||||
## Inbound-ы (Настройки сервера)
|
|
||||||
|
|
||||||
Добавьте по одной строке на каждый слушатель. Общие поля:
|
|
||||||
|
|
||||||
| Поле | Что значит |
|
|
||||||
|------|------------|
|
|
||||||
| **Метка** | Уникальное имя inbound-а |
|
|
||||||
| **Включить** | Включить/выключить этот inbound |
|
|
||||||
| **Брандмауэр** | *Разрешить доступ из интернета* — выкл = доступен только из LAN, вкл = порт открывается на WAN |
|
|
||||||
| **Тип** | Протокол inbound-а (см. ниже) |
|
|
||||||
| **Адрес прослушивания / Порт прослушивания** | Адрес привязки (по умолчанию `::`) и уникальный порт |
|
|
||||||
| **Имя пользователя / Пароль** | Где протокол использует учётные данные |
|
|
||||||
|
|
||||||
Поля конкретного протокола (метод шифрования, TLS, транспорт, мультиплекс…) появляются в окне редактирования **после выбора Типа**. Они один-в-один повторяют опции inbound-ов sing-box — поэтому здесь они не дублируются, см. **[справочник inbound-ов sing-box](https://sing-box.sagernet.org/configuration/inbound/)** по каждому полю и страницы по типам (например, [VLESS](https://sing-box.sagernet.org/configuration/inbound/vless/), [Trojan](https://sing-box.sagernet.org/configuration/inbound/trojan/), [Hysteria2](https://sing-box.sagernet.org/configuration/inbound/hysteria2/)).
|
|
||||||
|
|
||||||
### Типы inbound-ов
|
|
||||||
|
|
||||||
**Всегда доступны:** AnyTLS, HTTP, **Смешанный** (HTTP + SOCKS на одном порту), Shadowsocks, SOCKS, Trojan, VLESS, VMess.
|
|
||||||
|
|
||||||
Зависят от ядра/сборки (в списке появляются только если ядро их поддерживает — проверьте теги, как в [Поддерживаемых протоколах](Supported-Protocols-ru)):
|
|
||||||
|
|
||||||
- **Hysteria, Hysteria2, NaïveProxy, TUIC** — требуют сборку `with_quic`.
|
|
||||||
- **MTProxy** — только sing-box-extended.
|
|
||||||
|
|
||||||
## TLS и сертификаты
|
|
||||||
|
|
||||||
Inbound-ы с TLS дают обычные настройки (мин./макс. версия, наборы шифров) плюс либо **ваш собственный сертификат**, либо, на сборке `with_acme`, авто-выпуск через **ACME** (Let's Encrypt / ZeroSSL, через HTTP-01 или DNS-01). Они повторяют sing-box поле-в-поле — см. **[TLS в sing-box](https://sing-box.sagernet.org/configuration/shared/tls/)** и его [раздел ACME](https://sing-box.sagernet.org/configuration/shared/tls/#acme).
|
|
||||||
|
|
||||||
## Советы
|
|
||||||
|
|
||||||
- Самая быстрая личная точка входа: **Смешанный** + надёжный пароль — направьте SOCKS/HTTP-прокси клиента на IP и порт роутера.
|
|
||||||
- Публичная, устойчивая к блокировкам точка входа: TLS-тип (VLESS / Trojan / Hysteria2) с ACME **DNS-01**, чтобы не открывать порт 80.
|
|
||||||
- Оставляйте **Брандмауэр** выключенным для inbound-а, которым пользуетесь только из LAN.
|
|
||||||
|
|
||||||
См. также: [Поддерживаемые протоколы](Supported-Protocols-ru) · [Управление ядром](Core-Management-ru) · [Первая настройка](Getting-Started-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
🇬🇧 [English](Subscriptions-en) | 🇷🇺 [Русский](Subscriptions-ru)
|
|
||||||
|
|
||||||
# Subscriptions & Node Import
|
|
||||||
|
|
||||||
There are four ways to get nodes into Re:HomeProxy. All live on **Node Settings → Nodes** (import buttons) and **Node Settings → Subscriptions**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Import share links
|
|
||||||
|
|
||||||
The **Import share links** button accepts one link per line. Supported schemes:
|
|
||||||
|
|
||||||
| Scheme | Protocol |
|
|
||||||
|--------|----------|
|
|
||||||
| `vless://` | VLESS (incl. Reality, XHTTP) |
|
|
||||||
| `vmess://` | VMess (v2rayN format) |
|
|
||||||
| `ss://` | Shadowsocks / Shadowsocks 2022 |
|
|
||||||
| `trojan://` | Trojan |
|
|
||||||
| `hysteria://`, `hysteria2://`, `hy2://` | Hysteria / Hysteria2 |
|
|
||||||
| `tuic://` | TUIC |
|
|
||||||
| `naive://` | NaïveProxy |
|
|
||||||
| `mieru://` | Mieru |
|
|
||||||
| `ssh://` | SSH |
|
|
||||||
| `wireguard://` | WireGuard |
|
|
||||||
| `vpn://` | **AmneziaVPN** share link — see below |
|
|
||||||
|
|
||||||
### AmneziaVPN `vpn://` links
|
|
||||||
|
|
||||||
Re:HomeProxy fully decodes Amnezia's `vpn://` share format (`base64url(qCompress(zlib JSON))`) right in the browser — no helper tool needed. It reads the container inside and recognises **both** Amnezia config types automatically:
|
|
||||||
|
|
||||||
- **AmneziaWG** (`amnezia-awg` / `amnezia-awg2`) → an **AmneziaWG** node, with the full obfuscation parameter set (`Jc`, `Jmin`, `Jmax`, `S1`–`S4`, `H1`–`H4`, `I1`–`I5`), MTU, keepalive and keys. *Requires the sing-box-extended core* — see [Supported Protocols](Supported-Protocols-en).
|
|
||||||
- **Xray** (`amnezia-xray`) → the Xray outbound inside is parsed into a node, including **VLESS + Reality/TLS** and the transport (`ws`, `grpc`, `xhttp`/`splithttp`, HTTPUpgrade) with their settings.
|
|
||||||
|
|
||||||
So an Amnezia link works whether it carries an AmneziaWG or an Xray profile.
|
|
||||||
|
|
||||||
### Base64
|
|
||||||
|
|
||||||
Subscription and share-link payloads are very often **base64**-encoded — this is the norm, not an edge case. Re:HomeProxy decodes base64 transparently:
|
|
||||||
|
|
||||||
- A pasted block that is one big base64 blob is decoded into its list of links.
|
|
||||||
- Individual `ss://` / `vmess://` links carry their own base64 (userinfo, VMess JSON) — handled automatically.
|
|
||||||
- Subscription URLs that return a base64 list (the classic format) are decoded on fetch.
|
|
||||||
|
|
||||||
You normally don't need to decode anything yourself — just paste the link or the blob.
|
|
||||||
|
|
||||||
Two options apply during import:
|
|
||||||
|
|
||||||
- **Allow insecure** — marks imported TLS nodes as `tls_insecure` (skip cert verification). Use only if you trust the server.
|
|
||||||
- **Packet encoding** — applied to imported VLESS/VMess nodes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Import a .conf file
|
|
||||||
|
|
||||||
The **Import .conf** button reads a **WireGuard** or **AmneziaWG** `.conf` file and creates a node from it — handy for WARP/AmneziaWG configs you already have. (AmneziaWG nodes require the **sing-box-extended** core — see [Supported Protocols](Supported-Protocols-en).)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Subscription URLs (auto-updating)
|
|
||||||
|
|
||||||
On the **Subscriptions** tab, add one or more **Subscription URL-s**. The router fetches them and keeps the node list in sync. Re:HomeProxy understands several response formats automatically:
|
|
||||||
|
|
||||||
- **Base64 / plain share-link lists** — the classic subscription format (one encoded link per line).
|
|
||||||
- **sing-box JSON / Hiddify** — when the subscription is served as a sing-box/Hiddify JSON config. The correct format is requested via the **User-Agent**, so the same URL can return different formats to different clients.
|
|
||||||
- **Xray / V2Ray JSON** — config-array JSON (e.g. *connliberty*-style) is parsed into nodes; this path is User-Agent gated as well.
|
|
||||||
|
|
||||||
### Update settings
|
|
||||||
|
|
||||||
- **Auto update** — enable periodic refresh; pick the **Update time** (hour of day, default 02:00).
|
|
||||||
- **Update via proxy** — fetch the subscription through the proxy (useful when the subscription host itself is blocked). Off by default to avoid a startup chicken-and-egg.
|
|
||||||
- Nodes can also be refreshed on demand from the UI.
|
|
||||||
|
|
||||||
### Filtering subscription nodes
|
|
||||||
|
|
||||||
- **Filter nodes** — `Disable`, `Blacklist mode` (drop matching), or `Whitelist mode` (keep only matching).
|
|
||||||
- **Filter keywords** — the match list; **regex** is supported.
|
|
||||||
|
|
||||||
This is useful to drop dead/region-locked nodes or to keep only a curated subset (e.g. only certain countries/protocols for URLTest).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Which protocols can I import?
|
|
||||||
|
|
||||||
That depends on the **core** you installed — a node type only works if the core supports it (e.g. AmneziaWG needs sing-box-extended; some QUIC/Naive protocols need specific build tags). See **[Supported Protocols](Supported-Protocols-en)** for the full matrix and how to check your build's tags.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Imported subscription nodes are grouped by their subscription; manual nodes stay separate.
|
|
||||||
- If an import reports "no valid link found", check the scheme is one of the above and that the link isn't truncated.
|
|
||||||
- For URLTest selection across many subscription nodes, combine **Filter nodes** here with a per-rule **URLTest** target in [RU Proxy Rules](Routing-and-Access-Control-en).
|
|
||||||
|
|
||||||
See also: [Getting Started](Getting-Started-en) · [Supported Protocols](Supported-Protocols-en) · [Routing & Access Control](Routing-and-Access-Control-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
🇬🇧 [English](Subscriptions-en) | 🇷🇺 [Русский](Subscriptions-ru)
|
|
||||||
|
|
||||||
# Подписки и импорт узлов
|
|
||||||
|
|
||||||
Есть четыре способа добавить узлы в Re:HomeProxy. Все они — на **Настройки узлов → Узлы** (кнопки импорта) и **Настройки узлов → Подписки**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. Импорт share-ссылок
|
|
||||||
|
|
||||||
Кнопка **Импортировать ссылку-конфигурацию для подключения** (на вкладке **Узлы**) принимает по одной ссылке на строку. Поддерживаемые схемы:
|
|
||||||
|
|
||||||
| Схема | Протокол |
|
|
||||||
|-------|----------|
|
|
||||||
| `vless://` | VLESS (в т. ч. Reality, XHTTP) |
|
|
||||||
| `vmess://` | VMess (формат v2rayN) |
|
|
||||||
| `ss://` | Shadowsocks / Shadowsocks 2022 |
|
|
||||||
| `trojan://` | Trojan |
|
|
||||||
| `hysteria://`, `hysteria2://`, `hy2://` | Hysteria / Hysteria2 |
|
|
||||||
| `tuic://` | TUIC |
|
|
||||||
| `naive://` | NaïveProxy |
|
|
||||||
| `mieru://` | Mieru |
|
|
||||||
| `ssh://` | SSH |
|
|
||||||
| `wireguard://` | WireGuard |
|
|
||||||
| `vpn://` | **AmneziaVPN** share-ссылка — см. ниже |
|
|
||||||
|
|
||||||
### Ссылки AmneziaVPN `vpn://`
|
|
||||||
|
|
||||||
Re:HomeProxy полностью декодирует share-формат Amnezia `vpn://` (`base64url(qCompress(zlib JSON))`) прямо в браузере — без вспомогательных утилит. Он читает контейнер внутри и автоматически распознаёт **оба** типа конфигов Amnezia:
|
|
||||||
|
|
||||||
- **AmneziaWG** (`amnezia-awg` / `amnezia-awg2`) → узел **AmneziaWG** с полным набором параметров обфускации (`Jc`, `Jmin`, `Jmax`, `S1`–`S4`, `H1`–`H4`, `I1`–`I5`), MTU, keepalive и ключами. *Требует ядро sing-box-extended* — см. [Поддерживаемые протоколы](Supported-Protocols-ru).
|
|
||||||
- **Xray** (`amnezia-xray`) → вложенный outbound Xray разбирается в узел, включая **VLESS + Reality/TLS** и транспорт (`ws`, `grpc`, `xhttp`/`splithttp`, HTTPUpgrade) с их настройками.
|
|
||||||
|
|
||||||
Так что ссылка Amnezia работает, несёт ли она профиль AmneziaWG или Xray.
|
|
||||||
|
|
||||||
### Base64
|
|
||||||
|
|
||||||
Содержимое подписок и share-ссылок очень часто закодировано в **base64** — это норма, а не редкий случай. Re:HomeProxy декодирует base64 прозрачно:
|
|
||||||
|
|
||||||
- Вставленный блок, представляющий собой один большой base64-блоб, разворачивается в список ссылок.
|
|
||||||
- Отдельные ссылки `ss://` / `vmess://` несут собственный base64 (userinfo, JSON VMess) — обрабатывается автоматически.
|
|
||||||
- Ссылки подписок, возвращающие base64-список (классический формат), декодируются при скачивании.
|
|
||||||
|
|
||||||
Обычно ничего декодировать вручную не нужно — просто вставьте ссылку или блоб.
|
|
||||||
|
|
||||||
При импорте действуют две опции:
|
|
||||||
|
|
||||||
- **Разрешить небезопасные** — помечает импортируемые TLS-узлы как `tls_insecure` (пропуск проверки сертификата). Используйте, только если доверяете серверу.
|
|
||||||
- **Кодирование пакетов (packet encoding)** — применяется к импортируемым узлам VLESS/VMess.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Импорт файла .conf
|
|
||||||
|
|
||||||
Кнопка **Импорт WG (.conf) файла** читает файл конфигурации **WireGuard** или **AmneziaWG** `.conf` и создаёт из него узел — удобно для готовых конфигов WARP/AmneziaWG. (Узлы AmneziaWG требуют ядро **sing-box-extended** — см. [Поддерживаемые протоколы](Supported-Protocols-ru).)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. Ссылки подписок (с автообновлением)
|
|
||||||
|
|
||||||
На вкладке **Подписки** добавьте одну или несколько **ссылок подписок**. Роутер скачивает их и поддерживает список узлов в актуальном состоянии. Re:HomeProxy автоматически распознаёт несколько форматов ответа:
|
|
||||||
|
|
||||||
- **Base64 / обычные списки share-ссылок** — классический формат подписки (по одной кодированной ссылке на строку).
|
|
||||||
- **sing-box JSON / Hiddify** — когда подписка отдаётся как JSON-конфиг sing-box/Hiddify. Нужный формат запрашивается через **User-Agent**, поэтому один и тот же URL может возвращать разным клиентам разные форматы.
|
|
||||||
- **Xray / V2Ray JSON** — JSON с массивом конфигов (например, в стиле *connliberty*) разбирается в узлы; этот путь также завязан на User-Agent.
|
|
||||||
|
|
||||||
### Настройки обновления
|
|
||||||
|
|
||||||
- **Автообновление** — включить периодическое обновление; выберите **Время обновления** (час суток, по умолчанию 02:00).
|
|
||||||
- **Обновлять через прокси** — скачивать подписку через прокси (полезно, если сам хост подписки заблокирован). По умолчанию выключено, чтобы избежать проблемы «курицы и яйца» при старте.
|
|
||||||
- Узлы также можно обновить вручную из интерфейса.
|
|
||||||
|
|
||||||
### Фильтрация узлов подписки
|
|
||||||
|
|
||||||
- **Фильтр узлов** — `Отключено`, `Режим чёрного списка` (отбрасывать совпадения) или `Режим белого списка` (оставлять только совпадения).
|
|
||||||
- **Ключевые слова фильтра** — список совпадений; поддерживается **регулярное выражение**.
|
|
||||||
|
|
||||||
Удобно, чтобы отбросить мёртвые/региональные узлы или оставить только нужный набор (например, только определённые страны/протоколы для URLTest).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Какие протоколы можно импортировать?
|
|
||||||
|
|
||||||
Зависит от установленного **ядра** — тип узла работает, только если ядро его поддерживает (например, AmneziaWG требует sing-box-extended; некоторым протоколам QUIC/Naive нужны определённые build-теги). Полную матрицу и проверку тегов сборки см. в **[Поддерживаемые протоколы](Supported-Protocols-ru)**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Примечания
|
|
||||||
|
|
||||||
- Импортированные узлы подписки группируются по своей подписке; узлы, добавленные вручную, остаются отдельно.
|
|
||||||
- Если импорт сообщает «не найдено валидных ссылок», проверьте, что схема — одна из перечисленных выше и ссылка не обрезана.
|
|
||||||
- Для выбора через URLTest среди множества узлов подписки сочетайте **Фильтр узлов** здесь с целью **URLTest** для конкретного правила в [RU Proxy Rules](Routing-and-Access-Control-ru).
|
|
||||||
|
|
||||||
См. также: [Первая настройка](Getting-Started-ru) · [Поддерживаемые протоколы](Supported-Protocols-ru) · [Маршрутизация и контроль доступа](Routing-and-Access-Control-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
🇬🇧 [English](Supported-Protocols-en) | 🇷🇺 [Русский](Supported-Protocols-ru)
|
|
||||||
|
|
||||||
# Supported Protocols
|
|
||||||
|
|
||||||
Re:HomeProxy runs on a choice of cores: [hiddify-core](https://github.com/hiddify/hiddify-core) (default) or [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) — both forks of [sing-box](https://sing-box.sagernet.org) with additional protocols and features not available upstream. The protocols shown in the node editor depend on **which core you installed** and how it was compiled on your device. You choose and install the core on the **Core management** section (Services → Re:HomeProxy → Core & Tools).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Filling in the node editor
|
|
||||||
|
|
||||||
When you add a node by hand, the editor exposes the underlying **sing-box outbound** options — TLS (including **Reality**, **uTLS** fingerprints, **ECH**), the transport (**gRPC / WebSocket / HTTP / HTTPUpgrade / XHTTP**), and **multiplex**. Rather than duplicate sing-box's reference here, match each field to your server using the upstream docs:
|
|
||||||
|
|
||||||
- [Outbound reference](https://sing-box.sagernet.org/configuration/outbound/) — every outbound type and its fields
|
|
||||||
- [TLS (shared)](https://sing-box.sagernet.org/configuration/shared/tls/) — Reality, uTLS, ECH, ALPN
|
|
||||||
- [V2Ray transports](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/) — gRPC / WS / HTTP / HTTPUpgrade
|
|
||||||
- [Multiplex](https://sing-box.sagernet.org/configuration/shared/multiplex/)
|
|
||||||
|
|
||||||
Most users never fill these by hand — importing a share link or subscription sets them for you (see [Subscriptions & Node Import](Subscriptions-en)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Extended-core features (not in upstream sing-box)
|
|
||||||
|
|
||||||
Both cores are sing-box forks that add features missing from upstream. Some are **hiddify-core only**; others are in **both** hiddify-core and sing-box-extended (noted per item):
|
|
||||||
|
|
||||||
### TLS Fragmentation (`tls_fragment`) *(hiddify-core only)*
|
|
||||||
Splits the TLS ClientHello handshake across multiple TCP packets so that the SNI (Server Name Indication) field — which reveals the destination domain — arrives in separate fragments. DPI systems that inspect only whole packets to identify and block domains cannot reassemble the SNI in time, so the connection passes through undetected.
|
|
||||||
|
|
||||||
**Fragment modes:**
|
|
||||||
- **SNI/Domain** — splits packets into two pieces; simple and effective in most cases
|
|
||||||
- **Random** — divides into many very small pieces for maximum obfuscation; use when SNI mode alone is insufficient
|
|
||||||
|
|
||||||
**Key parameters:**
|
|
||||||
- **Fragment size** — recommended 100–200 bytes; ideally one byte less than the domain name length
|
|
||||||
- **Fragment interval** — timing between fragments; tune per ISP if needed
|
|
||||||
- **Mixed SNI case** — randomises capitalisation of the SNI (e.g. `wWw.ExAmPlE.cOm`) to defeat case-sensitive matching
|
|
||||||
- **Padding** — appends random data to the domain field
|
|
||||||
|
|
||||||
> **Note:** Do not enable fragment and padding at the same time — they cancel each other out. Effectiveness varies by ISP and may require parameter tuning.
|
|
||||||
|
|
||||||
Can be applied to any protocol that uses TLS (VLESS, VMess, Trojan, etc.).
|
|
||||||
|
|
||||||
*Source: [How the TLS Trick works and its usage — hiddify.com](https://hiddify.com/manager/basic-concepts-and-troubleshooting/How-the-TLS-Trick-works-and-its-usage/#tls-fragment)*
|
|
||||||
|
|
||||||
### XHTTP Transport *(both cores)*
|
|
||||||
A modern HTTP-based transport for VLESS designed for CDN compatibility and multiplexing. Not in upstream sing-box, but available on **both** hiddify-core and sing-box-extended. See the VLESS section below.
|
|
||||||
|
|
||||||
### Additional Protocols *(both cores)*
|
|
||||||
MieruTCP / MieruUDP and extended NaïveProxy variants — available on **both** cores (see below).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Always Available
|
|
||||||
|
|
||||||
### Direct
|
|
||||||
Bypasses the proxy — traffic goes to the destination without any tunneling. Used in route rules for local or trusted destinations.
|
|
||||||
|
|
||||||
### AnyTLS
|
|
||||||
A TLS-based multiplexing protocol developed by the hiddify team. Designed to be simple, efficient, and hard to fingerprint. Good choice when other TLS-based protocols are being blocked.
|
|
||||||
|
|
||||||
### HTTP
|
|
||||||
Plain HTTP proxy (RFC 7231 CONNECT method). Supports username/password authentication. Not encrypted — use only on trusted networks or wrapped in TLS via another layer.
|
|
||||||
|
|
||||||
### Shadowsocks
|
|
||||||
One of the most widely used anti-censorship protocols. Encrypts traffic with a pre-shared key and cipher (AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305, etc.). Simple to set up. Does not use TLS — traffic is obfuscated but does not look like HTTPS.
|
|
||||||
|
|
||||||
**Shadowsocks 2022** (`2022-blake3-aes-128-gcm`, `2022-blake3-aes-256-gcm`, `2022-blake3-chacha20-poly1305`) is also supported. It improves on the original with stronger authenticated encryption, replay protection, and better performance. If your server supports it, prefer the 2022 variants. ShadowTLS (see below) is designed to work with Shadowsocks 2022.
|
|
||||||
|
|
||||||
### ShadowTLS
|
|
||||||
A TLS camouflage wrapper designed for use with **Shadowsocks 2022** (not the original Shadowsocks). Wraps the underlying connection in a TLS handshake that impersonates a real HTTPS server, making traffic indistinguishable from legitimate TLS to passive observers. The TLS handshake is genuine — it completes with a real server — so SNI-based blocking and TLS fingerprinting are defeated. Requires a ShadowTLS-aware server alongside a Shadowsocks 2022 backend.
|
|
||||||
|
|
||||||
### Socks
|
|
||||||
SOCKS4 and SOCKS5 proxy. Supports optional username/password authentication (SOCKS5). No encryption — suitable for use within a trusted network or as a local outbound.
|
|
||||||
|
|
||||||
### SSH
|
|
||||||
Tunnels traffic over an SSH connection using port forwarding. Useful when only SSH is accessible and other ports are blocked. Requires an SSH server with a valid account. Slower than dedicated proxy protocols due to SSH overhead.
|
|
||||||
|
|
||||||
### Trojan
|
|
||||||
Disguises proxy traffic as regular HTTPS by using TLS with a valid certificate. A server hosting Trojan looks identical to an HTTPS web server to outside observers. Falls back to serving a real web page for non-Trojan connections, making it very hard to detect.
|
|
||||||
|
|
||||||
**Supported transports:**
|
|
||||||
| Transport | CDN Compatible | Notes |
|
|
||||||
|-----------|:--------------:|-------|
|
|
||||||
| TCP (raw TLS) | No | Default; lowest overhead |
|
|
||||||
| WebSocket | **Yes** | Works behind Cloudflare and other CDNs |
|
|
||||||
| gRPC | **Yes** | Works behind Cloudflare (requires gRPC support on CDN) |
|
|
||||||
| HTTP/2 | No | Multiplexed; not CDN-friendly without WebSocket/gRPC |
|
|
||||||
|
|
||||||
WebSocket + TLS is the most common setup for CDN (Cloudflare) deployment — the CDN terminates TLS and forwards WebSocket traffic to the origin server.
|
|
||||||
|
|
||||||
### VLESS
|
|
||||||
A lightweight successor to VMess without the extra encryption layer (relies on the transport for security, typically TLS). Widely used with Xray-based servers.
|
|
||||||
|
|
||||||
**Supported transports:**
|
|
||||||
| Transport | CDN Compatible | Notes |
|
|
||||||
|-----------|:--------------:|-------|
|
|
||||||
| TCP (raw TLS) | No | Standard setup |
|
|
||||||
| WebSocket | **Yes** | CDN-friendly; widely supported |
|
|
||||||
| gRPC | **Yes** | CDN-friendly |
|
|
||||||
| HTTP/2 | No | Multiplexed connections |
|
|
||||||
| HTTPUpgrade | **Yes** | Lightweight HTTP upgrade handshake |
|
|
||||||
| **XHTTP** | **Yes** | Not in upstream sing-box; on both cores — see below |
|
|
||||||
|
|
||||||
**XHTTP** is an extended transport for VLESS, available on **both** hiddify-core and sing-box-extended (but not upstream sing-box). It uses chunked HTTP transfers over a single or multiplexed connection, designed specifically for CDN compatibility and to avoid patterns detectable as non-browser traffic.
|
|
||||||
|
|
||||||
### VMess
|
|
||||||
The original V2Ray protocol. Includes its own encryption on top of the transport layer. Slightly more overhead than VLESS but very widely deployed.
|
|
||||||
|
|
||||||
**Supported transports:**
|
|
||||||
| Transport | CDN Compatible | Notes |
|
|
||||||
|-----------|:--------------:|-------|
|
|
||||||
| TCP | No | |
|
|
||||||
| WebSocket | **Yes** | Most common CDN setup |
|
|
||||||
| gRPC | **Yes** | |
|
|
||||||
| HTTP/2 | No | |
|
|
||||||
| HTTPUpgrade | **Yes** | |
|
|
||||||
|
|
||||||
VMess over WebSocket + TLS is one of the most common configurations for Cloudflare CDN deployment — the CDN hides the origin server's real IP.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requires `with_quic` Build Tag
|
|
||||||
|
|
||||||
### Hysteria
|
|
||||||
QUIC-based high-throughput proxy protocol (version 1). Uses a modified QUIC stack that tolerates packet loss better than TCP-based protocols — useful on lossy or high-latency connections. Version 1 is largely superseded by Hysteria2.
|
|
||||||
|
|
||||||
### Hysteria2
|
|
||||||
Improved and simplified version of Hysteria. Uses the Salamander obfuscation protocol and BBR congestion control. Significantly better performance than v1 and harder to detect. Recommended over Hysteria v1 for new setups.
|
|
||||||
|
|
||||||
### TUIC
|
|
||||||
QUIC-based protocol with built-in multiplexing and 0-RTT connection establishment. Low latency and efficient. Requires a TUIC server (v5 protocol).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requires `with_naive_outbound` Build Tag
|
|
||||||
|
|
||||||
### NaïveProxy
|
|
||||||
Uses the **actual Chrome network stack** to make proxy traffic indistinguishable from real Chrome browser HTTPS traffic. Extremely resistant to traffic analysis and fingerprinting because the TLS implementation, cipher choices, and behavior are identical to a real browser.
|
|
||||||
|
|
||||||
Two transport variants:
|
|
||||||
|
|
||||||
| Variant | Protocol | Notes |
|
|
||||||
|---------|----------|-------|
|
|
||||||
| **NaiveTLS** | HTTPS (TLS 1.3 over TCP) | Mimics Chrome HTTPS; most common |
|
|
||||||
| **NaiveQUIC** | HTTP/3 (QUIC) | Mimics Chrome HTTP/3; harder to block but requires UDP |
|
|
||||||
|
|
||||||
NaiveTLS is the standard choice. NaiveQUIC offers better performance where UDP is available but may be blocked in some environments that drop unknown UDP traffic.
|
|
||||||
|
|
||||||
NaïveProxy requires a compatible server (e.g., Caddy with the `forwardproxy` plugin).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requires `with_wireguard` + `with_gvisor` Build Tags
|
|
||||||
|
|
||||||
### WireGuard
|
|
||||||
Modern VPN protocol with a minimal codebase and strong cryptography (Curve25519, ChaCha20-Poly1305). Very fast and battery-efficient. Can be used as an outbound to tunnel all proxy traffic through a WireGuard VPN endpoint (e.g., a VPS or a service like Cloudflare WARP).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Requires the sing-box-extended core
|
|
||||||
|
|
||||||
These node types are available when you install **sing-box-extended** instead of hiddify-core (pick your core on the **Core Management** page). hiddify-core does **not** support them.
|
|
||||||
|
|
||||||
### AmneziaWG
|
|
||||||
An obfuscated variant of WireGuard. It adds junk packets and randomised handshake headers (the `Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`–`H4`, `I1`–`I5` parameters) so that DPI systems which detect and block plain WireGuard no longer recognise the traffic. Set the obfuscation parameters to match your AmneziaWG server (or a Cloudflare WARP endpoint running AmneziaWG). Same fast Curve25519 / ChaCha20-Poly1305 cryptography as WireGuard, but the packets no longer look like WireGuard on the wire.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mieru — requires `with_quic`
|
|
||||||
|
|
||||||
### MieruTCP / MieruUDP
|
|
||||||
Anti-censorship protocol developed independently of sing-box, added by the extended cores (hiddify-core **and** sing-box-extended). Uses fully randomized traffic patterns with no identifiable headers or handshakes, making it very difficult to detect or classify by DPI.
|
|
||||||
|
|
||||||
- **MieruTCP** — TCP transport variant
|
|
||||||
- **MieruUDP** — UDP transport variant; higher throughput, requires UDP access
|
|
||||||
|
|
||||||
Requires a Mieru server. See the [Mieru project](https://github.com/enfein/mieru) for server setup.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Coming in Future hiddify-core Versions
|
|
||||||
|
|
||||||
### DNSTT (planned)
|
|
||||||
DNS tunneling protocol — tunnels proxy traffic inside DNS queries and responses. Extremely useful in heavily restricted environments where only DNS traffic is allowed (e.g., captive portals, some corporate networks). Significantly lower throughput than other protocols due to DNS packet size limits, but works where nothing else does.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How to Check What Your Build Supports
|
|
||||||
|
|
||||||
Run the version command for **whichever core you installed**:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
hiddify-core version # if you run hiddify-core
|
|
||||||
sing-box version # if you run sing-box-extended
|
|
||||||
```
|
|
||||||
|
|
||||||
Look at the `Tags:` line. Protocols that require a specific tag will only appear in the node editor if that tag is present.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
```
|
|
||||||
Tags: with_quic,with_wireguard,with_gvisor,with_naive_outbound,...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Note on NaïveProxy
|
|
||||||
|
|
||||||
NaïveProxy availability is controlled by the `with_naive_outbound` build tag — it is not architecture-specific. Some builds omit it to reduce binary size. Check your installed version as shown above before assuming it is unavailable.
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
🇬🇧 [English](Supported-Protocols-en) | 🇷🇺 [Русский](Supported-Protocols-ru)
|
|
||||||
|
|
||||||
# Поддерживаемые протоколы
|
|
||||||
|
|
||||||
Re:HomeProxy работает на выбор ядра: [hiddify-core](https://github.com/hiddify/hiddify-core) (по умолчанию) или [sing-box-extended](https://github.com/shtorm-7/sing-box-extended) — оба форки [sing-box](https://sing-box.sagernet.org) с дополнительными протоколами и функциями, недоступными в upstream. Отображаемые в редакторе узлов протоколы зависят от того, **какое ядро вы установили** и с какими флагами оно собрано на вашем устройстве. Ядро выбирается и устанавливается в разделе **Управление ядром** (Сервисы → Re:HomeProxy → Ядро и службы).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Заполнение полей в редакторе узлов
|
|
||||||
|
|
||||||
Когда вы добавляете узел вручную, редактор открывает базовые опции **outbound-а sing-box** — TLS (включая **Reality**, отпечатки **uTLS**, **ECH**), транспорт (**gRPC / WebSocket / HTTP / HTTPUpgrade / XHTTP**) и **мультиплекс**. Чтобы не дублировать справочник sing-box, сверяйте каждое поле со своим сервером по официальной документации:
|
|
||||||
|
|
||||||
- [Справочник outbound-ов](https://sing-box.sagernet.org/configuration/outbound/) — все типы outbound-ов и их поля
|
|
||||||
- [TLS (общее)](https://sing-box.sagernet.org/configuration/shared/tls/) — Reality, uTLS, ECH, ALPN
|
|
||||||
- [Транспорты V2Ray](https://sing-box.sagernet.org/configuration/shared/v2ray-transport/) — gRPC / WS / HTTP / HTTPUpgrade
|
|
||||||
- [Мультиплекс](https://sing-box.sagernet.org/configuration/shared/multiplex/)
|
|
||||||
|
|
||||||
Большинству пользователей не нужно заполнять это вручную — импорт share-ссылки или подписки делает это за вас (см. [Подписки и импорт узлов](Subscriptions-ru)).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Возможности расширенных ядер (отсутствуют в upstream sing-box)
|
|
||||||
|
|
||||||
Оба ядра — форки sing-box, добавляющие функции, которых нет в upstream. Часть из них **только в hiddify-core**, часть — в **обоих** ядрах (hiddify-core и sing-box-extended), отмечено по каждому пункту:
|
|
||||||
|
|
||||||
### Фрагментация TLS (`tls_fragment`) *(только hiddify-core)*
|
|
||||||
Разбивает TLS ClientHello на несколько TCP-пакетов так, чтобы поле SNI (Server Name Indication) — раскрывающее доменное имя назначения — передавалось в отдельных фрагментах. Системы DPI, анализирующие только целые пакеты для идентификации и блокировки доменов, не успевают собрать SNI воедино, и соединение проходит незамеченным.
|
|
||||||
|
|
||||||
**Режимы фрагментации:**
|
|
||||||
- **SNI/Domain** — разбивает пакеты на два фрагмента; просто и эффективно в большинстве случаев
|
|
||||||
- **Random** — делит на множество очень мелких фрагментов для максимальной обфускации; используйте, если режима SNI недостаточно
|
|
||||||
|
|
||||||
**Ключевые параметры:**
|
|
||||||
- **Размер фрагмента** — рекомендуется 100–200 байт; в идеале на один байт меньше длины доменного имени
|
|
||||||
- **Интервал фрагментации** — задержка между фрагментами; настраивайте под конкретного провайдера
|
|
||||||
- **Mixed SNI case** — рандомизирует регистр символов SNI (например, `wWw.ExAmPlE.cOm`) для обхода блокировок, зависящих от регистра
|
|
||||||
- **Padding** — добавляет случайные данные к полю домена
|
|
||||||
|
|
||||||
> **Важно:** Не включайте фрагментацию и padding одновременно — они взаимно нейтрализуют друг друга. Эффективность зависит от провайдера и может потребовать подбора параметров.
|
|
||||||
|
|
||||||
Применяется к любому протоколу, использующему TLS (VLESS, VMess, Trojan и др.).
|
|
||||||
|
|
||||||
*Источник: [How the TLS Trick works and its usage — hiddify.com](https://hiddify.com/manager/basic-concepts-and-troubleshooting/How-the-TLS-Trick-works-and-its-usage/#tls-fragment)*
|
|
||||||
|
|
||||||
### Транспорт XHTTP *(оба ядра)*
|
|
||||||
Современный HTTP-транспорт для VLESS, разработанный для совместимости с CDN и мультиплексирования. Отсутствует в upstream sing-box, но доступен в **обоих** ядрах — hiddify-core и sing-box-extended. Подробнее — в разделе VLESS ниже.
|
|
||||||
|
|
||||||
### Дополнительные протоколы *(оба ядра)*
|
|
||||||
MieruTCP / MieruUDP и расширенные варианты NaïveProxy — доступны в **обоих** ядрах (см. ниже).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Всегда доступны
|
|
||||||
|
|
||||||
### Direct
|
|
||||||
Прямое подключение без проксирования. Используется в правилах маршрутизации для локальных или доверенных адресов.
|
|
||||||
|
|
||||||
### AnyTLS
|
|
||||||
TLS-протокол с мультиплексированием, разработанный командой hiddify. Простой, эффективный и сложно поддающийся отпечатыванию. Хороший выбор, когда другие TLS-протоколы блокируются.
|
|
||||||
|
|
||||||
### HTTP
|
|
||||||
Обычный HTTP-прокси (метод CONNECT по RFC 7231). Поддерживает аутентификацию по имени пользователя и паролю. Не шифруется — используйте только в доверенных сетях или в сочетании с TLS-обёрткой.
|
|
||||||
|
|
||||||
### Shadowsocks
|
|
||||||
Один из наиболее распространённых антицензурных протоколов. Шифрует трафик с помощью общего ключа и шифра (AES-128-GCM, AES-256-GCM, ChaCha20-Poly1305 и др.). Прост в настройке. Не использует TLS — трафик обфусцирован, но не выглядит как HTTPS.
|
|
||||||
|
|
||||||
**Shadowsocks 2022** (`2022-blake3-aes-128-gcm`, `2022-blake3-aes-256-gcm`, `2022-blake3-chacha20-poly1305`) также поддерживается. Улучшенная версия с более стойким аутентифицированным шифрованием, защитой от повторного воспроизведения и лучшей производительностью. Если сервер поддерживает — используйте варианты 2022. ShadowTLS (см. ниже) рассчитан на работу именно с Shadowsocks 2022.
|
|
||||||
|
|
||||||
### ShadowTLS
|
|
||||||
TLS-обёртка, разработанная для использования с **Shadowsocks 2022** (не с оригинальным Shadowsocks). Оборачивает соединение в TLS-рукопожатие, имитирующее настоящий HTTPS-сервер — трафик неотличим от легитимного TLS для пассивного наблюдателя. TLS-рукопожатие настоящее: оно завершается с реальным сервером, поэтому блокировки по SNI и отпечатку TLS не работают. Требует сервер с поддержкой ShadowTLS и бэкенд на Shadowsocks 2022.
|
|
||||||
|
|
||||||
### Socks
|
|
||||||
Прокси SOCKS4 и SOCKS5. Поддерживает опциональную аутентификацию (SOCKS5). Без шифрования — подходит для использования внутри доверенной сети или как локальный outbound.
|
|
||||||
|
|
||||||
### SSH
|
|
||||||
Туннелирует трафик через SSH-соединение с использованием проброса портов. Полезен, когда доступен только SSH, а другие порты заблокированы. Требует SSH-сервер с учётной записью. Медленнее специализированных протоколов из-за накладных расходов SSH.
|
|
||||||
|
|
||||||
### Trojan
|
|
||||||
Маскирует прокси-трафик под обычный HTTPS, используя TLS с действующим сертификатом. Сервер с Trojan выглядит снаружи идентично HTTPS-сайту. Для не-Trojan соединений отдаёт настоящую веб-страницу, что делает его крайне сложным для обнаружения.
|
|
||||||
|
|
||||||
**Поддерживаемые транспорты:**
|
|
||||||
| Транспорт | CDN-совместим | Примечания |
|
|
||||||
|-----------|:-------------:|-----------|
|
|
||||||
| TCP (raw TLS) | Нет | По умолчанию; минимальные накладные расходы |
|
|
||||||
| WebSocket | **Да** | Работает за Cloudflare и другими CDN |
|
|
||||||
| gRPC | **Да** | Работает за Cloudflare (требует поддержки gRPC на CDN) |
|
|
||||||
| HTTP/2 | Нет | Мультиплексирование; не CDN-совместим без WS/gRPC |
|
|
||||||
|
|
||||||
WebSocket + TLS — наиболее распространённая схема для CDN (Cloudflare): CDN завершает TLS и проксирует WebSocket-трафик на origin-сервер.
|
|
||||||
|
|
||||||
### VLESS
|
|
||||||
Лёгкий преемник VMess без дополнительного слоя шифрования (безопасность обеспечивается транспортом, обычно TLS). Широко используется с серверами на основе Xray.
|
|
||||||
|
|
||||||
**Поддерживаемые транспорты:**
|
|
||||||
| Транспорт | CDN-совместим | Примечания |
|
|
||||||
|-----------|:-------------:|-----------|
|
|
||||||
| TCP (raw TLS) | Нет | Стандартная схема |
|
|
||||||
| WebSocket | **Да** | CDN-совместим; широко поддерживается |
|
|
||||||
| gRPC | **Да** | CDN-совместим |
|
|
||||||
| HTTP/2 | Нет | Мультиплексированные соединения |
|
|
||||||
| HTTPUpgrade | **Да** | Лёгкое HTTP upgrade рукопожатие |
|
|
||||||
| **XHTTP** | **Да** | Нет в upstream sing-box; есть в обоих ядрах — см. ниже |
|
|
||||||
|
|
||||||
**XHTTP** — расширенный транспорт для VLESS, доступный в **обоих** ядрах (hiddify-core и sing-box-extended), но не в upstream sing-box. Использует chunked HTTP-передачу через одиночное или мультиплексированное соединение, специально разработан для совместимости с CDN и устранения паттернов, характерных для не-браузерного трафика.
|
|
||||||
|
|
||||||
### VMess
|
|
||||||
Оригинальный протокол V2Ray. Включает собственное шифрование поверх транспортного слоя. Чуть более высокие накладные расходы по сравнению с VLESS, но очень широко распространён.
|
|
||||||
|
|
||||||
**Поддерживаемые транспорты:**
|
|
||||||
| Транспорт | CDN-совместим | Примечания |
|
|
||||||
|-----------|:-------------:|-----------|
|
|
||||||
| TCP | Нет | |
|
|
||||||
| WebSocket | **Да** | Наиболее распространённая CDN-схема |
|
|
||||||
| gRPC | **Да** | |
|
|
||||||
| HTTP/2 | Нет | |
|
|
||||||
| HTTPUpgrade | **Да** | |
|
|
||||||
|
|
||||||
VMess через WebSocket + TLS — одна из наиболее распространённых конфигураций для Cloudflare CDN, скрывающего реальный IP origin-сервера.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требуют флаг сборки `with_quic`
|
|
||||||
|
|
||||||
### Hysteria
|
|
||||||
Высокопроизводительный прокси-протокол на основе QUIC (версия 1). Использует модифицированный стек QUIC, лучше переносящий потерю пакетов по сравнению с TCP — полезен на нестабильных или высоколатентных соединениях. Версия 1 в основном вытеснена Hysteria2.
|
|
||||||
|
|
||||||
### Hysteria2
|
|
||||||
Улучшенная и упрощённая версия Hysteria. Использует обфускацию Salamander и управление перегрузкой BBR. Значительно лучше по производительности, чем v1, и сложнее обнаруживается. Рекомендуется вместо Hysteria v1 для новых установок.
|
|
||||||
|
|
||||||
### TUIC
|
|
||||||
Протокол на основе QUIC со встроенным мультиплексированием и установкой соединения 0-RTT. Низкая задержка и высокая эффективность. Требует TUIC-сервер (протокол v5).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требует флаг сборки `with_naive_outbound`
|
|
||||||
|
|
||||||
### NaïveProxy
|
|
||||||
Использует **настоящий сетевой стек Chrome**, делая трафик прокси неотличимым от реального HTTPS-трафика браузера Chrome. Чрезвычайно устойчив к анализу трафика и отпечатыванию — реализация TLS, выбор шифров и поведение идентичны реальному браузеру.
|
|
||||||
|
|
||||||
Два варианта транспорта:
|
|
||||||
|
|
||||||
| Вариант | Протокол | Примечания |
|
|
||||||
|---------|----------|-----------|
|
|
||||||
| **NaiveTLS** | HTTPS (TLS 1.3 over TCP) | Имитирует HTTPS в Chrome; наиболее распространён |
|
|
||||||
| **NaiveQUIC** | HTTP/3 (QUIC) | Имитирует HTTP/3 в Chrome; сложнее заблокировать, но требует UDP |
|
|
||||||
|
|
||||||
NaiveTLS — стандартный выбор. NaiveQUIC даёт лучшую производительность там, где UDP доступен, но может блокироваться в средах, отбрасывающих неизвестный UDP-трафик.
|
|
||||||
|
|
||||||
NaïveProxy требует совместимый сервер (например, Caddy с плагином `forwardproxy`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требует флаги `with_wireguard` + `with_gvisor`
|
|
||||||
|
|
||||||
### WireGuard
|
|
||||||
Современный VPN-протокол с минимальной кодовой базой и стойкой криптографией (Curve25519, ChaCha20-Poly1305). Очень быстрый и энергоэффективный. Может использоваться как outbound для туннелирования всего прокси-трафика через WireGuard-эндпоинт (VPS или сервисы вроде Cloudflare WARP).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Требуется ядро sing-box-extended
|
|
||||||
|
|
||||||
Эти типы узлов доступны, если вместо hiddify-core установить **sing-box-extended** (ядро выбирается на странице **Управление ядром**). hiddify-core их **не** поддерживает.
|
|
||||||
|
|
||||||
### AmneziaWG
|
|
||||||
Маскирующийся вариант WireGuard. Он добавляет мусорные пакеты и рандомизированные заголовки рукопожатия (параметры `Jc`, `Jmin`, `Jmax`, `S1`, `S2`, `H1`–`H4`, `I1`–`I5`), благодаря чему DPI-системы, которые обнаруживают и блокируют обычный WireGuard, перестают распознавать трафик. Задайте параметры маскировки в соответствии с вашим сервером AmneziaWG (или эндпоинтом Cloudflare WARP на AmneziaWG). Та же быстрая криптография Curve25519 / ChaCha20-Poly1305, что и в WireGuard, но пакеты больше не выглядят как WireGuard.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Mieru — требует `with_quic`
|
|
||||||
|
|
||||||
### MieruTCP / MieruUDP
|
|
||||||
Антицензурный протокол, разработанный независимо от sing-box и добавленный расширенными ядрами (hiddify-core **и** sing-box-extended). Использует полностью случайные паттерны трафика без идентифицируемых заголовков или рукопожатий — крайне сложен для обнаружения и классификации системами DPI.
|
|
||||||
|
|
||||||
- **MieruTCP** — TCP-вариант транспорта
|
|
||||||
- **MieruUDP** — UDP-вариант; более высокая пропускная способность, требует доступ к UDP
|
|
||||||
|
|
||||||
Требует сервер Mieru. Настройка сервера — в [документации проекта Mieru](https://github.com/enfein/mieru).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ожидается в будущих версиях hiddify-core
|
|
||||||
|
|
||||||
### DNSTT (планируется)
|
|
||||||
Протокол DNS-туннелирования — передаёт прокси-трафик внутри DNS-запросов и ответов. Крайне полезен в жёстко ограниченных средах, где разрешён только DNS-трафик (например, captive portal, некоторые корпоративные сети). Значительно меньшая пропускная способность по сравнению с другими протоколами из-за ограничений размера DNS-пакетов, но работает там, где ничто другое не работает.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как проверить возможности вашей сборки
|
|
||||||
|
|
||||||
Выполните команду версии для **того ядра, которое установлено**:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
hiddify-core version # если установлен hiddify-core
|
|
||||||
sing-box version # если установлен sing-box-extended
|
|
||||||
```
|
|
||||||
|
|
||||||
Посмотрите на строку `Tags:`. Протоколы, требующие определённого тега, появятся в редакторе узлов только при его наличии.
|
|
||||||
|
|
||||||
Пример вывода:
|
|
||||||
```
|
|
||||||
Tags: with_quic,with_wireguard,with_gvisor,with_naive_outbound,...
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Примечание о NaïveProxy
|
|
||||||
|
|
||||||
Доступность NaïveProxy определяется флагом сборки `with_naive_outbound` — это не зависит от архитектуры устройства. Некоторые сборки не включают его для уменьшения размера бинарника. Проверьте установленную версию, как описано выше, прежде чем считать протокол недоступным.
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
🇬🇧 [English](Troubleshooting-en) | 🇷🇺 [Русский](Troubleshooting-ru)
|
|
||||||
|
|
||||||
# Troubleshooting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## "Object not found" Error When Saving
|
|
||||||
|
|
||||||
**Symptom:** Saving any form (client settings, access control lists, etc.) shows:
|
|
||||||
```
|
|
||||||
RPC call to luci.homeproxy/[method] failed with error -32000: Object not found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cause:** rpcd has not loaded the `luci.homeproxy` module. This commonly happens right after installing or upgrading the package if the post-install script sent a reload signal while rpcd was not yet running.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```sh
|
|
||||||
/etc/init.d/rpcd restart
|
|
||||||
```
|
|
||||||
Then **log out and log back in** to the web UI. An existing session does not pick up the new permissions without a fresh login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Proxy Modes Missing (No TProxy, No Tun)
|
|
||||||
|
|
||||||
**Symptom:** The Proxy Mode selector on the Client page only shows "Redirect TCP". TProxy and Tun options are absent even though the required kernel modules are installed.
|
|
||||||
|
|
||||||
**Cause:** Same as above — `luci.homeproxy` not loaded. The feature detection call (`singbox_get_features`) returns empty, so all capability flags are false and the options are hidden.
|
|
||||||
|
|
||||||
**Fix:** Same as above — restart rpcd and re-login.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeError on the Client Page
|
|
||||||
|
|
||||||
**Symptom:** Opening `/cgi-bin/luci/admin/services/homeproxy/client` shows:
|
|
||||||
```
|
|
||||||
TypeError: Cannot read properties of undefined (reading 'content')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cause:** The domain list RPC call failed (module not loaded or first boot), and an older version of the package did not handle this gracefully.
|
|
||||||
|
|
||||||
**Fix:** Restart rpcd and re-login as above. If you are running an older build, update to the latest release which includes the fix.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## All Three Issues Share the Same Root Fix
|
|
||||||
|
|
||||||
If you just installed or upgraded the package and see any of the above:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/rpcd restart
|
|
||||||
```
|
|
||||||
Log out of the web UI and log back in. Both steps are required.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Cannot Download Package via wget (HTTPS Fails)
|
|
||||||
|
|
||||||
**Symptom:**
|
|
||||||
```
|
|
||||||
Failed to send request: Operation not permitted
|
|
||||||
Failed to allocate uclient context
|
|
||||||
```
|
|
||||||
|
|
||||||
**Cause:** OpenWRT's built-in `wget` (uclient-fetch) has no SSL library installed, so HTTPS requests fail.
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```sh
|
|
||||||
opkg update && opkg install libustream-openssl ca-certificates
|
|
||||||
```
|
|
||||||
|
|
||||||
If `opkg` itself cannot reach the internet, download the `.ipk` or `.apk` file on a PC from the [Releases page](https://github.com/1andrevich/homeproxy-hiddify/releases) and transfer it to the router with `scp`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## No Internet Access When Re:HomeProxy Is Enabled (mwan3 Conflict)
|
|
||||||
|
|
||||||
**Symptom:** Ping and DNS work, but HTTP/HTTPS traffic fails when Re:HomeProxy is enabled. Stopping mwan3 restores connectivity.
|
|
||||||
|
|
||||||
**Cause:** mwan3 uses fwmarks and policy routing to steer traffic between WAN interfaces. When the proxy core makes outbound connections, mwan3 can intercept and misroute them.
|
|
||||||
|
|
||||||
**Fix:** Tell the OS to skip mwan3's routing logic for traffic already handled by Re:HomeProxy (marked `0x64`).
|
|
||||||
|
|
||||||
Add the following rule as a persistent firewall include:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
uci add firewall include
|
|
||||||
uci set firewall.@include[-1].path='/etc/firewall.d/homeproxy-mwan3'
|
|
||||||
uci set firewall.@include[-1].type='script'
|
|
||||||
uci set firewall.@include[-1].reload='1'
|
|
||||||
uci commit firewall
|
|
||||||
|
|
||||||
mkdir -p /etc/firewall.d
|
|
||||||
cat > /etc/firewall.d/homeproxy-mwan3 << 'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
iptables -t mangle -I OUTPUT 1 -m mark --mark 0x64/0x64 -j RETURN
|
|
||||||
EOF
|
|
||||||
chmod +x /etc/firewall.d/homeproxy-mwan3
|
|
||||||
|
|
||||||
/etc/init.d/firewall reload
|
|
||||||
```
|
|
||||||
|
|
||||||
To verify the rule is active:
|
|
||||||
```sh
|
|
||||||
iptables -t mangle -L OUTPUT -n --line-numbers | head -5
|
|
||||||
```
|
|
||||||
The `0x64` mark rule should appear as line 1.
|
|
||||||
|
|
||||||
> This fix deliberately uses `iptables` because **mwan3** operates at the iptables layer. On a fw4 / nftables system it still applies through the iptables-nft compatibility shim — it does not conflict with Re:HomeProxy's nft chains.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Subscription Update Button Does Nothing
|
|
||||||
|
|
||||||
**Symptom:** Clicking "Update subscriptions" shows a spinner but node count stays at zero after the page reloads.
|
|
||||||
|
|
||||||
**Fix:** Update to the latest release. Earlier builds used a background execution method that did not work correctly from the rpcd context. The current version uses the correct execution path.
|
|
||||||
|
|
||||||
If you are on the latest release and still see this, run manually over SSH to check for errors:
|
|
||||||
```sh
|
|
||||||
ucode /etc/homeproxy/scripts/update_subscriptions.uc
|
|
||||||
```
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
🇬🇧 [English](Troubleshooting-en) | 🇷🇺 [Русский](Troubleshooting-ru)
|
|
||||||
|
|
||||||
# Устранение неполадок
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Ошибка «Object not found» при сохранении
|
|
||||||
|
|
||||||
**Симптом:** При сохранении любой формы (настройки клиента, списки контроля доступа и др.) появляется:
|
|
||||||
```
|
|
||||||
RPC call to luci.homeproxy/[method] failed with error -32000: Object not found
|
|
||||||
```
|
|
||||||
|
|
||||||
**Причина:** rpcd не загрузил модуль `luci.homeproxy`. Чаще всего это происходит сразу после установки или обновления пакета, если скрипт post-install отправил сигнал перезагрузки, когда rpcd ещё не был запущен.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
```sh
|
|
||||||
/etc/init.d/rpcd restart
|
|
||||||
```
|
|
||||||
Затем **выйдите из веб-интерфейса и войдите снова**. Существующая сессия не подхватывает новые разрешения без повторного входа.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Недоступны режимы прокси (нет TProxy, нет Tun)
|
|
||||||
|
|
||||||
**Симптом:** Селектор режима прокси на странице «Клиент» показывает только «Redirect TCP». Опции TProxy и Tun отсутствуют, хотя необходимые модули ядра установлены.
|
|
||||||
|
|
||||||
**Причина:** Та же, что выше — `luci.homeproxy` не загружен. Вызов определения возможностей (`singbox_get_features`) возвращает пустой объект, все флаги оказываются ложными и опции скрываются.
|
|
||||||
|
|
||||||
**Решение:** То же — перезапустить rpcd и войти заново.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## TypeError на странице «Клиент»
|
|
||||||
|
|
||||||
**Симптом:** При открытии `/cgi-bin/luci/admin/services/homeproxy/client` появляется:
|
|
||||||
```
|
|
||||||
TypeError: Cannot read properties of undefined (reading 'content')
|
|
||||||
```
|
|
||||||
|
|
||||||
**Причина:** Вызов RPC для загрузки списков доменов завершился неудачей (модуль не загружен или первая загрузка системы), а старая версия пакета не обрабатывала это корректно.
|
|
||||||
|
|
||||||
**Решение:** Перезапустите rpcd и войдите снова, как описано выше. Если вы используете старую сборку — обновитесь до последнего релиза, в котором это исправлено.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Все три проблемы — одно решение
|
|
||||||
|
|
||||||
Если вы только что установили или обновили пакет и наблюдаете любую из перечисленных ошибок:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
/etc/init.d/rpcd restart
|
|
||||||
```
|
|
||||||
Выйдите из веб-интерфейса и войдите снова. Нужны оба шага.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Не удаётся скачать пакет через wget (HTTPS не работает)
|
|
||||||
|
|
||||||
**Симптом:**
|
|
||||||
```
|
|
||||||
Failed to send request: Operation not permitted
|
|
||||||
Failed to allocate uclient context
|
|
||||||
```
|
|
||||||
|
|
||||||
**Причина:** Встроенный `wget` в OpenWRT (uclient-fetch) не имеет установленной SSL-библиотеки и не умеет работать с HTTPS.
|
|
||||||
|
|
||||||
**Решение:**
|
|
||||||
```sh
|
|
||||||
opkg update && opkg install libustream-openssl ca-certificates
|
|
||||||
```
|
|
||||||
|
|
||||||
Если сам `opkg` не имеет доступа к интернету — скачайте файл `.ipk` или `.apk` на компьютере со [страницы релизов](https://github.com/1andrevich/homeproxy-hiddify/releases) и передайте на роутер через `scp`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Нет интернета при включённом Re:HomeProxy (конфликт с mwan3)
|
|
||||||
|
|
||||||
**Симптом:** Ping и DNS работают, но HTTP/HTTPS-трафик не проходит при включённом Re:HomeProxy. Остановка mwan3 восстанавливает связь.
|
|
||||||
|
|
||||||
**Причина:** mwan3 использует fwmark и policy routing для распределения трафика между WAN-интерфейсами. Когда ядро прокси устанавливает исходящие соединения, mwan3 может перехватить их и направить не туда.
|
|
||||||
|
|
||||||
**Решение:** Сообщить ОС пропускать логику маршрутизации mwan3 для трафика, уже обработанного Re:HomeProxy (помечен `0x64`).
|
|
||||||
|
|
||||||
Добавьте правило в виде постоянного include файрвола:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
uci add firewall include
|
|
||||||
uci set firewall.@include[-1].path='/etc/firewall.d/homeproxy-mwan3'
|
|
||||||
uci set firewall.@include[-1].type='script'
|
|
||||||
uci set firewall.@include[-1].reload='1'
|
|
||||||
uci commit firewall
|
|
||||||
|
|
||||||
mkdir -p /etc/firewall.d
|
|
||||||
cat > /etc/firewall.d/homeproxy-mwan3 << 'EOF'
|
|
||||||
#!/bin/sh
|
|
||||||
iptables -t mangle -I OUTPUT 1 -m mark --mark 0x64/0x64 -j RETURN
|
|
||||||
EOF
|
|
||||||
chmod +x /etc/firewall.d/homeproxy-mwan3
|
|
||||||
|
|
||||||
/etc/init.d/firewall reload
|
|
||||||
```
|
|
||||||
|
|
||||||
Для проверки, что правило применилось:
|
|
||||||
```sh
|
|
||||||
iptables -t mangle -L OUTPUT -n --line-numbers | head -5
|
|
||||||
```
|
|
||||||
Правило с меткой `0x64` должно быть первым в списке.
|
|
||||||
|
|
||||||
> Здесь намеренно используется `iptables`, потому что **mwan3** работает на уровне iptables. На системе с fw4 / nftables правило всё равно применяется через слой совместимости iptables-nft и не конфликтует с nft-цепочками Re:HomeProxy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Кнопка обновления подписок не работает
|
|
||||||
|
|
||||||
**Симптом:** После нажатия кнопки «Обновить подписки» появляется спиннер, но количество узлов остаётся нулевым после перезагрузки страницы.
|
|
||||||
|
|
||||||
**Решение:** Обновитесь до последнего релиза. В более ранних сборках использовался метод фонового выполнения, который некорректно работал в контексте rpcd. В текущей версии используется правильный механизм.
|
|
||||||
|
|
||||||
Если вы уже используете последний релиз и проблема сохраняется — запустите вручную через SSH, чтобы увидеть возможные ошибки:
|
|
||||||
```sh
|
|
||||||
ucode /etc/homeproxy/scripts/update_subscriptions.uc
|
|
||||||
```
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
🇬🇧 [English](Zapret-en) | 🇷🇺 [Русский](Zapret-ru)
|
|
||||||
|
|
||||||
# Zapret
|
|
||||||
|
|
||||||
**Zapret 2** is a second, packet-level DPI-bypass built into Re:HomeProxy. It un-throttles and unblocks sites **without any VPN subscription** by desyncing the handshake of selected flows directly on the wire. Re:HomeProxy bundles the [bol-van/zapret2](https://github.com/bol-van/zapret2) engine (`nfqws2`) and manages it for you.
|
|
||||||
|
|
||||||
It complements [ByeDPI](ByeDPI-en): same goal, different layer. See [ByeDPI vs Zapret](#byedpi-vs-zapret) below.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What it is — and what it is not
|
|
||||||
|
|
||||||
Zapret runs the **`nfqws2`** packet processor. The selected traffic is tagged in the firewall and handed to an **NFQUEUE**, where `nfqws2` rewrites the first few packets of each connection (fake packets, splitting, disorder, TCP-MD5/seq tricks, TLS-record manipulation) so your ISP's Deep Packet Inspection can no longer recognise — and therefore can no longer throttle or block — the traffic.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| ✅ **Un-throttles DPI-filtered sites** | The common case for YouTube, Discord and similar services slowed or blocked by DPI. |
|
|
||||||
| ✅ **No subscription, no server** | A local packet trick — no VPN node, account or remote server. |
|
|
||||||
| ✅ **Works on both cores** | Implemented as a marked direct outbound; works identically on hiddify-core and sing-box-extended. |
|
|
||||||
| ✅ **Handles TCP *and* UDP/QUIC** | Unlike ByeDPI, the NFQUEUE path also desyncs UDP flows (used for Discord voice). |
|
|
||||||
| ❌ **Does not encrypt or hide traffic** | Your ISP still sees *which* sites you visit; Zapret only changes *how* the first packets look. It is **not a VPN**. |
|
|
||||||
| ❌ **Cannot reach fully IP-blocked sites** | If a site is blocked by IP/DNS (not just throttled), only a real proxy/VPN node can reach it. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## How it works
|
|
||||||
|
|
||||||
Zapret is wired as a routing **target**, not a SOCKS node:
|
|
||||||
|
|
||||||
1. The config generator adds a `direct` outbound called **`zapret-out`** stamped with a `routing_mark` (default mark `110`).
|
|
||||||
2. When a routing rule selects `zapret-out`, the core egresses that flow **directly**, but tagged with the mark.
|
|
||||||
3. An nft chain (`homeproxy_zapret_queue`) catches the marked packets and sends the first 1–12 packets of each TCP/UDP connection to an **NFQUEUE**.
|
|
||||||
4. `nfqws2` reads the queue and desyncs the handshake, then lets the connection continue normally.
|
|
||||||
|
|
||||||
Because it is a marked *direct* outbound (not a tunnel), `zapret-out` falls back to plain `direct-out` automatically when Zapret is disabled, so the generated config always stays valid. Loop-avoidance returns for the Zapret mark and `nfqws2`'s own reinjected (DESYNC_MARK) packets are added to the firewall automatically — you don't touch nft.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Enabling Zapret
|
|
||||||
|
|
||||||
1. Go to **Services → Re:HomeProxy → Node Settings** and open the **Zapret** tab.
|
|
||||||
2. If `nfqws2` is not installed yet, use the **Install** button — Re:HomeProxy fetches the right [zapret2-openwrt](https://github.com/1andrevich/zapret2-openwrt) package for your architecture.
|
|
||||||
3. Tick **Enable**, pick a strategy preset (see below), and **Save & Apply**.
|
|
||||||
|
|
||||||
That's all for the engine. The `--qnum` / `--user` / `--fwmark` / `--lua-init` arguments are added automatically — you only edit the desync strategy itself.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Strategies and presets
|
|
||||||
|
|
||||||
A "strategy" is the set of `nfqws2` options applied to every flow routed to Zapret. There are **no hostlists** — the routing rules already decide *what* is sent here, so the strategy only decides *how* to desync it. As with ByeDPI, **there is no universal best strategy** — what works depends on your ISP's DPI.
|
|
||||||
|
|
||||||
The **Strategy preset** picker fills the strategy field for you and is grouped into two sets:
|
|
||||||
|
|
||||||
| Group | What it is |
|
|
||||||
|-------|------------|
|
|
||||||
| **Recommended** | A small curated set, purpose-named (e.g. *Default (fake + multidisorder)*, *Multisplit*, *Fake only*). Start here. |
|
|
||||||
| **Full-test pool** | The larger technique-named pool used by the full tester. |
|
|
||||||
|
|
||||||
Presets are shipped read-only in `/etc/homeproxy/zapret_candidates.json` and are based on [bol-van/zapret2](https://github.com/bol-van/zapret2) (nfqws2/blockcheck2) with some adapted from [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube) (MIT). You can also leave the preset on *custom* and type your own option string in the **Desync strategy** field.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## The strategy tester
|
|
||||||
|
|
||||||
Because the right strategy is ISP-specific, the Zapret tab includes a **tester** that never touches your live traffic — it spins up a **temporary NFQUEUE scoped to four test sites** and checks whether each TLS handshake completes:
|
|
||||||
|
|
||||||
- **Test current strategy** — runs whatever is in the strategy field against **YouTube**, **Telegram**, **Discord** and **Speedtest.net**, showing each site as ✓ / ✗ with the handshake time, plus an `ok/total` count.
|
|
||||||
- **Full strategy test** — sweeps the whole preset pool and reports which candidates pass, so you can apply the best one.
|
|
||||||
|
|
||||||
Pick a strategy that passes all four sites. If several do, prefer one from the **Recommended** group.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Discord voice (opt-in)
|
|
||||||
|
|
||||||
Enabling **Zapret voice** routes Discord's voice-server UDP ranges (and call/voice UDP ports) to `zapret-out` **before** the normal proxy/call rules, so Discord voice is desynced via Zapret instead of being proxied. The UDP port ranges come from [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube). This option is honoured only when Zapret is enabled.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Routing traffic through Zapret
|
|
||||||
|
|
||||||
Once enabled, **Zapret** becomes a selectable routing target. The typical setup mirrors ByeDPI:
|
|
||||||
|
|
||||||
- **Alongside a VPN node (recommended):** in `proxy_banned_ru` mode, open **RU Proxy Rules** and set a *specific list's* target to **Zapret** — e.g. route YouTube and Discord through Zapret (no VPN bandwidth used) while everything else uses your VPN.
|
|
||||||
- **Per service:** because Zapret is selected per rule, you can mix it freely with the proxy and ByeDPI across different lists.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ByeDPI vs Zapret
|
|
||||||
|
|
||||||
Both bypass DPI without a VPN; they operate at different layers, so the right one depends on your ISP.
|
|
||||||
|
|
||||||
| | **ByeDPI** | **Zapret 2** |
|
|
||||||
|---|---|---|
|
|
||||||
| Layer | Local SOCKS proxy (`ciadpi`) | Kernel NFQUEUE packet mangler (`nfqws2`) |
|
|
||||||
| Appears as | Selectable in routing rules ("ByeDPI") | Selectable in routing rules ("Zapret") |
|
|
||||||
| TCP | ✅ | ✅ |
|
|
||||||
| UDP / QUIC | Carried, but not desynced | ✅ desynced (used for Discord voice) |
|
|
||||||
| DNS | Forced direct, bypassing ByeDPI (routing it through ByeDPI corrupts DoH/DoT) | Not in the DNS path — Zapret only touches flows routed to it |
|
|
||||||
| Try when | YouTube throttling, simple per-site offload | ByeDPI doesn't beat your DPI, or you need UDP/voice desync |
|
|
||||||
|
|
||||||
There's no harm in installing both and using the tester on each — keep whichever scores all-green on your network.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
| Symptom | Likely cause / fix |
|
|
||||||
|---------|--------------------|
|
|
||||||
| A site still loads slowly or won't open | The strategy isn't right for your ISP — run the **Full strategy test** and apply a passing one. |
|
|
||||||
| Tester shows everything failing | `nfqws2` may not be installed/running — check the **Zapret** tab and the [Diagnostics](Troubleshooting-en) page (it reports install / running / queue status). |
|
|
||||||
| Discord voice still drops | Enable **Zapret voice**, then re-test. |
|
|
||||||
| A site is unreachable, not just slow | It may be IP-blocked, not throttled — Zapret can't help; route it through a proxy/VPN node. |
|
|
||||||
|
|
||||||
See also: [ByeDPI](ByeDPI-en) · [Core Management](Core-Management-en) · [Supported Protocols](Supported-Protocols-en) · [Troubleshooting](Troubleshooting-en)
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
🇬🇧 [English](Zapret-en) | 🇷🇺 [Русский](Zapret-ru)
|
|
||||||
|
|
||||||
# Zapret
|
|
||||||
|
|
||||||
**Zapret 2** — второй, пакетный обход DPI, встроенный в Re:HomeProxy. Он снимает троттлинг и разблокирует сайты **без какой-либо VPN-подписки**, рассинхронизируя рукопожатие выбранных потоков прямо на сетевом уровне. Re:HomeProxy включает движок [bol-van/zapret2](https://github.com/bol-van/zapret2) (`nfqws2`) и управляет им за вас.
|
|
||||||
|
|
||||||
Он дополняет [ByeDPI](ByeDPI-ru): цель та же, уровень разный. См. [ByeDPI или Zapret](#byedpi-или-zapret) ниже.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что это — и чем оно не является
|
|
||||||
|
|
||||||
Zapret запускает пакетный обработчик **`nfqws2`**. Выбранный трафик помечается в межсетевом экране и передаётся в **NFQUEUE**, где `nfqws2` переписывает первые несколько пакетов каждого соединения (фейковые пакеты, разбиение, переупорядочивание, трюки с TCP-MD5/seq, манипуляции с TLS-record), чтобы система DPI провайдера больше не распознавала трафик — а значит, не могла его замедлять или блокировать.
|
|
||||||
|
|
||||||
| | |
|
|
||||||
|---|---|
|
|
||||||
| ✅ **Снимает троттлинг сайтов под DPI** | Типичный случай — YouTube, Discord и подобные сервисы, замедленные или заблокированные DPI. |
|
|
||||||
| ✅ **Без подписки и сервера** | Локальный пакетный трюк — не нужен VPN-узел, аккаунт или удалённый сервер. |
|
|
||||||
| ✅ **Работает на обоих ядрах** | Реализован как помеченный direct-outbound; одинаково работает на hiddify-core и sing-box-extended. |
|
|
||||||
| ✅ **Обрабатывает TCP *и* UDP/QUIC** | В отличие от ByeDPI, путь через NFQUEUE рассинхронизирует и UDP-потоки (используется для голоса Discord). |
|
|
||||||
| ❌ **Не шифрует и не скрывает трафик** | Провайдер по-прежнему видит, *какие* сайты вы посещаете; Zapret меняет только то, *как выглядят* первые пакеты. Это **не VPN**. |
|
|
||||||
| ❌ **Не открывает сайты, заблокированные по IP** | Если сайт блокируется по IP/DNS (а не просто троттлится), достучаться до него может только настоящий прокси/VPN-узел. |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Как это работает
|
|
||||||
|
|
||||||
Zapret подключён как **цель** маршрутизации, а не как SOCKS-узел:
|
|
||||||
|
|
||||||
1. Генератор конфига добавляет direct-outbound с именем **`zapret-out`**, помеченный `routing_mark` (метка по умолчанию `110`).
|
|
||||||
2. Когда правило маршрутизации выбирает `zapret-out`, ядро выпускает поток **напрямую**, но с этой меткой.
|
|
||||||
3. Цепочка nft (`homeproxy_zapret_queue`) ловит помеченные пакеты и отправляет первые 1–12 пакетов каждого TCP/UDP-соединения в **NFQUEUE**.
|
|
||||||
4. `nfqws2` читает очередь и рассинхронизирует рукопожатие, после чего соединение продолжается как обычно.
|
|
||||||
|
|
||||||
Поскольку это помеченный *direct*-outbound (а не туннель), при выключенном Zapret `zapret-out` автоматически откатывается к обычному `direct-out`, так что генерируемый конфиг всегда остаётся валидным. Правила-исключения для метки Zapret и для собственных переинжектированных пакетов `nfqws2` (DESYNC_MARK) добавляются в межсетевой экран автоматически — править nft вручную не нужно.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Включение Zapret
|
|
||||||
|
|
||||||
1. Откройте **Сервисы → Re:HomeProxy → Настройки узлов** и вкладку **Zapret**.
|
|
||||||
2. Если `nfqws2` ещё не установлен, нажмите **Установить** — Re:HomeProxy скачает подходящий пакет [zapret2-openwrt](https://github.com/1andrevich/zapret2-openwrt) для вашей архитектуры.
|
|
||||||
3. Отметьте **Включить**, выберите пресет стратегии (см. ниже) и нажмите **Сохранить и применить**.
|
|
||||||
|
|
||||||
Этого достаточно. Аргументы `--qnum` / `--user` / `--fwmark` / `--lua-init` добавляются автоматически — вы редактируете только саму стратегию рассинхронизации.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Стратегии и пресеты
|
|
||||||
|
|
||||||
«Стратегия» — это набор опций `nfqws2`, применяемых к каждому потоку, направленному в Zapret. **Списков хостов нет** — правила маршрутизации уже решают, *что* сюда отправлять, поэтому стратегия определяет только то, *как* это рассинхронизировать. Как и с ByeDPI, **универсально лучшей стратегии не существует** — что работает, зависит от DPI вашего провайдера.
|
|
||||||
|
|
||||||
Выбор **Пресет стратегии** заполняет поле стратегии за вас и сгруппирован на два набора:
|
|
||||||
|
|
||||||
| Группа | Что это |
|
|
||||||
|--------|---------|
|
|
||||||
| **Рекомендуемые** | Небольшой подобранный набор с понятными названиями (например, *Default (fake + multidisorder)*, *Multisplit*, *Fake only*). Начните отсюда. |
|
|
||||||
| **Полный пул тестирования** | Более крупный пул с техническими названиями, используемый полным тестером. |
|
|
||||||
|
|
||||||
Пресеты поставляются только для чтения в `/etc/homeproxy/zapret_candidates.json` и основаны на [bol-van/zapret2](https://github.com/bol-van/zapret2) (nfqws2/blockcheck2), часть адаптирована из [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube) (MIT). Можно оставить пресет в режиме *custom* и ввести свою строку опций в поле **Стратегия рассинхронизации**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Тестер стратегий
|
|
||||||
|
|
||||||
Поскольку нужная стратегия зависит от провайдера, на вкладке Zapret есть **тестер**, который не трогает ваш живой трафик — он поднимает **временную NFQUEUE, ограниченную четырьмя тестовыми сайтами**, и проверяет, завершается ли каждое TLS-рукопожатие:
|
|
||||||
|
|
||||||
- **Проверить текущую стратегию** — запускает то, что в поле стратегии, против **YouTube**, **Telegram**, **Discord** и **Speedtest.net**, показывая каждый сайт как ✓ / ✗ со временем рукопожатия и счётчиком `ok/total`.
|
|
||||||
- **Полная проверка стратегий** — перебирает весь пул пресетов и сообщает, какие кандидаты проходят, чтобы применить лучший.
|
|
||||||
|
|
||||||
Выберите стратегию, проходящую все четыре сайта. Если подходит несколько — предпочтите вариант из группы **Рекомендуемые**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Голос Discord (по желанию)
|
|
||||||
|
|
||||||
Включение **Zapret voice** направляет UDP-диапазоны голосовых серверов Discord (и UDP-порты звонков/голоса) в `zapret-out` **до** обычных правил прокси/звонков, так что голос Discord рассинхронизируется через Zapret, а не проксируется. Диапазоны UDP-портов взяты из [flowseal/zapret-discord-youtube](https://github.com/flowseal/zapret-discord-youtube). Опция учитывается только при включённом Zapret.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Маршрутизация трафика через Zapret
|
|
||||||
|
|
||||||
После включения **Zapret** становится доступной целью маршрутизации. Типичная схема повторяет ByeDPI:
|
|
||||||
|
|
||||||
- **Вместе с VPN-узлом (рекомендуется):** в режиме `proxy_banned_ru` откройте **RU Proxy Rules** и задайте цель *конкретного списка* как **Zapret** — например, направьте YouTube и Discord через Zapret (без расхода трафика VPN), а всё остальное — через VPN.
|
|
||||||
- **По сервисам:** поскольку Zapret выбирается в каждом правиле, его можно свободно смешивать с прокси и ByeDPI по разным спискам.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ByeDPI или Zapret
|
|
||||||
|
|
||||||
Оба обходят DPI без VPN; работают на разных уровнях, поэтому выбор зависит от вашего провайдера.
|
|
||||||
|
|
||||||
| | **ByeDPI** | **Zapret 2** |
|
|
||||||
|---|---|---|
|
|
||||||
| Уровень | Локальный SOCKS-прокси (`ciadpi`) | Пакетный обработчик NFQUEUE в ядре (`nfqws2`) |
|
|
||||||
| Отображается как | Выбирается в правилах маршрутизации («ByeDPI») | Выбирается в правилах маршрутизации («Zapret») |
|
|
||||||
| TCP | ✅ | ✅ |
|
|
||||||
| UDP / QUIC | Переносится, но не рассинхронизируется | ✅ рассинхронизируется (используется для голоса Discord) |
|
|
||||||
| DNS | Принудительно резолвится напрямую в обход ByeDPI (через него рассинхрон ломает DoH/DoT) | Не в пути DNS — Zapret трогает только потоки, направленные на него |
|
|
||||||
| Когда пробовать | Троттлинг YouTube, простой обход по сайтам | Если ByeDPI не справляется с вашим DPI или нужен рассинхрон UDP/голоса |
|
|
||||||
|
|
||||||
Ничто не мешает установить оба и прогнать тестер на каждом — оставьте тот, что даёт «всё зелёное» в вашей сети.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Устранение неполадок
|
|
||||||
|
|
||||||
| Симптом | Вероятная причина / решение |
|
|
||||||
|---------|------------------------------|
|
|
||||||
| Сайт по-прежнему медленно грузится или не открывается | Стратегия не подходит вашему провайдеру — запустите **Полную проверку стратегий** и примените проходящую. |
|
|
||||||
| Тестер показывает, что всё падает | Возможно, `nfqws2` не установлен/не запущен — проверьте вкладку **Zapret** и страницу [Диагностики](Troubleshooting-ru) (она показывает статус установки / запуска / очереди). |
|
|
||||||
| Голос Discord по-прежнему рвётся | Включите **Zapret voice** и протестируйте заново. |
|
|
||||||
| Сайт недоступен, а не просто медленный | Возможно, он заблокирован по IP, а не троттлится — Zapret тут не поможет; направьте его через прокси/VPN-узел. |
|
|
||||||
|
|
||||||
См. также: [ByeDPI](ByeDPI-ru) · [Управление ядром](Core-Management-ru) · [Поддерживаемые протоколы](Supported-Protocols-ru) · [Устранение неполадок](Troubleshooting-ru)
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
.DS_Store
|
||||||
|
/homeproxy
|
||||||
|
/docs/
|
||||||
File renamed without changes.
@@ -0,0 +1,48 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#
|
||||||
|
# Copyright (C) 2022-2023 ImmortalWrt.org
|
||||||
|
#
|
||||||
|
|
||||||
|
include $(TOPDIR)/rules.mk
|
||||||
|
|
||||||
|
LUCI_TITLE:=The modern ImmortalWrt proxy platform for ARM64/AMD64 (sing-box 1.14)
|
||||||
|
# Pure ucode/JS payload with no compiled code, so the package itself is arch
|
||||||
|
# independent. The real arch constraint comes from the +sing-box dependency,
|
||||||
|
# which the feed builds for every architecture Go supports (aarch64, arm,
|
||||||
|
# mipsel, riscv64, x86_64, ...); where no sing-box package exists, dependency
|
||||||
|
# resolution refuses the install. Checked against the ImmortalWrt 25.12.1
|
||||||
|
# package index (sing-box 1.14.0-r1 published for all of the above).
|
||||||
|
LUCI_PKGARCH:=all
|
||||||
|
LUCI_DEPENDS:= \
|
||||||
|
+sing-box \
|
||||||
|
+firewall4 \
|
||||||
|
+kmod-nft-tproxy \
|
||||||
|
+ip-full \
|
||||||
|
+kmod-tun \
|
||||||
|
+ucode-mod-digest
|
||||||
|
|
||||||
|
PKG_NAME:=luci-app-homeproxy
|
||||||
|
PKG_VERSION:=28.9.1.14
|
||||||
|
PKG_RELEASE:=1
|
||||||
|
|
||||||
|
# Only files the package actually ships belong here. The previous list named
|
||||||
|
# four paths that exist nowhere in the tree - certs/ and ruleset/ are created at
|
||||||
|
# runtime, and the two *_list.txt files are written by the LuCI ACL editor - so
|
||||||
|
# those declarations did nothing. What is listed instead is the resource data
|
||||||
|
# the router updates by itself: on a running device those files are newer than
|
||||||
|
# the ones in the package, and without this an upgrade would overwrite them.
|
||||||
|
define Package/luci-app-homeproxy/conffiles
|
||||||
|
/etc/config/homeproxy
|
||||||
|
/etc/homeproxy/resources/china_ip4.txt
|
||||||
|
/etc/homeproxy/resources/china_ip6.txt
|
||||||
|
/etc/homeproxy/resources/china_list.txt
|
||||||
|
/etc/homeproxy/resources/gfw_list.txt
|
||||||
|
/etc/homeproxy/resources/china_ip4.ver
|
||||||
|
/etc/homeproxy/resources/china_ip6.ver
|
||||||
|
/etc/homeproxy/resources/china_list.ver
|
||||||
|
/etc/homeproxy/resources/gfw_list.ver
|
||||||
|
endef
|
||||||
|
|
||||||
|
include $(TOPDIR)/feeds/luci/luci.mk
|
||||||
|
|
||||||
|
# call BuildPackage - OpenWrt buildroot signature
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
# luci-app-homeproxy-pro
|
||||||
|
|
||||||
|
**The modern ImmortalWrt proxy platform for ARM64 / AMD64**
|
||||||
|
|
||||||
|
基于 sing-box 1.14 内核的现代代理平台 — 简洁、高效、开箱即用。
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
## 项目定位
|
||||||
|
|
||||||
|
本项目是试验性产品,对 [homeproxy](https://github.com/szwjp/luci-app-homeproxy) 架构重构版:以 sing-box **1.14** 内核为唯一目标,
|
||||||
|
充分利用 1.14 引入的新特性,不再兼容 1.13 及更早内核。
|
||||||
|
|
||||||
|
## 运行要求
|
||||||
|
|
||||||
|
- ImmortalWrt / OpenWrt ≥ 24.10(`apk` 或 `opkg` 均可安装);sing-box ≥ 1.14.0 是硬要求;低于 1.14 时服务拒绝启动并记录明确日志
|
||||||
|
|
||||||
|
## 按功能性对比
|
||||||
|
|
||||||
|
### 代码组织
|
||||||
|
|
||||||
|
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 后端架构 | 单文件巨型(`generate_client.uc` 1217 行 / 40 KB;`parse_uri.uc` ~400 行 / 15 KB) | orchestrator + 5 子层(`parser` / `config` / `generator` / `subscription` / `runtime`)+ 公共库(`homeproxy.uc` / `firewall_utils.uc`)+ table-driven adapters | `root/etc/homeproxy/scripts/{config,parser,generator,subscription,runtime}/` |
|
||||||
|
| 前端架构 | 单一 66 KB `client.js` | `client.js` thin 入口 + 8 个 Tab 模块(`access` / `common` / `dns` / `nodes` / `routing` / `subscription` / `tun_dns` / `udp_nat`)+ 共享 helpers + 集中 `RPC.declare` | `htdocs/luci-static/resources/view/homeproxy/client/` |
|
||||||
|
| 协议建模 | 单 `parse_uri.uc` 内 13 个 scheme 分支 | `parser/{uri,flatten,normalize,mapping,protocols,validator}.uc` 6 个文件 + canonical Node 模型 + adapter table | `root/etc/homeproxy/scripts/parser/` + `config/{model,adapter}.uc` |
|
||||||
|
| Tab 模块化 | 1 个 `client.js` 含 12 个 Tab 渲染 | `homeproxy.js` (1153 行 helpers) + 4 顶层 view + 8 个 `client/*.js`;helpers 集中 RPC / MD5 / validators / statusPoller / renderSectionAdd / uploadCertificate | `view/homeproxy/client/{routing,nodes,dns,access,subscription,udp_nat,tun_dns,common}.js` |
|
||||||
|
|
||||||
|
### 稳定性
|
||||||
|
|
||||||
|
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| reload 事务 | 写完直接 restart | 生成 → `sing-box check` → 新实例 probe → 健康门(连续采样)→ 提为 known-good;不健康自动回滚 | `root/etc/init.d/homeproxy` + `runtime/{config,health}.sh` |
|
||||||
|
| 健康门 | `ubus` + `pgrep` 简单轮询 | `hp_wait_service` 连续 N 次采样(默认 3/30s)+ `netstat -p` 端口归属校验;不健康自动 rollback | `runtime/health.sh:hp_wait_service` |
|
||||||
|
| UCI 1.14 迁移 | r10/r11 自述较弱 | `migrate_config.uc` 36 checks 覆盖 1.14 DNS renames / `dns_server.address` 拆分 / `rcode://` → predefined rule / `rule_set_ipcidr_match_source` rename / `block-out`/`block-dns` → `action='reject'` / `auto_firewall` 重分发 / `block-dns` default-server | `scripts/migrate_config.uc` + `tests/ucode/test_migrate_config.sh` |
|
||||||
|
| sing-box 版本门 | 无 | `hp_require_singbox()` 在 start 时显式拒绝 `<1.14`,启动失败记录明确日志 | `runtime/service.sh:hp_require_singbox` |
|
||||||
|
|
||||||
|
### 安全
|
||||||
|
|
||||||
|
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| capabilities | `CAP_SYS_PTRACE` + `CAP_NET_RAW` + `CAP_NET_ADMIN` + `CAP_NET_BIND_SERVICE`(4 个) | 仅 `CAP_NET_ADMIN` + `CAP_NET_BIND_SERVICE`(2 个) | `root/etc/capabilities/homeproxy.json` + arch-guard guard 12 |
|
||||||
|
| 路径白名单(cert / rule) | 仅前端 UI 校验 | `validateHomeProxyPath()` 拒 traversal + relative,限 `/etc/homeproxy/` 与 `/tmp/homeproxy_*`;`validateCertificatePath()` 限 `/etc/homeproxy/certs/` `/etc/acme/` `/etc/ssl/`;前后端列表 arch-guard 29 锁同步 | `homeproxy.uc:48 / :99` + `homeproxy.js:HP_CERT_PATH_ROOTS` |
|
||||||
|
| 订阅 token 脱敏 | 调用点各自脱敏 | `wGETVerbose()` 内部脱敏(scheme/host/port 保留,userinfo / path / query 全脱;mock 与生产 `redactUrl` 由 arch-guard 35 锁一致) | `homeproxy.uc:226 (redactUrl)` + arch-guard 14 / 35 |
|
||||||
|
| crontab 权限 | `sed -i`(GNU-only)+ mv 后留给 0644 | `sed + mv` 跨 busybox / GNU / BSD + `chmod 600` 修 root crontab 0644 → 0600 | `runtime/service.sh:hp_crontab_drop` |
|
||||||
|
| procd jail mount | 默认未对所有模式 mount | tun/wireguard 自动 unjail(要 host netns),其他模式 mount HP_DIR + /etc/acme + /etc/ssl + /tmp/dhcp.leases + /etc/localtime + /etc/TZ | `runtime/service.sh:hp_procd_client_instance` |
|
||||||
|
| cert upload 并发 | 4 个按钮共用 `/tmp/homeproxy_certificate.tmp` | 4 个按钮各自 `/tmp/homeproxy_cert_<name>.tmp`,ACL 显式列 4 个写路径 | `homeproxy.js:uploadCertificate` + `acl.d/luci-app-homeproxy.json` |
|
||||||
|
| firewall 字段白名单 | 部分值校验 | `firewall_pre.uc` `tun_name` 严格 `^[A-Za-z0-9_.-]{1,15}$` 防 nft 注入;`firewall_post.ut` 每个 UCI 派生值走 `*_to_nftarr()` 校验器(guard 11 锁模板全覆盖) | `firewall_pre.uc:32` + `firewall_utils.uc` |
|
||||||
|
|
||||||
|
### 测试 / CI
|
||||||
|
|
||||||
|
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 架构守卫 | 无 | `tests/arch-guard.sh` **36 个 guard / 131 个 check** 锁跨文件一致性(UCICONFIG_DIR / ACL / capabilities / conffiles / CERT_PATH_ROOTS / `redactUrl` mock / uCode 语法 / sing-box floor / adapter 共享表 / …) | `tests/arch-guard.sh` (guards 1-19, 21-37) |
|
||||||
|
| 测试规模 | 7 个脚本 / 约 27 个 check | `tests/` 下 78 个文件:ucode 套件 + 7 个 frontend 验证器 + golden snapshot + mocks + 离线 runtime 测试;arch-guard 单跑 131 个 check | `tests/print-stats.sh` 实测 |
|
||||||
|
| CI | `build` + `i18n` 两条平行 workflow | `build` 用 `workflow_call` 依赖 `arch-test`;`arch-test` 8 步(翻译 fast gate → toolchain cache → toolchain 校验 → `tests/run.sh` 唯一套件入口 → 模板检查);`on-target` 手动 ssh + 不安装 | `.github/workflows/{arch-test,build,on-target}.yml` |
|
||||||
|
| uCode dialect pin | 跟随 upstream HEAD(已放宽) | pin `UCODE_REV=2026.01.16~85922056` + `test_ucode_grammar.sh` canary 锁 strict 语法(拒 `export function … }` 无 `;`、对象/数组解构) | `tests/toolchain/build-ucode-linux.sh:UCODE_REV` + `tests/ucode/test_ucode_grammar.sh` |
|
||||||
|
| MD5 实现 | 388 行 minified snippet;vmess 分支漏 `vmess_global_padding` | RFC 1321 完整实现 + `tests/frontend-md5.js` 锁 RFC 向量 + 与 Node `crypto.createHash('md5')` 双向 cross-check(44 / 44 PASS) | `homeproxy.js:calcStringMD5` + `tests/frontend-md5.js` |
|
||||||
|
|
||||||
|
### UX / Ops
|
||||||
|
|
||||||
|
| 维度 | upstream 形态 | pro 形态 | 证据 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Status 语义 | 2 态(RUNNING / NOT RUNNING) | 3 态(+ **STATUS UNKNOWN** 黄),区分 "rpc 没回" vs "service 死了" | `homeproxy.js:statusLabel` + `client.js:renderStatus` |
|
||||||
|
| 前端特异性 bug 修复 | `stubValidator` 缺 `factory` 致 ip6addr TypeError;`L.bind(hp.renderSectionAdd, …)` 把 factory 塞 `extra_class` 槽致 `InvalidCharacterError` | 已修:`stubValidator` 持 `factory: validation`;`renderSectionAdd` 改薄 wrap(arch-guard 22 / 23 锁 binding 形态) | `view/homeproxy/client.js:73` + `homeproxy.js:999` |
|
||||||
|
| 协议增改成本 | 改 110 行 ternary(README 旧行) | 改 5 个表行:`model.uc CREDENTIALS` + `loader.uc PROTOCOL_OPTIONS` + `adapter.uc OPTION_FIELDS` + `fixtures/` + golden snapshot;arch-guard 5 强制 fixtures 同步 | `config/{model,loader,adapter}.uc` + `tests/fixtures/generators/` |
|
||||||
|
| 资源更新 | jsdelivr 单一镜像 | 4 镜像 fallback(`fastly.jsdelivr.net` / `gcore.jsdelivr.net` / `cdn.jsdelivr.net` / `raw.githubusercontent.com`)+ UI「上次成功时间」(arch-guard 17 / 18 锁) | `runtime/dns.sh` + arch-guard 17 / 18 |
|
||||||
|
| ECH 上传 | 后端 case 缺失 | 补齐 `client_ech_conf` 标签 + `isValidECHConfig()` PEM 校验;ACL 显式列 4 个 tmp 路径 | `homeproxy.uc:isValidECHConfig` + `luci.homeproxy:certificate_write` |
|
||||||
|
| 默认测试主机 | 硬编码作者内网 IP | `HP_TEST_HOST` 注入,未设时 fail-fast;`on-target.yml` ;ssh 前 `ssh -o ConnectTimeout=5` 先确认可达(不要从记忆里写地址) | `tests/run.sh` + `.github/workflows/on-target.yml` |
|
||||||
|
| 文档 | 标准(README + CONTRIBUTING + SECURITY) | 精简(README only),`docs/` 自 2026-09-16 起只留本地(untrack + `.gitignore`),远端公开仓库不再发布 | `README.md` + `docs/`(本地) |
|
||||||
|
| 路由 ops | — | 替换文件只动 JS / menu / acl / rpcd ucode(reload 不影响网络);更深层改动前 `cp /etc/config/homeproxy /tmp/hp-r<NN>-preflight-<DATE>/`,保留上一发版 .apk 24h;`killall -HUP rpcd` 由 apk scripts 自动做 | `runtime/{config,service}.sh` |
|
||||||
|
|
||||||
|
**一句话总结**:pro 的核心价值是**把"单文件能跑"变成"orchestrator + table-driven adapter + 可独立测试的模块"**,并把约束、质量、回滚三件事从靠人盯变成靠代码执行(arch-guard 131 checks 静态锁住跨文件不变量)。
|
||||||
|
|
||||||
|
## 已知限制
|
||||||
|
|
||||||
|
- **试验性**:不承诺 API/配置稳定,重大变更可能在 minor 版本里发生。
|
||||||
|
- **真机实测单平台**:本仓库只在 x86-64 软路由上做过端到端验证。
|
||||||
|
- 本包不会编译 sing-box。系统固件必须自带 sing-box 1.14+;否则依赖解析直接失败。
|
||||||
|
|
||||||
|
## 贡献
|
||||||
|
|
||||||
|
欢迎 issue / PR。架构边界规则是可执行的,固化在 `tests/arch-guard.sh`(PR 必跑,
|
||||||
|
每条规则都注明对应的 guard);架构层面的变更请先开 issue 讨论,避免在
|
||||||
|
PR review 里来回拉扯。
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
[GPL-2.0-only](LICENSE) — 版权归 ImmortalWrt.org 与各贡献者。
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,183 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require form';
|
||||||
|
'require network';
|
||||||
|
'require poll';
|
||||||
|
'require uci';
|
||||||
|
'require validation';
|
||||||
|
'require view';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
'require view.homeproxy.client.common as common';
|
||||||
|
'require view.homeproxy.client.routing as routing';
|
||||||
|
'require view.homeproxy.client.nodes as nodes';
|
||||||
|
'require view.homeproxy.client.dns as dns';
|
||||||
|
'require view.homeproxy.client.access as access';
|
||||||
|
'require view.homeproxy.client.subscription as subscription';
|
||||||
|
'require view.homeproxy.client.udp_nat as udp_nat';
|
||||||
|
'require view.homeproxy.client.tun_dns as tun_dns';
|
||||||
|
|
||||||
|
/* Module-scoped guard: register the view-level status poll exactly
|
||||||
|
* once, regardless of how many times the view's render() runs (LuCI
|
||||||
|
* sometimes re-invokes the view handler on UCI commit). Without this,
|
||||||
|
* poll.add() inside a section render() handler leaks a fresh handler
|
||||||
|
* on every render and the status bar starts updating multiple times
|
||||||
|
* per tick. */
|
||||||
|
/* Built on the first render, so the poll closure captures that render's
|
||||||
|
* features.version. The "have we registered" state lives inside
|
||||||
|
* hp.statusPoller(), shared with the other view. */
|
||||||
|
let ensureStatusPoll = null;
|
||||||
|
|
||||||
|
function getServiceStatus() {
|
||||||
|
/* The fallback is `null`, not `{}`: that is what lets the caller tell
|
||||||
|
* "the status query did not answer" from "the service is not running".
|
||||||
|
* With the default `{}` the try/catch below swallowed the difference and
|
||||||
|
* reported a failed query as NOT RUNNING. */
|
||||||
|
return hp.rpcCall('list', ['homeproxy'],
|
||||||
|
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||||
|
if (res === null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return res['homeproxy']['instances']['sing-box-c']['running'] === true;
|
||||||
|
} catch (e) {
|
||||||
|
/* It answered, and there is no such instance. */
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatus(isRunning, version) {
|
||||||
|
/* Defense-in-depth: features.version comes from `sing-box version`
|
||||||
|
* stdout parsed by a regex, but it is interpolated into an
|
||||||
|
* `innerHTML =` sink. Force the version through a strict allow-list
|
||||||
|
* regex (semver-ish) so a hostile sing-box binary - or a future
|
||||||
|
* change to the parser - cannot smuggle HTML into this template.
|
||||||
|
* Anything that does not match renders as 'unknown' instead of
|
||||||
|
* being silently dropped. */
|
||||||
|
let safeVersion = 'unknown';
|
||||||
|
if (typeof version === 'string' && /^[\w.\-+]+$/.test(version))
|
||||||
|
safeVersion = version;
|
||||||
|
|
||||||
|
const state = hp.statusLabel(isRunning);
|
||||||
|
return '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>'
|
||||||
|
.format(state.color, _('HomeProxy'), safeVersion, state.text);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stubValidator = {
|
||||||
|
/* Forward the type functions from the real validator module and let
|
||||||
|
* them call back into their factory. validation.js exports the
|
||||||
|
* ValidatorFactory instance; without `factory` here the chained
|
||||||
|
* apply() falls off the edge and `this.factory.parseIPv4` throws
|
||||||
|
* "undefined is not an object" the first time a node's address is
|
||||||
|
* fed through `ip6addr`/`ip4addr`/`cidr4`/etc. (commit 67d2c3a
|
||||||
|
* missed the factory wiring when it added this helper). */
|
||||||
|
factory: validation,
|
||||||
|
apply(type, value, args) {
|
||||||
|
if (value != null)
|
||||||
|
this.value = value;
|
||||||
|
|
||||||
|
return validation.types[type].apply(this, args);
|
||||||
|
},
|
||||||
|
assert(condition) {
|
||||||
|
return !!condition;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return view.extend({
|
||||||
|
load() {
|
||||||
|
return Promise.all([
|
||||||
|
uci.load('homeproxy'),
|
||||||
|
hp.getBuiltinFeatures(),
|
||||||
|
network.getHostHints()
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
let m, s;
|
||||||
|
|
||||||
|
let features = data[1],
|
||||||
|
hosts = data[2]?.hosts;
|
||||||
|
|
||||||
|
/* Cache all configured proxy nodes, they will be called multiple times */
|
||||||
|
let proxy_nodes = {};
|
||||||
|
uci.sections('homeproxy', 'node', (res) => {
|
||||||
|
let nodeaddr = ((res.type === 'direct') ? res.override_address : res.address) || '',
|
||||||
|
nodeport = ((res.type === 'direct') ? res.override_port : res.port) || '';
|
||||||
|
|
||||||
|
proxy_nodes[res['.name']] =
|
||||||
|
String.format('[%s] %s', res.type, res.label || ((stubValidator.apply('ip6addr', nodeaddr) ?
|
||||||
|
String.format('[%s]', nodeaddr) : nodeaddr) + ':' + nodeport));
|
||||||
|
});
|
||||||
|
|
||||||
|
m = new form.Map('homeproxy', _('HomeProxy'),
|
||||||
|
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
|
||||||
|
|
||||||
|
s = m.section(form.TypedSection);
|
||||||
|
s.render = function () {
|
||||||
|
/* The status bar is rendered into the page body by the
|
||||||
|
* view-level poll handler below. This section only paints the
|
||||||
|
* placeholder; registering poll.add() here would leak a fresh
|
||||||
|
* poll handler every time the user calls map.reset() (every
|
||||||
|
* UCI write re-renders this section). */
|
||||||
|
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
||||||
|
E('p', { id: 'service_status' }, _('Collecting data...'))
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* View-level poll: registered exactly once per navigation, never
|
||||||
|
* inside a section's render() handler. getElementById may return
|
||||||
|
* null when LuCI swaps the DOM tree between renders - guard
|
||||||
|
* against it instead of throwing. */
|
||||||
|
if (ensureStatusPoll === null)
|
||||||
|
ensureStatusPoll = hp.statusPoller({
|
||||||
|
poll: poll,
|
||||||
|
read: getServiceStatus,
|
||||||
|
paint: function(res) {
|
||||||
|
let view = document.getElementById('service_status');
|
||||||
|
if (view)
|
||||||
|
view.innerHTML = renderStatus(res, features.version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureStatusPoll();
|
||||||
|
|
||||||
|
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
||||||
|
|
||||||
|
/* ctx bundles the values each tab needs so the tab modules don't
|
||||||
|
* have to recompute them. The destructuring keys are intentionally
|
||||||
|
* explicit - a tab that grabs something not in this list is a bug. */
|
||||||
|
const ctx = {
|
||||||
|
s, features, hosts, proxy_nodes,
|
||||||
|
stubValidator, self: this,
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Tab ordering follows the order of these calls: routing,
|
||||||
|
* routing_node, routing_rule, dns, dns_server, dns_rule, ruleset,
|
||||||
|
* dns_cache, control, udp_nat, tun_dns. The DNS tab sits before
|
||||||
|
* Access Control because the pages a preset-routing-mode user
|
||||||
|
* actually sees are Routing Settings, DNS Settings, Access Control,
|
||||||
|
* UDP NAT Settings and TUN DNS, in that order. The shared
|
||||||
|
* rule-section bodies live in common.js and are called out-of-order
|
||||||
|
* with respect to their file to keep the rendering sequence intact. */
|
||||||
|
routing.render(ctx);
|
||||||
|
nodes.renderRoutingNodes(ctx);
|
||||||
|
routing.renderRoutingRules(ctx);
|
||||||
|
dns.renderDnsSettings(ctx);
|
||||||
|
nodes.renderDnsServers(ctx);
|
||||||
|
dns.renderDnsRules(ctx);
|
||||||
|
subscription.render(ctx);
|
||||||
|
dns.renderDnsCache(ctx);
|
||||||
|
access.render(ctx);
|
||||||
|
udp_nat.renderUdpNat(ctx);
|
||||||
|
tun_dns.renderTunDns(ctx);
|
||||||
|
|
||||||
|
return m.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
'require tools.firewall as fwtool';
|
||||||
|
'require tools.widgets as widgets';
|
||||||
|
|
||||||
|
/* Access Control tab. Five sub-tabs (interface control, LAN IP policy,
|
||||||
|
* WAN IP policy, proxy domain list, direct domain list). Each domain-list
|
||||||
|
* sub-tab reads/writes the proxy_list / direct_list files through an
|
||||||
|
* ubus RPC; the load callback is the only path that touches that RPC. */
|
||||||
|
function render(ctx) {
|
||||||
|
const { s, hosts, stubValidator } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('control', _('Access Control'));
|
||||||
|
|
||||||
|
o = s.taboption('control', form.SectionValue, '_control', form.NamedSection, 'control', 'homeproxy');
|
||||||
|
ss = o.subsection;
|
||||||
|
|
||||||
|
/* Interface control start */
|
||||||
|
ss.tab('interface', _('Interface Control'));
|
||||||
|
|
||||||
|
so = ss.taboption('interface', widgets.DeviceSelect, 'listen_interfaces', _('Listen interfaces'),
|
||||||
|
_('Only process traffic from specific interfaces. Leave empty for all.'));
|
||||||
|
so.multiple = true;
|
||||||
|
so.noaliases = true;
|
||||||
|
|
||||||
|
so = ss.taboption('interface', widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||||
|
_('Bind outbound traffic to specific interface. Leave empty to auto detect.'));
|
||||||
|
so.multiple = false;
|
||||||
|
so.noaliases = true;
|
||||||
|
/* Interface control end */
|
||||||
|
|
||||||
|
/* LAN IP policy start */
|
||||||
|
ss.tab('lan_ip_policy', _('LAN IP Policy'));
|
||||||
|
|
||||||
|
so = ss.taboption('lan_ip_policy', form.ListValue, 'lan_proxy_mode', _('Proxy filter mode'));
|
||||||
|
so.value('disabled', _('Disable'));
|
||||||
|
so.value('listed_only', _('Proxy listed only'));
|
||||||
|
so.value('except_listed', _('Proxy all except listed'));
|
||||||
|
so.default = 'disabled';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv4_ips', _('Direct IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||||
|
so.depends('lan_proxy_mode', 'except_listed');
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_direct_ipv6_ips', _('Direct IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||||
|
so.depends({'lan_proxy_mode': 'except_listed', 'homeproxy.config.ipv6_support': '1'});
|
||||||
|
|
||||||
|
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_direct_mac_addrs', _('Direct MAC-s'), null, hosts);
|
||||||
|
so.depends('lan_proxy_mode', 'except_listed');
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||||
|
so.depends('lan_proxy_mode', 'listed_only');
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||||
|
so.depends({'lan_proxy_mode': 'listed_only', 'homeproxy.config.ipv6_support': '1'});
|
||||||
|
|
||||||
|
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_proxy_mac_addrs', _('Proxy MAC-s'), null, hosts);
|
||||||
|
so.depends('lan_proxy_mode', 'listed_only');
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv4_ips', _('Gaming mode IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_gaming_mode_ipv6_ips', _('Gaming mode IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||||
|
so.depends('homeproxy.config.ipv6_support', '1');
|
||||||
|
|
||||||
|
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_gaming_mode_mac_addrs', _('Gaming mode MAC-s'), null, hosts);
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv4_ips', _('Global proxy IPv4 IP-s'), null, 'ipv4', hosts, true);
|
||||||
|
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||||
|
|
||||||
|
so = fwtool.addIPOption(ss, 'lan_ip_policy', 'lan_global_proxy_ipv6_ips', _('Global proxy IPv6 IP-s'), null, 'ipv6', hosts, true);
|
||||||
|
so.depends({'homeproxy.config.routing_mode': /^((?!custom).)+$/, 'homeproxy.config.ipv6_support': '1'});
|
||||||
|
|
||||||
|
so = fwtool.addMACOption(ss, 'lan_ip_policy', 'lan_global_proxy_mac_addrs', _('Global proxy MAC-s'), null, hosts);
|
||||||
|
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||||
|
/* LAN IP policy end */
|
||||||
|
|
||||||
|
/* WAN IP policy start */
|
||||||
|
ss.tab('wan_ip_policy', _('WAN IP Policy'));
|
||||||
|
|
||||||
|
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv4_ips', _('Proxy IPv4 IP-s'));
|
||||||
|
so.datatype = 'or(ip4addr, cidr4)';
|
||||||
|
|
||||||
|
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_proxy_ipv6_ips', _('Proxy IPv6 IP-s'));
|
||||||
|
so.datatype = 'or(ip6addr, cidr6)';
|
||||||
|
so.depends('homeproxy.config.ipv6_support', '1');
|
||||||
|
|
||||||
|
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv4_ips', _('Direct IPv4 IP-s'));
|
||||||
|
so.datatype = 'or(ip4addr, cidr4)';
|
||||||
|
|
||||||
|
so = ss.taboption('wan_ip_policy', form.DynamicList, 'wan_direct_ipv6_ips', _('Direct IPv6 IP-s'));
|
||||||
|
so.datatype = 'or(ip6addr, cidr6)';
|
||||||
|
so.depends('homeproxy.config.ipv6_support', '1');
|
||||||
|
/* WAN IP policy end */
|
||||||
|
|
||||||
|
/* Proxy domain list start */
|
||||||
|
ss.tab('proxy_domain_list', _('Proxy Domain List'));
|
||||||
|
|
||||||
|
so = ss.taboption('proxy_domain_list', form.TextValue, '_proxy_domain_list');
|
||||||
|
so.rows = 10;
|
||||||
|
so.monospace = true;
|
||||||
|
so.datatype = 'hostname';
|
||||||
|
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||||
|
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||||
|
so.load = function(/* ... */) {
|
||||||
|
return hp.rpcCall('acllist_read', ['proxy_list'],
|
||||||
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||||
|
/* acllist_read returns { content: null, error: '...' }
|
||||||
|
* when the backend rejected the request (bad type, file
|
||||||
|
* unreadable, ...). Surface the error instead of silently
|
||||||
|
* resolving to "" which makes the field look unchanged. */
|
||||||
|
if (res && res.error)
|
||||||
|
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||||
|
return res ? res.content : '';
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
so.write = function(_section_id, value) {
|
||||||
|
return hp.rpcCall('acllist_write', ['proxy_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||||
|
/* Without this, a backend error like "UCI commit failed"
|
||||||
|
* is silently dropped and the user sees the old value
|
||||||
|
* reappear after refresh with no explanation. */
|
||||||
|
if (ret && ret.result === false)
|
||||||
|
throw (ret.error || 'unknown error');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
so.remove = function(/* ... */) {
|
||||||
|
/* routing_mode is an option of the page's 'config' NamedSection, not
|
||||||
|
* of this nested 'control' one: `this.section.formvalue(...)` walks
|
||||||
|
* the children of `this.section`, never found it and returned null,
|
||||||
|
* so the test below was always true and a reset wiped the file even
|
||||||
|
* in custom mode, where the list is in use. Read the saved UCI value
|
||||||
|
* the backend will act on. */
|
||||||
|
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||||
|
if (routing_mode !== 'custom')
|
||||||
|
return hp.rpcCall('acllist_write', ['proxy_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||||
|
if (ret && ret.result === false)
|
||||||
|
throw (ret.error || 'unknown error');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value)
|
||||||
|
for (let i of value.split('\n'))
|
||||||
|
if (i && !stubValidator.apply('hostname', i))
|
||||||
|
return _('Expecting: %s').format(_('valid hostname'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* Proxy domain list end */
|
||||||
|
|
||||||
|
/* Direct domain list start */
|
||||||
|
ss.tab('direct_domain_list', _('Direct Domain List'));
|
||||||
|
|
||||||
|
so = ss.taboption('direct_domain_list', form.TextValue, '_direct_domain_list');
|
||||||
|
so.rows = 10;
|
||||||
|
so.monospace = true;
|
||||||
|
so.datatype = 'hostname';
|
||||||
|
so.description = _('One domain per line. No spaces, no comments, no wildcards; the backend rejects invalid characters and tells you which line.');
|
||||||
|
so.depends({'homeproxy.config.routing_mode': 'custom', '!reverse': true});
|
||||||
|
so.load = function(/* ... */) {
|
||||||
|
return hp.rpcCall('acllist_read', ['direct_list'],
|
||||||
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||||
|
if (res && res.error)
|
||||||
|
ui.addNotification(null, E('p', _('Failed to read domain list: %s.').format(res.error)));
|
||||||
|
return res ? res.content : '';
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
so.write = function(_section_id, value) {
|
||||||
|
return hp.rpcCall('acllist_write', ['direct_list', value], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||||
|
if (ret && ret.result === false)
|
||||||
|
throw (ret.error || 'unknown error');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
so.remove = function(/* ... */) {
|
||||||
|
/* Same `this.section` mistake as the proxy list above. */
|
||||||
|
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||||
|
if (routing_mode !== 'custom')
|
||||||
|
return hp.rpcCall('acllist_write', ['direct_list', ''], { params: ['type', 'content'], expect: { '': {} } }).then((ret) => {
|
||||||
|
if (ret && ret.result === false)
|
||||||
|
throw (ret.error || 'unknown error');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value)
|
||||||
|
for (let i of value.split('\n'))
|
||||||
|
if (i && !stubValidator.apply('hostname', i))
|
||||||
|
return _('Expecting: %s').format(_('valid hostname'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* Direct domain list end */
|
||||||
|
/* ACL settings end */
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ render });
|
||||||
@@ -0,0 +1,566 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
|
||||||
|
/* Build the shared body of a routing/dns rule section. The two sections
|
||||||
|
* differ in their tab/option names, the sniffed-protocol value list, the
|
||||||
|
* order of the protocol/network/query-type triplet, a handful of
|
||||||
|
* descriptions and their action-specific field sets; everything else is
|
||||||
|
* emitted identically. */
|
||||||
|
function renderRuleSection(s, kind, self) {
|
||||||
|
let o, ss, so;
|
||||||
|
let is_dns = (kind === 'dns');
|
||||||
|
let uci_type = is_dns ? 'dns_rule' : 'routing_rule';
|
||||||
|
|
||||||
|
s.tab(uci_type, is_dns ? _('DNS Rules') : _('Routing Rules'));
|
||||||
|
o = s.taboption(uci_type, form.SectionValue, '_' + uci_type, form.GridSection, uci_type);
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
ss.addremove = true;
|
||||||
|
ss.rowcolors = true;
|
||||||
|
ss.sortable = true;
|
||||||
|
ss.nodescriptions = true;
|
||||||
|
ss.modaltitle = L.bind(hp.loadModalTitle, hp, is_dns ? _('DNS rule') : _('Routing rule'),
|
||||||
|
is_dns ? _('Add a DNS rule') : _('Add a routing rule'), 'homeproxy');
|
||||||
|
ss.sectiontitle = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||||
|
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||||
|
* form hands a call site's button factory to the wrong argument. */
|
||||||
|
ss.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(ss, extra_class);
|
||||||
|
};
|
||||||
|
|
||||||
|
ss.tab('field_other', _('Other fields'));
|
||||||
|
ss.tab('field_host', _('Host/IP fields'));
|
||||||
|
ss.tab('field_port', _('Port fields'));
|
||||||
|
ss.tab('fields_process', _('Process fields'));
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'label', _('Label'));
|
||||||
|
so.load = L.bind(hp.loadDefaultLabel, self, 'homeproxy');
|
||||||
|
so.validate = L.bind(hp.validateUniqueValue, self, 'homeproxy', uci_type, 'label');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'enabled', _('Enable'));
|
||||||
|
so.default = so.enabled;
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'mode', _('Mode'),
|
||||||
|
is_dns ? _('The default rule uses the following matching logic:<br/>' +
|
||||||
|
'<code>(domain || domain_suffix || domain_keyword || domain_regex)</code> &&<br/>' +
|
||||||
|
'<code>(port || port_range)</code> &&<br/>' +
|
||||||
|
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||||
|
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||||
|
'<code>other fields</code>.<br/>' +
|
||||||
|
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.') :
|
||||||
|
_('The default rule uses the following matching logic:<br/>' +
|
||||||
|
'<code>(domain || domain_suffix || domain_keyword || domain_regex || ip_cidr || ip_is_private)</code> &&<br/>' +
|
||||||
|
'<code>(port || port_range)</code> &&<br/>' +
|
||||||
|
'<code>(source_ip_cidr || source_ip_is_private)</code> &&<br/>' +
|
||||||
|
'<code>(source_port || source_port_range)</code> &&<br/>' +
|
||||||
|
'<code>other fields</code>.<br/>' +
|
||||||
|
'Additionally, included rule sets can be considered merged rather than as a single rule sub-item.'));
|
||||||
|
so.value('default', _('Default'));
|
||||||
|
so.default = 'default';
|
||||||
|
so.rmempty = false;
|
||||||
|
so.readonly = true;
|
||||||
|
if (is_dns)
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'ip_version', _('IP version'),
|
||||||
|
is_dns ? null : _('4 or 6. Not limited if empty.'));
|
||||||
|
so.value('4', _('IPv4'));
|
||||||
|
so.value('6', _('IPv6'));
|
||||||
|
so.value('', _('Both'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
function addSniffedProtocol() {
|
||||||
|
let opt = ss.taboption('field_other', form.MultiValue, 'protocol', _('Protocol'),
|
||||||
|
_('Sniffed protocol, see <a target="_blank" href="https://sing-box.sagernet.org/configuration/route/sniff/">Sniff</a> for details.'));
|
||||||
|
opt.value('bittorrent', _('BitTorrent'));
|
||||||
|
if (!is_dns)
|
||||||
|
opt.value('dns', _('DNS'));
|
||||||
|
opt.value('dtls', _('DTLS'));
|
||||||
|
opt.value('http', _('HTTP'));
|
||||||
|
opt.value('quic', _('QUIC'));
|
||||||
|
opt.value('rdp', _('RDP'));
|
||||||
|
opt.value('ssh', _('SSH'));
|
||||||
|
opt.value('stun', _('STUN'));
|
||||||
|
opt.value('tls', _('TLS'));
|
||||||
|
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addNetwork() {
|
||||||
|
let opt = ss.taboption('field_other', form.ListValue, 'network', _('Network'));
|
||||||
|
opt.value('tcp', _('TCP'));
|
||||||
|
opt.value('udp', _('UDP'));
|
||||||
|
opt.value('', _('Both'));
|
||||||
|
|
||||||
|
return opt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dns) {
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'query_type', _('Query type'),
|
||||||
|
_('Match query type.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = addNetwork();
|
||||||
|
so = addSniffedProtocol();
|
||||||
|
} else {
|
||||||
|
so = addSniffedProtocol();
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'client', _('Client'),
|
||||||
|
_('Sniffed client type (QUIC client type or SSH client name).'));
|
||||||
|
so.value('chromium', _('Chromium / Cronet'));
|
||||||
|
so.value('firefox', _('Firefox / uquic firefox'));
|
||||||
|
so.value('quic-go', _('quic-go / uquic chrome'));
|
||||||
|
so.value('safari', _('Safari / Apple Network API'));
|
||||||
|
so.depends('protocol', 'quic');
|
||||||
|
so.depends('protocol', 'ssh');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = addNetwork();
|
||||||
|
}
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'user', _('User'),
|
||||||
|
_('Match user name.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', hp.CBIStaticList, 'rule_set', _('Rule set'),
|
||||||
|
is_dns ? _('Match rule set. DNS: rules referencing rule-sets that contain query_type are incompatible with legacy address filters (ip_cidr / ip_is_private) — use match_response instead (1.14).') :
|
||||||
|
_('Match rule set. Since 1.14: a rule-set holding a single default rule is merged into this rule; any other rule-set matches as an OR collection.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
uci.sections('homeproxy', 'ruleset', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'rule_set_ip_cidr_match_source', _('Rule set IP CIDR as source IP'),
|
||||||
|
is_dns ? _('Make IP CIDR in rule sets match the source IP.') : _('Make IP CIDR in rule set used to match the source IP.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'invert', _('Invert'),
|
||||||
|
_('Invert match result.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'action', _('Action'));
|
||||||
|
so.value('route', _('Route'));
|
||||||
|
so.value('route-options', _('Route options'));
|
||||||
|
so.value('reject', _('Reject'));
|
||||||
|
if (is_dns) {
|
||||||
|
so.value('predefined', _('Predefined'));
|
||||||
|
so.value('evaluate', _('Evaluate (1.14)'));
|
||||||
|
} else {
|
||||||
|
so.value('resolve', _('Resolve'));
|
||||||
|
}
|
||||||
|
so.default = 'route';
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
if (is_dns) {
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'server', _('Server'),
|
||||||
|
_('Tag of the target dns server.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'dns_disable_cache', _('Disable dns cache'),
|
||||||
|
_('Disable cache and save cache in this query.'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'rewrite_ttl', _('Rewrite TTL'),
|
||||||
|
_('Rewrite TTL in DNS responses.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||||
|
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||||
|
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'match_response', _('Match response'),
|
||||||
|
_('1 matches the latest untagged evaluate result; other values match an evaluate tag (1.14).'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'evaluate_tag', _('Evaluate tag'),
|
||||||
|
_('Optional tag for this evaluate result.'));
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'race', _('Race'),
|
||||||
|
_('Judge this response-dependent rule in parallel; first match wins (1.14).'));
|
||||||
|
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'speculative', _('Speculative'),
|
||||||
|
_('Send the query before pending race rules are judged (1.14).'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'disable_optimistic_cache', _('Disable optimistic cache'),
|
||||||
|
_('Disable optimistic DNS caching for this query.'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'dns_timeout', _('Query timeout'),
|
||||||
|
_('Override dns.timeout for this query, in seconds.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'remove_client_subnet', _('Remove EDNS client subnet'),
|
||||||
|
_('Remove the edns0-subnet record from the query (1.14).'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'evaluate');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'response_rcode', _('Response RCode'),
|
||||||
|
_('Match the evaluated response code (requires match_response).'));
|
||||||
|
for (let rc of [ 'NOERROR', 'FORMERR', 'SERVFAIL', 'NXDOMAIN', 'NOTIMP', 'REFUSED' ])
|
||||||
|
so.value(rc);
|
||||||
|
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'response_answer', _('Response answer'),
|
||||||
|
_('Text DNS records to match in the evaluated answer.'));
|
||||||
|
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'response_ns', _('Response NS'));
|
||||||
|
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'response_extra', _('Response extra'));
|
||||||
|
so.depends({'action': 'route', 'match_response': /[\s\S]/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||||
|
so.value('default', _('Reply with REFUSED'));
|
||||||
|
so.value('drop', _('Drop requests'));
|
||||||
|
so.default = 'default';
|
||||||
|
so.depends('action', 'reject');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop requests'),
|
||||||
|
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||||
|
_('Method'), _('Drop requests')));
|
||||||
|
so.depends('reject_method', 'default');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'predefined_rcode', _('RCode'),
|
||||||
|
_('The response code.'));
|
||||||
|
so.value('NOERROR');
|
||||||
|
so.value('FORMERR');
|
||||||
|
so.value('SERVFAIL');
|
||||||
|
so.value('NXDOMAIN');
|
||||||
|
so.value('NOTIMP');
|
||||||
|
so.value('REFUSED');
|
||||||
|
so.default = 'NOERROR';
|
||||||
|
so.depends('action', 'predefined');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'predefined_answer', _('Answer'),
|
||||||
|
_('List of text DNS record to respond as answers.'));
|
||||||
|
so.depends('action', 'predefined');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'predefined_ns', _('NS'),
|
||||||
|
_('List of text DNS record to respond as name servers.'));
|
||||||
|
so.depends('action', 'predefined');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.DynamicList, 'predefined_extra', _('Extra records'),
|
||||||
|
_('List of text DNS record to respond as extra records.'));
|
||||||
|
so.depends('action', 'predefined');
|
||||||
|
so.modalonly = true;
|
||||||
|
} else {
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'outbound', _('Outbound'),
|
||||||
|
_('Tag of the target outbound.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('direct-out', _('Direct'));
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.rmempty = false;
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'override_address', _('Override address'),
|
||||||
|
_('Override the connection destination address.'));
|
||||||
|
so.datatype = 'ipaddr';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'override_port', _('Override port'),
|
||||||
|
_('Override the connection destination port.'));
|
||||||
|
so.datatype = 'port';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'udp_disable_domain_unmapping', _('Disable UDP domain unmapping'),
|
||||||
|
_('If enabled, for UDP proxy requests addressed to a domain, the original packet address will be sent in the response instead of the mapped domain.'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'udp_connect', _('connect UDP connections'),
|
||||||
|
_('If enabled, attempts to connect UDP connection to the destination instead of listen.'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'udp_timeout', _('UDP timeout'),
|
||||||
|
_('Timeout for UDP connections.<br/>Setting a larger value than the UDP timeout in inbounds will have no effect.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'tls_record_fragment', _('TLS record fragment'),
|
||||||
|
_('Fragment TLS handshake into multiple TLS records.'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'tls_fragment', _('TLS fragment'),
|
||||||
|
_('Fragment TLS handshakes. Due to poor performance, try <code>%s</code> first.').format(
|
||||||
|
_('TLS record fragment')));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'tls_fragment_fallback_delay', _('Fragment fallback delay'),
|
||||||
|
_('The fallback value in milliseconds used when TLS segmentation cannot automatically determine the wait time.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.placeholder = '500';
|
||||||
|
so.depends('tls_fragment', '1');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'tls_spoof', _('TLS spoof SNI (1.14)'),
|
||||||
|
_('Inject a forged TLS ClientHello carrying this SNI before the real one to fool SNI-filtering middleboxes. Requires elevated privileges.'));
|
||||||
|
so.datatype = 'hostname';
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'tls_spoof_method', _('TLS spoof method (1.14)'),
|
||||||
|
_('How the forged segment is rejected by the real server.'));
|
||||||
|
so.value('', _('wrong-sequence (default)'));
|
||||||
|
so.value('wrong-checksum', _('wrong-checksum'));
|
||||||
|
so.value('wrong-ack', _('wrong-ack'));
|
||||||
|
so.value('wrong-md5', _('wrong-md5'));
|
||||||
|
so.value('wrong-timestamp', _('wrong-timestamp'));
|
||||||
|
so.depends('action', 'route');
|
||||||
|
so.depends('action', 'route-options');
|
||||||
|
so.depends('tls_spoof', /[\s\S]/);
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'resolve_server', _('DNS server'),
|
||||||
|
_('Specifies DNS server tag to use instead of selecting through DNS routing.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('', _('Default'));
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'reject_method', _('Method'));
|
||||||
|
so.value('default', _('Reply with TCP RST / ICMP port unreachable'));
|
||||||
|
so.value('drop', _('Drop packets'));
|
||||||
|
so.depends('action', 'reject');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'reject_no_drop', _('Don\'t drop packets'),
|
||||||
|
_('<code>%s</code> will be temporarily overwritten to <code>%s</code> after 50 triggers in 30s if not enabled.').format(
|
||||||
|
_('Method'), _('Drop packets')));
|
||||||
|
so.depends('reject_method', 'default');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.ListValue, 'resolve_strategy', _('Resolve strategy'),
|
||||||
|
_('Domain strategy for resolving the domain names.'));
|
||||||
|
for (let i in hp.dns_strategy)
|
||||||
|
so.value(i, hp.dns_strategy[i]);
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'resolve_disable_cache', _('Disable DNS cache'),
|
||||||
|
_('Disable DNS cache in this query.'));
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'resolve_rewrite_ttl', _('Rewrite TTL'),
|
||||||
|
_('Rewrite TTL in DNS responses.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'resolve_client_subnet', _('EDNS Client subnet'),
|
||||||
|
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||||
|
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Flag, 'resolve_disable_optimistic_cache', _('Disable optimistic cache'),
|
||||||
|
_('Disable optimistic DNS caching in this lookup (1.14).'));
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_other', form.Value, 'resolve_timeout', _('Query timeout'),
|
||||||
|
_('Override dns.timeout for this lookup, in seconds (1.14).'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.depends('action', 'resolve');
|
||||||
|
so.modalonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'domain', _('Domain name'),
|
||||||
|
_('Match full domain.'));
|
||||||
|
so.datatype = 'hostname';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'domain_suffix', _('Domain suffix'),
|
||||||
|
_('Match domain suffix.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'domain_keyword', _('Domain keyword'),
|
||||||
|
_('Match domain using keyword.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'domain_regex', _('Domain regex'),
|
||||||
|
_('Match domain using regular expression.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'source_ip_cidr', _('Source IP CIDR'),
|
||||||
|
_('Match source IP CIDR.'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.Flag, 'source_ip_is_private', _('Match private source IP'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'ip_cidr', _('IP CIDR'),
|
||||||
|
is_dns ? _('Match IP CIDR with the evaluated response (needs match_response; old rules are auto-wrapped in 1.14).') :
|
||||||
|
_('Match IP CIDR.'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.Flag, 'ip_is_private', _('Match private IP'),
|
||||||
|
is_dns ? _('Match private IP with the evaluated response (needs match_response).') : null);
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'source_mac_address', _('Source MAC address'),
|
||||||
|
_('Match LAN device by MAC address.'));
|
||||||
|
so.datatype = 'macaddr';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.DynamicList, 'source_hostname', _('Source hostname'),
|
||||||
|
_('Match LAN device hostname via neighbor resolution; enable find_neighbor.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
if (is_dns) {
|
||||||
|
so = ss.taboption('field_host', form.Value, 'query_client_subnet', _('Query EDNS client subnet'),
|
||||||
|
_('Match the EDNS Client Subnet in the query (1.14).'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_host', form.Flag, 'query_dnssec', _('Match DNSSEC OK'),
|
||||||
|
_('Match queries with the DNSSEC OK bit set (1.14).'));
|
||||||
|
so.modalonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
so = ss.taboption('field_port', form.DynamicList, 'source_port', _('Source port'),
|
||||||
|
_('Match source port.'));
|
||||||
|
so.datatype = 'port';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_port', form.DynamicList, 'source_port_range', _('Source port range'),
|
||||||
|
_('Match source port range. Format as START:/:END/START:END.'));
|
||||||
|
so.validate = hp.validatePortRange;
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_port', form.DynamicList, 'port', _('Port'),
|
||||||
|
_('Match port.'));
|
||||||
|
so.datatype = 'port';
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('field_port', form.DynamicList, 'port_range', _('Port range'),
|
||||||
|
_('Match port range. Format as START:/:END/START:END.'));
|
||||||
|
so.validate = hp.validatePortRange;
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('fields_process', form.DynamicList, 'process_name', _('Process name'),
|
||||||
|
_('Match process name.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('fields_process', form.DynamicList, 'process_path', _('Process path'),
|
||||||
|
_('Match process path.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.taboption('fields_process', form.DynamicList, 'process_path_regex', _('Process path (regex)'),
|
||||||
|
_('Match process path using regular expression.'));
|
||||||
|
so.modalonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ renderRuleSection });
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
'require view.homeproxy.client.common as common';
|
||||||
|
|
||||||
|
/* The four cache options exist on the 'dns' NamedSection in both the custom
|
||||||
|
* mode tab (renderDnsSettings) and the preset mode tab (renderDnsCache).
|
||||||
|
* Both used to spell them out separately, which registered the same four
|
||||||
|
* option names twice in one map: LuCI keeps the first registration for writes
|
||||||
|
* and lookupOption(), so the second copy's dependencies did nothing while its
|
||||||
|
* description could drift. They are declared here once and rendered from
|
||||||
|
* wherever the tab needs them, in the original order.
|
||||||
|
*
|
||||||
|
* The depends() are the custom-mode copy's: with the cache disabled the
|
||||||
|
* optimistic cache and its persisted file have no effect, so both are hidden
|
||||||
|
* (disable_cache_expire is itself conditional on disable_cache). */
|
||||||
|
function renderDnsCacheOptions(ss) {
|
||||||
|
let so;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'optimistic_cache', _('Optimistic DNS cache'),
|
||||||
|
_('Return expired cache immediately and refresh in background (sing-box 1.14).'));
|
||||||
|
so.depends('disable_cache', '0');
|
||||||
|
so.depends('disable_cache_expire', '0');
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'optimistic_timeout', _('Optimistic cache timeout'),
|
||||||
|
_('Max time an expired entry may be served. Examples: 3d, 1h.'));
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'dns_timeout', _('DNS query timeout'),
|
||||||
|
_('Default timeout per DNS query in seconds (sing-box default: 10).'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'cache_file_store_dns', _('Store DNS cache'),
|
||||||
|
_('Persist DNS cache across restarts (sing-box 1.14, replaces Store RDRC).'));
|
||||||
|
so.depends('disable_cache', '0');
|
||||||
|
so.rmempty = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DNS settings tab plus the wrapping 'dns' NamedSection. Called after
|
||||||
|
* routing.renderRoutingRules() so the NamedSection chain reads
|
||||||
|
* config -> routing_node -> routing_rule -> dns from left to right. */
|
||||||
|
function renderDnsSettings(ctx) {
|
||||||
|
const { s } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('dns', _('DNS Settings'));
|
||||||
|
o = s.taboption('dns', form.SectionValue, '_dns', form.NamedSection, 'dns', 'homeproxy');
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
so = ss.option(form.ListValue, 'default_strategy', _('Default DNS strategy'),
|
||||||
|
_('The DNS strategy for resolving the domain name in the address.'));
|
||||||
|
for (let i in hp.dns_strategy)
|
||||||
|
so.value(i, hp.dns_strategy[i]);
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'default_server', _('Default DNS server'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.default = 'default-dns';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'disable_cache', _('Disable DNS cache'));
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'disable_cache_expire', _('Disable cache expire'));
|
||||||
|
so.depends('disable_cache', '0');
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'client_subnet', _('EDNS Client subnet'),
|
||||||
|
_('Append a <code>edns0-subnet</code> OPT extra record with the specified IP prefix to every query by default.<br/>' +
|
||||||
|
'If value is an IP address instead of prefix, <code>/32</code> or <code>/128</code> will be appended automatically.'));
|
||||||
|
so.datatype = 'or(cidr, ipaddr)';
|
||||||
|
|
||||||
|
renderDnsCacheOptions(ss);
|
||||||
|
/* DNS settings end */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DNS rules sub-section. Split out from renderDnsSettings() so the
|
||||||
|
* orchestrator can call it after nodes.renderDnsServers() and preserve
|
||||||
|
* the original tab ordering (dns, dns_server, dns_rule). */
|
||||||
|
function renderDnsRules(ctx) {
|
||||||
|
const { s, self } = ctx;
|
||||||
|
|
||||||
|
/* DNS rules start */
|
||||||
|
common.renderRuleSection(s, 'dns', self);
|
||||||
|
/* DNS rules end */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DNS tab for the preset routing modes. Titled 'DNS Settings' exactly like
|
||||||
|
* the custom-mode tab in renderDnsSettings(): the two are mutually exclusive
|
||||||
|
* (custom vs the presets), so whichever one a user lands on is called the
|
||||||
|
* same thing.
|
||||||
|
*
|
||||||
|
* The three server options below are config.* (not dns.*) options that used
|
||||||
|
* to live on the routing tab. They are DNS settings, and the routing page
|
||||||
|
* only carried them because of the order the old monolithic client.js
|
||||||
|
* happened to be written in. They stay on the 'config' NamedSection, so
|
||||||
|
* only the tab they render under changed. */
|
||||||
|
function renderDnsCache(ctx) {
|
||||||
|
const { s, stubValidator } = ctx;
|
||||||
|
let o, ss;
|
||||||
|
|
||||||
|
s.tab('dns_cache', _('DNS Settings'));
|
||||||
|
|
||||||
|
o = s.taboption('dns_cache', form.Value, 'dns_server', _('DNS server'),
|
||||||
|
_('Support UDP, TCP, DoH, DoQ, DoT. TCP protocol will be used if not specified.'));
|
||||||
|
o.value('wan', _('WAN DNS (read from interface)'));
|
||||||
|
o.value('1.1.1.1', _('CloudFlare Public DNS (1.1.1.1)'));
|
||||||
|
o.value('208.67.222.222', _('Cisco Public DNS (208.67.222.222)'));
|
||||||
|
o.value('8.8.8.8', _('Google Public DNS (8.8.8.8)'));
|
||||||
|
o.value('', '---');
|
||||||
|
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||||
|
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||||
|
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||||
|
o.default = '8.8.8.8';
|
||||||
|
o.rmempty = false;
|
||||||
|
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||||
|
o.validate = function(section_id, value) {
|
||||||
|
if (section_id && !['wan'].includes(value)) {
|
||||||
|
if (!value)
|
||||||
|
return _('Expecting: %s').format(_('non-empty value'));
|
||||||
|
|
||||||
|
let ipv6_support = this.section.formvalue(section_id, 'ipv6_support');
|
||||||
|
try {
|
||||||
|
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||||
|
if (stubValidator.apply('hostname', url.hostname))
|
||||||
|
return true;
|
||||||
|
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||||
|
return true;
|
||||||
|
else if ((ipv6_support === '1') && stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||||
|
return true;
|
||||||
|
else
|
||||||
|
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (!stubValidator.apply((ipv6_support === '1') ? 'ipaddr' : 'ip4addr', value))
|
||||||
|
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('dns_cache', form.Value, 'china_dns_server', _('China DNS server'),
|
||||||
|
_('The dns server for resolving China domains. Support UDP, TCP, DoH, DoQ, DoT.'));
|
||||||
|
o.value('wan', _('WAN DNS (read from interface)'));
|
||||||
|
o.value('223.5.5.5', _('Aliyun Public DNS (223.5.5.5)'));
|
||||||
|
o.value('210.2.4.8', _('CNNIC Public DNS (210.2.4.8)'));
|
||||||
|
o.value('119.29.29.29', _('Tencent Public DNS (119.29.29.29)'));
|
||||||
|
o.value('117.50.10.10', _('ThreatBook Public DNS (117.50.10.10)'));
|
||||||
|
o.depends('routing_mode', 'bypass_mainland_china');
|
||||||
|
o.default = '223.5.5.5';
|
||||||
|
o.rmempty = false;
|
||||||
|
o.validate = function(section_id, value) {
|
||||||
|
if (section_id && !['wan'].includes(value)) {
|
||||||
|
if (!value)
|
||||||
|
return _('Expecting: %s').format(_('non-empty value'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = new URL(value.replace(/^.*:\/\//, 'http://'));
|
||||||
|
if (stubValidator.apply('hostname', url.hostname))
|
||||||
|
return true;
|
||||||
|
else if (stubValidator.apply('ip4addr', url.hostname))
|
||||||
|
return true;
|
||||||
|
else if (stubValidator.apply('ip6addr', url.hostname.match(/^\[(.+)\]$/)?.[1]))
|
||||||
|
return true;
|
||||||
|
else
|
||||||
|
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
if (!stubValidator.apply('ipaddr', value))
|
||||||
|
return _('Expecting: %s').format(_('valid DNS server address'));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('dns_cache', form.Flag, 'cn_ip_fallback', _('CN-IP DNS fallback (sing-box 1.14)'),
|
||||||
|
_('When the main DNS returns a mainland China IP, re-resolve via China DNS using evaluate/match_response.'));
|
||||||
|
o.depends('routing_mode', 'bypass_mainland_china');
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('dns_cache', form.SectionValue, '_dns_cache', form.NamedSection, 'dns', 'homeproxy');
|
||||||
|
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||||
|
ss = o.subsection;
|
||||||
|
|
||||||
|
renderDnsCacheOptions(ss);
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ renderDnsSettings, renderDnsRules, renderDnsCache });
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
'require tools.widgets as widgets';
|
||||||
|
|
||||||
|
/* Routing nodes sub-grid (the per-outbound entries that routing_rule
|
||||||
|
* actions dispatch to). Called after routing.render() so the SectionValue
|
||||||
|
* grid nesting is intact. */
|
||||||
|
function renderRoutingNodes(ctx) {
|
||||||
|
const { s, proxy_nodes } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('routing_node', _('Routing Nodes'));
|
||||||
|
o = s.taboption('routing_node', form.SectionValue, '_routing_node', form.GridSection, 'routing_node');
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
ss.addremove = true;
|
||||||
|
ss.rowcolors = true;
|
||||||
|
ss.sortable = true;
|
||||||
|
ss.nodescriptions = true;
|
||||||
|
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Routing node'), _('Add a routing node'), 'homeproxy');
|
||||||
|
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||||
|
* form hands a call site's button factory to the wrong argument. */
|
||||||
|
ss.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(ss, extra_class);
|
||||||
|
};
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'label', _('Label'));
|
||||||
|
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'label');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||||
|
so.default = so.enabled;
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'node', _('Node'),
|
||||||
|
_('Outbound node'));
|
||||||
|
so.value('urltest', _('URLTest'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
so.value(i, proxy_nodes[i]);
|
||||||
|
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'routing_node', 'node');
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'domain_resolver', _('Domain resolver'),
|
||||||
|
_('For resolving domain name in the server address.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('', _('Default'));
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.depends({'node': 'urltest', '!reverse': true});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||||
|
_('The domain strategy for resolving the domain name in the address.'));
|
||||||
|
for (let i in hp.dns_strategy)
|
||||||
|
so.value(i, hp.dns_strategy[i]);
|
||||||
|
so.depends({'node': 'urltest', '!reverse': true});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(widgets.DeviceSelect, 'bind_interface', _('Bind interface'),
|
||||||
|
_('The network interface to bind to.'));
|
||||||
|
so.multiple = false;
|
||||||
|
so.noaliases = true;
|
||||||
|
so.depends({'outbound': '', 'node': /^((?!urltest$).)+$/});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||||
|
_('The tag of the upstream outbound.<br/>Other dial fields will be ignored when enabled.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('', _('Direct'));
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res['.name'] !== section_id && res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value) {
|
||||||
|
let node = this.section.formvalue(section_id, 'node');
|
||||||
|
|
||||||
|
let conflict = false;
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res['.name'] !== section_id) {
|
||||||
|
if (res.outbound === section_id && res['.name'] == value)
|
||||||
|
conflict = true;
|
||||||
|
else if (res.node === 'urltest' && res.urltest_nodes?.includes(node) && res['.name'] == value)
|
||||||
|
conflict = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (conflict)
|
||||||
|
return _('Recursive outbound detected!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.depends({'node': 'urltest', '!reverse': true});
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.option(hp.CBIStaticList, 'urltest_nodes', _('URLTest nodes'),
|
||||||
|
_('List of nodes to test.'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
so.value(i, proxy_nodes[i]);
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
/* The repository's other validators take (section_id, value) and check
|
||||||
|
* `value` before dereferencing it. This one re-read the value through
|
||||||
|
* `this.section.formvalue()`, which returns null when the widget is not
|
||||||
|
* in the DOM, so `.length` threw a TypeError - the save aborted with a
|
||||||
|
* console error instead of the "non-empty value" message. The framework
|
||||||
|
* already hands the current value in (getValidator is
|
||||||
|
* L.bind(this.validate, this, section_id)), so use it. */
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value && !value.length)
|
||||||
|
return _('Expecting: %s').format(_('non-empty value'));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'urltest_url', _('Test URL'),
|
||||||
|
_('The URL to test.'));
|
||||||
|
so.placeholder = 'https://www.gstatic.com/generate_204';
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value) {
|
||||||
|
try {
|
||||||
|
let url = new URL(value);
|
||||||
|
if (!url.hostname)
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'urltest_interval', _('Test interval'),
|
||||||
|
_('The test interval in seconds.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.placeholder = '180';
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value) {
|
||||||
|
let idle_timeout = this.section.formvalue(section_id, 'urltest_idle_timeout') || '1800';
|
||||||
|
if (parseInt(value) > parseInt(idle_timeout))
|
||||||
|
return _('Test interval must be less or equal than idle timeout.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'urltest_tolerance', _('Test tolerance'),
|
||||||
|
_('The test tolerance in milliseconds.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.placeholder = '50';
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'urltest_idle_timeout', _('Idle timeout'),
|
||||||
|
_('The idle timeout in seconds.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.placeholder = '1800';
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'urltest_interrupt_exist_connections', _('Interrupt existing connections'),
|
||||||
|
_('Interrupt existing connections when the selected outbound has changed.'));
|
||||||
|
so.depends('node', 'urltest');
|
||||||
|
so.modalonly = true;
|
||||||
|
/* Routing nodes end */
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DNS servers sub-grid (per-server entries that dns_rule 'route'/'evaluate'
|
||||||
|
* actions can target). Called after dns.renderDnsSettings() so the dns
|
||||||
|
* NamedSection is in place. */
|
||||||
|
function renderDnsServers(ctx) {
|
||||||
|
const { s } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('dns_server', _('DNS Servers'));
|
||||||
|
o = s.taboption('dns_server', form.SectionValue, '_dns_server', form.GridSection, 'dns_server');
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
ss.addremove = true;
|
||||||
|
ss.rowcolors = true;
|
||||||
|
ss.sortable = true;
|
||||||
|
ss.nodescriptions = true;
|
||||||
|
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('DNS server'), _('Add a DNS server'), 'homeproxy');
|
||||||
|
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||||
|
* form hands a call site's button factory to the wrong argument. */
|
||||||
|
ss.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(ss, extra_class);
|
||||||
|
};
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'label', _('Label'));
|
||||||
|
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'dns_server', 'label');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||||
|
so.default = so.enabled;
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||||
|
so.value('udp', _('UDP'));
|
||||||
|
so.value('tcp', _('TCP'));
|
||||||
|
so.value('tls', _('TLS'));
|
||||||
|
so.value('https', _('HTTPS'));
|
||||||
|
so.value('h3', _('HTTP/3'));
|
||||||
|
so.value('quic', _('QUIC'));
|
||||||
|
so.default = 'udp';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'server', _('Address'),
|
||||||
|
_('The address of the dns server.'));
|
||||||
|
so.datatype = 'or(hostname, ipaddr)';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'server_port', _('Port'),
|
||||||
|
_('The port of the DNS server.'));
|
||||||
|
so.placeholder = 'auto';
|
||||||
|
so.datatype = 'port';
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'path', _('Path'),
|
||||||
|
_('The path of the DNS server.'));
|
||||||
|
so.placeholder = '/dns-query';
|
||||||
|
so.depends('type', 'https');
|
||||||
|
so.depends('type', 'h3');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.DynamicList, 'headers', _('Headers'),
|
||||||
|
_('Additional headers to be sent to the DNS server.'));
|
||||||
|
so.depends('type', 'https');
|
||||||
|
so.depends('type', 'h3');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'tls_sni', _('TLS SNI'),
|
||||||
|
_('Used to verify the hostname on the returned certificates.'));
|
||||||
|
so.depends('type', 'tls');
|
||||||
|
so.depends('type', 'https');
|
||||||
|
so.depends('type', 'h3');
|
||||||
|
so.depends('type', 'quic');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'address_resolver', _('Address resolver'),
|
||||||
|
_('Tag of a another server to resolve the domain name in the address. Required if address contains domain.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('', _('None'));
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res['.name'] !== section_id && res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value) {
|
||||||
|
let conflict = false;
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res['.name'] !== section_id)
|
||||||
|
if (res.address_resolver === section_id && res['.name'] == value)
|
||||||
|
conflict = true;
|
||||||
|
});
|
||||||
|
if (conflict)
|
||||||
|
return _('Recursive resolver detected!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'address_strategy', _('Address strategy'),
|
||||||
|
_('The domain strategy for resolving the domain name in the address.'));
|
||||||
|
for (let i in hp.dns_strategy)
|
||||||
|
so.value(i, hp.dns_strategy[i]);
|
||||||
|
so.depends({'address_resolver': '', '!reverse': true});
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||||
|
_('Tag of an outbound for connecting to the dns server.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('direct-out', _('Direct'));
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.default = 'direct-out';
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
/* DNS servers end */
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ renderRoutingNodes, renderDnsServers });
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
'require view.homeproxy.client.common as common';
|
||||||
|
|
||||||
|
/* Routing tab and the wrapping custom-routing NamedSection. The
|
||||||
|
* routing_node tab lives in nodes.js; the routing_rule sub-section is
|
||||||
|
* shared with dns and lives in common.js as renderRuleSection. */
|
||||||
|
function render(ctx) {
|
||||||
|
const { s, proxy_nodes, features, stubValidator } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('routing', _('Routing Settings'));
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.ListValue, 'main_node', _('Main node'));
|
||||||
|
o.value('nil', _('Disable'));
|
||||||
|
o.value('urltest', _('URLTest'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
o.value(i, proxy_nodes[i]);
|
||||||
|
o.default = 'nil';
|
||||||
|
o.depends({'routing_mode': 'custom', '!reverse': true});
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('routing', hp.CBIStaticList, 'main_urltest_nodes', _('URLTest nodes'),
|
||||||
|
_('List of nodes to test.'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
o.value(i, proxy_nodes[i]);
|
||||||
|
o.depends('main_node', 'urltest');
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Value, 'main_urltest_interval', _('Test interval'),
|
||||||
|
_('The test interval in seconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '180';
|
||||||
|
o.depends('main_node', 'urltest');
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Value, 'main_urltest_tolerance', _('Test tolerance'),
|
||||||
|
_('The test tolerance in milliseconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '50';
|
||||||
|
o.depends('main_node', 'urltest');
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.ListValue, 'main_udp_node', _('Main UDP node'));
|
||||||
|
o.value('nil', _('Disable'));
|
||||||
|
o.value('same', _('Same as main node'));
|
||||||
|
o.value('urltest', _('URLTest'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
o.value(i, proxy_nodes[i]);
|
||||||
|
o.default = 'nil';
|
||||||
|
o.depends({'routing_mode': /^((?!custom).)+$/, 'proxy_mode': /^((?!redirect$).)+$/});
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('routing', hp.CBIStaticList, 'main_udp_urltest_nodes', _('URLTest nodes'),
|
||||||
|
_('List of nodes to test.'));
|
||||||
|
for (let i in proxy_nodes)
|
||||||
|
o.value(i, proxy_nodes[i]);
|
||||||
|
o.depends('main_udp_node', 'urltest');
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Value, 'main_udp_urltest_interval', _('Test interval'),
|
||||||
|
_('The test interval in seconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '180';
|
||||||
|
o.depends('main_udp_node', 'urltest');
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Value, 'main_udp_urltest_tolerance', _('Test tolerance'),
|
||||||
|
_('The test tolerance in milliseconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '50';
|
||||||
|
o.depends('main_udp_node', 'urltest');
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.ListValue, 'routing_mode', _('Routing mode'));
|
||||||
|
o.value('gfwlist', _('GFWList'));
|
||||||
|
o.value('bypass_mainland_china', _('Bypass mainland China'));
|
||||||
|
o.value('proxy_mainland_china', _('Only proxy mainland China'));
|
||||||
|
o.value('custom', _('Custom routing'));
|
||||||
|
o.value('global', _('Global'));
|
||||||
|
o.default = 'bypass_mainland_china';
|
||||||
|
o.rmempty = false;
|
||||||
|
o.onchange = function(ev, section_id, value) {
|
||||||
|
/* Save on every transition, not only into custom: leaving custom
|
||||||
|
* leaves the user's `routing.default_outbound`, `default_outbound_dns`
|
||||||
|
* and `bypass_cn_traffic` in UCI. The generator's read-only-in-custom
|
||||||
|
* gate (5573269) keeps the generation valid, but the residue stays in
|
||||||
|
* the file until the next time someone enters custom and saves
|
||||||
|
* again, which is invisible to the user. Triggering save on every
|
||||||
|
* change makes the UCI state track the mode the user actually picked. */
|
||||||
|
if (section_id && value)
|
||||||
|
this.map.save(null, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Value, 'routing_port', _('Routing ports'),
|
||||||
|
_('Specify target ports to be proxied. Multiple ports must be separated by commas.'));
|
||||||
|
o.value('', _('All ports'));
|
||||||
|
o.value('common', _('Common ports only (bypass P2P traffic)'));
|
||||||
|
o.validate = function(section_id, value) {
|
||||||
|
if (section_id && value && value !== 'common') {
|
||||||
|
|
||||||
|
let ports = [];
|
||||||
|
for (let i of value.split(',')) {
|
||||||
|
if (!stubValidator.apply('port', i) && !stubValidator.apply('portrange', i))
|
||||||
|
return _('Expecting: %s').format(_('valid port value'));
|
||||||
|
if (ports.includes(i))
|
||||||
|
return _('Port %s alrealy exists!').format(i);
|
||||||
|
ports = ports.concat(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.ListValue, 'proxy_mode', _('Proxy mode'));
|
||||||
|
o.value('redirect', _('Redirect TCP'));
|
||||||
|
if (features.hp_has_tproxy)
|
||||||
|
o.value('redirect_tproxy', _('Redirect TCP + TProxy UDP'));
|
||||||
|
if (features.hp_has_ip_full && features.hp_has_tun) {
|
||||||
|
o.value('redirect_tun', _('Redirect TCP + Tun UDP'));
|
||||||
|
o.value('tun', _('Tun TCP/UDP'));
|
||||||
|
} else {
|
||||||
|
o.description = _('To enable Tun support, you need to install <code>ip-full</code> and <code>kmod-tun</code>');
|
||||||
|
}
|
||||||
|
o.default = 'redirect_tproxy';
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('routing', form.Flag, 'ipv6_support', _('IPv6 support'));
|
||||||
|
o.default = o.enabled;
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
/* Custom routing settings start */
|
||||||
|
/* Routing settings start */
|
||||||
|
o = s.taboption('routing', form.SectionValue, '_routing', form.NamedSection, 'routing', 'homeproxy');
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
so = ss.option(form.ListValue, 'tcpip_stack', _('TCP/IP stack'),
|
||||||
|
_('TCP/IP stack.'));
|
||||||
|
if (features.with_gvisor) {
|
||||||
|
so.value('mixed', _('Mixed'));
|
||||||
|
so.value('gvisor', _('gVisor'));
|
||||||
|
}
|
||||||
|
so.value('system', _('System'));
|
||||||
|
so.default = 'system';
|
||||||
|
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||||
|
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||||
|
so.rmempty = false;
|
||||||
|
so.onchange = function(ev, section_id, value) {
|
||||||
|
let desc = ev.target.nextElementSibling;
|
||||||
|
if (value === 'mixed')
|
||||||
|
desc.innerHTML = _('Mixed <code>system</code> TCP stack and <code>gVisor</code> UDP stack.')
|
||||||
|
else if (value === 'gvisor')
|
||||||
|
desc.innerHTML = _('Based on google/gvisor.');
|
||||||
|
else if (value === 'system')
|
||||||
|
desc.innerHTML = _('Less compatibility and sometimes better performance.');
|
||||||
|
}
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'endpoint_independent_nat', _('Enable endpoint-independent NAT'),
|
||||||
|
_('Performance may degrade slightly, so it is not recommended to enable on when it is not needed.'));
|
||||||
|
so.default = so.enabled;
|
||||||
|
so.depends('tcpip_stack', 'mixed');
|
||||||
|
so.depends('tcpip_stack', 'gvisor');
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'udp_timeout', _('UDP NAT expiration time'),
|
||||||
|
_('In seconds.'));
|
||||||
|
so.datatype = 'uinteger';
|
||||||
|
so.placeholder = '300';
|
||||||
|
so.depends('homeproxy.config.proxy_mode', 'redirect_tproxy');
|
||||||
|
so.depends('homeproxy.config.proxy_mode', 'redirect_tun');
|
||||||
|
so.depends('homeproxy.config.proxy_mode', 'tun');
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'bypass_cn_traffic', _('Bypass CN traffic'),
|
||||||
|
_('Bypass mainland China traffic via firewall rules by default.'));
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'find_neighbor', _('Find neighbor hosts'),
|
||||||
|
_('Enable neighbor resolution so rules can match LAN devices by hostname (1.14).'));
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'domain_strategy', _('Domain strategy'),
|
||||||
|
_('If set, the requested domain name will be resolved to IP before routing.'));
|
||||||
|
for (let i in hp.dns_strategy)
|
||||||
|
so.value(i, hp.dns_strategy[i]);
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'default_outbound', _('Default outbound'),
|
||||||
|
_('Default outbound for connections not matched by any routing rules.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('nil', _('Disable (the service)'));
|
||||||
|
this.value('direct-out', _('Direct'));
|
||||||
|
this.value('block-out', _('Block'));
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.default = 'nil';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'default_outbound_dns', _('Default outbound DNS'),
|
||||||
|
_('Default DNS server for resolving domain name in the server address.'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('default-dns', _('Default DNS (issued by WAN)'));
|
||||||
|
this.value('system-dns', _('System DNS'));
|
||||||
|
uci.sections('homeproxy', 'dns_server', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.default = 'default-dns';
|
||||||
|
so.rmempty = false;
|
||||||
|
/* Routing settings end */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Routing rule sub-section. Split out from render() so the orchestrator can
|
||||||
|
* call it after nodes.renderRoutingNodes() and preserve the original tab
|
||||||
|
* ordering (routing, routing_node, routing_rule). */
|
||||||
|
function renderRoutingRules(ctx) {
|
||||||
|
const { s, self } = ctx;
|
||||||
|
|
||||||
|
/* Routing rules start */
|
||||||
|
common.renderRuleSection(s, 'routing', self);
|
||||||
|
/* Routing rules end */
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ render, renderRoutingRules });
|
||||||
+139
@@ -0,0 +1,139 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
|
||||||
|
/* Rule set sub-grid (per-rule-set entries: local file or remote URL with
|
||||||
|
* download metadata). Called after dns.renderDnsRules() so the
|
||||||
|
* ruleset tab appears after the dns_rule sub-tab. */
|
||||||
|
function render(ctx) {
|
||||||
|
const { s } = ctx;
|
||||||
|
let o, ss, so;
|
||||||
|
|
||||||
|
s.tab('ruleset', _('Rule Set'));
|
||||||
|
o = s.taboption('ruleset', form.SectionValue, '_ruleset', form.GridSection, 'ruleset');
|
||||||
|
o.depends('routing_mode', 'custom');
|
||||||
|
|
||||||
|
ss = o.subsection;
|
||||||
|
ss.addremove = true;
|
||||||
|
ss.rowcolors = true;
|
||||||
|
ss.sortable = true;
|
||||||
|
ss.nodescriptions = true;
|
||||||
|
ss.modaltitle = L.bind(hp.loadModalTitle, hp, _('Rule set'), _('Add a rule set'), 'homeproxy');
|
||||||
|
ss.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the bound
|
||||||
|
* form hands a call site's button factory to the wrong argument. */
|
||||||
|
ss.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(ss, extra_class);
|
||||||
|
};
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'label', _('Label'));
|
||||||
|
so.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
so.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'ruleset', 'label');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Flag, 'enabled', _('Enable'));
|
||||||
|
so.default = so.enabled;
|
||||||
|
so.rmempty = false;
|
||||||
|
so.editable = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'type', _('Type'));
|
||||||
|
so.value('local', _('Local'));
|
||||||
|
so.value('remote', _('Remote'));
|
||||||
|
so.default = 'remote';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'format', _('Format'));
|
||||||
|
so.value('binary', _('Binary file'));
|
||||||
|
so.value('source', _('Source file'));
|
||||||
|
so.default = 'binary';
|
||||||
|
so.rmempty = false;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'path', _('Path'));
|
||||||
|
so.datatype = 'file';
|
||||||
|
so.placeholder = '/etc/homeproxy/ruleset/example.json';
|
||||||
|
so.rmempty = false;
|
||||||
|
so.depends('type', 'local');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'url', _('Rule set URL'));
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id) {
|
||||||
|
if (!value)
|
||||||
|
return _('Expecting: %s').format(_('non-empty value'));
|
||||||
|
let extra = this.section.formvalue(section_id, 'extra_tags') || [];
|
||||||
|
if (extra.length && !value.includes('{tag}'))
|
||||||
|
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = new URL(value);
|
||||||
|
if (!url.hostname)
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.rmempty = false;
|
||||||
|
so.depends('type', 'remote');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.ListValue, 'outbound', _('Outbound'),
|
||||||
|
_('Outbound used to download this rule-set (via sing-box 1.14 http_clients).'));
|
||||||
|
so.load = function(section_id) {
|
||||||
|
delete this.keylist;
|
||||||
|
delete this.vallist;
|
||||||
|
|
||||||
|
this.value('', _('Default'));
|
||||||
|
this.value('direct-out', _('Direct'));
|
||||||
|
uci.sections('homeproxy', 'routing_node', (res) => {
|
||||||
|
if (res.enabled === '1')
|
||||||
|
this.value(res['.name'], res.label);
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.super('load', section_id);
|
||||||
|
}
|
||||||
|
so.depends('type', 'remote');
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'initial_path', _('Initial path'),
|
||||||
|
_('Local file with initial rule-set content; avoids blocking startup on first download (1.14).'));
|
||||||
|
so.datatype = 'file';
|
||||||
|
so.depends('type', 'remote');
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.DynamicList, 'extra_tags', _('Extra tags'),
|
||||||
|
_('Extra rule-set tags sharing these options. Requires {tag} in path/url (1.14).'));
|
||||||
|
so.depends('type', 'remote');
|
||||||
|
so.validate = function(section_id, value) {
|
||||||
|
if (section_id && value && value.length) {
|
||||||
|
let rule_url = this.section.formvalue(section_id, 'url') || '';
|
||||||
|
if (!rule_url.includes('{tag}'))
|
||||||
|
return _('Expecting: %s').format(_('{tag} in URL when extra tags are used'));
|
||||||
|
let rule_initial = this.section.formvalue(section_id, 'initial_path') || '';
|
||||||
|
if (rule_initial && !rule_initial.includes('{tag}'))
|
||||||
|
return _('Expecting: %s').format(_('{tag} in initial path when extra tags are used'));
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
so.modalonly = true;
|
||||||
|
|
||||||
|
so = ss.option(form.Value, 'update_interval', _('Update interval'),
|
||||||
|
_('Update interval of rule set.'));
|
||||||
|
so.placeholder = '1d';
|
||||||
|
so.depends('type', 'remote');
|
||||||
|
/* Rule set settings end */
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ render });
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
|
||||||
|
/* TUN DNS tab - the rightmost tab. These two options configure sing-box's
|
||||||
|
* own DNS handling inside the TUN interface, which is a different thing from
|
||||||
|
* the DNS servers and cache settings on the "DNS Settings" page; keeping them
|
||||||
|
* there made three labels starting with "DNS server"/"TUN DNS" sit next to
|
||||||
|
* each other on one page.
|
||||||
|
*
|
||||||
|
* They are config.* options and depend on proxy_mode (TUN modes only), not on
|
||||||
|
* routing_mode, so they appear for custom and preset routing alike. The tab
|
||||||
|
* has no depends() of its own: both options depend on proxy_mode, so any
|
||||||
|
* other proxy mode hides them and LuCI drops the empty tab with them. */
|
||||||
|
function renderTunDns(ctx) {
|
||||||
|
const { s } = ctx;
|
||||||
|
let o;
|
||||||
|
|
||||||
|
s.tab('tun_dns', _('TUN DNS'));
|
||||||
|
|
||||||
|
o = s.taboption('tun_dns', form.ListValue, 'tun_dns_mode', _('TUN DNS mode (1.14)'),
|
||||||
|
_('Since sing-box 1.14 the default (Unset) behaves as hijack: sing-box sets the platform interface DNS and hijacks port 53. On OpenWrt this overlaps with the own dnsmasq/nftables DNS hijack of this plugin, so keep Disabled on a gateway unless you need sing-box to own TUN DNS.'));
|
||||||
|
o.value('default', _('Unset (default)'));
|
||||||
|
o.value('disabled', _('Disabled'));
|
||||||
|
o.value('native', _('Native'));
|
||||||
|
o.value('hijack', _('Hijack'));
|
||||||
|
o.depends('proxy_mode', 'redirect_tun');
|
||||||
|
o.depends('proxy_mode', 'tun');
|
||||||
|
o.default = 'default';
|
||||||
|
o.rmempty = true;
|
||||||
|
|
||||||
|
o = s.taboption('tun_dns', form.DynamicList, 'tun_dns_address', _('TUN DNS addresses (1.14)'));
|
||||||
|
o.datatype = 'ipaddr';
|
||||||
|
/* The old `proxy_mode: /^((?!custom).)+$/` was written as if proxy_mode
|
||||||
|
* could be 'custom' - it cannot (the values are redirect / redirect_tproxy
|
||||||
|
* / redirect_tun / tun), so the condition was always true and the address
|
||||||
|
* field showed up in plain redirect mode too. Match the two TUN modes,
|
||||||
|
* the same way tun_dns_mode above does. */
|
||||||
|
o.depends({'proxy_mode': /^(redirect_tun|tun)$/, 'tun_dns_mode': /^(disabled|native|hijack)$/});
|
||||||
|
o.modalonly = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ renderTunDns });
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
'require baseclass';
|
||||||
|
'require form';
|
||||||
|
|
||||||
|
/* UDP NAT tab - the rightmost tab. sing-box's UDP NAT knobs are not routing
|
||||||
|
* decisions, so they get their own page instead of sitting at the bottom of
|
||||||
|
* the routing tab. They are config.* options and stay on the 'config'
|
||||||
|
* NamedSection, so only the tab they render under changes.
|
||||||
|
*
|
||||||
|
* The tab has no depends() of its own: all three options depend on
|
||||||
|
* proxy_mode, so a custom proxy mode hides every option and LuCI drops the
|
||||||
|
* empty tab with them. */
|
||||||
|
function renderUdpNat(ctx) {
|
||||||
|
const { s } = ctx;
|
||||||
|
let o;
|
||||||
|
|
||||||
|
s.tab('udp_nat', _('UDP NAT Settings'));
|
||||||
|
|
||||||
|
o = s.taboption('udp_nat', form.ListValue, 'udp_mapping', _('UDP NAT mapping (1.14)'));
|
||||||
|
o.value('endpoint_independent', _('Endpoint independent'));
|
||||||
|
o.value('address_dependent', _('Address dependent'));
|
||||||
|
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||||
|
o.description = _('sing-box default; recommended for home use (small NAT table, QUIC/game friendly). Choose stricter only for specific UDP issues.');
|
||||||
|
o.depends('proxy_mode', 'redirect_tproxy');
|
||||||
|
o.depends('proxy_mode', 'redirect_tun');
|
||||||
|
o.depends('proxy_mode', 'tun');
|
||||||
|
o.default = 'endpoint_independent';
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('udp_nat', form.ListValue, 'udp_filtering', _('UDP NAT filtering (1.14)'));
|
||||||
|
o.value('endpoint_independent', _('Endpoint independent'));
|
||||||
|
o.value('address_dependent', _('Address dependent'));
|
||||||
|
o.value('address_and_port_dependent', _('Address and port dependent'));
|
||||||
|
o.description = _('sing-box default; recommended for home use (accepts replies from any remote after mapping). Choose stricter only for specific UDP issues.');
|
||||||
|
o.depends('proxy_mode', 'redirect_tproxy');
|
||||||
|
o.depends('proxy_mode', 'redirect_tun');
|
||||||
|
o.depends('proxy_mode', 'tun');
|
||||||
|
o.default = 'endpoint_independent';
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('udp_nat', form.Value, 'udp_nat_max', _('UDP NAT sessions max (1.14)'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends('proxy_mode', 'redirect_tproxy');
|
||||||
|
o.depends('proxy_mode', 'redirect_tun');
|
||||||
|
o.depends('proxy_mode', 'tun');
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseclass.extend({ renderUdpNat });
|
||||||
@@ -0,0 +1,922 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
'require form';
|
||||||
|
'require uci';
|
||||||
|
'require ui';
|
||||||
|
'require view';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
|
||||||
|
function allowInsecureConfirm(ev, _section_id, value) {
|
||||||
|
if (value === '1' && !confirm(_('Are you sure to allow insecure?')))
|
||||||
|
ev.target.firstElementChild.checked = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeSettings(section, data, features, main_node, routing_mode) {
|
||||||
|
let s = section, o;
|
||||||
|
s.rowcolors = true;
|
||||||
|
s.sortable = true;
|
||||||
|
s.nodescriptions = true;
|
||||||
|
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Node'), _('Add a node'), 'homeproxy');
|
||||||
|
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
|
||||||
|
if (routing_mode !== 'custom') {
|
||||||
|
o = s.option(form.Button, '_apply', _('Apply'));
|
||||||
|
o.editable = true;
|
||||||
|
o.modalonly = false;
|
||||||
|
o.inputstyle = 'apply';
|
||||||
|
o.inputtitle = function(section_id) {
|
||||||
|
if (main_node == section_id) {
|
||||||
|
this.readonly = true;
|
||||||
|
return _('Applied');
|
||||||
|
} else {
|
||||||
|
this.readonly = false;
|
||||||
|
return _('Apply');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.onclick = function(ev, section_id) {
|
||||||
|
uci.set('homeproxy', 'config', 'main_node', section_id);
|
||||||
|
|
||||||
|
return this.map.save(null, true).then(() => {
|
||||||
|
ui.changes.apply(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'label', _('Label'));
|
||||||
|
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
|
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'node', 'label');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = hp.renderProtocolOptions(s, { features: features, side: 'client' });
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'address', _('Address'));
|
||||||
|
o.datatype = 'host';
|
||||||
|
o.depends({'type': 'direct', '!reverse': true});
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'port', _('Port'));
|
||||||
|
o.datatype = 'port';
|
||||||
|
o.depends({'type': 'direct', '!reverse': true});
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'username', _('Username'));
|
||||||
|
o.depends('type', 'http');
|
||||||
|
o.depends('type', 'socks');
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'password', _('Password'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('type', 'anytls');
|
||||||
|
o.depends('type', 'http');
|
||||||
|
o.depends('type', 'hysteria2');
|
||||||
|
o.depends('type', 'shadowsocks');
|
||||||
|
o.depends('type', 'snell');
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.depends('type', 'trojan');
|
||||||
|
o.depends('type', 'tuic');
|
||||||
|
o.depends({'type': 'shadowtls', 'shadowtls_version': '2'});
|
||||||
|
o.depends({'type': 'shadowtls', 'shadowtls_version': '3'});
|
||||||
|
o.depends({'type': 'socks', 'socks_version': '5'});
|
||||||
|
o.validate = hp.validatePassword([ 'anytls', 'shadowsocks', 'shadowtls', 'snell', 'trojan' ]);
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* Direct config */
|
||||||
|
o = s.option(form.ListValue, 'proxy_protocol', _('Proxy protocol'),
|
||||||
|
_('Write proxy protocol in the connection header.'));
|
||||||
|
o.value('', _('Disable'));
|
||||||
|
o.value('1', _('v1'));
|
||||||
|
o.value('2', _('v2'));
|
||||||
|
o.depends('type', 'direct');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* AnyTLS config start */
|
||||||
|
o = s.option(form.Value, 'anytls_idle_session_check_interval', _('Idle session check interval'),
|
||||||
|
_('Interval checking for idle sessions, in seconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '30';
|
||||||
|
o.depends('type', 'anytls');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'anytls_idle_session_timeout', _('Idle session check timeout'),
|
||||||
|
_('In the check, close sessions that have been idle for longer than this, in seconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '30';
|
||||||
|
o.depends('type', 'anytls');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'anytls_min_idle_session', _('Minimum idle sessions'),
|
||||||
|
_('In the check, at least the first <code>n</code> idle sessions are kept open.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '0';
|
||||||
|
o.depends('type', 'anytls');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* AnyTLS config end */
|
||||||
|
|
||||||
|
/* Hysteria (2) config start */
|
||||||
|
o = s.option(form.DynamicList, 'hysteria_hopping_port', _('Hopping port'));
|
||||||
|
o.depends('type', 'hysteria');
|
||||||
|
o.depends('type', 'hysteria2');
|
||||||
|
o.validate = hp.validatePortRange;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'hysteria_hop_interval', _('Hop interval'),
|
||||||
|
_('Port hopping interval in seconds.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '30';
|
||||||
|
o.depends({'type': 'hysteria', 'hysteria_hopping_port': /[\s\S]/});
|
||||||
|
o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'hysteria_hop_interval_max', _('Max hop interval (1.14)'),
|
||||||
|
_('Maximum port hopping interval in seconds; the actual interval is randomized between the two values. Hysteria2 only.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.placeholder = '300';
|
||||||
|
o.depends({'type': 'hysteria2', 'hysteria_hopping_port': /[\s\S]/});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
||||||
|
o.value('udp');
|
||||||
|
/* WeChat-Video / FakeTCP are unsupported by sing-box currently
|
||||||
|
* o.value('wechat-video');
|
||||||
|
* o.value('faketcp');
|
||||||
|
*/
|
||||||
|
o.default = 'udp';
|
||||||
|
o.depends('type', 'hysteria');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* Shared with the server form: the auth/obfs cluster and the bandwidth
|
||||||
|
caps. They are separate calls because the two forms place them at
|
||||||
|
different points, which is why the order is preserved rather than
|
||||||
|
normalised. */
|
||||||
|
hp.renderHysteriaAuthObfs(s, {});
|
||||||
|
hp.renderHysteriaBandwidth(s);
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'hysteria_bbr_profile', _('BBR profile (1.14)'),
|
||||||
|
_('BBR congestion control algorithm profile. Hysteria2 only.'));
|
||||||
|
o.value('', _('Standard (default)'));
|
||||||
|
o.value('conservative', _('Conservative'));
|
||||||
|
o.value('aggressive', _('Aggressive'));
|
||||||
|
o.depends('type', 'hysteria2');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'hysteria_disable_chrome_parrot', _('Disable Chrome QUIC fingerprint (1.14)'),
|
||||||
|
_('Disable Chrome QUIC handshake parroting, which is enabled by default since sing-box 1.14. Turn this on only when the server uses an Ed25519 certificate or the handshake otherwise fails.'));
|
||||||
|
o.depends('type', 'hysteria2');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Hysteria (2) config end */
|
||||||
|
|
||||||
|
/* Shadowsocks config start */
|
||||||
|
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
||||||
|
for (let i of hp.shadowsocks_encrypt_methods)
|
||||||
|
o.value(i);
|
||||||
|
for (let i of hp.shadowsocks_stream_methods)
|
||||||
|
o.value(i);
|
||||||
|
o.default = 'aes-128-gcm';
|
||||||
|
o.depends('type', 'shadowsocks');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'shadowsocks_plugin', _('Plugin'));
|
||||||
|
o.value('', _('none'));
|
||||||
|
o.value('obfs-local');
|
||||||
|
o.value('v2ray-plugin');
|
||||||
|
o.depends('type', 'shadowsocks');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'shadowsocks_plugin_opts', _('Plugin opts'));
|
||||||
|
o.depends('shadowsocks_plugin', 'obfs-local');
|
||||||
|
o.depends('shadowsocks_plugin', 'v2ray-plugin');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Shadowsocks config end */
|
||||||
|
|
||||||
|
/* ShadowTLS config */
|
||||||
|
o = s.option(form.ListValue, 'shadowtls_version', _('ShadowTLS version'));
|
||||||
|
o.value('1', _('v1'));
|
||||||
|
o.value('2', _('v2'));
|
||||||
|
o.value('3', _('v3'));
|
||||||
|
o.default = '1';
|
||||||
|
o.depends('type', 'shadowtls');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* Socks config */
|
||||||
|
o = s.option(form.ListValue, 'socks_version', _('Socks version'));
|
||||||
|
o.value('4', _('Socks4'));
|
||||||
|
o.value('4a', _('Socks4A'));
|
||||||
|
o.value('5', _('Socks5'));
|
||||||
|
o.default = '5';
|
||||||
|
o.depends('type', 'socks');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* SSH config start */
|
||||||
|
o = s.option(form.Value, 'ssh_client_version', _('Client version'),
|
||||||
|
_('Random version will be used if empty.'));
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.DynamicList, 'ssh_host_key', _('Host key'),
|
||||||
|
_('Accept any if empty.'));
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.DynamicList, 'ssh_host_key_algo', _('Host key algorithms'))
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.DynamicList, 'ssh_priv_key', _('Private key'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'ssh_priv_key_pp', _('Private key passphrase'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('type', 'ssh');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* SSH config end */
|
||||||
|
|
||||||
|
/* TUIC config start */
|
||||||
|
hp.renderTuicOptions(s, { side: 'client' });
|
||||||
|
/* Tuic config end */
|
||||||
|
|
||||||
|
/* VMess / VLESS config start */
|
||||||
|
o = s.option(form.ListValue, 'vless_flow', _('Flow'));
|
||||||
|
o.value('', _('None'));
|
||||||
|
o.value('xtls-rprx-vision');
|
||||||
|
o.depends('type', 'vless');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'vmess_alterid', _('Alter ID'),
|
||||||
|
_('Legacy protocol support (VMess MD5 Authentication) is provided for compatibility purposes only, use of alterId > 1 is not recommended.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends('type', 'vmess');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'vmess_encrypt', _('Encrypt method'));
|
||||||
|
o.value('auto');
|
||||||
|
o.value('none');
|
||||||
|
o.value('zero');
|
||||||
|
o.value('aes-128-gcm');
|
||||||
|
o.value('chacha20-poly1305');
|
||||||
|
o.default = 'auto';
|
||||||
|
o.depends('type', 'vmess');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'vmess_global_padding', _('Global padding'),
|
||||||
|
_('Protocol parameter. Will waste traffic randomly if enabled (enabled by default in v2ray and cannot be disabled).'));
|
||||||
|
o.default = o.enabled;
|
||||||
|
o.depends('type', 'vmess');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'vmess_authenticated_length', _('Authenticated length'),
|
||||||
|
_('Protocol parameter. Enable length block encryption.'));
|
||||||
|
o.depends('type', 'vmess');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* VMess config end */
|
||||||
|
|
||||||
|
/* Transport config start */
|
||||||
|
hp.renderTransportOptions(s, { features: features, side: 'client' });
|
||||||
|
/* Transport config end */
|
||||||
|
|
||||||
|
/* Wireguard config start */
|
||||||
|
o = s.option(form.DynamicList, 'wireguard_local_address', _('Local address'),
|
||||||
|
_('List of IP (v4 or v6) addresses prefixes to be assigned to the interface.'));
|
||||||
|
o.datatype = 'cidr';
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'wireguard_private_key', _('Private key'),
|
||||||
|
_('WireGuard requires base64-encoded private keys.'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'wireguard_peer_public_key', _('Peer pubkic key'),
|
||||||
|
_('WireGuard peer public key.'));
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'wireguard_pre_shared_key', _('Pre-shared key'),
|
||||||
|
_('WireGuard pre-shared key.'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.validate = L.bind(hp.validateBase64Key, this, 44);
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.DynamicList, 'wireguard_reserved', _('Reserved field bytes'));
|
||||||
|
o.datatype = 'integer';
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'wireguard_mtu', _('MTU'));
|
||||||
|
o.datatype = 'range(0,9000)';
|
||||||
|
o.placeholder = '1408';
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'wireguard_persistent_keepalive_interval', _('Persistent keepalive interval'),
|
||||||
|
_('In seconds. Disabled by default.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends('type', 'wireguard');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Wireguard config end */
|
||||||
|
|
||||||
|
/* Mux config start */
|
||||||
|
/* The flag, padding and the TCP Brutal group are shared with the server
|
||||||
|
form; only the dialling knobs below are client-specific. */
|
||||||
|
hp.renderMuxOptions(s, { features: features });
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'multiplex_protocol', _('Protocol'),
|
||||||
|
_('Multiplex protocol.'));
|
||||||
|
o.value('h2mux');
|
||||||
|
o.value('smux');
|
||||||
|
o.value('yamux');
|
||||||
|
o.default = 'h2mux';
|
||||||
|
o.depends('multiplex', '1');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'multiplex_max_connections', _('Maximum connections'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends('multiplex', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'multiplex_min_streams', _('Minimum streams'),
|
||||||
|
_('Minimum multiplexed streams in a connection before opening a new connection.'));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends('multiplex', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'multiplex_max_streams', _('Maximum streams'),
|
||||||
|
_('Maximum multiplexed streams in a connection before opening a new connection.<br/>' +
|
||||||
|
'Conflict with <code>%s</code> and <code>%s</code>.').format(
|
||||||
|
_('Maximum connections'), _('Minimum streams')));
|
||||||
|
o.datatype = 'uinteger';
|
||||||
|
o.depends({'multiplex': '1', 'multiplex_max_connections': '', 'multiplex_min_streams': ''});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
/* Mux config end */
|
||||||
|
|
||||||
|
/* Snell config start */
|
||||||
|
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
|
||||||
|
_('sing-box implements Snell v4/v5 wire as v4 and v6. The pre-shared key (Password above) must be 12-255 bytes for v6.'));
|
||||||
|
/* {4, 6} is the OUTBOUND's valid set, not a subset someone forgot to
|
||||||
|
finish: sing-box 1.14 rejects version 5 on a snell outbound
|
||||||
|
("unsupported version: 5") and accepts 4 and 6. The server form offers
|
||||||
|
{5, 6} because its INBOUND is the mirror image (version 4 is rejected
|
||||||
|
there). Measured against the target sing-box; see the plan's 2.11. */
|
||||||
|
o.value('4', _('v4'));
|
||||||
|
o.value('6', _('v6'));
|
||||||
|
o.default = '4';
|
||||||
|
o.depends('type', 'snell');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'snell_userkey', _('User key'),
|
||||||
|
_('Optional; only required when connecting to a multi-user Snell server.'));
|
||||||
|
o.depends('type', 'snell');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'snell_reuse', _('Connection reuse'),
|
||||||
|
_('Enable connection reuse (the Snell v2 CONNECT command).'));
|
||||||
|
o.depends('type', 'snell');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
|
||||||
|
_('HTTP obfuscation. v4 only.'));
|
||||||
|
o.value('', _('none'));
|
||||||
|
o.value('http', _('http'));
|
||||||
|
o.depends({'type': 'snell', 'snell_version': '4'});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'snell_obfs_host', _('Obfuscation host'),
|
||||||
|
_('HTTP Host header sent when obfuscation mode is http. bing.com is used by default.'));
|
||||||
|
o.depends({'type': 'snell', 'snell_version': '4', 'snell_obfs_mode': 'http'});
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Snell config end */
|
||||||
|
|
||||||
|
/* TLS config start */
|
||||||
|
hp.renderTlsOptions(s, {
|
||||||
|
side: 'client',
|
||||||
|
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'shadowtls', 'trojan', 'tuic', 'vless', 'vmess' ],
|
||||||
|
tls_forced_types: [ 'anytls', 'hysteria', 'hysteria2', 'shadowtls', 'tuic' ],
|
||||||
|
oninsecurechange: allowInsecureConfirm
|
||||||
|
});
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'tls_self_sign', _('Append self-signed certificate'),
|
||||||
|
_('If you have the root certificate, use this option instead of allowing insecure.'));
|
||||||
|
o.depends('tls_insecure', '0');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_cert_path', _('Certificate path'),
|
||||||
|
_('The path to the server certificate, in PEM format.'));
|
||||||
|
o.value('/etc/homeproxy/certs/client_ca.pem');
|
||||||
|
o.depends('tls_self_sign', '1');
|
||||||
|
o.validate = hp.validateCertificatePath;
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Button, '_upload_cert', _('Upload certificate'),
|
||||||
|
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||||
|
o.inputstyle = 'action';
|
||||||
|
o.inputtitle = _('Upload...');
|
||||||
|
o.depends({'tls_self_sign': '1', 'tls_cert_path': '/etc/homeproxy/certs/client_ca.pem'});
|
||||||
|
o.onclick = L.bind(hp.uploadCertificate, hp, _('certificate'), 'client_ca');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'tls_ech', _('Enable ECH'),
|
||||||
|
_('ECH (Encrypted Client Hello) is a TLS extension that allows a client to encrypt the first part of its ClientHello message.'));
|
||||||
|
o.depends('tls', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_ech_config_path', _('ECH config path'),
|
||||||
|
_('The path to the ECH config, in PEM format. If empty, load from DNS will be attempted.'));
|
||||||
|
o.value('/etc/homeproxy/certs/client_ech_conf.pem');
|
||||||
|
/* Same policy as cert_path / key_path: sing-box reads this file as root and
|
||||||
|
the backend drops any path outside CERT_PATH_ROOTS (validateCertificatePath
|
||||||
|
in homeproxy.uc). Without it the UI accepted a path the generator then
|
||||||
|
silently discarded. */
|
||||||
|
o.validate = hp.validateCertificatePath;
|
||||||
|
o.depends('tls_ech', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Button, '_upload_ech_config', _('Upload ECH config'),
|
||||||
|
_('<strong>Save your configuration before uploading files!</strong>'));
|
||||||
|
o.inputstyle = 'action';
|
||||||
|
o.inputtitle = _('Upload...');
|
||||||
|
o.depends({'tls_ech': '1', 'tls_ech_config_path': '/etc/homeproxy/certs/client_ech_conf.pem'});
|
||||||
|
o.onclick = L.bind(hp.uploadCertificate, hp, _('ECH config'), 'client_ech_conf');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
if (features.with_utls) {
|
||||||
|
o = s.option(form.ListValue, 'tls_utls', _('uTLS fingerprint'),
|
||||||
|
_('uTLS is a fork of "crypto/tls", which provides ClientHello fingerprinting resistance.'));
|
||||||
|
o.value('', _('Disable'));
|
||||||
|
o.value('360');
|
||||||
|
o.value('android');
|
||||||
|
o.value('chrome');
|
||||||
|
o.value('edge');
|
||||||
|
o.value('firefox');
|
||||||
|
o.value('ios');
|
||||||
|
o.value('qq');
|
||||||
|
o.value('random');
|
||||||
|
o.value('randomized');
|
||||||
|
o.value('safari');
|
||||||
|
o.depends({'tls': '1', 'type': /^((?!hysteria2?|tuic$).)+$/});
|
||||||
|
o.validate = function(section_id, value) {
|
||||||
|
if (section_id) {
|
||||||
|
let tls_reality = this.map.findElement('id', 'cbid.homeproxy.%s.tls_reality'.format(section_id)).firstElementChild;
|
||||||
|
if (tls_reality.checked && !value)
|
||||||
|
return _('Expecting: %s').format(_('non-empty value'));
|
||||||
|
|
||||||
|
let vless_flow = this.map.lookupOption('vless_flow', section_id)[0].formvalue(section_id);
|
||||||
|
if ((tls_reality.checked || vless_flow) && ['360', 'android'].includes(value))
|
||||||
|
return _('Unsupported fingerprint!');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'tls_reality', _('REALITY'));
|
||||||
|
o.depends({'tls': '1', 'type': 'anytls'});
|
||||||
|
o.depends({'tls': '1', 'type': 'vless'});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_reality_public_key', _('REALITY public key'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('tls_reality', '1');
|
||||||
|
o.rmempty = false;
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_reality_short_id', _('REALITY short ID'));
|
||||||
|
o.password = true;
|
||||||
|
o.depends('tls_reality', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
}
|
||||||
|
/* TLS config end */
|
||||||
|
|
||||||
|
/* Extra settings start */
|
||||||
|
o = s.option(form.Flag, 'tcp_fast_open', _('TCP fast open'));
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'tcp_multi_path', _('MultiPath TCP'));
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'udp_fragment', _('UDP Fragment'),
|
||||||
|
_('Enable UDP fragmentation.'));
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Flag, 'udp_over_tcp', _('UDP over TCP'),
|
||||||
|
_('Enable the SUoT protocol, requires server support. Conflict with multiplex.'));
|
||||||
|
o.depends('type', 'socks');
|
||||||
|
o.depends({'type': 'shadowsocks', 'multiplex': '0'});
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'udp_over_tcp_version', _('SUoT version'));
|
||||||
|
o.value('1', _('v1'));
|
||||||
|
o.value('2', _('v2'));
|
||||||
|
o.default = '2';
|
||||||
|
o.depends('udp_over_tcp', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Extra settings end */
|
||||||
|
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
return view.extend({
|
||||||
|
load() {
|
||||||
|
return Promise.all([
|
||||||
|
uci.load('homeproxy'),
|
||||||
|
hp.getBuiltinFeatures()
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
|
||||||
|
render(data) {
|
||||||
|
let m, s, o, ss, so;
|
||||||
|
let main_node = uci.get('homeproxy', 'config', 'main_node');
|
||||||
|
let routing_mode = uci.get('homeproxy', 'config', 'routing_mode');
|
||||||
|
let features = data[1];
|
||||||
|
|
||||||
|
/* Cache subscription information, it will be called multiple times.
|
||||||
|
* hp.subscriptionInfo() owns the URL parsing, the malformed-escape
|
||||||
|
* fallback and the escaping of the fragment, all three of which used
|
||||||
|
* to be inline here: decodeURIComponent threw on a fragment like
|
||||||
|
* '#100%' and took the whole page render down with it, and the decoded
|
||||||
|
* fragment went unescaped into a tab title. */
|
||||||
|
let subinfo = [];
|
||||||
|
for (let suburl of (uci.get('homeproxy', 'subscription', 'subscription_url') || [])) {
|
||||||
|
const info = hp.subscriptionInfo(suburl);
|
||||||
|
if (info)
|
||||||
|
subinfo.push(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
m = new form.Map('homeproxy', _('Edit nodes'));
|
||||||
|
|
||||||
|
s = m.section(form.NamedSection, 'subscription', 'homeproxy');
|
||||||
|
|
||||||
|
/* Node settings start */
|
||||||
|
/* User nodes start */
|
||||||
|
s.tab('node', _('Nodes'));
|
||||||
|
o = s.taboption('node', form.SectionValue, '_node', form.GridSection, 'node');
|
||||||
|
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||||
|
ss.addremove = true;
|
||||||
|
ss.filter = function(section_id) {
|
||||||
|
for (let info of subinfo)
|
||||||
|
if (info.hash === uci.get('homeproxy', section_id, 'grouphash'))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
/* Import subscription links start */
|
||||||
|
/* Thanks to luci-app-shadowsocks-libev */
|
||||||
|
ss.handleLinkImport = function() {
|
||||||
|
let textarea = new ui.Textarea();
|
||||||
|
ui.showModal(_('Import share links'), [
|
||||||
|
E('p', _('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.')),
|
||||||
|
textarea.render(),
|
||||||
|
E('div', { class: 'right' }, [
|
||||||
|
E('button', {
|
||||||
|
class: 'btn',
|
||||||
|
click: ui.hideModal
|
||||||
|
}, [ _('Cancel') ]),
|
||||||
|
'',
|
||||||
|
E('button', {
|
||||||
|
class: 'btn cbi-button-action',
|
||||||
|
click: ui.createHandlerFn(this, () => {
|
||||||
|
let input_links = textarea.getValue().trim().split('\n');
|
||||||
|
if (!input_links || !input_links[0])
|
||||||
|
return ui.hideModal();
|
||||||
|
|
||||||
|
/* Remove duplicate lines */
|
||||||
|
input_links = input_links.reduce((pre, cur) =>
|
||||||
|
(!pre.includes(cur) && pre.push(cur), pre), []);
|
||||||
|
|
||||||
|
let allow_insecure = uci.get('homeproxy', 'subscription', 'allow_insecure');
|
||||||
|
let packet_encoding = uci.get('homeproxy', 'subscription', 'packet_encoding');
|
||||||
|
|
||||||
|
/* Every link is parsed - and rejected - by the
|
||||||
|
* backend parser (parser/uri.uc), so importing a
|
||||||
|
* link and receiving it through a subscription
|
||||||
|
* can no longer disagree about what it means. */
|
||||||
|
return Promise.all(input_links.map((l) => hp.parseShareLink(l)))
|
||||||
|
.then((configs) => {
|
||||||
|
let imported_node = 0;
|
||||||
|
configs.forEach((config) => {
|
||||||
|
if (!config)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (config.tls === '1' && allow_insecure === '1')
|
||||||
|
config.tls_insecure = '1';
|
||||||
|
if (['vless', 'vmess'].includes(config.type))
|
||||||
|
config.packet_encoding = packet_encoding;
|
||||||
|
|
||||||
|
let nameHash = hp.calcStringMD5(config.label);
|
||||||
|
let sid = uci.add('homeproxy', 'node', nameHash);
|
||||||
|
Object.keys(config).forEach((k) => {
|
||||||
|
uci.set('homeproxy', sid, k, config[k]);
|
||||||
|
});
|
||||||
|
imported_node++;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imported_node === 0)
|
||||||
|
ui.addNotification(null, E('p', _('No valid share link found.')));
|
||||||
|
else
|
||||||
|
ui.addNotification(null, E('p', _('Successfully imported %s nodes of total %s.').format(
|
||||||
|
imported_node, input_links.length)));
|
||||||
|
|
||||||
|
return uci.save()
|
||||||
|
.then(L.bind(this.map.load, this.map))
|
||||||
|
.then(L.bind(this.map.reset, this.map))
|
||||||
|
.then(ui.hideModal)
|
||||||
|
.catch((e) => {
|
||||||
|
/* The .catch above used to swallow
|
||||||
|
* every error silently, so a failed
|
||||||
|
* hideModal (L.ui is undefined; this
|
||||||
|
* module imported ui, not L.ui) made
|
||||||
|
* the modal look stuck after a
|
||||||
|
* successful import. Surface it now:
|
||||||
|
* the import itself ran, the modal
|
||||||
|
* just did not close, and the user
|
||||||
|
* deserves to know which. */
|
||||||
|
ui.addNotification(null, E('p', e && e.message
|
||||||
|
? _('Failed to close the import dialog: %s').format(e.message)
|
||||||
|
: _('Imported the nodes, but the dialog did not close automatically. Close it manually.')));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, [ _('Import') ])
|
||||||
|
])
|
||||||
|
])
|
||||||
|
}
|
||||||
|
/* The shared renderer (homeproxy.js) appends the extra button this
|
||||||
|
* page needs; it used to carry a verbatim copy of the whole method.
|
||||||
|
* The factory gets the section back so the handler binds to it, the
|
||||||
|
* same receiver the copy's `this` had.
|
||||||
|
*
|
||||||
|
* Why a plain wrapper, not L.bind(hp.renderSectionAdd, this, ss, factory):
|
||||||
|
* L.bind forwards its preset args in front of the caller's args, so
|
||||||
|
* the bound function calls hp.renderSectionAdd.call(this, ss, factory,
|
||||||
|
* extra_class) - the factory ends up in the extra_class slot and the
|
||||||
|
* parent's classList.add() then coerces the function source to a
|
||||||
|
* string and throws InvalidCharacterError the moment CBI invokes
|
||||||
|
* renderSectionAdd('cbi-tblsection-create'). Passing them through
|
||||||
|
* in their natural positions (section, extra_class, extra_button)
|
||||||
|
* is the only order that lines up. */
|
||||||
|
const importShareLinksFactory = (section) => E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-add',
|
||||||
|
'title': _('Import share links'),
|
||||||
|
'click': ui.createHandlerFn(section, 'handleLinkImport')
|
||||||
|
}, [ _('Import share links') ]);
|
||||||
|
ss.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(ss, extra_class, importShareLinksFactory);
|
||||||
|
};
|
||||||
|
/* Import subscription links end */
|
||||||
|
/* User nodes end */
|
||||||
|
|
||||||
|
/* Subscription nodes start */
|
||||||
|
for (const info of subinfo) {
|
||||||
|
s.tab('sub_' + info.hash, _('Sub (%s)').format(info.title));
|
||||||
|
o = s.taboption('sub_' + info.hash, form.SectionValue, '_sub_' + info.hash, form.GridSection, 'node');
|
||||||
|
ss = renderNodeSettings(o.subsection, data, features, main_node, routing_mode);
|
||||||
|
ss.filter = function(section_id) {
|
||||||
|
return (uci.get('homeproxy', section_id, 'grouphash') === info.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* Subscription nodes end */
|
||||||
|
/* Node settings end */
|
||||||
|
|
||||||
|
/* Subscriptions settings start */
|
||||||
|
s.tab('subscription', _('Subscriptions'));
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Flag, 'auto_update', _('Auto update'),
|
||||||
|
_('Auto update subscriptions and geodata.'));
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.ListValue, 'auto_update_time', _('Update time'));
|
||||||
|
for (let i = 0; i < 24; i++)
|
||||||
|
o.value(i, i + ':00');
|
||||||
|
o.default = '2';
|
||||||
|
o.depends('auto_update', '1');
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Flag, 'update_via_proxy', _('Update via proxy'),
|
||||||
|
_('Update subscriptions via proxy.'));
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.DynamicList, 'subscription_url', _('Subscription URL-s'),
|
||||||
|
_('Support Hysteria, Shadowsocks, Trojan, v2rayN (VMess), and XTLS (VLESS) online configuration delivery standard.'));
|
||||||
|
o.validate = function(section_id, value) {
|
||||||
|
if (section_id && value) {
|
||||||
|
try {
|
||||||
|
let url = new URL(value);
|
||||||
|
if (!url.hostname)
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
catch(e) {
|
||||||
|
return _('Expecting: %s').format(_('valid URL'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.ListValue, 'filter_nodes', _('Filter nodes'),
|
||||||
|
_('Drop/keep specific nodes from subscriptions.'));
|
||||||
|
o.value('disabled', _('Disable'));
|
||||||
|
o.value('blacklist', _('Blacklist mode'));
|
||||||
|
o.value('whitelist', _('Whitelist mode'));
|
||||||
|
o.default = 'disabled';
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.DynamicList, 'filter_keywords', _('Filter keywords'),
|
||||||
|
_('Drop/keep nodes that contain the specific keywords. <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions">Regex</a> is supported.'));
|
||||||
|
o.depends({'filter_nodes': 'disabled', '!reverse': true});
|
||||||
|
o.rmempty = false;
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Value, 'user_agent', _('User-Agent'));
|
||||||
|
o.placeholder = 'Wget/1.21 (HomeProxy, like v2rayN)';
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Flag, 'allow_insecure', _('Allow insecure'),
|
||||||
|
_('Allow insecure connection by default when add nodes from subscriptions.') +
|
||||||
|
'<br/>' +
|
||||||
|
_('This is <strong>DANGEROUS</strong>, your traffic is almost like <strong>PLAIN TEXT</strong>! Use at your own risk!'));
|
||||||
|
o.rmempty = false;
|
||||||
|
o.onchange = allowInsecureConfirm;
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.ListValue, 'packet_encoding', _('Default packet encoding'));
|
||||||
|
o.value('', _('none'));
|
||||||
|
o.value('packetaddr', _('packet addr (v2ray-core v5+)'));
|
||||||
|
o.value('xudp', _('Xudp (Xray-core)'));
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Button, '_save_subscriptions', _('Save subscriptions settings'),
|
||||||
|
_('NOTE: Save current settings before updating subscriptions.'));
|
||||||
|
o.inputstyle = 'apply';
|
||||||
|
o.inputtitle = _('Save current settings');
|
||||||
|
o.onclick = function() {
|
||||||
|
return this.map.save(null, true).then(() => {
|
||||||
|
ui.changes.apply(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Button, '_update_subscriptions', _('Update nodes from subscriptions'));
|
||||||
|
o.inputstyle = 'apply';
|
||||||
|
o.inputtitle = function(section_id) {
|
||||||
|
let sublist = uci.get('homeproxy', section_id, 'subscription_url') || [];
|
||||||
|
if (sublist.length > 0) {
|
||||||
|
return _('Update %s subscriptions').format(sublist.length);
|
||||||
|
} else {
|
||||||
|
this.readonly = true;
|
||||||
|
return _('No subscription available')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.onclick = function() {
|
||||||
|
/* The updater pipeline (wget through a CN -> overseas link,
|
||||||
|
UCI commit, /etc/init.d/homeproxy reload, the 30 s sing-box
|
||||||
|
health gate) takes 5-15 s on a real device, which exceeds
|
||||||
|
the browser XHR timeout. The old synchronous RPC waited
|
||||||
|
for the whole thing inside one XHR; the browser saw
|
||||||
|
"XHR request timed out" while the backend completed
|
||||||
|
cleanly, and the user got a stale page with no signal.
|
||||||
|
|
||||||
|
The ubus method now spawns the script detached and
|
||||||
|
returns immediately with {result:true, async:true}; we
|
||||||
|
poll update_subscriptions_status (which reads the
|
||||||
|
orchestrator's lock + the last log lines) until the run
|
||||||
|
finishes, then reload. A failure is a status poll that
|
||||||
|
stays in 'running' past the deadline, or a log tail that
|
||||||
|
never recorded the success marker. */
|
||||||
|
return hp.rpcCall('update_subscriptions').then((res) => {
|
||||||
|
if (res && res.running)
|
||||||
|
return ui.addNotification(null, E('p',
|
||||||
|
_('Subscription update is already running. This page will reload when the previous run finishes.')));
|
||||||
|
|
||||||
|
if (res && res.result === false)
|
||||||
|
return ui.addNotification(null, E('p', [
|
||||||
|
_('Could not start subscription update: %s').format(res.error || _('unknown error'))
|
||||||
|
]));
|
||||||
|
|
||||||
|
const notification = ui.addNotification(null,
|
||||||
|
E('p', _('Updating subscriptions\u2026')));
|
||||||
|
|
||||||
|
/* The status page already drives LuCI's poll-status
|
||||||
|
* indicator via hp.statusPoller(); client/server do
|
||||||
|
* too. The node view polls on demand for a single
|
||||||
|
* click, so it doesn't go through that path - mirror
|
||||||
|
* the same "Refreshing\u2026" pill on the top right
|
||||||
|
* via ui.showIndicator so the user sees the same
|
||||||
|
* affordance on every tab. The hide is in every exit
|
||||||
|
* branch below; without it the indicator would survive
|
||||||
|
* until the next page load. */
|
||||||
|
ui.showIndicator('hp-subscriptions',
|
||||||
|
_('Updating subscriptions\u2026'));
|
||||||
|
|
||||||
|
const finish = (fn) => {
|
||||||
|
ui.hideIndicator('hp-subscriptions');
|
||||||
|
return fn();
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 90 s is a generous ceiling: even a slow fetch plus a
|
||||||
|
worst-case 60 s rollback restart fits, and a stuck
|
||||||
|
poll past that is worth telling the user about
|
||||||
|
rather than reloading silently. */
|
||||||
|
const deadline = Date.now() + 90000;
|
||||||
|
|
||||||
|
const poll = () => hp.rpcCall('update_subscriptions_status').then((s) => {
|
||||||
|
if (!s || !s.running) {
|
||||||
|
/* Detect a clean run by looking for the
|
||||||
|
* success marker the orchestrator writes at
|
||||||
|
* the very end ("Successfully updated
|
||||||
|
* subscriptions"). A run that ends without
|
||||||
|
* it - the orchestrator's try/catch still
|
||||||
|
* removes the lock - is treated as a
|
||||||
|
* failure so the user sees what went
|
||||||
|
* wrong instead of reloading onto a
|
||||||
|
* silently broken state. */
|
||||||
|
const ok = s && s.log_tail &&
|
||||||
|
/Successfully updated subscriptions/m.test(s.log_tail);
|
||||||
|
return Promise.resolve(notification).then((n) => {
|
||||||
|
if (n && typeof n.close === 'function')
|
||||||
|
n.close();
|
||||||
|
if (ok)
|
||||||
|
return finish(() => location.reload());
|
||||||
|
return finish(() => ui.addNotification(null, E('p', [
|
||||||
|
_('Subscription update did not complete cleanly. Last log:'),
|
||||||
|
E('pre', {}, s && s.log_tail ? s.log_tail : _('(empty)'))
|
||||||
|
])));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (Date.now() > deadline) {
|
||||||
|
return Promise.resolve(notification).then((n) => {
|
||||||
|
if (n && typeof n.close === 'function')
|
||||||
|
n.close();
|
||||||
|
return finish(() => ui.addNotification(null, E('p',
|
||||||
|
_('Subscription update is taking longer than 90 seconds. Check the log and reload manually.'))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||||
|
}).catch(() => {
|
||||||
|
/* A transient RPC failure (ubusd hiccup, etc.)
|
||||||
|
* is not the run failing - keep polling. */
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 1000)).then(poll);
|
||||||
|
});
|
||||||
|
|
||||||
|
return poll();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
o = s.taboption('subscription', form.Button, '_remove_subscriptions', _('Remove all nodes from subscriptions'));
|
||||||
|
o.inputstyle = 'reset';
|
||||||
|
o.inputtitle = function() {
|
||||||
|
let subnodes = [];
|
||||||
|
uci.sections('homeproxy', 'node', (res) => {
|
||||||
|
if (res.grouphash)
|
||||||
|
subnodes = subnodes.concat(res['.name'])
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subnodes.length > 0) {
|
||||||
|
return _('Remove %s nodes').format(subnodes.length);
|
||||||
|
} else {
|
||||||
|
this.readonly = true;
|
||||||
|
return _('No subscription node');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
o.onclick = function() {
|
||||||
|
let subnodes = [];
|
||||||
|
uci.sections('homeproxy', 'node', (res) => {
|
||||||
|
if (res.grouphash)
|
||||||
|
subnodes = subnodes.concat(res['.name'])
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let i in subnodes)
|
||||||
|
uci.remove('homeproxy', subnodes[i]);
|
||||||
|
|
||||||
|
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_node')))
|
||||||
|
uci.set('homeproxy', 'config', 'main_node', 'nil');
|
||||||
|
|
||||||
|
if (subnodes.includes(uci.get('homeproxy', 'config', 'main_udp_node')))
|
||||||
|
uci.set('homeproxy', 'config', 'main_udp_node', 'nil');
|
||||||
|
|
||||||
|
this.inputtitle = _('%s nodes removed').format(subnodes.length);
|
||||||
|
this.readonly = true;
|
||||||
|
|
||||||
|
return this.map.save(null, true);
|
||||||
|
}
|
||||||
|
/* Subscriptions settings end */
|
||||||
|
|
||||||
|
return m.render();
|
||||||
|
}
|
||||||
|
});
|
||||||
+128
-422
@@ -7,7 +7,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
'require form';
|
'require form';
|
||||||
'require poll';
|
'require poll';
|
||||||
'require rpc';
|
|
||||||
'require uci';
|
'require uci';
|
||||||
'require ui';
|
'require ui';
|
||||||
'require view';
|
'require view';
|
||||||
@@ -15,12 +14,14 @@
|
|||||||
'require homeproxy as hp';
|
'require homeproxy as hp';
|
||||||
'require tools.widgets as widgets';
|
'require tools.widgets as widgets';
|
||||||
|
|
||||||
const callServiceList = rpc.declare({
|
/* Module-scoped guard for the view-level status poll: see the comment
|
||||||
object: 'service',
|
* next to the registration site in render(). Without it, the section's
|
||||||
method: 'list',
|
* render() leaks a fresh poll handler every time LuCI re-renders on
|
||||||
params: ['name'],
|
* a UCI commit, and the bar updates multiple times per tick. */
|
||||||
expect: { '': {} }
|
/* Built on the first render, so the poll closure captures that render's
|
||||||
});
|
* features.version. The "have we registered" state lives inside
|
||||||
|
* hp.statusPoller(), shared with the other view. */
|
||||||
|
let ensureStatusPoll = null;
|
||||||
|
|
||||||
const CBIGenValue = form.Value.extend({
|
const CBIGenValue = form.Value.extend({
|
||||||
__name__: 'CBI.GenValue',
|
__name__: 'CBI.GenValue',
|
||||||
@@ -42,28 +43,36 @@ const CBIGenValue = form.Value.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function getServiceStatus() {
|
function getServiceStatus() {
|
||||||
return L.resolveDefault(callServiceList('homeproxy'), {}).then((res) => {
|
/* The fallback is `null`, not `{}`: that is what lets the caller tell
|
||||||
let isRunning = false;
|
* "the status query did not answer" from "the service is not running".
|
||||||
|
* With the default `{}` the try/catch below swallowed the difference and
|
||||||
|
* reported a failed query as NOT RUNNING. */
|
||||||
|
return hp.rpcCall('list', ['homeproxy'],
|
||||||
|
{ object: 'service', params: ['name'], expect: { '': {} }, fallback: null }).then((res) => {
|
||||||
|
if (res === null)
|
||||||
|
return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
isRunning = res['homeproxy']['instances']['hiddify-c']['running'];
|
return res['homeproxy']['instances']['sing-box-s']['running'] === true;
|
||||||
} catch (e) { }
|
} catch (e) {
|
||||||
return isRunning;
|
/* It answered, and there is no such instance. */
|
||||||
|
return false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderStatus(isRunning, features) {
|
function renderStatus(isRunning, version) {
|
||||||
let coreName = features.core_type === 'singbox' ? 'sing-box' :
|
/* Same allow-list gate as client.js renderStatus(): the sing-box
|
||||||
features.core_type === 'hiddify' ? 'hiddify-core' : null;
|
* version string is interpolated into an innerHTML sink, so we
|
||||||
let verStr = features.version ? 'v' + features.version : _('unknown');
|
* force it through a strict regex before formatting. Anything that
|
||||||
let coreStr = coreName ? ('%s %s').format(coreName, verStr) : _('no core installed');
|
* does not look like a version (alnum + . - +) becomes 'unknown'. */
|
||||||
let spanTemp = '<em><span style="color:%s"><strong>%s (%s) %s</strong></span></em>';
|
let safeVersion = 'unknown';
|
||||||
let renderHTML;
|
if (typeof version === 'string' && /^[\w.\-+]+$/.test(version))
|
||||||
if (isRunning)
|
safeVersion = version;
|
||||||
renderHTML = spanTemp.format('green', _('Re:HomeProxy Server'), coreStr, _('RUNNING'));
|
|
||||||
else
|
|
||||||
renderHTML = spanTemp.format('red', _('Re:HomeProxy Server'), coreStr, _('NOT RUNNING'));
|
|
||||||
|
|
||||||
return renderHTML;
|
const state = hp.statusLabel(isRunning);
|
||||||
|
return '<em><span style="color:%s"><strong>%s (sing-box v%s) %s</strong></span></em>'
|
||||||
|
.format(state.color, _('HomeProxy Server'), safeVersion, state.text);
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGenKey(option) {
|
function handleGenKey(option) {
|
||||||
@@ -73,15 +82,9 @@ function handleGenKey(option) {
|
|||||||
return this.map.findElement('id', 'widget.' + this.cbid(section_id).replace(/\.[^\.]+$/, '.') + option);
|
return this.map.findElement('id', 'widget.' + this.cbid(section_id).replace(/\.[^\.]+$/, '.') + option);
|
||||||
}, this);
|
}, this);
|
||||||
|
|
||||||
const callSingBoxGenerator = rpc.declare({
|
|
||||||
object: 'luci.homeproxy',
|
|
||||||
method: 'singbox_generator',
|
|
||||||
params: ['type', 'params'],
|
|
||||||
expect: { '': {} }
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof option === 'object') {
|
if (typeof option === 'object') {
|
||||||
return callSingBoxGenerator(option.type, option.params).then((res) => {
|
return hp.rpcCall('singbox_generator', [option.type, option.params],
|
||||||
|
{ params: ['type', 'params'], expect: { '': {} } }).then((res) => {
|
||||||
if (res.result)
|
if (res.result)
|
||||||
option.callback.call(this, res.result).forEach(([k, v]) => {
|
option.callback.call(this, res.result).forEach(([k, v]) => {
|
||||||
widget(k).value = v ?? '';
|
widget(k).value = v ?? '';
|
||||||
@@ -130,23 +133,36 @@ return view.extend({
|
|||||||
let m, s, o;
|
let m, s, o;
|
||||||
let features = data[1];
|
let features = data[1];
|
||||||
|
|
||||||
m = new form.Map('homeproxy', _('Re:HomeProxy Server'),
|
m = new form.Map('homeproxy', _('HomeProxy Server'),
|
||||||
_('The modern multi-core proxy platform. Fork of ImmortalWrt.'));
|
_('The modern ImmortalWrt proxy platform for ARM64/AMD64.'));
|
||||||
|
|
||||||
s = m.section(form.TypedSection);
|
s = m.section(form.TypedSection);
|
||||||
s.render = function() {
|
s.render = function() {
|
||||||
poll.add(() => {
|
/* The status bar is rendered into the page body by the
|
||||||
return L.resolveDefault(getServiceStatus()).then((res) => {
|
* view-level poll handler below. Registering poll.add()
|
||||||
let view = document.getElementById('service_status');
|
* here would leak a fresh handler every time the user calls
|
||||||
view.innerHTML = renderStatus(res, features);
|
* map.reset() (every UCI write re-renders this section). */
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
return E('div', { class: 'cbi-section', id: 'status_bar' }, [
|
||||||
E('p', { id: 'service_status' }, _('Collecting data...'))
|
E('p', { id: 'service_status' }, _('Collecting data...'))
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* View-level poll: registered exactly once per navigation. The
|
||||||
|
* getElementById lookup may return null when LuCI swaps the DOM
|
||||||
|
* between renders, so guard against it. */
|
||||||
|
if (ensureStatusPoll === null)
|
||||||
|
ensureStatusPoll = hp.statusPoller({
|
||||||
|
poll: poll,
|
||||||
|
read: getServiceStatus,
|
||||||
|
paint: function(res) {
|
||||||
|
let view = document.getElementById('service_status');
|
||||||
|
if (view)
|
||||||
|
view.innerHTML = renderStatus(res, features.version);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ensureStatusPoll();
|
||||||
|
|
||||||
s = m.section(form.NamedSection, 'server', 'homeproxy', _('Global settings'));
|
s = m.section(form.NamedSection, 'server', 'homeproxy', _('Global settings'));
|
||||||
|
|
||||||
o = s.option(form.Flag, 'enabled', _('Enable'));
|
o = s.option(form.Flag, 'enabled', _('Enable'));
|
||||||
@@ -157,13 +173,17 @@ return view.extend({
|
|||||||
s.rowcolors = true;
|
s.rowcolors = true;
|
||||||
s.sortable = true;
|
s.sortable = true;
|
||||||
s.nodescriptions = true;
|
s.nodescriptions = true;
|
||||||
s.modaltitle = L.bind(hp.loadModalTitle, this, _('Server'), _('Add a server'), data[0]);
|
s.modaltitle = L.bind(hp.loadModalTitle, hp, _('Server'), _('Add a server'), 'homeproxy');
|
||||||
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, data[0]);
|
s.sectiontitle = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
s.renderSectionAdd = L.bind(hp.renderSectionAdd, this, s);
|
/* Plain wrapper, not L.bind(): see hp.renderSectionAdd() for why the
|
||||||
|
* bound form hands a call site's button factory to the wrong argument. */
|
||||||
|
s.renderSectionAdd = function(extra_class) {
|
||||||
|
return hp.renderSectionAdd(s, extra_class);
|
||||||
|
};
|
||||||
|
|
||||||
o = s.option(form.Value, 'label', _('Label'));
|
o = s.option(form.Value, 'label', _('Label'));
|
||||||
o.load = L.bind(hp.loadDefaultLabel, this, data[0]);
|
o.load = L.bind(hp.loadDefaultLabel, this, 'homeproxy');
|
||||||
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'server', 'label');
|
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'server', 'label');
|
||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
@@ -176,25 +196,7 @@ return view.extend({
|
|||||||
_('Allow access from the Internet.'));
|
_('Allow access from the Internet.'));
|
||||||
o.editable = true;
|
o.editable = true;
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'type', _('Type'));
|
o = hp.renderProtocolOptions(s, { features: features, side: 'server' });
|
||||||
o.value('anytls', _('AnyTLS'));
|
|
||||||
o.value('http', _('HTTP'));
|
|
||||||
if (features.with_quic) {
|
|
||||||
o.value('hysteria', _('Hysteria'));
|
|
||||||
o.value('hysteria2', _('Hysteria2'));
|
|
||||||
o.value('naive', _('NaïveProxy'));
|
|
||||||
}
|
|
||||||
o.value('mixed', _('Mixed'));
|
|
||||||
if (features.core_type === 'singbox')
|
|
||||||
o.value('mtproxy', _('MTProxy'));
|
|
||||||
o.value('shadowsocks', _('Shadowsocks'));
|
|
||||||
o.value('socks', _('Socks'));
|
|
||||||
o.value('trojan', _('Trojan'));
|
|
||||||
if (features.with_quic)
|
|
||||||
o.value('tuic', _('Tuic'));
|
|
||||||
o.value('vless', _('VLESS'));
|
|
||||||
o.value('vmess', _('VMess'));
|
|
||||||
o.rmempty = false;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'address', _('Listen address'));
|
o = s.option(form.Value, 'address', _('Listen address'));
|
||||||
o.placeholder = '::';
|
o.placeholder = '::';
|
||||||
@@ -204,7 +206,7 @@ return view.extend({
|
|||||||
o = s.option(form.Value, 'port', _('Listen port'),
|
o = s.option(form.Value, 'port', _('Listen port'),
|
||||||
_('The port must be unique.'));
|
_('The port must be unique.'));
|
||||||
o.datatype = 'port';
|
o.datatype = 'port';
|
||||||
o.validate = L.bind(hp.validateUniqueValue, this, data[0], 'server', 'port');
|
o.validate = L.bind(hp.validateUniqueValue, this, 'homeproxy', 'server', 'port');
|
||||||
|
|
||||||
o = s.option(form.Value, 'username', _('Username'));
|
o = s.option(form.Value, 'username', _('Username'));
|
||||||
o.depends('type', 'http');
|
o.depends('type', 'http');
|
||||||
@@ -219,31 +221,10 @@ return view.extend({
|
|||||||
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
|
o.depends({'type': /^(http|mixed|naive|socks)$/, 'username': /[\s\S]/});
|
||||||
o.depends('type', 'hysteria2');
|
o.depends('type', 'hysteria2');
|
||||||
o.depends('type', 'shadowsocks');
|
o.depends('type', 'shadowsocks');
|
||||||
|
o.depends('type', 'snell');
|
||||||
o.depends('type', 'trojan');
|
o.depends('type', 'trojan');
|
||||||
o.depends('type', 'tuic');
|
o.depends('type', 'tuic');
|
||||||
o.validate = function(section_id, value) {
|
o.validate = hp.validatePassword([ 'anytls', 'http', 'mixed', 'naive', 'shadowsocks', 'snell', 'socks', 'trojan' ]);
|
||||||
if (section_id) {
|
|
||||||
let type = this.section.formvalue(section_id, 'type');
|
|
||||||
let required_type = [ 'anytls', 'http', 'mixed', 'naive', 'socks', 'shadowsocks', 'trojan' ];
|
|
||||||
|
|
||||||
if (required_type.includes(type)) {
|
|
||||||
if (type === 'shadowsocks') {
|
|
||||||
let encmode = this.section.formvalue(section_id, 'shadowsocks_encrypt_method');
|
|
||||||
if (encmode === 'none')
|
|
||||||
return true;
|
|
||||||
else if (encmode === '2022-blake3-aes-128-gcm')
|
|
||||||
return hp.validateBase64Key(24, section_id, value);
|
|
||||||
else if (['2022-blake3-aes-256-gcm', '2022-blake3-chacha20-poly1305'].includes(encmode))
|
|
||||||
return hp.validateBase64Key(44, section_id, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!value)
|
|
||||||
return _('Expecting: %s').format(_('non-empty value'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
/* AnyTLS config */
|
/* AnyTLS config */
|
||||||
@@ -252,49 +233,6 @@ return view.extend({
|
|||||||
o.depends('type', 'anytls');
|
o.depends('type', 'anytls');
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
/* MTProxy config start */
|
|
||||||
o = s.option(form.DynamicList, 'mtproxy_secrets', _('Secrets'),
|
|
||||||
_('List of MTProxy secrets. Each secret is a 32-character hex string (16 bytes). One secret per user.'));
|
|
||||||
o.depends('type', 'mtproxy');
|
|
||||||
o.validate = function(section_id, value) {
|
|
||||||
if (section_id && value && !/^[0-9a-fA-F]{32}$/.test(value))
|
|
||||||
return _('Expecting: %s').format(_('32 hex characters'));
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
o.rmempty = false;
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'domain_fronting_host', _('Domain fronting host'),
|
|
||||||
_('Target hostname for FakeTLS domain fronting. Clients will see TLS to this domain.'));
|
|
||||||
o.datatype = 'hostname';
|
|
||||||
o.depends('type', 'mtproxy');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'domain_fronting_port', _('Domain fronting port'));
|
|
||||||
o.datatype = 'port';
|
|
||||||
o.default = '443';
|
|
||||||
o.depends({'type': 'mtproxy', 'domain_fronting_host': /[\s\S]/});
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'mtproxy_idle_timeout', _('Idle timeout'),
|
|
||||||
_('Close idle connections after this duration (e.g. 5m, 30s).'));
|
|
||||||
o.placeholder = '5m';
|
|
||||||
o.depends('type', 'mtproxy');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'mtproxy_handshake_timeout', _('Handshake timeout'),
|
|
||||||
_('Maximum time to complete handshake (e.g. 10s).'));
|
|
||||||
o.placeholder = '10s';
|
|
||||||
o.depends('type', 'mtproxy');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'mtproxy_concurrency', _('Concurrency'),
|
|
||||||
_('Worker pool size. Leave empty for default.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('type', 'mtproxy');
|
|
||||||
o.modalonly = true;
|
|
||||||
/* MTProxy config end */
|
|
||||||
|
|
||||||
/* Hysteria (2) config start */
|
/* Hysteria (2) config start */
|
||||||
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
o = s.option(form.ListValue, 'hysteria_protocol', _('Protocol'));
|
||||||
o.value('udp');
|
o.value('udp');
|
||||||
@@ -307,70 +245,8 @@ return view.extend({
|
|||||||
o.rmempty = false;
|
o.rmempty = false;
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_down_mbps', _('Max download speed'),
|
hp.renderHysteriaBandwidth(s);
|
||||||
_('Max download speed in Mbps.'));
|
hp.renderHysteriaAuthObfs(s, { obfsPasswordWidget: CBIGenValue });
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.depends('type', 'hysteria2');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_up_mbps', _('Max upload speed'),
|
|
||||||
_('Max upload speed in Mbps.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.depends('type', 'hysteria2');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'hysteria_auth_type', _('Authentication type'));
|
|
||||||
o.value('', _('Disable'));
|
|
||||||
o.value('base64', _('Base64'));
|
|
||||||
o.value('string', _('String'));
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_auth_payload', _('Authentication payload'));
|
|
||||||
o.password = true;
|
|
||||||
o.depends({'type': 'hysteria', 'hysteria_auth_type': /[\s\S]/});
|
|
||||||
o.rmempty = false;
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'hysteria_obfs_type', _('Obfuscate type'));
|
|
||||||
o.value('', _('Disable'));
|
|
||||||
o.value('salamander', _('Salamander'));
|
|
||||||
o.depends('type', 'hysteria2');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(CBIGenValue, 'hysteria_obfs_password', _('Obfuscate password'));
|
|
||||||
o.password = true;
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.depends({'type': 'hysteria2', 'hysteria_obfs_type': /[\s\S]/});
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_recv_window_conn', _('QUIC stream receive window'),
|
|
||||||
_('The QUIC stream-level flow control window for receiving data.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.default = '67108864';
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_recv_window_client', _('QUIC connection receive window'),
|
|
||||||
_('The QUIC connection-level flow control window for receiving data.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.default = '15728640';
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'hysteria_max_conn_client', _('QUIC maximum concurrent bidirectional streams'),
|
|
||||||
_('The maximum number of QUIC concurrent bidirectional streams that a peer is allowed to open.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.default = '1024';
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Flag, 'hysteria_disable_mtu_discovery', _('Disable Path MTU discovery'),
|
|
||||||
_('Disables Path MTU Discovery (RFC 8899). Packets will then be at most 1252 (IPv4) / 1232 (IPv6) bytes in size.'));
|
|
||||||
o.depends('type', 'hysteria');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
|
o = s.option(form.Flag, 'hysteria_ignore_client_bandwidth', _('Ignore client bandwidth'),
|
||||||
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
|
_('Tell the client to use the BBR flow control algorithm instead of Hysteria CC.'));
|
||||||
@@ -383,51 +259,39 @@ return view.extend({
|
|||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
/* Hysteria (2) config end */
|
/* Hysteria (2) config end */
|
||||||
|
|
||||||
|
/* Snell config start */
|
||||||
|
o = s.option(form.ListValue, 'snell_version', _('Snell version'),
|
||||||
|
_('The pre-shared key (password) above must be 12-255 bytes for v6.'));
|
||||||
|
/* {5, 6} is the INBOUND's valid set: sing-box 1.14 rejects version 4
|
||||||
|
on a snell inbound and accepts 5 and 6 (6 needs a >= 12 byte psk).
|
||||||
|
The node form offers {4, 6} for the same reason, mirrored. Do not
|
||||||
|
"unify" these two lists - see the plan's 2.11. */
|
||||||
|
o.value('5', _('v5'));
|
||||||
|
o.value('6', _('v6'));
|
||||||
|
o.default = '5';
|
||||||
|
o.depends('type', 'snell');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.ListValue, 'snell_obfs_mode', _('Obfuscation mode'),
|
||||||
|
_('HTTP obfuscation. v5 only.'));
|
||||||
|
o.value('', _('none'));
|
||||||
|
o.value('http', _('http'));
|
||||||
|
o.depends({'type': 'snell', 'snell_version': '5'});
|
||||||
|
o.modalonly = true;
|
||||||
|
/* Snell config end */
|
||||||
|
|
||||||
/* Shadowsocks config */
|
/* Shadowsocks config */
|
||||||
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
o = s.option(form.ListValue, 'shadowsocks_encrypt_method', _('Encrypt method'));
|
||||||
for (let i of hp.shadowsocks_encrypt_methods)
|
for (let i of hp.shadowsocks_encrypt_methods)
|
||||||
o.value(i);
|
o.value(i);
|
||||||
|
/* The legacy stream ciphers are client-only; see homeproxy.js's
|
||||||
|
shadowsocks_stream_methods for why, and the plan's 2.11. */
|
||||||
o.default = 'aes-128-gcm';
|
o.default = 'aes-128-gcm';
|
||||||
o.depends('type', 'shadowsocks');
|
o.depends('type', 'shadowsocks');
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
/* Tuic config start */
|
/* Tuic config start */
|
||||||
o = s.option(CBIGenValue, 'uuid', _('UUID'));
|
hp.renderTuicOptions(s, { side: 'server', uuidWidget: CBIGenValue });
|
||||||
o.password = true;
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.depends('type', 'vless');
|
|
||||||
o.depends('type', 'vmess');
|
|
||||||
o.validate = hp.validateUUID;
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'tuic_congestion_control', _('Congestion control algorithm'),
|
|
||||||
_('QUIC congestion control algorithm.'));
|
|
||||||
o.value('cubic');
|
|
||||||
o.value('new_reno');
|
|
||||||
o.value('bbr');
|
|
||||||
o.default = 'cubic';
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'tuic_auth_timeout', _('Auth timeout'),
|
|
||||||
_('How long the server should wait for the client to send the authentication command (in seconds).'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.default = '3';
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Flag, 'tuic_enable_zero_rtt', _('Enable 0-RTT handshake'),
|
|
||||||
_('Enable 0-RTT QUIC connection handshake on the client side. This is not impacting much on the performance, as the protocol is fully multiplexed.<br/>' +
|
|
||||||
'Disabling this is highly recommended, as it is vulnerable to replay attacks.'));
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'tuic_heartbeat', _('Heartbeat interval'),
|
|
||||||
_('Interval for sending heartbeat packets for keeping the connection alive (in seconds).'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.default = '10';
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.modalonly = true;
|
|
||||||
/* Tuic config end */
|
/* Tuic config end */
|
||||||
|
|
||||||
/* VLESS / VMess config start */
|
/* VLESS / VMess config start */
|
||||||
@@ -445,200 +309,19 @@ return view.extend({
|
|||||||
/* VMess config end */
|
/* VMess config end */
|
||||||
|
|
||||||
/* Transport config start */
|
/* Transport config start */
|
||||||
o = s.option(form.ListValue, 'transport', _('Transport'),
|
hp.renderTransportOptions(s, { features: features, side: 'server' });
|
||||||
_('No TCP transport, plain HTTP is merged into the HTTP transport.'));
|
|
||||||
o.value('', _('None'));
|
|
||||||
o.value('grpc', _('gRPC'));
|
|
||||||
o.value('http', _('HTTP'));
|
|
||||||
o.value('httpupgrade', _('HTTPUpgrade'));
|
|
||||||
o.value('quic', _('QUIC'));
|
|
||||||
o.value('ws', _('WebSocket'));
|
|
||||||
o.depends('type', 'trojan');
|
|
||||||
o.depends('type', 'vless');
|
|
||||||
o.depends('type', 'vmess');
|
|
||||||
o.onchange = function(ev, section_id, value) {
|
|
||||||
let desc = this.map.findElement('id', 'cbid.homeproxy.%s.transport'.format(section_id)).nextElementSibling;
|
|
||||||
if (value === 'http')
|
|
||||||
desc.innerHTML = _('TLS is not enforced. If TLS is not configured, plain HTTP 1.1 is used.');
|
|
||||||
else if (value === 'quic')
|
|
||||||
desc.innerHTML = _('No additional encryption support: It\'s basically duplicate encryption.');
|
|
||||||
else
|
|
||||||
desc.innerHTML = _('No TCP transport, plain HTTP is merged into the HTTP transport.');
|
|
||||||
|
|
||||||
let tls_element = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
|
|
||||||
if ((value === 'http' && tls_element.checked) || (value === 'grpc' && !features.with_grpc))
|
|
||||||
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
|
||||||
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.');
|
|
||||||
else if (value === 'grpc' && features.with_grpc)
|
|
||||||
this.map.findElement('id', 'cbid.homeproxy.%s.http_idle_timeout'.format(section_id)).nextElementSibling.innerHTML =
|
|
||||||
_('If the transport doesn\'t see any activity after a duration of this time (in seconds), it pings the client to check if the connection is still active.');
|
|
||||||
}
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
/* gRPC config start */
|
|
||||||
o = s.option(form.Value, 'grpc_servicename', _('gRPC service name'));
|
|
||||||
o.depends('transport', 'grpc');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
/* gRPC config end */
|
|
||||||
|
|
||||||
/* HTTP(Upgrade) config start */
|
|
||||||
o = s.option(form.DynamicList, 'http_host', _('Host'));
|
|
||||||
o.datatype = 'hostname';
|
|
||||||
o.depends('transport', 'http');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'httpupgrade_host', _('Host'));
|
|
||||||
o.datatype = 'hostname';
|
|
||||||
o.depends('transport', 'httpupgrade');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'http_path', _('Path'));
|
|
||||||
o.depends('transport', 'http');
|
|
||||||
o.depends('transport', 'httpupgrade');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'http_method', _('Method'));
|
|
||||||
o.depends('transport', 'http');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'http_idle_timeout', _('Idle timeout'),
|
|
||||||
_('Specifies the time (in seconds) until idle clients should be closed with a GOAWAY frame. PING frames are not considered as activity.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('transport', 'grpc');
|
|
||||||
o.depends({'transport': 'http', 'tls': '1'});
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
if (features.with_grpc) {
|
|
||||||
o = s.option(form.Value, 'http_ping_timeout', _('Ping timeout'),
|
|
||||||
_('The timeout (in seconds) that after performing a keepalive check, the client will wait for activity. If no activity is detected, the connection will be closed.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('transport', 'grpc');
|
|
||||||
o.modalonly = true;
|
|
||||||
}
|
|
||||||
/* HTTP config end */
|
|
||||||
|
|
||||||
/* WebSocket config start */
|
|
||||||
o = s.option(form.Value, 'ws_host', _('Host'));
|
|
||||||
o.depends('transport', 'ws');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'ws_path', _('Path'));
|
|
||||||
o.depends('transport', 'ws');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'websocket_early_data', _('Early data'),
|
|
||||||
_('Allowed payload size is in the request.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.value('2048');
|
|
||||||
o.depends('transport', 'ws');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'websocket_early_data_header', _('Early data header name'),
|
|
||||||
_('Early data is sent in path instead of header by default.') +
|
|
||||||
'<br/>' +
|
|
||||||
_('To be compatible with Xray-core, set this to <code>Sec-WebSocket-Protocol</code>.'));
|
|
||||||
o.value('Sec-WebSocket-Protocol');
|
|
||||||
o.depends('transport', 'ws');
|
|
||||||
o.modalonly = true;
|
|
||||||
/* WebSocket config end */
|
|
||||||
|
|
||||||
/* Transport config end */
|
/* Transport config end */
|
||||||
|
|
||||||
/* Mux config start */
|
/* Mux config start */
|
||||||
o = s.option(form.Flag, 'multiplex', _('Multiplex'));
|
hp.renderMuxOptions(s, { features: features });
|
||||||
o.depends('type', 'shadowsocks');
|
|
||||||
o.depends('type', 'trojan');
|
|
||||||
o.depends('type', 'vless');
|
|
||||||
o.depends('type', 'vmess');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Flag, 'multiplex_padding', _('Enable padding'));
|
|
||||||
o.depends('multiplex', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
if (features.hp_has_tcp_brutal) {
|
|
||||||
o = s.option(form.Flag, 'multiplex_brutal', _('Enable TCP Brutal'),
|
|
||||||
_('Enable TCP Brutal congestion control algorithm'));
|
|
||||||
o.depends('multiplex', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'multiplex_brutal_down', _('Download bandwidth'),
|
|
||||||
_('Download bandwidth in Mbps.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('multiplex_brutal', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'multiplex_brutal_up', _('Upload bandwidth'),
|
|
||||||
_('Upload bandwidth in Mbps.'));
|
|
||||||
o.datatype = 'uinteger';
|
|
||||||
o.depends('multiplex_brutal', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
}
|
|
||||||
/* Mux config end */
|
/* Mux config end */
|
||||||
|
|
||||||
/* TLS config start */
|
/* TLS config start */
|
||||||
o = s.option(form.Flag, 'tls', _('TLS'));
|
hp.renderTlsOptions(s, {
|
||||||
o.depends('type', 'anytls');
|
side: 'server',
|
||||||
o.depends('type', 'http');
|
type_depends: [ 'anytls', 'http', 'hysteria', 'hysteria2', 'naive', 'trojan', 'tuic', 'vless', 'vmess' ],
|
||||||
o.depends('type', 'hysteria');
|
tls_forced_types: [ 'hysteria', 'hysteria2', 'tuic' ]
|
||||||
o.depends('type', 'hysteria2');
|
});
|
||||||
o.depends('type', 'naive');
|
|
||||||
o.depends('type', 'trojan');
|
|
||||||
o.depends('type', 'tuic');
|
|
||||||
o.depends('type', 'vless');
|
|
||||||
o.depends('type', 'vmess');
|
|
||||||
o.rmempty = false;
|
|
||||||
o.validate = function(section_id, value) {
|
|
||||||
if (section_id) {
|
|
||||||
let type = this.map.lookupOption('type', section_id)[0].formvalue(section_id);
|
|
||||||
let tls = this.map.findElement('id', 'cbid.homeproxy.%s.tls'.format(section_id)).firstElementChild;
|
|
||||||
|
|
||||||
if (['hysteria', 'hysteria2', 'tuic'].includes(type)) {
|
|
||||||
tls.checked = true;
|
|
||||||
tls.disabled = true;
|
|
||||||
} else {
|
|
||||||
tls.disabled = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.Value, 'tls_sni', _('TLS SNI'),
|
|
||||||
_('Used to verify the hostname on the returned certificates unless insecure is given.'));
|
|
||||||
o.depends('tls', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.DynamicList, 'tls_alpn', _('TLS ALPN'),
|
|
||||||
_('List of supported application level protocols, in order of preference.'));
|
|
||||||
o.depends('tls', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'tls_min_version', _('Minimum TLS version'),
|
|
||||||
_('The minimum TLS version that is acceptable.'));
|
|
||||||
o.value('', _('default'));
|
|
||||||
for (let i of hp.tls_versions)
|
|
||||||
o.value(i);
|
|
||||||
o.depends('tls', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(form.ListValue, 'tls_max_version', _('Maximum TLS version'),
|
|
||||||
_('The maximum TLS version that is acceptable.'));
|
|
||||||
o.value('', _('default'));
|
|
||||||
for (let i of hp.tls_versions)
|
|
||||||
o.value(i);
|
|
||||||
o.depends('tls', '1');
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
o = s.option(hp.CBIStaticList, 'tls_cipher_suites', _('Cipher suites'),
|
|
||||||
_('The elliptic curves that will be used in an ECDHE handshake, in preference order. If empty, the default will be used.'));
|
|
||||||
for (let i of hp.tls_cipher_suites)
|
|
||||||
o.value(i);
|
|
||||||
o.depends('tls', '1');
|
|
||||||
o.optional = true;
|
|
||||||
o.modalonly = true;
|
|
||||||
|
|
||||||
if (features.with_acme) {
|
if (features.with_acme) {
|
||||||
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
|
o = s.option(form.Flag, 'tls_acme', _('Enable ACME'),
|
||||||
@@ -673,6 +356,29 @@ return view.extend({
|
|||||||
}
|
}
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_acme_key_type', _('ACME key type'),
|
||||||
|
_('Private key type for new certificates (1.14). Empty uses the default.'));
|
||||||
|
o.value('ed25519');
|
||||||
|
o.value('p256');
|
||||||
|
o.value('p384');
|
||||||
|
o.value('rsa2048');
|
||||||
|
o.value('rsa4096');
|
||||||
|
o.depends('tls_acme', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'tls_acme_profile', _('ACME profile'),
|
||||||
|
_('ACME profile for certificate issuance (1.14). Example: shortlived.'));
|
||||||
|
o.placeholder = 'shortlived';
|
||||||
|
o.depends('tls_acme', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
|
o = s.option(form.TextValue, 'tls_acme_account_key', _('ACME account key'),
|
||||||
|
_('PEM-encoded private key of an existing ACME account (1.14).'));
|
||||||
|
o.monospace = true;
|
||||||
|
o.rows = 3;
|
||||||
|
o.depends('tls_acme', '1');
|
||||||
|
o.modalonly = true;
|
||||||
|
|
||||||
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
|
o = s.option(form.Value, 'tls_acme_provider', _('CA provider'),
|
||||||
_('The ACME CA provider to use.'));
|
_('The ACME CA provider to use.'));
|
||||||
o.value('letsencrypt', _('Let\'s Encrypt'));
|
o.value('letsencrypt', _('Let\'s Encrypt'));
|
||||||
@@ -818,7 +524,7 @@ return view.extend({
|
|||||||
o.inputstyle = 'action';
|
o.inputstyle = 'action';
|
||||||
o.inputtitle = _('Upload...');
|
o.inputtitle = _('Upload...');
|
||||||
o.depends({'tls': '1', 'tls_cert_path': '/etc/homeproxy/certs/server_publickey.pem'});
|
o.depends({'tls': '1', 'tls_cert_path': '/etc/homeproxy/certs/server_publickey.pem'});
|
||||||
o.onclick = L.bind(hp.uploadCertificate, this, _('certificate'), 'server_publickey');
|
o.onclick = L.bind(hp.uploadCertificate, hp, _('certificate'), 'server_publickey');
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
o = s.option(form.Value, 'tls_key_path', _('Key path'),
|
o = s.option(form.Value, 'tls_key_path', _('Key path'),
|
||||||
@@ -837,7 +543,7 @@ return view.extend({
|
|||||||
o.inputstyle = 'action';
|
o.inputstyle = 'action';
|
||||||
o.inputtitle = _('Upload...');
|
o.inputtitle = _('Upload...');
|
||||||
o.depends({'tls': '1', 'tls_key_path': '/etc/homeproxy/certs/server_privatekey.pem'});
|
o.depends({'tls': '1', 'tls_key_path': '/etc/homeproxy/certs/server_privatekey.pem'});
|
||||||
o.onclick = L.bind(hp.uploadCertificate, this, _('private key'), 'server_privatekey');
|
o.onclick = L.bind(hp.uploadCertificate, hp, _('private key'), 'server_privatekey');
|
||||||
o.modalonly = true;
|
o.modalonly = true;
|
||||||
|
|
||||||
o = s.option(form.TextValue, 'tls_ech_key', _('ECH key'));
|
o = s.option(form.TextValue, 'tls_ech_key', _('ECH key'));
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2022-2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
'require dom';
|
||||||
|
'require form';
|
||||||
|
'require fs';
|
||||||
|
'require poll';
|
||||||
|
'require uci';
|
||||||
|
'require ui';
|
||||||
|
'require view';
|
||||||
|
|
||||||
|
'require homeproxy as hp';
|
||||||
|
|
||||||
|
/* Thanks to luci-app-aria2 */
|
||||||
|
const css = ' \
|
||||||
|
#log_textarea { \
|
||||||
|
padding: 10px; \
|
||||||
|
text-align: left; \
|
||||||
|
} \
|
||||||
|
#log_textarea pre { \
|
||||||
|
padding: .5rem; \
|
||||||
|
word-break: break-all; \
|
||||||
|
margin: 0; \
|
||||||
|
}';
|
||||||
|
|
||||||
|
const hp_dir = '/var/run/homeproxy';
|
||||||
|
|
||||||
|
/* One view-level poll for all three log views.
|
||||||
|
*
|
||||||
|
* getRuntimeLog() is bound to an option's render() hook, and every map.reset()
|
||||||
|
* - any UCI write, a log level change, saving the token, updating a resource -
|
||||||
|
* re-renders each of those options. poll.add() in there therefore added a
|
||||||
|
* fresh handler per re-render, so every tick read each log file once per
|
||||||
|
* leftover handler, and the stale handlers painted the detached textarea of a
|
||||||
|
* previous render.
|
||||||
|
*
|
||||||
|
* This reuses hp.statusPoller() (see client.js), the same once-per-view
|
||||||
|
* registrar that fixes the status bar leak: its flag lives inside the returned
|
||||||
|
* registrar, so one handler is added no matter how often render() runs. It is
|
||||||
|
* called with all three files at once because a single tick reads them
|
||||||
|
* together.
|
||||||
|
*
|
||||||
|
* `log_targets` always holds the textarea installed by the *current* render,
|
||||||
|
* so the poll paints into the live DOM without an id lookup - the three
|
||||||
|
* textareas share the `#log_textarea` id and cannot be told apart by id. */
|
||||||
|
const log_targets = {};
|
||||||
|
|
||||||
|
function readRuntimeLog(filename) {
|
||||||
|
return fs.read_direct(String.format('%s/%s.log', hp_dir, filename), 'text')
|
||||||
|
.then((res) => E('pre', { 'wrap': 'pre' }, [
|
||||||
|
res.trim() || _('Log is empty.')
|
||||||
|
]))
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.toString().includes('NotFoundError'))
|
||||||
|
return E('pre', { 'wrap': 'pre' }, [ _('Log file does not exist.') ]);
|
||||||
|
|
||||||
|
return E('pre', { 'wrap': 'pre' }, [ _('Unknown error: %s').format(err) ]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureLogPoll = hp.statusPoller({
|
||||||
|
poll: poll,
|
||||||
|
read: () => Promise.all(Object.keys(log_targets).map((filename) =>
|
||||||
|
readRuntimeLog(filename).then((node) => ({ filename: filename, node: node })))),
|
||||||
|
paint: (entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const target = log_targets[entry.filename];
|
||||||
|
if (target)
|
||||||
|
dom.content(target, entry.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function getConnStat(o, site) {
|
||||||
|
o.default = E('div', { 'style': 'cbi-value-field' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button cbi-button-action',
|
||||||
|
'click': ui.createHandlerFn(this, () => {
|
||||||
|
return hp.rpcCall('connection_check', [site],
|
||||||
|
{ params: ['site'], expect: { '': {} } }).then((ret) => {
|
||||||
|
let ele = o.default.firstElementChild.nextElementSibling;
|
||||||
|
if (ret.result) {
|
||||||
|
ele.style.setProperty('color', 'green');
|
||||||
|
ele.innerHTML = _('passed');
|
||||||
|
} else {
|
||||||
|
ele.style.setProperty('color', 'red');
|
||||||
|
ele.innerHTML = _('failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, [ _('Check') ]),
|
||||||
|
' ',
|
||||||
|
E('strong', { 'style': 'color:gray' }, _('unchecked')),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getResVersion(o, type) {
|
||||||
|
return hp.rpcCall('resources_get_version', [type],
|
||||||
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||||
|
/* Review M7: the version string (the upstream commit date) and
|
||||||
|
* `res.updated_at` (when *this router* last succeeded) are
|
||||||
|
* distinct. Show both: the upstream date stays the same across
|
||||||
|
* a successful re-run, so it cannot answer "did the cron job run
|
||||||
|
* last night" - only `updated_at` does. */
|
||||||
|
const updated_label = res.updated_at
|
||||||
|
? _('(last updated %s)').format(res.updated_at)
|
||||||
|
: _('(never updated on this device)');
|
||||||
|
let spanTemp = E('div', { 'style': 'cbi-value-field' }, [
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button cbi-button-action',
|
||||||
|
'click': ui.createHandlerFn(this, () => {
|
||||||
|
return hp.rpcCall('resources_update', [type],
|
||||||
|
{ params: ['type'], expect: { '': {} } }).then((res) => {
|
||||||
|
switch (res.status) {
|
||||||
|
case 0:
|
||||||
|
o.description = _('Successfully updated.');
|
||||||
|
break;
|
||||||
|
case 1:
|
||||||
|
o.description = _('Update failed.');
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
|
o.description = _('Already in updating.');
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
o.description = _('Already at the latest version.');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
o.description = _('Unknown error.');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return o.map.reset();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, [ _('Check update') ]),
|
||||||
|
' ',
|
||||||
|
E('strong', { 'style': (res.error ? 'color:red' : 'color:green') },
|
||||||
|
[ res.error ? 'not found' : res.version ]
|
||||||
|
),
|
||||||
|
' ',
|
||||||
|
E('small', { 'style': 'color:gray' }, [ updated_label ]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
o.default = spanTemp;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `filename` is the log's basename (and log_clean's type argument), passed in
|
||||||
|
* by the caller. It used to be reverse-engineered from the option name with
|
||||||
|
* `o.option.split('_')[1]`, which only works while every option is spelled
|
||||||
|
* `_<filename>_logview` - rename one and the reader, the poll and the clean
|
||||||
|
* button silently disagree about which file they mean.
|
||||||
|
*
|
||||||
|
* The parameter sits after `name`, before the framework's
|
||||||
|
* (option_index, section_id, in_table): L.bind prepends the bound arguments,
|
||||||
|
* so the call sites pass it and the framework keeps handing in the rest. */
|
||||||
|
function getRuntimeLog(o, name, filename, _option_index, section_id, _in_table) {
|
||||||
|
let section, log_level_el;
|
||||||
|
switch (filename) {
|
||||||
|
case 'homeproxy':
|
||||||
|
section = null;
|
||||||
|
break;
|
||||||
|
case 'sing-box-c':
|
||||||
|
section = 'config';
|
||||||
|
break;
|
||||||
|
case 'sing-box-s':
|
||||||
|
section = 'server';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section) {
|
||||||
|
const selected = uci.get('homeproxy', section, 'log_level') || 'warn';
|
||||||
|
const choices = {
|
||||||
|
trace: _('Trace'),
|
||||||
|
debug: _('Debug'),
|
||||||
|
info: _('Info'),
|
||||||
|
warn: _('Warn'),
|
||||||
|
error: _('Error'),
|
||||||
|
fatal: _('Fatal'),
|
||||||
|
panic: _('Panic')
|
||||||
|
};
|
||||||
|
|
||||||
|
log_level_el = E('select', {
|
||||||
|
'id': o.cbid(section_id),
|
||||||
|
'class': 'cbi-input-select',
|
||||||
|
'style': 'margin-left: 4px; width: 6em;',
|
||||||
|
'change': ui.createHandlerFn(this, (ev) => {
|
||||||
|
uci.set('homeproxy', section, 'log_level', ev.target.value);
|
||||||
|
return o.map.save(null, true).then(() => {
|
||||||
|
ui.changes.apply(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.keys(choices).forEach((v) => {
|
||||||
|
log_level_el.appendChild(E('option', {
|
||||||
|
'value': v,
|
||||||
|
'selected': (v === selected) ? '' : null
|
||||||
|
}, [ choices[v] ]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const log_textarea = E('div', { 'id': 'log_textarea' },
|
||||||
|
E('img', {
|
||||||
|
'src': L.resource('icons/loading.svg'),
|
||||||
|
'alt': _('Loading'),
|
||||||
|
'style': 'vertical-align:middle'
|
||||||
|
}, _('Collecting data...'))
|
||||||
|
);
|
||||||
|
|
||||||
|
log_targets[filename] = log_textarea;
|
||||||
|
ensureLogPoll();
|
||||||
|
|
||||||
|
return E([
|
||||||
|
E('style', [ css ]),
|
||||||
|
E('div', {'class': 'cbi-map'}, [
|
||||||
|
E('h3', {'name': 'content', 'style': 'align-items: center; display: flex;'}, [
|
||||||
|
_('%s log').format(name),
|
||||||
|
log_level_el || '',
|
||||||
|
E('button', {
|
||||||
|
'class': 'btn cbi-button cbi-button-action',
|
||||||
|
'style': 'margin-left: 4px;',
|
||||||
|
'click': ui.createHandlerFn(this, () => {
|
||||||
|
return hp.rpcCall('log_clean', [filename],
|
||||||
|
{ params: ['type'], expect: { '': {} } });
|
||||||
|
})
|
||||||
|
}, [ _('Clean log') ])
|
||||||
|
]),
|
||||||
|
E('div', {'class': 'cbi-section'}, [
|
||||||
|
log_textarea,
|
||||||
|
E('div', {'style': 'text-align:right'},
|
||||||
|
E('small', {}, _('Refresh every %s seconds.').format(L.env.pollinterval))
|
||||||
|
)
|
||||||
|
])
|
||||||
|
])
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return view.extend({
|
||||||
|
render() {
|
||||||
|
let m, s, o;
|
||||||
|
|
||||||
|
m = new form.Map('homeproxy');
|
||||||
|
|
||||||
|
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Connection check'));
|
||||||
|
s.anonymous = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_check_baidu', _('BaiDu'));
|
||||||
|
o.cfgvalue = L.bind(getConnStat, this, o, 'baidu');
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_check_google', _('Google'));
|
||||||
|
o.cfgvalue = L.bind(getConnStat, this, o, 'google');
|
||||||
|
|
||||||
|
s = m.section(form.NamedSection, 'config', 'homeproxy', _('Resources management'));
|
||||||
|
s.anonymous = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_china_ip4_version', _('China IPv4 list version'));
|
||||||
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip4');
|
||||||
|
o.rawhtml = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_china_ip6_version', _('China IPv6 list version'));
|
||||||
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_ip6');
|
||||||
|
o.rawhtml = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_china_list_version', _('China list version'));
|
||||||
|
o.cfgvalue = L.bind(getResVersion, this, o, 'china_list');
|
||||||
|
o.rawhtml = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_gfw_list_version', _('GFW list version'));
|
||||||
|
o.cfgvalue = L.bind(getResVersion, this, o, 'gfw_list');
|
||||||
|
o.rawhtml = true;
|
||||||
|
|
||||||
|
o = s.option(form.Value, 'github_token', _('GitHub token'));
|
||||||
|
o.password = true;
|
||||||
|
o.renderWidget = function() {
|
||||||
|
let node = form.Value.prototype.renderWidget.apply(this, arguments);
|
||||||
|
|
||||||
|
(node.querySelector('.control-group') || node).appendChild(E('button', {
|
||||||
|
'class': 'cbi-button cbi-button-apply',
|
||||||
|
'title': _('Save'),
|
||||||
|
'click': ui.createHandlerFn(this, () => {
|
||||||
|
return this.map.save(null, true).then(() => {
|
||||||
|
ui.changes.apply(true);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}, [ _('Save') ]));
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
s = m.section(form.NamedSection, 'config', 'homeproxy');
|
||||||
|
s.anonymous = true;
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_homeproxy_logview');
|
||||||
|
o.render = L.bind(getRuntimeLog, this, o, _('HomeProxy'), 'homeproxy');
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_sing-box-c_logview');
|
||||||
|
o.render = L.bind(getRuntimeLog, this, o, _('sing-box client'), 'sing-box-c');
|
||||||
|
|
||||||
|
o = s.option(form.DummyValue, '_sing-box-s_logview');
|
||||||
|
o.render = L.bind(getRuntimeLog, this, o, _('sing-box server'), 'sing-box-s');
|
||||||
|
|
||||||
|
return m.render();
|
||||||
|
},
|
||||||
|
|
||||||
|
handleSaveApply: null,
|
||||||
|
handleSave: null,
|
||||||
|
handleReset: null
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
zh_Hans
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"bounding": [
|
||||||
|
"CAP_NET_ADMIN",
|
||||||
|
"CAP_NET_BIND_SERVICE"
|
||||||
|
],
|
||||||
|
"effective": [
|
||||||
|
"CAP_NET_ADMIN",
|
||||||
|
"CAP_NET_BIND_SERVICE"
|
||||||
|
],
|
||||||
|
"ambient": [
|
||||||
|
"CAP_NET_ADMIN",
|
||||||
|
"CAP_NET_BIND_SERVICE"
|
||||||
|
],
|
||||||
|
"permitted": [
|
||||||
|
"CAP_NET_ADMIN",
|
||||||
|
"CAP_NET_BIND_SERVICE"
|
||||||
|
],
|
||||||
|
"inheritable": [
|
||||||
|
"CAP_NET_ADMIN",
|
||||||
|
"CAP_NET_BIND_SERVICE"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
|
||||||
|
config homeproxy 'infra'
|
||||||
|
option __warning 'DO NOT EDIT THIS SECTION, OR YOU ARE ON YOUR OWN!'
|
||||||
|
option common_port '22,53,80,143,443,465,587,853,873,993,995,5222,8080,8443,9418'
|
||||||
|
option mixed_port '5330'
|
||||||
|
option redirect_port '5331'
|
||||||
|
option tproxy_port '5332'
|
||||||
|
option dns_port '5333'
|
||||||
|
option dns_redirect '1'
|
||||||
|
option ntp_server 'nil'
|
||||||
|
option udp_timeout ''
|
||||||
|
option tun_name 'singtun0'
|
||||||
|
option tun_addr4 '172.19.0.1/30'
|
||||||
|
option tun_addr6 'fdfe:dcba:9876::1/126'
|
||||||
|
option tun_mtu '9000'
|
||||||
|
option table_mark '100'
|
||||||
|
option self_mark '100'
|
||||||
|
option tproxy_mark '101'
|
||||||
|
option tun_mark '102'
|
||||||
|
|
||||||
|
config homeproxy 'migration'
|
||||||
|
option crontab '1'
|
||||||
|
|
||||||
|
config homeproxy 'config'
|
||||||
|
option main_node 'nil'
|
||||||
|
option main_udp_node 'same'
|
||||||
|
option dns_server '8.8.8.8'
|
||||||
|
option china_dns_server '223.5.5.5'
|
||||||
|
option routing_mode 'bypass_mainland_china'
|
||||||
|
option routing_port 'common'
|
||||||
|
option proxy_mode 'redirect_tproxy'
|
||||||
|
option ipv6_support '1'
|
||||||
|
option github_token ''
|
||||||
|
option log_level 'warn'
|
||||||
|
# Functional health probes (runtime/health.sh). They run after the
|
||||||
|
# process/listener gate and only log by default: a probe can fail because
|
||||||
|
# the uplink is down or a captive portal answers, and rolling a good
|
||||||
|
# configuration back for that would be worse than the condition probed
|
||||||
|
# for. Set health_probe_strict to 1 only if a failing probe must fail the
|
||||||
|
# start (and therefore trigger the rollback).
|
||||||
|
option health_probe_strict '0'
|
||||||
|
option health_probe_name 'example.com'
|
||||||
|
option cn_ip_fallback '0'
|
||||||
|
option tun_dns_mode ''
|
||||||
|
option udp_mapping ''
|
||||||
|
option udp_filtering ''
|
||||||
|
option udp_nat_max ''
|
||||||
|
|
||||||
|
config homeproxy 'control'
|
||||||
|
option lan_proxy_mode 'disabled'
|
||||||
|
list wan_proxy_ipv4_ips '91.105.192.0/23'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.4.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.8.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.16.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.12.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.20.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '91.108.56.0/22'
|
||||||
|
list wan_proxy_ipv4_ips '149.154.160.0/20'
|
||||||
|
list wan_proxy_ipv4_ips '185.76.151.0/24'
|
||||||
|
list wan_proxy_ipv4_ips '203.208.50.66/32'
|
||||||
|
list wan_proxy_ipv6_ips '2001:67c:4e8::/48'
|
||||||
|
list wan_proxy_ipv6_ips '2001:b28:f23c::/48'
|
||||||
|
list wan_proxy_ipv6_ips '2001:b28:f23d::/48'
|
||||||
|
list wan_proxy_ipv6_ips '2001:b28:f23f::/48'
|
||||||
|
list wan_proxy_ipv6_ips '2a0a:f280::/32'
|
||||||
|
|
||||||
|
config homeproxy 'routing'
|
||||||
|
option default_outbound 'direct-out'
|
||||||
|
option default_outbound_dns 'default-dns'
|
||||||
|
option find_neighbor '0'
|
||||||
|
|
||||||
|
config homeproxy 'dns'
|
||||||
|
option default_strategy 'prefer_ipv4'
|
||||||
|
option default_server 'default-dns'
|
||||||
|
option disable_cache '0'
|
||||||
|
option disable_cache_expire '0'
|
||||||
|
option optimistic_cache '0'
|
||||||
|
option optimistic_timeout ''
|
||||||
|
option dns_timeout ''
|
||||||
|
option cache_file_store_dns '0'
|
||||||
|
|
||||||
|
config homeproxy 'subscription'
|
||||||
|
option auto_update '0'
|
||||||
|
option allow_insecure '0'
|
||||||
|
option packet_encoding 'xudp'
|
||||||
|
option update_via_proxy '0'
|
||||||
|
option filter_nodes 'blacklist'
|
||||||
|
list filter_keywords '重置|到期|过期|剩余|套餐'
|
||||||
|
list filter_keywords 'Expiration|Remaining'
|
||||||
|
|
||||||
|
config homeproxy 'server'
|
||||||
|
option enabled '0'
|
||||||
|
option log_level 'warn'
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
2026-09-24 327d9639f80a6f4524f710076e6c43945d187430
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
|||||||
|
2026-09-25 6723a8edb901e7ed1ad82e3f8b37d53534794cc3
|
||||||
+4
-3
@@ -7,12 +7,13 @@ NAME="homeproxy"
|
|||||||
|
|
||||||
log_max_size="50" #KB
|
log_max_size="50" #KB
|
||||||
main_log_file="/var/run/$NAME/$NAME.log"
|
main_log_file="/var/run/$NAME/$NAME.log"
|
||||||
hiddifyc_log_file="/var/run/$NAME/hiddify-c.log"
|
singc_log_file="/var/run/$NAME/sing-box-c.log"
|
||||||
|
sings_log_file="/var/run/$NAME/sing-box-s.log"
|
||||||
|
|
||||||
while true; do
|
while true; do
|
||||||
sleep 180
|
sleep 180
|
||||||
for i in "$main_log_file" "$hiddifyc_log_file"; do
|
for i in "$main_log_file" "$singc_log_file" "$sings_log_file"; do
|
||||||
[ -s "$i" ] || continue
|
[ -s "$i" ] || continue
|
||||||
[ "$(( $(ls -l "$i" | awk -F ' ' '{print $5}') / 1024 >= log_max_size))" -eq "0" ] || echo "" > "$i"
|
[ "$(( $(wc -c < "$i") / 1024 >= log_max_size))" -eq "0" ] || : > "$i"
|
||||||
done
|
done
|
||||||
done
|
done
|
||||||
@@ -0,0 +1,765 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Stage A1.1 of the architecture refactor:
|
||||||
|
*
|
||||||
|
* Node -> Protocol Adapter -> SingBox Outbound
|
||||||
|
*
|
||||||
|
* This replaces the single 110-line `generate_outbound()` whose protocol
|
||||||
|
* branches were expressed as ~60 ternaries inside one object literal. Three
|
||||||
|
* properties are deliberate:
|
||||||
|
*
|
||||||
|
* kept the shared TLS/transport/multiplex code is still the existing
|
||||||
|
* production builders - the adapter only supplies them a
|
||||||
|
* Node-shaped view instead of a flat UCI dict.
|
||||||
|
* pinned the emitted JSON is frozen by tests/snapshots/generator/
|
||||||
|
* outbounds.json, so a field change for any protocol is a
|
||||||
|
* reviewable diff (test_golden_outbounds.sh).
|
||||||
|
* dropped a per-protocol Adapter class hierarchy. The protocols differ by
|
||||||
|
* which *fields* they carry, not by behaviour, so the honest
|
||||||
|
* extension point is the field tables below, not eleven classes.
|
||||||
|
*
|
||||||
|
* Adding a protocol means adding three entries: CREDENTIALS (model.uc),
|
||||||
|
* PROTOCOL_OPTIONS (loader.uc) and OPTION_FIELDS here - and a node in
|
||||||
|
* tests/fixtures/generators/outbounds.uci, which
|
||||||
|
* tests/ucode/test_protocol_inventory.sh enforces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
isEmpty, strToBool, strToInt, strToTime, removeBlankAttrs,
|
||||||
|
buildTLSObject, buildTransportObject, parse_port
|
||||||
|
} from '../homeproxy.uc';
|
||||||
|
|
||||||
|
import { Node, Inbound, INBOUND_TLS_SERVER } from './model.uc';
|
||||||
|
|
||||||
|
/* Per-protocol required credential fields. Node.validate() covers the
|
||||||
|
* cross-protocol rules (type/address/port/TLS); this table is the
|
||||||
|
* per-protocol half - "vless needs uuid, vmess needs uuid, trojan needs
|
||||||
|
* password, snell needs psk, ..." - and it is the Adapter's job because
|
||||||
|
* the requirement is shaped by what the sing-box outbound for each
|
||||||
|
* protocol will actually accept, not by anything the model knows about
|
||||||
|
* UCI. The keys are the canonical credential names the Loader uses
|
||||||
|
* (see CREDENTIALS in model.uc), so the check is data-driven and adding
|
||||||
|
* a protocol is a one-line change. */
|
||||||
|
export const REQUIRED_CREDENTIALS = {
|
||||||
|
vless: ['uuid'],
|
||||||
|
vmess: ['uuid'],
|
||||||
|
trojan: ['password'],
|
||||||
|
hysteria2: ['password'],
|
||||||
|
tuic: ['uuid', 'password'],
|
||||||
|
shadowsocks: ['password'],
|
||||||
|
snell: ['psk'],
|
||||||
|
anytls: ['password'],
|
||||||
|
shadowtls: ['password'],
|
||||||
|
ssh: ['user'],
|
||||||
|
/* These three have no hard required credential - the protocol can
|
||||||
|
* run anonymously (http / socks) or the auth is delivered out of
|
||||||
|
* band (hysteria, direct). Node.validate()'s address/port check
|
||||||
|
* is enough. */
|
||||||
|
hysteria: [],
|
||||||
|
http: [],
|
||||||
|
socks: [],
|
||||||
|
direct: []
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Run a Node through the per-protocol required-field check. Returns
|
||||||
|
* the (possibly empty) list of human-readable problems.
|
||||||
|
*
|
||||||
|
* The same ucode quirk that bit Node.validate() bites here too: push()
|
||||||
|
* returns the value pushed, not the new array, so we build the result
|
||||||
|
* via spread instead of the obvious `problems = push(problems, X)`. */
|
||||||
|
function protocol_problems(node) {
|
||||||
|
const required = REQUIRED_CREDENTIALS[node.type] || [];
|
||||||
|
const creds = node.credentials || {};
|
||||||
|
let problems = [];
|
||||||
|
|
||||||
|
for (let field in required) {
|
||||||
|
if (!creds[field])
|
||||||
|
problems = [...problems, `${node.type} requires ${field}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* WireGuard is the one protocol whose material does not live in
|
||||||
|
* `credentials`: the endpoint builder reads node.protocol_options.
|
||||||
|
* Checking it here means a broken WireGuard node is caught by the
|
||||||
|
* same "is this node buildable?" path as every other protocol, which
|
||||||
|
* is what lets a urltest list prune it instead of failing the whole
|
||||||
|
* configuration. */
|
||||||
|
if (node.type === 'wireguard') {
|
||||||
|
const opts = node.protocol_options || {};
|
||||||
|
for (let field in ['local_address', 'private_key', 'peer_public_key'])
|
||||||
|
if (!opts[field])
|
||||||
|
problems = [...problems, `wireguard requires ${field}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- field transforms --------------------------------------------------- */
|
||||||
|
|
||||||
|
/* A table value is either a function of the domain object (a Node for the
|
||||||
|
* outbound tables, an Inbound for the server tables), a literal, or a
|
||||||
|
* string naming a transform (in which case the value is null - the spec
|
||||||
|
* is present so the key still gets emitted, but the value will be
|
||||||
|
* stripped by removeBlankAttrs()). */
|
||||||
|
const TRANSFORMS = {
|
||||||
|
raw: (value) => value,
|
||||||
|
bool: (value) => strToBool(value),
|
||||||
|
int: (value) => strToInt(value),
|
||||||
|
time: (value) => strToTime(value)
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Shared by the outbound and the inbound field tables: the spec is a
|
||||||
|
* plain function in every table except the outbound one, which carries a
|
||||||
|
* few TRANSFORMS markers for keys that exist but are runtime-owned. */
|
||||||
|
function resolve(spec, subject) {
|
||||||
|
if (type(spec) === 'function')
|
||||||
|
return spec(subject);
|
||||||
|
|
||||||
|
if (type(spec) === 'string' && spec in TRANSFORMS)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return spec;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- field tables ------------------------------------------------------- */
|
||||||
|
|
||||||
|
/* Fields every protocol emits. Each is null when absent, and nulls are
|
||||||
|
* stripped once, at the end, by removeBlankAttrs(). Cross-protocol common
|
||||||
|
* fields live in node.common (Loader's load_common); protocol-specific
|
||||||
|
* canonical names live in node.protocol_options. The legacy
|
||||||
|
* `node.raw` opaque bag was dropped - the Adapter reads only the explicit
|
||||||
|
* sub-objects, so no escape hatch exists.
|
||||||
|
*
|
||||||
|
* This table is applied AFTER the literal in build_outbound(), so a key
|
||||||
|
* listed here wins over whatever that literal set. A field the runtime owns
|
||||||
|
* (routing_mark, from the `mark` argument) therefore must NOT be listed
|
||||||
|
* here, not even as `null`: the loop would overwrite the value with null and
|
||||||
|
* removeBlankAttrs() would drop the field entirely. routing_mark is what
|
||||||
|
* exempts sing-box's own proxy connections from the nft redirect chain, so
|
||||||
|
* losing it loops the traffic back into the redirect inbound. Guard 32 in
|
||||||
|
* tests/arch-guard.sh keeps the literal's keys and this table disjoint. */
|
||||||
|
const COMMON_FIELDS = {
|
||||||
|
server: (node) => node.address,
|
||||||
|
server_port: (node) => strToInt(node.port),
|
||||||
|
proxy_protocol: (node) => strToInt(node.common.proxy_protocol),
|
||||||
|
tcp_fast_open: (node) => strToBool(node.common.tcp_fast_open),
|
||||||
|
tcp_multi_path: (node) => strToBool(node.common.tcp_multi_path),
|
||||||
|
udp_fragment: (node) => strToBool(node.common.udp_fragment),
|
||||||
|
packet_encoding: (node) => node.protocol_options.packet_encoding || null
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The node form stores an SSH private key in a DynamicList, i.e. one UCI
|
||||||
|
* list entry per line of the OpenSSH/PEM blob (a UCI option cannot hold
|
||||||
|
* newlines). sing-box wants the whole key as one string, so re-join the
|
||||||
|
* lines. A single-entry value is passed through unchanged.
|
||||||
|
*
|
||||||
|
* Declared before CLAIM_FIELDS on purpose: ucode resolves module-level names
|
||||||
|
* lexically, so a callee has to appear above its caller in the file. */
|
||||||
|
function ssh_private_key(value) {
|
||||||
|
if (type(value) === 'array')
|
||||||
|
return length(value) ? join('\n', value) : null;
|
||||||
|
|
||||||
|
return value || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Credential fields: one canonical set, emitted under the name each protocol
|
||||||
|
* wants. This is where the old builder repeated itself. */
|
||||||
|
function CLAIM_FIELDS(node) {
|
||||||
|
switch (node.type) {
|
||||||
|
case 'snell':
|
||||||
|
return {
|
||||||
|
psk: node.credentials.psk,
|
||||||
|
userkey: node.credentials.userkey,
|
||||||
|
reuse: strToBool(node.protocol_options.reuse)
|
||||||
|
};
|
||||||
|
case 'ssh':
|
||||||
|
return {
|
||||||
|
user: node.credentials.user,
|
||||||
|
password: node.credentials.password,
|
||||||
|
private_key: ssh_private_key(node.credentials.private_key),
|
||||||
|
private_key_passphrase: node.credentials.private_key_passphrase
|
||||||
|
};
|
||||||
|
case 'hysteria':
|
||||||
|
return {
|
||||||
|
auth: (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||||
|
auth_str: (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
username: node.credentials.username,
|
||||||
|
uuid: node.credentials.uuid,
|
||||||
|
password: node.credentials.password,
|
||||||
|
method: node.credentials.method
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-protocol option fields, keyed by the same canonical names the model
|
||||||
|
* exposes. Anything not modelled here is still in node.protocol_options
|
||||||
|
* (which is itself populated from the protocol's PROTOCOL_OPTIONS row in
|
||||||
|
* the Loader). */
|
||||||
|
export const OPTION_FIELDS = {
|
||||||
|
vless: {
|
||||||
|
flow: (node) => node.protocol_options.flow
|
||||||
|
/* No udp_over_tcp here: sing-box 1.14 rejects that field on a vless
|
||||||
|
* outbound ("json: unknown field"). It used to be emitted, so a
|
||||||
|
* node whose type was switched from shadowsocks to vless kept the
|
||||||
|
* stale UCI option and produced a config sing-box refused outright.
|
||||||
|
* The node form never offered the option for vless anyway. */
|
||||||
|
},
|
||||||
|
/* `mode` (snell v6 traffic shaping) is deliberately absent: sing-box
|
||||||
|
* 1.14 rejects it as an outbound field, and the node form only offered
|
||||||
|
* it for snell_version 6, which this sing-box does not support either. */
|
||||||
|
snell: {
|
||||||
|
version: (node) => strToInt(node.protocol_options.version) || 4,
|
||||||
|
obfs_mode: (node) => node.protocol_options.obfs_mode || null,
|
||||||
|
obfs_host: (node) => node.protocol_options.obfs_host || null
|
||||||
|
},
|
||||||
|
/* A4.1: shadowsocks. SIP002 plugin support is emitted only when both
|
||||||
|
* plugin and plugin_opts are present, matching the generator's ternary
|
||||||
|
* that emits `plugin: ...` unconditionally and lets removeBlankAttrs
|
||||||
|
* drop empties. `udp_over_tcp` mirrors the vless shape. */
|
||||||
|
shadowsocks: {
|
||||||
|
plugin: (node) => node.protocol_options.plugin || null,
|
||||||
|
plugin_opts: (node) => node.protocol_options.plugin_opts || null,
|
||||||
|
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||||
|
enabled: true,
|
||||||
|
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||||
|
} : null
|
||||||
|
},
|
||||||
|
/* A4.2: anytls idle session tuning (sing-box 1.14 lets the client probe
|
||||||
|
* upstream sessions and proactively close stuck ones). */
|
||||||
|
anytls: {
|
||||||
|
idle_session_check_interval: (node) => strToTime(node.protocol_options.idle_session_check_interval),
|
||||||
|
idle_session_timeout: (node) => strToTime(node.protocol_options.idle_session_timeout),
|
||||||
|
min_idle_session: (node) => strToInt(node.protocol_options.min_idle_session)
|
||||||
|
},
|
||||||
|
/* A4.3: http - no protocol-specific options; transport / tls / multiplex
|
||||||
|
* are shared. */
|
||||||
|
http: {},
|
||||||
|
/* A4.4: socks. The node form offers UDP-over-TCP for socks and sing-box
|
||||||
|
* accepts it on the socks outbound, but the adapter never emitted it, so
|
||||||
|
* the setting was silently ignored. */
|
||||||
|
socks: {
|
||||||
|
version: (node) => node.protocol_options.version,
|
||||||
|
udp_over_tcp: (node) => (node.protocol_options.udp_over_tcp === '1') ? {
|
||||||
|
enabled: true,
|
||||||
|
version: strToInt(node.protocol_options.udp_over_tcp_version)
|
||||||
|
} : null
|
||||||
|
},
|
||||||
|
/* A4.5: tuic */
|
||||||
|
tuic: {
|
||||||
|
congestion_control: (node) => node.protocol_options.congestion_control,
|
||||||
|
udp_relay_mode: (node) => node.protocol_options.udp_relay_mode,
|
||||||
|
udp_over_stream: (node) => strToBool(node.protocol_options.udp_over_stream),
|
||||||
|
zero_rtt_handshake: (node) => strToBool(node.protocol_options.zero_rtt_handshake),
|
||||||
|
heartbeat: (node) => strToTime(node.protocol_options.heartbeat)
|
||||||
|
},
|
||||||
|
/* A4.6: trojan - no protocol-specific options. */
|
||||||
|
trojan: {},
|
||||||
|
/* A4.7: shadowtls */
|
||||||
|
shadowtls: {
|
||||||
|
version: (node) => strToInt(node.protocol_options.version)
|
||||||
|
},
|
||||||
|
/* A4.8: hysteria (v1). The generator emits two fields: `auth` when the
|
||||||
|
* payload is base64, `auth_str` when it is a string. Node.validate()
|
||||||
|
* only catches the obvious case; the conditional keeps the table-driven
|
||||||
|
* shape.
|
||||||
|
*
|
||||||
|
* obfs is the plain obfuscation password string here. The node form
|
||||||
|
* only offers the password for v1 (the type selector is hysteria2-only)
|
||||||
|
* and sing-box rejects the {type, password} object on a hysteria
|
||||||
|
* outbound - that shape belongs to hysteria2. Emitting the object made
|
||||||
|
* any obfuscated hysteria node produce a config sing-box refused. */
|
||||||
|
hysteria: {
|
||||||
|
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||||
|
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||||
|
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||||
|
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||||
|
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||||
|
obfs: (node) => node.protocol_options.obfs_password || null
|
||||||
|
},
|
||||||
|
/* A4.9: hysteria2 - extends hysteria with hop_interval_max /
|
||||||
|
* server_ports / bbr_profile / disable_chrome_parrot. The obfs
|
||||||
|
* shape is the same. Note sing-box 1.14 uses `server_ports`
|
||||||
|
* (port hopping list), not `hopping_port` - the latter is the
|
||||||
|
* UCI option name, so the rename happens here. */
|
||||||
|
hysteria2: {
|
||||||
|
auth: (node) => (node.protocol_options.auth_type === 'base64') ? node.protocol_options.auth_payload : null,
|
||||||
|
auth_str: (node) => (node.protocol_options.auth_type === 'string') ? node.protocol_options.auth_payload : null,
|
||||||
|
up_mbps: (node) => strToInt(node.protocol_options.up_mbps),
|
||||||
|
down_mbps: (node) => strToInt(node.protocol_options.down_mbps),
|
||||||
|
hop_interval: (node) => strToTime(node.protocol_options.hop_interval),
|
||||||
|
hop_interval_max: (node) => strToTime(node.protocol_options.hop_interval_max),
|
||||||
|
server_ports: (node) => node.protocol_options.hopping_port,
|
||||||
|
obfs: (node) => node.protocol_options.obfs_type ? {
|
||||||
|
type: node.protocol_options.obfs_type,
|
||||||
|
password: node.protocol_options.obfs_password
|
||||||
|
} : null,
|
||||||
|
bbr_profile: (node) => node.protocol_options.bbr_profile || null,
|
||||||
|
disable_chrome_parrot: (node) => (node.protocol_options.disable_chrome_parrot === '1') ? true : null
|
||||||
|
},
|
||||||
|
/* A4.10: vmess */
|
||||||
|
vmess: {
|
||||||
|
alter_id: (node) => strToInt(node.protocol_options.alter_id),
|
||||||
|
security: (node) => node.protocol_options.security,
|
||||||
|
global_padding: (node) => strToBool(node.protocol_options.global_padding)
|
||||||
|
},
|
||||||
|
/* SSH: host-key pinning and the client banner, matching sing-box's ssh
|
||||||
|
* outbound field names. */
|
||||||
|
ssh: {
|
||||||
|
client_version: (node) => node.protocol_options.client_version || null,
|
||||||
|
host_key: (node) => node.protocol_options.host_key || null,
|
||||||
|
host_key_algorithms: (node) => node.protocol_options.host_key_algorithms || null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- shared builders ---------------------------------------------------- */
|
||||||
|
|
||||||
|
function build_multiplex(mux) {
|
||||||
|
if (!mux || mux.enabled !== '1')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
protocol: mux.protocol,
|
||||||
|
max_connections: strToInt(mux.max_connections),
|
||||||
|
min_streams: strToInt(mux.min_streams),
|
||||||
|
max_streams: strToInt(mux.max_streams),
|
||||||
|
padding: strToBool(mux.padding),
|
||||||
|
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||||
|
enabled: true,
|
||||||
|
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||||
|
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- adapter ------------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Both validation layers as one list. Standalone (not a method) because the
|
||||||
|
* OutboundFactory literal below cannot reference itself during its own
|
||||||
|
* initialisation: ucode resolves the binding lexically and throws
|
||||||
|
* "Can't access lexical declaration before initialization". */
|
||||||
|
function outbound_problems(node) {
|
||||||
|
return [
|
||||||
|
...Node.validate(node),
|
||||||
|
...protocol_problems(node)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Node -> sing-box outbound object. Pure: no UCI, no module state, no file
|
||||||
|
* access; `mark` is passed in instead of read from a global.
|
||||||
|
*
|
||||||
|
* Standalone for the same reason outbound_problems() is: the literal below
|
||||||
|
* cannot name OutboundFactory in any nested scope, not even inside an arrow
|
||||||
|
* body, without tripping ucode's "Can't access lexical declaration before
|
||||||
|
* initialization" check. Validation is done by the callers. */
|
||||||
|
function build_outbound(node, mark) {
|
||||||
|
const outbound = {
|
||||||
|
type: node.type,
|
||||||
|
tag: Node.tag(node),
|
||||||
|
routing_mark: strToInt(mark)
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let field, spec in COMMON_FIELDS)
|
||||||
|
outbound[field] = resolve(spec, node);
|
||||||
|
|
||||||
|
const claims = CLAIM_FIELDS(node);
|
||||||
|
for (let field, value in claims)
|
||||||
|
outbound[field] = value;
|
||||||
|
|
||||||
|
const options = OPTION_FIELDS[node.type] || {};
|
||||||
|
for (let field, spec in options)
|
||||||
|
outbound[field] = resolve(spec, node);
|
||||||
|
|
||||||
|
outbound.multiplex = build_multiplex(node.multiplex);
|
||||||
|
outbound.tls = buildTLSObject(node.tls, false);
|
||||||
|
outbound.transport = buildTransportObject(node.transport, false);
|
||||||
|
|
||||||
|
if (node.type === 'direct')
|
||||||
|
outbound.proxy_protocol = strToInt(node.common.proxy_protocol);
|
||||||
|
|
||||||
|
/* removeBlankAttrs() is what the generator applies before writing, and
|
||||||
|
it drops every null the field tables produced. Cleaning here means
|
||||||
|
this function returns the final artifact, so a golden-snapshot test
|
||||||
|
can compare it against what sing-box would actually be handed. */
|
||||||
|
return removeBlankAttrs(outbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OutboundFactory = {
|
||||||
|
/* Validation split out of create() so a caller can decide whether a
|
||||||
|
* broken node is fatal. Two-layer: Node.validate() covers
|
||||||
|
* cross-protocol rules, protocol_problems() the per-protocol ones. */
|
||||||
|
problems: outbound_problems,
|
||||||
|
|
||||||
|
/* True when create() would succeed. Used to prune optional node lists
|
||||||
|
* (urltest candidates) so one misconfigured node cannot abort the
|
||||||
|
* entire configuration. */
|
||||||
|
buildable: (node) => length(outbound_problems(node)) === 0,
|
||||||
|
|
||||||
|
/* Node -> { outbound, problems }. Same result as create() but never
|
||||||
|
* dies, for nodes the user only *may* route through. */
|
||||||
|
tryCreate: (node, mark) => {
|
||||||
|
const problems = outbound_problems(node);
|
||||||
|
|
||||||
|
if (length(problems))
|
||||||
|
return { outbound: null, problems: problems };
|
||||||
|
|
||||||
|
return { outbound: build_outbound(node, mark), problems: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Node -> sing-box outbound object, or die() when the node is not
|
||||||
|
* buildable. Use problems()/buildable()/tryCreate() for nodes whose
|
||||||
|
* breakage must not be fatal (urltest candidates). */
|
||||||
|
create: (node, mark) => {
|
||||||
|
const problems = outbound_problems(node);
|
||||||
|
|
||||||
|
if (length(problems))
|
||||||
|
die(`node '${node.id}': ${join(', ', problems)}\n`);
|
||||||
|
|
||||||
|
return build_outbound(node, mark);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- WireGuard endpoint ------------------------------------------------- */
|
||||||
|
|
||||||
|
/* WireGuard is emitted as a
|
||||||
|
* sing-box *endpoint*, not an outbound, so it needs its own builder.
|
||||||
|
* That builder used to live in generator/outbound.uc, which meant the
|
||||||
|
* generator still held protocol business logic for one protocol. It
|
||||||
|
* now lives here, next to the outbound factory, and the generator only
|
||||||
|
* decides *when* an endpoint is needed (routing-mode split, urltest
|
||||||
|
* resolution).
|
||||||
|
*
|
||||||
|
* `ctx` carries the process-wide UDP tuning the runtime owns
|
||||||
|
* (udp_mapping / udp_filtering / udp_nat_max) - same shape
|
||||||
|
* build_outbounds() already passes around, so callers do not change. */
|
||||||
|
function build_endpoint(node, ctx) {
|
||||||
|
const opts = node.protocol_options || {};
|
||||||
|
const common = node.common || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: node.type,
|
||||||
|
tag: 'cfg-' + node.id + '-out',
|
||||||
|
address: opts.local_address,
|
||||||
|
mtu: strToInt(opts.mtu),
|
||||||
|
private_key: opts.private_key,
|
||||||
|
peers: (node.type === 'wireguard') ? [
|
||||||
|
{
|
||||||
|
address: node.address,
|
||||||
|
port: strToInt(node.port),
|
||||||
|
allowed_ips: [
|
||||||
|
'0.0.0.0/0',
|
||||||
|
'::/0'
|
||||||
|
],
|
||||||
|
persistent_keepalive_interval: strToInt(opts.persistent_keepalive_interval),
|
||||||
|
public_key: opts.peer_public_key,
|
||||||
|
pre_shared_key: opts.pre_shared_key,
|
||||||
|
reserved: parse_port(opts.reserved),
|
||||||
|
}
|
||||||
|
] : null,
|
||||||
|
system: (node.type === 'wireguard') ? false : null,
|
||||||
|
tcp_fast_open: strToBool(common.tcp_fast_open),
|
||||||
|
tcp_multi_path: strToBool(common.tcp_multi_path),
|
||||||
|
udp_fragment: strToBool(common.udp_fragment),
|
||||||
|
udp_mapping: !isEmpty(ctx.udp_mapping) ? ctx.udp_mapping : null,
|
||||||
|
udp_filtering: !isEmpty(ctx.udp_filtering) ? ctx.udp_filtering : null,
|
||||||
|
udp_nat_max: ctx.udp_nat_max
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EndpointFactory = {
|
||||||
|
/* Node -> sing-box endpoint object, or null when there is nothing to
|
||||||
|
* build. Unlike OutboundFactory.create() this never dies: an endpoint
|
||||||
|
* is only ever reached for a node the routing layer already selected,
|
||||||
|
* and the caller treats null as "skip this endpoint". */
|
||||||
|
create: (node, ctx) => {
|
||||||
|
if (type(node) !== 'object' || isEmpty(node))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return build_endpoint(node, ctx);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- server inbound ----------------------------------------------------- */
|
||||||
|
|
||||||
|
/* The server half of the Adapter. Same data-table approach as
|
||||||
|
* the outbound side: the protocol differences are *fields*, not
|
||||||
|
* behaviour, so they are expressed as tables rather than a branch per
|
||||||
|
* protocol. generator/server.uc used to spell every one of these out
|
||||||
|
* inline while reading flat UCI keys off the section dict.
|
||||||
|
*
|
||||||
|
* Adding a protocol means adding two rows: INBOUND_CREDENTIALS and
|
||||||
|
* INBOUND_OPTIONS in model.uc, and an INBOUND_OPTION_FIELDS row here. */
|
||||||
|
|
||||||
|
/* Listener-level fields every inbound emits. Each value is a function of
|
||||||
|
* the Inbound, or null (which removeBlankAttrs() strips). */
|
||||||
|
const INBOUND_COMMON_FIELDS = {
|
||||||
|
listen: (inbound) => inbound.address || '::',
|
||||||
|
listen_port: (inbound) => strToInt(inbound.port),
|
||||||
|
bind_interface: (inbound) => inbound.common.bind_interface,
|
||||||
|
reuse_addr: (inbound) => strToBool(inbound.common.reuse_addr),
|
||||||
|
tcp_fast_open: (inbound) => strToBool(inbound.common.tcp_fast_open),
|
||||||
|
tcp_multi_path: (inbound) => strToBool(inbound.common.tcp_multi_path),
|
||||||
|
udp_fragment: (inbound) => strToBool(inbound.common.udp_fragment),
|
||||||
|
udp_timeout: (inbound) => strToTime(inbound.common.udp_timeout),
|
||||||
|
network: (inbound) => inbound.common.network
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Listener fields a protocol must NOT be given even when the UCI section
|
||||||
|
* happens to carry the option. sing-box 1.14's snell inbound takes no
|
||||||
|
* udp_fragment / udp_timeout / network; the previous generator had a
|
||||||
|
* dedicated build_snell_inbound() that omitted them, and this table
|
||||||
|
* keeps that behaviour instead of relying on the user not setting them. */
|
||||||
|
const INBOUND_COMMON_OMIT = {
|
||||||
|
snell: ['udp_fragment', 'udp_timeout', 'network']
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Protocols whose sing-box inbound has no users[] block. snell carries a
|
||||||
|
* single top-level psk, shadowsocks a top-level password + method; giving
|
||||||
|
* either of them a users[] entry makes sing-box reject the section
|
||||||
|
* ("snell: bad user key" - the users entry is interpreted as an
|
||||||
|
* additional user key). */
|
||||||
|
const INBOUND_NO_USERS = ['shadowsocks', 'snell'];
|
||||||
|
|
||||||
|
/* Credential fields the inbound emits, keyed by the sing-box field
|
||||||
|
* name. Most protocols put them in the users[] block (below); snell
|
||||||
|
* wants a single top-level `psk`, and shadowsocks / shadowtls a
|
||||||
|
* top-level password (plus method for shadowsocks), so those are
|
||||||
|
* handled here. */
|
||||||
|
function INBOUND_CLAIM_FIELDS(inbound) {
|
||||||
|
switch (inbound.type) {
|
||||||
|
case 'snell':
|
||||||
|
return { psk: inbound.credentials.psk };
|
||||||
|
case 'shadowsocks':
|
||||||
|
return {
|
||||||
|
method: inbound.credentials.method,
|
||||||
|
password: inbound.credentials.password
|
||||||
|
};
|
||||||
|
case 'shadowtls':
|
||||||
|
return { password: inbound.credentials.password };
|
||||||
|
default:
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The users[] entry. sing-box wants one user per inbound; every
|
||||||
|
* protocol not listed in INBOUND_NO_USERS accepts the block. */
|
||||||
|
function build_inbound_user(inbound) {
|
||||||
|
const creds = inbound.credentials || {};
|
||||||
|
const opts = inbound.protocol_options || {};
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: !(inbound.type in ['http', 'mixed', 'naive', 'socks']) ?
|
||||||
|
'cfg-' + inbound.id + '-server' : null,
|
||||||
|
username: creds.username,
|
||||||
|
password: creds.password,
|
||||||
|
|
||||||
|
/* Hysteria (1) authentication: exactly one of the two
|
||||||
|
* spellings, selected by the auth type. */
|
||||||
|
auth: (opts.auth_type === 'base64') ? creds.auth_base64 : null,
|
||||||
|
auth_str: (opts.auth_type === 'string') ? creds.auth_str : null,
|
||||||
|
|
||||||
|
uuid: creds.uuid,
|
||||||
|
|
||||||
|
/* VLESS / VMess. Both are per-user fields: sing-box has no
|
||||||
|
* top-level `flow` on a vless inbound nor `alterId` on a vmess
|
||||||
|
* one, which is why INBOUND_OPTION_FIELDS has no row for these
|
||||||
|
* two protocols. */
|
||||||
|
flow: opts.flow,
|
||||||
|
alterId: strToInt(opts.alter_id)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-protocol inbound fields, keyed by the sing-box field name. Only
|
||||||
|
* the protocols whose sing-box inbound carries something beyond the
|
||||||
|
* shared listener/credential/TLS/transport set need a row. */
|
||||||
|
const INBOUND_OPTION_FIELDS = {
|
||||||
|
anytls: {
|
||||||
|
padding_scheme: (inbound) => inbound.protocol_options.padding_scheme
|
||||||
|
},
|
||||||
|
/* Hysteria (1) and Hysteria2 share three fields and differ on
|
||||||
|
* obfs: v1 carries the obfuscation as a plain password string,
|
||||||
|
* v2 as a {type, password, min_packet_size, max_packet_size}
|
||||||
|
* object. Emitting v1's obfs as a string is what the generator
|
||||||
|
* always did - the {type, ...} shape on a v1 inbound is a
|
||||||
|
* sing-box rejection. */
|
||||||
|
hysteria: {
|
||||||
|
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||||
|
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||||
|
obfs: (inbound) => inbound.protocol_options.obfs_password,
|
||||||
|
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||||
|
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||||
|
},
|
||||||
|
hysteria2: {
|
||||||
|
up_mbps: (inbound) => strToInt(inbound.protocol_options.up_mbps),
|
||||||
|
down_mbps: (inbound) => strToInt(inbound.protocol_options.down_mbps),
|
||||||
|
obfs: (inbound) => inbound.protocol_options.obfs_type ? {
|
||||||
|
type: inbound.protocol_options.obfs_type,
|
||||||
|
password: inbound.protocol_options.obfs_password,
|
||||||
|
min_packet_size: strToInt(inbound.protocol_options.obfs_min_packet_size),
|
||||||
|
max_packet_size: strToInt(inbound.protocol_options.obfs_max_packet_size)
|
||||||
|
/* hysteria2 takes an obfs *object* here. Falling back to the bare
|
||||||
|
* password string (which is what hysteria v1 wants) makes sing-box
|
||||||
|
* reject the inbound with "cannot unmarshal string into Go struct
|
||||||
|
* field Hysteria2InboundOptions.obfs" - so no type means no obfs. */
|
||||||
|
} : null,
|
||||||
|
ignore_client_bandwidth: (inbound) => strToBool(inbound.protocol_options.ignore_client_bandwidth),
|
||||||
|
masquerade: (inbound) => inbound.protocol_options.masquerade
|
||||||
|
},
|
||||||
|
snell: {
|
||||||
|
version: (inbound) => strToInt(inbound.protocol_options.version) || 5,
|
||||||
|
obfs_mode: (inbound) => inbound.protocol_options.obfs_mode
|
||||||
|
/* no `mode`: sing-box 1.14 rejects it on a snell inbound; it
|
||||||
|
* was a v6-only option and v6 is not supported. */
|
||||||
|
},
|
||||||
|
tuic: {
|
||||||
|
congestion_control: (inbound) => inbound.protocol_options.congestion_control,
|
||||||
|
auth_timeout: (inbound) => strToTime(inbound.protocol_options.auth_timeout),
|
||||||
|
zero_rtt_handshake: (inbound) => strToBool(inbound.protocol_options.zero_rtt_handshake),
|
||||||
|
heartbeat: (inbound) => strToTime(inbound.protocol_options.heartbeat)
|
||||||
|
}
|
||||||
|
/* No row for vless / vmess: both protocols' only per-protocol knobs
|
||||||
|
* (flow, alterId) are per-user fields, emitted by
|
||||||
|
* build_inbound_user() above. sing-box has no top-level `flow` on a
|
||||||
|
* vless inbound nor `alterId` on a vmess one, so adding a row here
|
||||||
|
* would emit a field sing-box rejects. The Loader still loads them
|
||||||
|
* into inbound.protocol_options (INBOUND_OPTIONS in model.uc), which
|
||||||
|
* is where build_inbound_user() reads them from.
|
||||||
|
*
|
||||||
|
* No row for trojan / shadowtls either: everything they carry is
|
||||||
|
* shared (credentials / TLS / transport). */
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The server-only TLS tail, re-keyed from the Inbound's canonical
|
||||||
|
* names into the UCI option names buildTLSObject() reads. That shared
|
||||||
|
* builder is exercised directly by tests/ucode/test_tls_transport.uc
|
||||||
|
* and is used by both the client and the server path, so its signature
|
||||||
|
* stays as it is; this is the single place the flat names survive. */
|
||||||
|
function build_tls_server_extras(tls_server) {
|
||||||
|
const extras = {};
|
||||||
|
|
||||||
|
for (let canonical, uci in INBOUND_TLS_SERVER)
|
||||||
|
extras[uci] = tls_server[canonical];
|
||||||
|
|
||||||
|
return extras;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Build the inbound `multiplex` block. The server side has no
|
||||||
|
* max_connections / min_streams / max_streams (those are client
|
||||||
|
* dialling knobs), so this is deliberately a smaller shape than the
|
||||||
|
* outbound factory's build_multiplex(). */
|
||||||
|
function build_inbound_multiplex(mux) {
|
||||||
|
if (!mux || mux.enabled !== '1')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
padding: strToBool(mux.padding),
|
||||||
|
brutal: (mux.brutal && mux.brutal.enabled === '1') ? {
|
||||||
|
enabled: true,
|
||||||
|
up_mbps: strToInt(mux.brutal.up_mbps),
|
||||||
|
down_mbps: strToInt(mux.brutal.down_mbps)
|
||||||
|
} : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inbound -> sing-box inbound object. Pure: no UCI, no file access. */
|
||||||
|
function build_inbound(inbound) {
|
||||||
|
const out = {
|
||||||
|
type: inbound.type,
|
||||||
|
tag: Inbound.tag(inbound)
|
||||||
|
};
|
||||||
|
|
||||||
|
const omit = INBOUND_COMMON_OMIT[inbound.type] || [];
|
||||||
|
|
||||||
|
for (let field, spec in INBOUND_COMMON_FIELDS) {
|
||||||
|
if (field in omit)
|
||||||
|
continue;
|
||||||
|
out[field] = resolve(spec, inbound);
|
||||||
|
}
|
||||||
|
|
||||||
|
const claims = INBOUND_CLAIM_FIELDS(inbound);
|
||||||
|
for (let field, value in claims)
|
||||||
|
out[field] = value;
|
||||||
|
|
||||||
|
const options = INBOUND_OPTION_FIELDS[inbound.type] || {};
|
||||||
|
for (let field, spec in options)
|
||||||
|
out[field] = resolve(spec, inbound);
|
||||||
|
|
||||||
|
out.users = (inbound.type in INBOUND_NO_USERS) ? null : [ build_inbound_user(inbound) ];
|
||||||
|
|
||||||
|
out.multiplex = build_inbound_multiplex(inbound.multiplex);
|
||||||
|
out.tls = buildTLSObject(inbound.tls, true, build_tls_server_extras(inbound.tls_server || {}));
|
||||||
|
out.transport = buildTransportObject(inbound.transport, true);
|
||||||
|
|
||||||
|
/* Same contract as build_outbound(): return the final artifact so a
|
||||||
|
* golden snapshot can compare it against what sing-box is handed. */
|
||||||
|
return removeBlankAttrs(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Per-protocol requirements, mirroring OutboundFactory's
|
||||||
|
* REQUIRED_CREDENTIALS: what the sing-box inbound for this protocol
|
||||||
|
* needs in order to be usable. */
|
||||||
|
const REQUIRED_INBOUND_CREDENTIALS = {
|
||||||
|
vless: ['uuid'],
|
||||||
|
vmess: ['uuid'],
|
||||||
|
trojan: ['password'],
|
||||||
|
hysteria2: ['password'],
|
||||||
|
tuic: ['uuid', 'password'],
|
||||||
|
shadowsocks: ['password'],
|
||||||
|
snell: ['psk'],
|
||||||
|
anytls: ['password'],
|
||||||
|
shadowtls: ['password'],
|
||||||
|
/* These run without a credential: http / mixed / socks / naive can
|
||||||
|
* be anonymous, and hysteria (1) may carry its auth out of band. */
|
||||||
|
hysteria: [],
|
||||||
|
http: [],
|
||||||
|
mixed: [],
|
||||||
|
naive: [],
|
||||||
|
socks: []
|
||||||
|
};
|
||||||
|
|
||||||
|
function inbound_problems(inbound) {
|
||||||
|
let problems = [...Inbound.validate(inbound)];
|
||||||
|
|
||||||
|
const required = REQUIRED_INBOUND_CREDENTIALS[inbound.type] || [];
|
||||||
|
const creds = inbound.credentials || {};
|
||||||
|
|
||||||
|
for (let field in required)
|
||||||
|
if (!creds[field])
|
||||||
|
problems = [...problems, `${inbound.type} requires ${field}`];
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const InboundFactory = {
|
||||||
|
problems: inbound_problems,
|
||||||
|
|
||||||
|
buildable: (inbound) => length(inbound_problems(inbound)) === 0,
|
||||||
|
|
||||||
|
/* Inbound -> { inbound, problems }. Never dies, so a single broken
|
||||||
|
* server section can be reported by the caller instead of taking
|
||||||
|
* the whole server instance down. */
|
||||||
|
tryCreate: (inbound) => {
|
||||||
|
const problems = inbound_problems(inbound);
|
||||||
|
|
||||||
|
if (length(problems))
|
||||||
|
return { inbound: null, problems: problems };
|
||||||
|
|
||||||
|
return { inbound: build_inbound(inbound), problems: [] };
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Inbound -> sing-box inbound object, or die(). */
|
||||||
|
create: (inbound) => {
|
||||||
|
const problems = inbound_problems(inbound);
|
||||||
|
|
||||||
|
if (length(problems))
|
||||||
|
die(`inbound '${inbound.id}': ${join(', ', problems)}\n`);
|
||||||
|
|
||||||
|
return build_inbound(inbound);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Stage A1.1 of the architecture refactor:
|
||||||
|
*
|
||||||
|
* UCI -> Config Loader -> HomeProxyConfig
|
||||||
|
*
|
||||||
|
* This file originated as the demo loader and was promoted into the
|
||||||
|
* production tree; the demo copy is gone, so this is the only one.
|
||||||
|
*
|
||||||
|
* What this layer does:
|
||||||
|
* - owns the only `uci.cursor()` in the client configuration path
|
||||||
|
* - reads general + infra + nodes + dns + routing + access_control + server
|
||||||
|
* - never emits sing-box JSON
|
||||||
|
* - never mutates/commits UCI
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { cursor } from 'uci';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Config, Node, CREDENTIALS, Inbound,
|
||||||
|
INBOUND_CREDENTIALS, INBOUND_OPTIONS, INBOUND_COMMON, INBOUND_TLS_SERVER
|
||||||
|
} from './model.uc';
|
||||||
|
|
||||||
|
import { PROTOCOL_TO_UCI as PROTOCOL_OPTIONS } from '../parser/mapping.uc';
|
||||||
|
|
||||||
|
const UCICONFIG = 'homeproxy';
|
||||||
|
|
||||||
|
const SECTION = {
|
||||||
|
main: 'config',
|
||||||
|
infra: 'infra',
|
||||||
|
node: 'node',
|
||||||
|
dns: 'dns',
|
||||||
|
dns_server: 'dns_server',
|
||||||
|
dns_rule: 'dns_rule',
|
||||||
|
routing: 'routing',
|
||||||
|
routing_node: 'routing_node',
|
||||||
|
routing_rule: 'routing_rule',
|
||||||
|
ruleset: 'ruleset',
|
||||||
|
control: 'control',
|
||||||
|
subscription: 'subscription',
|
||||||
|
server: 'server'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Booleans are UCI '0'/'1'; keep the conversion in one place. */
|
||||||
|
function bool(value) {
|
||||||
|
return value === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function opt(uci, section, name) {
|
||||||
|
/* UCI returns null for an absent option; the old code papered over that
|
||||||
|
* with `|| ''` at every call site, losing the distinction between "not
|
||||||
|
* set" and "set to empty". The model keeps null instead. */
|
||||||
|
return uci.get(UCICONFIG, SECTION[section], name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function node_opt(uci, section_id, name) {
|
||||||
|
return uci.get(UCICONFIG, section_id, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- sub-object extraction --------------------------------------------- */
|
||||||
|
|
||||||
|
/* These three functions are the whole reason a Node is hierarchical: the
|
||||||
|
* shared TLS/transport/multiplex builders (homeproxy.uc) take a flat,
|
||||||
|
* prefixed dict today, so every caller has to hand them the right subset. A
|
||||||
|
* sub-object makes the boundary explicit and testable.
|
||||||
|
*
|
||||||
|
* load_tls and load_transport are exported so the server generator can
|
||||||
|
* shape its UCI inbound sections the same way; only the multiplex shape is
|
||||||
|
* Loader-internal because no caller outside the client node path needs it. */
|
||||||
|
|
||||||
|
export function load_tls(get) {
|
||||||
|
return {
|
||||||
|
enabled: get('tls'),
|
||||||
|
server_name: get('tls_sni'),
|
||||||
|
insecure: get('tls_insecure'),
|
||||||
|
alpn: get('tls_alpn'),
|
||||||
|
min_version: get('tls_min_version'),
|
||||||
|
max_version: get('tls_max_version'),
|
||||||
|
handshake_timeout: get('tls_handshake_timeout'),
|
||||||
|
cipher_suites: get('tls_cipher_suites'),
|
||||||
|
cert_path: get('tls_cert_path'),
|
||||||
|
ech: {
|
||||||
|
enabled: get('tls_ech'),
|
||||||
|
config: get('tls_ech_config'),
|
||||||
|
config_path: get('tls_ech_config_path')
|
||||||
|
},
|
||||||
|
utls: {
|
||||||
|
fingerprint: get('tls_utls')
|
||||||
|
},
|
||||||
|
reality: {
|
||||||
|
enabled: get('tls_reality'),
|
||||||
|
public_key: get('tls_reality_public_key'),
|
||||||
|
short_id: get('tls_reality_short_id')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function load_transport(get) {
|
||||||
|
const transport = get('transport');
|
||||||
|
|
||||||
|
if (transport == null || transport === '')
|
||||||
|
return { type: null };
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: transport,
|
||||||
|
host: get('http_host') || get('httpupgrade_host'),
|
||||||
|
path: get('http_path') || get('ws_path'),
|
||||||
|
headers: get('ws_host') ? { Host: get('ws_host') } : null,
|
||||||
|
method: get('http_method'),
|
||||||
|
max_early_data: get('websocket_early_data'),
|
||||||
|
early_data_header_name: get('websocket_early_data_header'),
|
||||||
|
service_name: get('grpc_servicename'),
|
||||||
|
idle_timeout: get('http_idle_timeout'),
|
||||||
|
ping_timeout: get('http_ping_timeout'),
|
||||||
|
permit_without_stream: get('grpc_permit_without_stream')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
function load_multiplex(get) {
|
||||||
|
return {
|
||||||
|
enabled: get('multiplex'),
|
||||||
|
protocol: get('multiplex_protocol'),
|
||||||
|
max_connections: get('multiplex_max_connections'),
|
||||||
|
min_streams: get('multiplex_min_streams'),
|
||||||
|
max_streams: get('multiplex_max_streams'),
|
||||||
|
padding: get('multiplex_padding'),
|
||||||
|
brutal: {
|
||||||
|
enabled: get('multiplex_brutal'),
|
||||||
|
up_mbps: get('multiplex_brutal_up'),
|
||||||
|
down_mbps: get('multiplex_brutal_down')
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cross-protocol common fields (every outbound protocol carries them, not
|
||||||
|
* just one). Lives as its own sub-object instead of being repeated in
|
||||||
|
* every PROTOCOL_OPTIONS row, and the Adapter reads from `node.common`. */
|
||||||
|
function load_common(get) {
|
||||||
|
return {
|
||||||
|
proxy_protocol: get('proxy_protocol'),
|
||||||
|
tcp_fast_open: get('tcp_fast_open'),
|
||||||
|
tcp_multi_path: get('tcp_multi_path'),
|
||||||
|
udp_fragment: get('udp_fragment')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- protocol options --------------------------------------------------- */
|
||||||
|
|
||||||
|
/* The canonical <-> UCI mapping table lives in scripts/parser/mapping.uc and
|
||||||
|
* is imported as PROTOCOL_OPTIONS above. The Loader no longer maintains its
|
||||||
|
* own copy, so the parser, the Loader, the parser's normalize(), and the
|
||||||
|
* Repository all read from the same source. Anything not listed in
|
||||||
|
* parser/mapping.uc for a given protocol is not part of the domain
|
||||||
|
* contract; a stale UCI option is now dropped at the parser side too.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function load_protocol_options(get, type) {
|
||||||
|
const mapping = PROTOCOL_OPTIONS[type] || {};
|
||||||
|
const options = {};
|
||||||
|
|
||||||
|
for (let canonical, uci_name in mapping)
|
||||||
|
options[canonical] = get(uci_name);
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pull the protocol's credential fields out under canonical names, using the
|
||||||
|
* single CREDENTIALS table from the model. This is the loader half of the
|
||||||
|
* mapping that the old code spread across ~20 ternaries in generate_outbound().
|
||||||
|
* Declared before Loader: ucode resolves module-level names at call time but
|
||||||
|
* not before they are declared. */
|
||||||
|
function load_credentials(get, type) {
|
||||||
|
const mapping = CREDENTIALS[type] || {};
|
||||||
|
const credentials = {};
|
||||||
|
|
||||||
|
for (let canonical, uci_name in mapping)
|
||||||
|
credentials[canonical] = get(uci_name);
|
||||||
|
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- domain sub-objects ------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Settings: only the keys that exist in UCI are kept (null is dropped), so
|
||||||
|
* the consumer can tell "not set" from "set to empty". A3 (Generator split)
|
||||||
|
* will own the actual key list - this loader just preserves what UCI has. */
|
||||||
|
function load_settings(uci, section, keys) {
|
||||||
|
const settings = {};
|
||||||
|
|
||||||
|
for (let key in keys) {
|
||||||
|
const v = uci.get(UCICONFIG, section, key);
|
||||||
|
if (v != null)
|
||||||
|
settings[key] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The raw UCI section dict that
|
||||||
|
* uci.foreach() yields carries internal fields prefixed with a dot
|
||||||
|
* (`.name`, `.index`, `.type`); downstream generators had to read those
|
||||||
|
* directly, and every consumer had to remember `cfg.enabled !== '1'`
|
||||||
|
* to gate a section. Normalise once here so consumers see a flat
|
||||||
|
* domain object: the dot-prefixed pseudo-fields are dropped, the UCI
|
||||||
|
* section name is exposed as `name`, and `enabled` (when present) is
|
||||||
|
* coerced to a real boolean.
|
||||||
|
*
|
||||||
|
* Fields with non-prefixed names that happen to collide with the UCI
|
||||||
|
* metadata (e.g. an option literally called `name` or `index`) would
|
||||||
|
* be shadowed; in this codebase that does not happen, and the Adapter keeps
|
||||||
|
* it that way. */
|
||||||
|
function normalize_section(cfg) {
|
||||||
|
const item = {};
|
||||||
|
|
||||||
|
for (let k, v in cfg) {
|
||||||
|
/* substr(), not k[0]: the target ucode (2026.01.16) rejects
|
||||||
|
* string indexing with "left-hand side expression is not an
|
||||||
|
* array or object". subscription/repository.uc already uses
|
||||||
|
* the substr() form for the same check. */
|
||||||
|
if (substr(k, 0, 1) === '.')
|
||||||
|
continue;
|
||||||
|
item[k] = v;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('enabled' in item)
|
||||||
|
item.enabled = (item.enabled === '1');
|
||||||
|
|
||||||
|
item.name = cfg['.name'];
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single-section + list sections grouped under a domain sub-object.
|
||||||
|
* Each list entry is the normalised section dict produced by
|
||||||
|
* normalize_section(): the UCI `.name`/`.index`/`.type` pseudo-fields
|
||||||
|
* are gone, `name` is set to the section name, and `enabled` is a
|
||||||
|
* boolean. Sing-box field names are still produced by the generator
|
||||||
|
* modules; this loader just preserves what UCI has. */
|
||||||
|
function load_sections(uci, type) {
|
||||||
|
const items = [];
|
||||||
|
|
||||||
|
uci.foreach(UCICONFIG, type, (cfg) => push(items, normalize_section(cfg)));
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_dns(uci) {
|
||||||
|
return {
|
||||||
|
settings: load_settings(uci, SECTION.dns, [
|
||||||
|
'default_strategy', 'default_server',
|
||||||
|
'disable_cache', 'disable_cache_expire',
|
||||||
|
'client_subnet',
|
||||||
|
'optimistic_cache', 'optimistic_timeout',
|
||||||
|
'dns_timeout', 'cache_file_store_dns'
|
||||||
|
]),
|
||||||
|
servers: load_sections(uci, SECTION.dns_server),
|
||||||
|
rules: load_sections(uci, SECTION.dns_rule)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function load_routing(uci) {
|
||||||
|
return {
|
||||||
|
settings: load_settings(uci, SECTION.routing, [
|
||||||
|
'default_outbound', 'default_outbound_dns',
|
||||||
|
'domain_strategy', 'find_neighbor',
|
||||||
|
'udp_timeout', 'tcpip_stack', 'endpoint_independent_nat'
|
||||||
|
]),
|
||||||
|
nodes: load_sections(uci, SECTION.routing_node),
|
||||||
|
rules: load_sections(uci, SECTION.routing_rule),
|
||||||
|
rulesets: load_sections(uci, SECTION.ruleset)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* access_control is two single sections: `control` (lan_proxy_mode,
|
||||||
|
* wan_proxy_*_ips) and `subscription` (auto_update, filter, urls). */
|
||||||
|
function load_access_control(uci) {
|
||||||
|
return {
|
||||||
|
control: load_settings(uci, SECTION.control, [
|
||||||
|
'bind_interface', 'lan_proxy_mode'
|
||||||
|
]),
|
||||||
|
/* wan_proxy_*_ips are list options on the control section; collect
|
||||||
|
them in their canonical form. */
|
||||||
|
wan_proxy_ipv4_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv4_ips') || [],
|
||||||
|
wan_proxy_ipv6_ips: uci.get(UCICONFIG, SECTION.control, 'wan_proxy_ipv6_ips') || [],
|
||||||
|
subscription: load_settings(uci, SECTION.subscription, [
|
||||||
|
'auto_update', 'allow_insecure',
|
||||||
|
'packet_encoding', 'update_via_proxy',
|
||||||
|
'filter_nodes', 'user_agent'
|
||||||
|
]),
|
||||||
|
subscription_urls: uci.get(UCICONFIG, SECTION.subscription, 'subscription_url') || [],
|
||||||
|
filter_keywords: uci.get(UCICONFIG, SECTION.subscription, 'filter_keywords') || []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The two "read a table of canonical -> UCI names off a section"
|
||||||
|
* helpers shared by the node and inbound paths. Same shape as
|
||||||
|
* load_protocol_options() above; split out so the server side reads
|
||||||
|
* the same way the client side does. */
|
||||||
|
function load_table(get, mapping) {
|
||||||
|
const out = {};
|
||||||
|
|
||||||
|
for (let canonical, uci_name in mapping)
|
||||||
|
out[canonical] = get(uci_name);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* A server inbound is now a
|
||||||
|
* domain object like a Node, built here and shaped into sing-box JSON
|
||||||
|
* by config/adapter.uc's InboundFactory. Before this the generator read
|
||||||
|
* the flat UCI section directly (`cfg.snell_version`,
|
||||||
|
* `cfg.shadowsocks_encrypt_method`, ...), so the server path had no
|
||||||
|
* domain model at all.
|
||||||
|
*
|
||||||
|
* Same sub-object split as Node: common (listener-level), credentials,
|
||||||
|
* tls (the shared shape the client uses too), tls_server (the
|
||||||
|
* server-only tail), transport, multiplex, protocol_options. */
|
||||||
|
function load_inbound(get, section) {
|
||||||
|
const type = get('type');
|
||||||
|
|
||||||
|
return Inbound.create({
|
||||||
|
id: section['.name'],
|
||||||
|
name: get('label') || section['.name'],
|
||||||
|
type: type,
|
||||||
|
enabled: section.enabled === '1',
|
||||||
|
|
||||||
|
address: get('address'),
|
||||||
|
port: get('port'),
|
||||||
|
firewall: get('firewall'),
|
||||||
|
|
||||||
|
common: load_table(get, INBOUND_COMMON),
|
||||||
|
credentials: load_table(get, INBOUND_CREDENTIALS[type] || {}),
|
||||||
|
tls: load_tls(get),
|
||||||
|
tls_server: load_table(get, INBOUND_TLS_SERVER),
|
||||||
|
transport: load_transport(get),
|
||||||
|
multiplex: load_multiplex(get),
|
||||||
|
protocol_options: load_table(get, INBOUND_OPTIONS[type] || {})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* server has one enabled/log_level single section + N inbound sections
|
||||||
|
* (each with a `type` of vless / trojan / shadowsocks / ...). */
|
||||||
|
function load_server(uci) {
|
||||||
|
const inbounds = [];
|
||||||
|
|
||||||
|
uci.foreach(UCICONFIG, SECTION.server, (section) => {
|
||||||
|
push(inbounds, load_inbound((name) => node_opt(uci, section['.name'], name), section));
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
settings: load_settings(uci, SECTION.server, [
|
||||||
|
'enabled', 'log_level'
|
||||||
|
]),
|
||||||
|
inbounds
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- loader ------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const Loader = {
|
||||||
|
/* Load the client side of the configuration. `dir` defaults to the
|
||||||
|
* production UCI directory but can be overridden, which removes the need
|
||||||
|
* for the test suite's `sed` of `const uci = cursor();`. */
|
||||||
|
load: (dir) => {
|
||||||
|
const uci = dir ? cursor(dir) : cursor();
|
||||||
|
|
||||||
|
uci.load(UCICONFIG);
|
||||||
|
|
||||||
|
const config = Config.create('homeproxy');
|
||||||
|
|
||||||
|
config.general = {
|
||||||
|
routing_mode: opt(uci, 'main', 'routing_mode') || 'bypass_mainland_china',
|
||||||
|
proxy_mode: opt(uci, 'main', 'proxy_mode') || 'redirect_tproxy',
|
||||||
|
main_node: opt(uci, 'main', 'main_node') || 'nil',
|
||||||
|
main_udp_node: opt(uci, 'main', 'main_udp_node') || 'nil',
|
||||||
|
/* Booleans stay as raw UCI strings here, same as Node. The
|
||||||
|
Adapter (A3) is the one that calls strToBool(); coercing in
|
||||||
|
the Loader and again in the Adapter produced two different
|
||||||
|
defaults for absent / garbage values during A1.1. */
|
||||||
|
ipv6_support: opt(uci, 'main', 'ipv6_support'),
|
||||||
|
|
||||||
|
/* A2.1: scalar + list fields on the `config` section. */
|
||||||
|
dns_server: opt(uci, 'main', 'dns_server'),
|
||||||
|
china_dns_server: opt(uci, 'main', 'china_dns_server'),
|
||||||
|
log_level: opt(uci, 'main', 'log_level') || 'warn',
|
||||||
|
tun_dns_mode: opt(uci, 'main', 'tun_dns_mode'),
|
||||||
|
tun_dns_address: opt(uci, 'main', 'tun_dns_address'),
|
||||||
|
udp_mapping: opt(uci, 'main', 'udp_mapping'),
|
||||||
|
udp_filtering: opt(uci, 'main', 'udp_filtering'),
|
||||||
|
udp_nat_max: opt(uci, 'main', 'udp_nat_max'),
|
||||||
|
cn_ip_fallback: opt(uci, 'main', 'cn_ip_fallback'),
|
||||||
|
main_urltest_nodes: opt(uci, 'main', 'main_urltest_nodes') || [],
|
||||||
|
main_urltest_interval: opt(uci, 'main', 'main_urltest_interval'),
|
||||||
|
main_urltest_tolerance: opt(uci, 'main', 'main_urltest_tolerance'),
|
||||||
|
main_udp_urltest_nodes: opt(uci, 'main', 'main_udp_urltest_nodes') || [],
|
||||||
|
main_udp_urltest_interval: opt(uci, 'main', 'main_udp_urltest_interval'),
|
||||||
|
main_udp_urltest_tolerance: opt(uci, 'main', 'main_udp_urltest_tolerance')
|
||||||
|
};
|
||||||
|
|
||||||
|
/* udp_timeout lives on two UCI sections: routing.udp_timeout
|
||||||
|
* (custom mode) and infra.udp_timeout (everything else). Mirror
|
||||||
|
* that split here so A2 can read either path without falling back
|
||||||
|
* to uci.get(). */
|
||||||
|
config.infra = load_settings(uci, SECTION.infra, [
|
||||||
|
'common_port', 'mixed_port', 'redirect_port', 'tproxy_port',
|
||||||
|
'dns_port', 'dns_redirect',
|
||||||
|
'tun_name', 'tun_addr4', 'tun_addr6', 'tun_mtu',
|
||||||
|
'table_mark', 'self_mark', 'tproxy_mark', 'tun_mark',
|
||||||
|
'ntp_server', 'udp_timeout'
|
||||||
|
]);
|
||||||
|
|
||||||
|
uci.foreach(UCICONFIG, SECTION.node, (section) => {
|
||||||
|
const get = (name) => node_opt(uci, section['.name'], name);
|
||||||
|
|
||||||
|
push(config.nodes, Node.create({
|
||||||
|
id: section['.name'],
|
||||||
|
name: get('label') || section['.name'],
|
||||||
|
type: get('type'),
|
||||||
|
address: get('address'),
|
||||||
|
port: get('port'),
|
||||||
|
common: load_common(get),
|
||||||
|
credentials: load_credentials(get, get('type')),
|
||||||
|
tls: load_tls(get),
|
||||||
|
transport: load_transport(get),
|
||||||
|
multiplex: load_multiplex(get),
|
||||||
|
protocol_options: load_protocol_options(get, get('type'))
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
config.dns = load_dns(uci);
|
||||||
|
config.routing = load_routing(uci);
|
||||||
|
config.access_control = load_access_control(uci);
|
||||||
|
config.server = load_server(uci);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Stage A1.1 of the architecture refactor:
|
||||||
|
*
|
||||||
|
* UCI -> Config Loader -> HomeProxyConfig / Node -> Adapter -> sing-box
|
||||||
|
*
|
||||||
|
* The point of this layer is that nothing below it may touch UCI, and nothing
|
||||||
|
* in it may know about sing-box JSON. A Node therefore describes *what the
|
||||||
|
* user configured*, not what sing-box wants: no `outbound`, no `type` field
|
||||||
|
* named after a sing-box outbound kind, no `tag` arithmetic.
|
||||||
|
*
|
||||||
|
* Every field the Adapter used to read off
|
||||||
|
* `node.raw.*` has been promoted to an explicit Node sub-object
|
||||||
|
* (`credentials`, `tls`, `transport`, `multiplex`, `common`,
|
||||||
|
* `protocol_options`), so the legacy `raw` opaque bag was dropped - no code
|
||||||
|
* reads it and leaving it in only made the next reader assume it was
|
||||||
|
* authoritative.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { isEmpty } from '../homeproxy.uc';
|
||||||
|
|
||||||
|
/* Canonical credential multiplexing for a Node. The current generator picks
|
||||||
|
* these apart with ternaries per field (`username` vs `user` vs `password` vs
|
||||||
|
* `psk`, keyed on every protocol); expressing them as data is what makes a new
|
||||||
|
* protocol a table entry instead of another branch.
|
||||||
|
*
|
||||||
|
* The value is the UCI option name (as the loader sees it on the flat UCI
|
||||||
|
* section), the key is the canonical credential name. The adapter later
|
||||||
|
* emits the field under whatever name the sing-box outbound wants
|
||||||
|
* (e.g. snell: credentials.psk is emitted as the outbound `psk` field,
|
||||||
|
* shadowsocks: credentials.method becomes outbound `method`).
|
||||||
|
*
|
||||||
|
* Fixed during Stage A1.1: snell `userkey` actually comes from the
|
||||||
|
* `snell_userkey` UCI option (not `username`), and shadowsocks `method`
|
||||||
|
* actually comes from `shadowsocks_encrypt_method` (not `method`).
|
||||||
|
* The earlier table assumed both mapped to bare option names and failed
|
||||||
|
* the skeleton test against the real fixture. */
|
||||||
|
export const CREDENTIALS = {
|
||||||
|
vless: { uuid: 'uuid' },
|
||||||
|
vmess: { uuid: 'uuid' },
|
||||||
|
trojan: { password: 'password' },
|
||||||
|
hysteria: { auth_str: 'auth', auth_base64: 'auth' },
|
||||||
|
hysteria2: { password: 'password' },
|
||||||
|
tuic: { uuid: 'uuid', password: 'password' },
|
||||||
|
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||||
|
socks: { username: 'username', password: 'password' },
|
||||||
|
http: { username: 'username', password: 'password' },
|
||||||
|
snell: { psk: 'password', userkey: 'snell_userkey' },
|
||||||
|
/* The node form writes ssh_priv_key / ssh_priv_key_pp, not the bare
|
||||||
|
* private_key this table used to claim, so the key the user pasted was
|
||||||
|
* never read. (There was also no PROTOCOL_OPTIONS row for ssh, so the
|
||||||
|
* rest of the SSH options were dropped as well.) */
|
||||||
|
ssh: { user: 'username', password: 'password',
|
||||||
|
private_key: 'ssh_priv_key', private_key_passphrase: 'ssh_priv_key_pp' },
|
||||||
|
anytls: { password: 'password' },
|
||||||
|
shadowtls: { password: 'password' },
|
||||||
|
direct: {}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Canonical credentials for a server Inbound. A different table from
|
||||||
|
* the client's CREDENTIALS because the server form exposes a smaller
|
||||||
|
* set (no ssh material, no snell userkey) and because snell's server
|
||||||
|
* side reads the psk off the same `password` UCI option the client
|
||||||
|
* node uses for its own psk. Keeping the two tables separate means a
|
||||||
|
* change to one side cannot silently alter the other.
|
||||||
|
*/
|
||||||
|
export const INBOUND_CREDENTIALS = {
|
||||||
|
vless: { uuid: 'uuid' },
|
||||||
|
vmess: { uuid: 'uuid' },
|
||||||
|
trojan: { password: 'password' },
|
||||||
|
hysteria: { auth_str: 'hysteria_auth_payload', auth_base64: 'hysteria_auth_payload' },
|
||||||
|
hysteria2: { password: 'password' },
|
||||||
|
tuic: { uuid: 'uuid', password: 'password' },
|
||||||
|
shadowsocks: { password: 'password', method: 'shadowsocks_encrypt_method' },
|
||||||
|
shadowtls: { password: 'password' },
|
||||||
|
snell: { psk: 'password' },
|
||||||
|
anytls: { password: 'password' },
|
||||||
|
http: { username: 'username', password: 'password' },
|
||||||
|
mixed: { username: 'username', password: 'password' },
|
||||||
|
naive: { username: 'username', password: 'password' },
|
||||||
|
socks: { username: 'username', password: 'password' }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Per-protocol options on a server Inbound, canonical -> UCI. This is
|
||||||
|
* the server-side counterpart of parser/mapping.uc's PROTOCOL_TO_UCI;
|
||||||
|
* the two are NOT identical - the server side exposes inbound-only
|
||||||
|
* knobs (anytls padding_scheme, hysteria masquerade / packet sizes /
|
||||||
|
* ignore_client_bandwidth, tuic auth_timeout) and does not expose the
|
||||||
|
* client's port-hopping options - so they stay separate tables. A
|
||||||
|
* protocol whose server-side row is missing here produces no
|
||||||
|
* protocol_options rather than reading a flat UCI key. */
|
||||||
|
export const INBOUND_OPTIONS = {
|
||||||
|
anytls: {
|
||||||
|
padding_scheme: 'anytls_padding_scheme'
|
||||||
|
},
|
||||||
|
hysteria: {
|
||||||
|
auth_type: 'hysteria_auth_type',
|
||||||
|
up_mbps: 'hysteria_up_mbps',
|
||||||
|
down_mbps: 'hysteria_down_mbps',
|
||||||
|
obfs_password: 'hysteria_obfs_password',
|
||||||
|
masquerade: 'hysteria_masquerade',
|
||||||
|
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||||
|
},
|
||||||
|
hysteria2: {
|
||||||
|
up_mbps: 'hysteria_up_mbps',
|
||||||
|
down_mbps: 'hysteria_down_mbps',
|
||||||
|
obfs_type: 'hysteria_obfs_type',
|
||||||
|
obfs_password: 'hysteria_obfs_password',
|
||||||
|
obfs_min_packet_size: 'hysteria_obfs_min_packet_size',
|
||||||
|
obfs_max_packet_size: 'hysteria_obfs_max_packet_size',
|
||||||
|
masquerade: 'hysteria_masquerade',
|
||||||
|
ignore_client_bandwidth: 'hysteria_ignore_client_bandwidth'
|
||||||
|
},
|
||||||
|
shadowsocks: {
|
||||||
|
method: 'shadowsocks_encrypt_method'
|
||||||
|
},
|
||||||
|
snell: {
|
||||||
|
version: 'snell_version',
|
||||||
|
obfs_mode: 'snell_obfs_mode'
|
||||||
|
},
|
||||||
|
tuic: {
|
||||||
|
congestion_control: 'tuic_congestion_control',
|
||||||
|
auth_timeout: 'tuic_auth_timeout',
|
||||||
|
zero_rtt_handshake: 'tuic_enable_zero_rtt',
|
||||||
|
heartbeat: 'tuic_heartbeat'
|
||||||
|
},
|
||||||
|
vless: {
|
||||||
|
flow: 'vless_flow'
|
||||||
|
},
|
||||||
|
vmess: {
|
||||||
|
alter_id: 'vmess_alterid'
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Listener-level fields every server inbound carries. Inbound-only
|
||||||
|
* (no client outbound emits them), so they are not part of the
|
||||||
|
* client's load_common() table. */
|
||||||
|
export const INBOUND_COMMON = {
|
||||||
|
bind_interface: 'bind_interface',
|
||||||
|
reuse_addr: 'reuse_addr',
|
||||||
|
tcp_fast_open: 'tcp_fast_open',
|
||||||
|
tcp_multi_path: 'tcp_multi_path',
|
||||||
|
udp_fragment: 'udp_fragment',
|
||||||
|
udp_timeout: 'udp_timeout',
|
||||||
|
network: 'network'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The server-only TLS tail. These options exist because a client
|
||||||
|
* outbound never needs them (key material, ACME, the server half of
|
||||||
|
* the REALITY handshake), so they live on the Inbound rather than
|
||||||
|
* being folded into the shared `tls` sub-object the Loader builds for
|
||||||
|
* both sides. Canonical -> UCI; the Adapter re-keys them back to the
|
||||||
|
* UCI names buildTLSObject() reads, which is the one place the flat
|
||||||
|
* names still exist. */
|
||||||
|
export const INBOUND_TLS_SERVER = {
|
||||||
|
key_path: 'tls_key_path',
|
||||||
|
ech_key: 'tls_ech_key',
|
||||||
|
reality_private_key: 'tls_reality_private_key',
|
||||||
|
reality_max_time_difference: 'tls_reality_max_time_difference',
|
||||||
|
reality_server_addr: 'tls_reality_server_addr',
|
||||||
|
reality_server_port: 'tls_reality_server_port',
|
||||||
|
acme: 'tls_acme',
|
||||||
|
acme_domain: 'tls_acme_domain',
|
||||||
|
acme_dsn: 'tls_acme_dsn',
|
||||||
|
acme_email: 'tls_acme_email',
|
||||||
|
acme_provider: 'tls_acme_provider',
|
||||||
|
acme_account_key: 'tls_acme_account_key',
|
||||||
|
acme_key_type: 'tls_acme_key_type',
|
||||||
|
acme_profile: 'tls_acme_profile',
|
||||||
|
acme_disable_http_challenge: 'tls_acme_dhc',
|
||||||
|
acme_disable_tls_alpn_challenge: 'tls_acme_dtac',
|
||||||
|
acme_alternative_http_port: 'tls_acme_ahp',
|
||||||
|
acme_alternative_tls_port: 'tls_acme_atp',
|
||||||
|
acme_external_account: 'tls_acme_external_account',
|
||||||
|
acme_external_account_key_id: 'tls_acme_ea_keyid',
|
||||||
|
acme_external_account_mac_key: 'tls_acme_ea_mackey',
|
||||||
|
dns01_challenge: 'tls_dns01_challenge',
|
||||||
|
dns01_provider: 'tls_dns01_provider',
|
||||||
|
dns01_access_key_id: 'tls_dns01_ali_akid',
|
||||||
|
dns01_access_key_secret: 'tls_dns01_ali_aksec',
|
||||||
|
dns01_region_id: 'tls_dns01_ali_rid',
|
||||||
|
dns01_api_token: 'tls_dns01_cf_api_token'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- Domain types ------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const Config = {
|
||||||
|
create: (name) => ({
|
||||||
|
name: name,
|
||||||
|
general: {},
|
||||||
|
nodes: [],
|
||||||
|
|
||||||
|
/* The five domain sub-objects the Loader fills in from the
|
||||||
|
* corresponding UCI sections (single + list). The Loader now
|
||||||
|
* normalises the list sections; downstream generators read only
|
||||||
|
* these sub-objects. */
|
||||||
|
dns: {},
|
||||||
|
routing: {},
|
||||||
|
access_control: {},
|
||||||
|
server: {}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Node = {
|
||||||
|
create: (opts) => ({
|
||||||
|
/* identity */
|
||||||
|
id: opts.id,
|
||||||
|
name: opts.name,
|
||||||
|
type: opts.type,
|
||||||
|
|
||||||
|
/* endpoint */
|
||||||
|
address: opts.address,
|
||||||
|
port: opts.port,
|
||||||
|
|
||||||
|
/* cross-protocol common fields (proxy_protocol, tcp_fast_open,
|
||||||
|
* tcp_multi_path, udp_fragment). The Adapter reads from here so
|
||||||
|
* it never has to reach back into UCI for these. */
|
||||||
|
common: opts.common || {},
|
||||||
|
|
||||||
|
/* credentials, already canonical */
|
||||||
|
credentials: opts.credentials || {},
|
||||||
|
|
||||||
|
/* transport/TLS are sub-objects, not flat prefixed keys: that is the
|
||||||
|
boundary the adapter needs in order to reuse the shared builders. */
|
||||||
|
tls: opts.tls || {},
|
||||||
|
transport: opts.transport || {},
|
||||||
|
multiplex: opts.multiplex || {},
|
||||||
|
|
||||||
|
/* per-protocol extras, canonical names only */
|
||||||
|
protocol_options: opts.protocol_options || {}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/* A node is usable only if the adapter can build a valid outbound from it.
|
||||||
|
* The old code discovers this by crashing inside the JSON builder.
|
||||||
|
*
|
||||||
|
* Scope: generic, cross-protocol sanity only. The Adapter layer owns
|
||||||
|
* per-protocol required-field rules (a vless needs uuid, vmess needs
|
||||||
|
* both uuid and a security cipher, snell needs a psk, ...) because
|
||||||
|
* those rules are sing-box-outbound-shaped. Anything the model can
|
||||||
|
* express without naming a protocol or a JSON field goes here.
|
||||||
|
*
|
||||||
|
* Booleans are still raw UCI strings at this layer; this validator
|
||||||
|
* compares against the literal '1' the way UCI emits them.
|
||||||
|
*
|
||||||
|
* Implementation note: ucode's push() returns the value pushed, not
|
||||||
|
* the new array, so the natural-looking `problems = push(problems, X)`
|
||||||
|
* pattern reassigns problems to a string and silently corrupts the
|
||||||
|
* accumulator. Build the array via spread instead - cheap because the
|
||||||
|
* validator is at most four checks. */
|
||||||
|
validate: (node) => {
|
||||||
|
let problems = [];
|
||||||
|
|
||||||
|
if (!node.type)
|
||||||
|
problems = [...problems, 'missing type'];
|
||||||
|
|
||||||
|
/* A direct outbound can run without a server/server_port - sing-box
|
||||||
|
* uses the inbound's destination as the upstream, which is the
|
||||||
|
* transparent-proxy case. For every other protocol the server
|
||||||
|
* and port are mandatory. */
|
||||||
|
if (node.type !== 'direct') {
|
||||||
|
if (!node.address)
|
||||||
|
problems = [...problems, 'missing address'];
|
||||||
|
|
||||||
|
if (node.port == null || int(node.port) < 1 || int(node.port) > 65535)
|
||||||
|
problems = [...problems, `invalid port '${node.port}'`];
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TLS without server_name is a sing-box-side rejection, but the
|
||||||
|
* rule itself is protocol-agnostic - the reality branch covers the
|
||||||
|
* only legitimate escape - so it stays here. */
|
||||||
|
if (node.tls.enabled === '1' && !node.tls.server_name && node.tls.reality.enabled !== '1')
|
||||||
|
problems = [...problems, 'TLS enabled without server_name'];
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* sing-box tag convention lives here, not scattered through the generator */
|
||||||
|
tag: (node) => 'cfg-' + node.id + '-out'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* The server-side counterpart of Node. Same layering discipline: it
|
||||||
|
* describes what the user configured (listen address/port, protocol,
|
||||||
|
* credentials, TLS, transport, per-protocol knobs) and knows nothing
|
||||||
|
* about sing-box JSON. The Inbound adapter (config/adapter.uc,
|
||||||
|
* InboundFactory) is what turns it into a sing-box inbound.
|
||||||
|
*
|
||||||
|
* Field grouping mirrors Node deliberately so the Adapter's two halves
|
||||||
|
* read the same way:
|
||||||
|
* common listener-level shared fields (bind_interface,
|
||||||
|
* reuse_addr, tcp_fast_open, ...)
|
||||||
|
* credentials canonical credential names
|
||||||
|
* tls shared TLS shape (same load_tls() the client uses)
|
||||||
|
* tls_server server-only TLS tail (key material, ACME, the
|
||||||
|
* server half of the REALITY handshake)
|
||||||
|
* transport shared transport shape (same load_transport())
|
||||||
|
* multiplex shared multiplex shape
|
||||||
|
* protocol_options per-protocol knobs
|
||||||
|
*/
|
||||||
|
export const Inbound = {
|
||||||
|
create: (opts) => ({
|
||||||
|
/* identity */
|
||||||
|
id: opts.id,
|
||||||
|
name: opts.name,
|
||||||
|
type: opts.type,
|
||||||
|
enabled: opts.enabled,
|
||||||
|
|
||||||
|
/* listener */
|
||||||
|
address: opts.address,
|
||||||
|
port: opts.port,
|
||||||
|
|
||||||
|
/* firewall is consumed by firewall_pre.uc (which decides the
|
||||||
|
* nft accept rules), not by the inbound adapter. It stays on the
|
||||||
|
* model so the server section is described in one place. */
|
||||||
|
firewall: opts.firewall,
|
||||||
|
|
||||||
|
common: opts.common || {},
|
||||||
|
credentials: opts.credentials || {},
|
||||||
|
tls: opts.tls || {},
|
||||||
|
tls_server: opts.tls_server || {},
|
||||||
|
transport: opts.transport || {},
|
||||||
|
multiplex: opts.multiplex || {},
|
||||||
|
|
||||||
|
protocol_options: opts.protocol_options || {}
|
||||||
|
}),
|
||||||
|
|
||||||
|
/* Same scope as Node.validate(): cross-protocol sanity the model
|
||||||
|
* can express without naming a sing-box field. The per-protocol
|
||||||
|
* rules live in the Adapter (InboundFactory.problems). */
|
||||||
|
validate: (inbound) => {
|
||||||
|
let problems = [];
|
||||||
|
|
||||||
|
if (!inbound.type)
|
||||||
|
problems = [...problems, 'missing type'];
|
||||||
|
|
||||||
|
/* An inbound with no listen address is legitimate (sing-box
|
||||||
|
* defaults to all interfaces), but a port is required. */
|
||||||
|
if (inbound.port == null || int(inbound.port) < 1 || int(inbound.port) > 65535)
|
||||||
|
problems = [...problems, `invalid port '${inbound.port}'`];
|
||||||
|
|
||||||
|
return problems;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* sing-box tag convention for a server inbound. Uses `id` (the UCI
|
||||||
|
* section name) rather than `name` (the user's label) so renaming a
|
||||||
|
* label does not change the tag. */
|
||||||
|
tag: (inbound) => 'cfg-' + inbound.id + '-in'
|
||||||
|
};
|
||||||
|
|
||||||
|
/* --- Config helpers ----------------------------------------------------- */
|
||||||
|
|
||||||
|
export const ConfigQuery = {
|
||||||
|
node_by_id: (config, id) => {
|
||||||
|
for (let node in config.nodes)
|
||||||
|
if (node.id === id)
|
||||||
|
return node;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Linear search by section name. The Loader normalises list sections so
|
||||||
|
* `name` is the UCI section name; the dotted `.name` UCI uses
|
||||||
|
* internally is gone from the loaded shape. */
|
||||||
|
find_by_name: (items, name) => {
|
||||||
|
for (let it in items)
|
||||||
|
if (it.name === name)
|
||||||
|
return it;
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/* Which UDP node the routing modes route through. The default (`nil`)
|
||||||
|
* means "no proxy node", which is a legitimate configuration. The
|
||||||
|
* equivalent main_node read is one-liner enough that no helper
|
||||||
|
* exists. */
|
||||||
|
main_udp_node_id: (config) => config.general.main_udp_node || 'nil'
|
||||||
|
};
|
||||||
@@ -0,0 +1,762 @@
|
|||||||
|
#!/usr/bin/utpl -S
|
||||||
|
|
||||||
|
{%-
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 ImmortalWrt.org
|
||||||
|
*
|
||||||
|
* Declares every homeproxy_* fw4 chain/set. The inventory of those objects is
|
||||||
|
* tracked in scripts/fw4_names.sh, which init.d/homeproxy uses to clean them up
|
||||||
|
* on stop; add/remove entries there together with any change below.
|
||||||
|
* tests/ucode/test_fw4_names.sh compares both lists, and
|
||||||
|
* tests/ucode/test_firewall_template.sh renders this template and checks the
|
||||||
|
* objects actually survive into the generated nftables script.
|
||||||
|
*
|
||||||
|
* Every UCI-derived value (IPv4, MAC, interface name, port) goes through
|
||||||
|
* one of the *_to_nftarr() helpers in firewall_utils.uc - not because the
|
||||||
|
* UCI writers are untrusted (UI validation is there), but because UCI is
|
||||||
|
* writable from anywhere on the LAN and the package principle is "backend
|
||||||
|
* must enforce". Why those helpers exist, and why dropping silently
|
||||||
|
* matches the IPv6 path, is pinned by tests/ucode/test_firewall_validators.uc
|
||||||
|
* and guard 11 in tests/arch-guard.sh.
|
||||||
|
*
|
||||||
|
* Keep this comment inside the ucode block: text placed before `{%-` has its
|
||||||
|
* trailing newline trimmed, which would glue it onto the first nft statement.
|
||||||
|
*/
|
||||||
|
import { readfile } from 'fs';
|
||||||
|
import { cursor } from 'uci';
|
||||||
|
import { isEmpty, isValidCIDR } from '/etc/homeproxy/scripts/homeproxy.uc';
|
||||||
|
import {
|
||||||
|
ipv4_to_nftarr,
|
||||||
|
mac_to_nftarr,
|
||||||
|
iface_to_nftarr,
|
||||||
|
ports_to_nftarr,
|
||||||
|
isValidPort,
|
||||||
|
isValidIface
|
||||||
|
} from '/etc/homeproxy/scripts/firewall_utils.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.addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Normalize a list of IPv6 entries into an nftables anonymous set
|
||||||
|
(or null when empty), so IPv6 ACLs match in a single set lookup
|
||||||
|
instead of one rule per address. */
|
||||||
|
function ipv6_to_nftarr(list) {
|
||||||
|
if (isEmpty(list))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
for (let ip in list) {
|
||||||
|
const r = resolve_ipv6(ip);
|
||||||
|
if (!isEmpty(r))
|
||||||
|
push(out, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty(out) ? null : `{ ${join(', ', out)} }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
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)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Single-value guards for the five fields that were still read straight from
|
||||||
|
* UCI and interpolated into the nft text: self_mark, redirect_port,
|
||||||
|
* tproxy_port, dns_port and tun_name. H1 fixed the *_to_nftarr() families but
|
||||||
|
* missed these; a ';' in one of them is a statement boundary in nft, and a
|
||||||
|
* non-numeric value makes fw4 reject the whole ruleset, which takes the
|
||||||
|
* router's firewall down with it. A rejected value falls back to the built-in
|
||||||
|
* default rather than being emitted, so the side keeps working. */
|
||||||
|
function port_or(str, fallback) {
|
||||||
|
return isValidPort(str) ? str : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function iface_or(str, fallback) {
|
||||||
|
return isValidIface(str) ? str : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* self_mark is used as a *match* value (`meta mark <x>`), so it has to be a
|
||||||
|
* plain number. resolve_mark() also returns set-expressions such as
|
||||||
|
* `mark and 0xff xor 0x100` for masked input, and those are only valid after
|
||||||
|
* `mark set`; anything that is not a bare hex value falls back. */
|
||||||
|
function mark_value_or(str, fallback) {
|
||||||
|
const m = resolve_mark(str);
|
||||||
|
return match(m || '', /^0x[0-9a-fA-F]+$/) ? m : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* gfwlist mode routes by the address set that dnsmasq fills through
|
||||||
|
* `nftset=`, and the gates below compare the destination against it. A dnsmasq
|
||||||
|
* built without HAVE_NFTSET never fills the set, and `ip daddr != @<empty set>`
|
||||||
|
* is true for every packet - the whole mode then silently routes everything
|
||||||
|
* direct while the DNS side keeps answering through sing-box. init.d records
|
||||||
|
* the capability in resources/.dnsmasq-nftset before this template is rendered,
|
||||||
|
* so the gates can be omitted there and sing-box decides by geoip instead.
|
||||||
|
* A missing file means "capability unknown" and keeps the historical output. */
|
||||||
|
function dnsmasq_nftset() {
|
||||||
|
let marker = null;
|
||||||
|
try {
|
||||||
|
marker = trim(readfile(resources_dir + '/.dnsmasq-nftset'));
|
||||||
|
} catch (e) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return marker === '' || marker === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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') || 'bypass_mainland_china';
|
||||||
|
let outbound_node, outbound_udp_node, china_dns_server, bypass_cn_traffic;
|
||||||
|
|
||||||
|
if (routing_mode !== 'custom') {
|
||||||
|
outbound_node = uci.get(cfgname, 'config', 'main_node') || 'nil';
|
||||||
|
outbound_udp_node = uci.get(cfgname, 'config', 'main_udp_node') || 'nil';
|
||||||
|
china_dns_server = uci.get(cfgname, 'config', 'china_dns_server');
|
||||||
|
} else {
|
||||||
|
outbound_node = uci.get(cfgname, 'routing', 'default_outbound') || 'nil';
|
||||||
|
bypass_cn_traffic = uci.get(cfgname, 'routing', 'bypass_cn_traffic') || '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (match(proxy_mode, /redirect/)) {
|
||||||
|
self_mark = mark_value_or(uci.get(cfgname, 'infra', 'self_mark'), resolve_mark('100'));
|
||||||
|
redirect_port = port_or(uci.get(cfgname, 'infra', 'redirect_port'), '5331');
|
||||||
|
}
|
||||||
|
if (match(proxy_mode, /tproxy/))
|
||||||
|
if (outbound_udp_node !== 'nil' || routing_mode === 'custom') {
|
||||||
|
tproxy_port = port_or(uci.get(cfgname, 'infra', 'tproxy_port'), '5332');
|
||||||
|
tproxy_mark = resolve_mark(uci.get(cfgname, 'infra', 'tproxy_mark') || '101') || resolve_mark('101');
|
||||||
|
}
|
||||||
|
if (match(proxy_mode, /tun/)) {
|
||||||
|
tun_name = iface_or(uci.get(cfgname, 'infra', 'tun_name'), 'singtun0');
|
||||||
|
tun_mark = resolve_mark(uci.get(cfgname, 'infra', 'tun_mark') || '102') || resolve_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',
|
||||||
|
/* dnsmasq's own port - NOT the sing-box DNS inbound port (infra.dns_port,
|
||||||
|
* 5333). Both were called `dns_port` here and in the generator, which
|
||||||
|
* made the hijack rule look like it pointed at the sing-box listener. */
|
||||||
|
dnsmasq_port = port_or(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 %}
|
||||||
|
|
||||||
|
{% if (routing_mode === 'gfwlist'): %}
|
||||||
|
set homeproxy_gfw_list_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
auto-merge
|
||||||
|
}
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
set homeproxy_gfw_list_v6 {
|
||||||
|
type ipv6_addr
|
||||||
|
flags interval
|
||||||
|
auto-merge
|
||||||
|
}
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% elif (match(routing_mode, /mainland_china/) || bypass_cn_traffic === '1'): %}
|
||||||
|
set homeproxy_mainland_addr_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
auto-merge
|
||||||
|
elements = {
|
||||||
|
{% for (let cnip4 in split(trim(readfile(resources_dir + '/china_ip4.txt')), /[\r\n]/)): %}
|
||||||
|
{% if (isValidCIDR(cnip4, 4)): %}
|
||||||
|
{{ cnip4 }},
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{% if ((ipv6_support === '1') || china_dns_server): %}
|
||||||
|
set homeproxy_mainland_addr_v6 {
|
||||||
|
type ipv6_addr
|
||||||
|
flags interval
|
||||||
|
auto-merge
|
||||||
|
elements = {
|
||||||
|
{% for (let cnip6 in split(trim(readfile(resources_dir + '/china_ip6.txt')), /[\r\n]/)): %}
|
||||||
|
{% if (isValidCIDR(cnip6, 6)): %}
|
||||||
|
{{ cnip6 }},
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
|
||||||
|
{# WAN ACL addresses #}
|
||||||
|
set homeproxy_wan_proxy_addr_v4 {
|
||||||
|
type ipv4_addr
|
||||||
|
flags interval
|
||||||
|
auto-merge
|
||||||
|
{% if (control_info.wan_proxy_ipv4_ips): %}
|
||||||
|
elements = { {{ ipv4_to_nftarr(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 = { {{ ipv6_to_nftarr(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 = { {{ ipv4_to_nftarr(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 = { {{ ipv6_to_nftarr(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 = { {{ ports_to_nftarr(routing_port) }} }
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# DNS hijack & TCP redirect #}
|
||||||
|
chain dstnat {
|
||||||
|
{% if ((dns_redirect === '1') && (dns_hijacked !== '1')): %}
|
||||||
|
{% if (control_info.listen_interfaces): %}
|
||||||
|
meta iifname {{ iface_to_nftarr(control_info.listen_interfaces) }}
|
||||||
|
{%- endif /* listen_interfaces */ %}
|
||||||
|
meta nfproto { ipv4, ipv6 } udp dport 53 counter redirect to :{{ dnsmasq_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 != {{ iface_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 {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_redirect
|
||||||
|
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_redirect
|
||||||
|
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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
|
||||||
|
|
||||||
|
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 {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||||
|
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_redirect_proxy_port
|
||||||
|
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||||
|
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||||
|
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||||
|
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||||
|
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_redirect_proxy
|
||||||
|
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_redirect_proxy
|
||||||
|
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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
|
||||||
|
meta nfproto { {{ (ipv6_support === '1') ? 'ipv4, ipv6' : 'ipv4' }} } meta l4proto tcp jump homeproxy_redirect
|
||||||
|
}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# UDP tproxy #}
|
||||||
|
{% if (match(proxy_mode, /tproxy/) && (outbound_udp_node !== 'nil' || routing_mode === 'custom')): %}
|
||||||
|
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 != {{ iface_to_nftarr([...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 {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_prerouting
|
||||||
|
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_prerouting
|
||||||
|
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||||
|
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tproxy_port
|
||||||
|
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||||
|
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||||
|
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||||
|
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% if (routing_mode !== 'custom'): %}
|
||||||
|
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||||
|
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||||
|
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter goto homeproxy_mangle_tproxy
|
||||||
|
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter goto homeproxy_mangle_tproxy
|
||||||
|
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {
|
||||||
|
meta mark {{ self_mark }} counter return
|
||||||
|
|
||||||
|
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 */ %}
|
||||||
|
|
||||||
|
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||||
|
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||||
|
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||||
|
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
|
||||||
|
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 != {{ iface_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 {{ ipv4_to_nftarr(control_info.lan_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun
|
||||||
|
{% endif /* lan_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun
|
||||||
|
{% endif /* lan_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.lan_direct_ipv4_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_direct_ipv6_ips) }} counter return
|
||||||
|
{% endif /* lan_direct_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_direct_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.lan_global_proxy_ipv4_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||||
|
{% endif /* lan_global_proxy_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_global_proxy_ipv6_ips) }} counter goto homeproxy_mangle_tun_mark
|
||||||
|
{% endif /* lan_global_proxy_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_global_proxy_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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 {{ ipv4_to_nftarr(control_info.wan_direct_ipv4_ips) }} counter return
|
||||||
|
{% endif /* wan_direct_ipv4_ips */ %}
|
||||||
|
{% if (control_info.wan_direct_ipv6_ips): %}
|
||||||
|
ip6 daddr {{ ipv6_to_nftarr(control_info.wan_direct_ipv6_ips) }} counter return
|
||||||
|
{% endif /* wan_direct_ipv6_ips */ %}
|
||||||
|
|
||||||
|
{% if (routing_mode === 'gfwlist' && dnsmasq_nftset()): %}
|
||||||
|
ip daddr != @homeproxy_gfw_list_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_gfw_list_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||||
|
{% elif (routing_mode === 'bypass_mainland_china' || bypass_cn_traffic === '1'): %}
|
||||||
|
ip daddr @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% if (routing_mode !== 'custom'): %}
|
||||||
|
udp dport { 80, 443 } counter reject comment "!{{ cfgname }}: Block QUIC (not proxyable)"
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
{% elif (routing_mode === 'proxy_mainland_china'): %}
|
||||||
|
ip daddr != @homeproxy_mainland_addr_v4 counter return
|
||||||
|
{% if (ipv6_support === '1'): %}
|
||||||
|
ip6 daddr != @homeproxy_mainland_addr_v6 counter return
|
||||||
|
{% endif /* ipv6_support */ %}
|
||||||
|
{% endif /* routing_mode */ %}
|
||||||
|
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv4_ips)): %}
|
||||||
|
ip saddr {{ ipv4_to_nftarr(control_info.lan_gaming_mode_ipv4_ips) }} counter meta mark set {{ tun_mark }}
|
||||||
|
{% endif /* lan_gaming_mode_ipv4_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_ipv6_ips)): %}
|
||||||
|
ip6 saddr {{ ipv6_to_nftarr(control_info.lan_gaming_mode_ipv6_ips) }} counter meta mark set {{ tun_mark }}
|
||||||
|
{% endif /* lan_gaming_mode_ipv6_ips */ %}
|
||||||
|
{% if (!isEmpty(control_info.lan_gaming_mode_mac_addrs)): %}
|
||||||
|
ether saddr {{ mac_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,99 @@
|
|||||||
|
#!/usr/bin/ucode
|
||||||
|
/* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Copyright (C) 2025 ImmortalWrt.org
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { writefile } from 'fs';
|
||||||
|
import { cursor } from 'uci';
|
||||||
|
import { isEmpty, RUN_DIR, validation } from './homeproxy.uc';
|
||||||
|
|
||||||
|
const cfgname = 'homeproxy';
|
||||||
|
const uci = cursor();
|
||||||
|
uci.load(cfgname);
|
||||||
|
|
||||||
|
const routing_mode = uci.get(cfgname, 'config', 'routing_mode') || 'bypass_mainland_china',
|
||||||
|
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';
|
||||||
|
|
||||||
|
/* Validate the interface name before it is interpolated into an nft
|
||||||
|
* ruleset that fw4 loads as root. It is a UCI value, and this file's own
|
||||||
|
* threat model treats UCI as untrusted - the port and network fields a few
|
||||||
|
* lines below are validated for exactly that reason, while tun_name was
|
||||||
|
* not. `;` and `}` need no newline, so a value like
|
||||||
|
* "singtun0 } ; chain evil { ..." would balance the template's trailing
|
||||||
|
* brace and add a chain of the attacker's choosing.
|
||||||
|
*
|
||||||
|
* Linux interface names are at most 15 characters and cannot contain
|
||||||
|
* whitespace or shell/nft metacharacters; anything outside that is not a
|
||||||
|
* name we could have created, so the tun rules are simply not emitted. */
|
||||||
|
if (outbound_node !== 'nil') {
|
||||||
|
const candidate = uci.get(cfgname, 'infra', 'tun_name') || 'singtun0';
|
||||||
|
|
||||||
|
if (match(candidate, /^[A-Za-z0-9_.-]{1,15}$/) && candidate !== '.' && candidate !== '..')
|
||||||
|
tun_name = candidate;
|
||||||
|
else
|
||||||
|
print(sprintf('WARN: ignoring invalid tun_name "%s" (not a valid interface name).', candidate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/* The section name is interpolated into the nft comment below, in a
|
||||||
|
* file fw4 loads as root. libuci only accepts [A-Za-z0-9_] in a
|
||||||
|
* section name (a `"`, `}`, `;`, space, `.` or `-` is "invalid
|
||||||
|
* character in name field" / "Invalid argument" on both the parser
|
||||||
|
* and the `uci set` path), so today no name that could close the
|
||||||
|
* comment can reach this loop through UCI at all - this mirrors the
|
||||||
|
* tun_name guard above rather than trusting a writer's in-memory
|
||||||
|
* cursor or a future libuci. The alphabet is exactly libuci's and
|
||||||
|
* there is deliberately no length cap (libuci has none, and a cap
|
||||||
|
* would skip a legal name and silently drop that server's rule).
|
||||||
|
* The check runs first so the two warnings below only ever echo a
|
||||||
|
* name that already passed it. */
|
||||||
|
if (!match(s['.name'], /^[A-Za-z0-9_]+$/)) {
|
||||||
|
print(`WARN: skipping server ${s['.name']}: invalid section name.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validation('port', s.port)) {
|
||||||
|
print(`WARN: skipping server ${s['.name']}: invalid port "${s.port}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let proto = s.network || '{ tcp, udp }';
|
||||||
|
if (!(proto in ['tcp', 'udp', '{ tcp, udp }'])) {
|
||||||
|
print(`WARN: skipping server ${s['.name']}: invalid network "${proto}".`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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');
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* Field validators for the nftables statements firewall_post.ut renders.
|
||||||
|
*
|
||||||
|
* Why this module exists: every UCI value the template concatenates into an
|
||||||
|
* nft expression is an injection surface. A malformed entry closes a set
|
||||||
|
* expression with `}`, and the next thing nft sees is whatever the attacker
|
||||||
|
* put after it - up to and including `accept` for a chain that should be a
|
||||||
|
* drop, or a `reject` outside any chain that the fw4 reload fails to
|
||||||
|
* compile, taking the whole router firewall with it. The homeproxy section
|
||||||
|
* in /etc/config/homeproxy is UCI-writable from the subscription updater,
|
||||||
|
* from any shell on the LAN and from any cron job the user has not locked
|
||||||
|
* down; `tests/ucode/test_firewall_validators.uc` covers that the helpers
|
||||||
|
* drop these inputs rather than letting them through, and `tests/arch-guard.sh`
|
||||||
|
* guard 11 covers that firewall_post.ut routes every field of these families
|
||||||
|
* through the helpers.
|
||||||
|
*
|
||||||
|
* Each helper returns a defensive shape:
|
||||||
|
* - null when the input was empty or every entry was rejected
|
||||||
|
* - otherwise `{ v1, v2, ... }` (the nftables anonymous-set spelling)
|
||||||
|
* so the template can plug the result into a statement unchanged. Invalid
|
||||||
|
* entries are dropped, matching the IPv6 helper's behaviour so the IPv4
|
||||||
|
* path is finally as defensive as the IPv6 path was.
|
||||||
|
*
|
||||||
|
* No logging on the drop path: utpl templates render to stdout, and any
|
||||||
|
* print() here would inject text into the nft ruleset. If a debug trail
|
||||||
|
* is ever wanted, do it from the template itself, not from these helpers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
/* isEmpty() lives in homeproxy.uc, not in the ucode globals. Without this
|
||||||
|
* import every helper below threw "access to undeclared variable isEmpty" the
|
||||||
|
* moment it was called, which no host-side test caught because the test
|
||||||
|
* harness stages its own stand-in module. */
|
||||||
|
import { isEmpty } from './homeproxy.uc';
|
||||||
|
|
||||||
|
/* IPv4 (with optional /CIDR) - the regex is the standard four-octet body
|
||||||
|
* followed by an optional /0..32. Anything outside that - "1.2.3.4.5",
|
||||||
|
* "1.2.3", " 1.2.3.4 ", "1.2.3.4 " - is rejected.
|
||||||
|
*
|
||||||
|
* Spelled out as one literal rather than built with `new RegExp(...)`: ucode
|
||||||
|
* has no `new` keyword, so the constructor form was a compile error
|
||||||
|
* ("Unexpected token") and the module never loaded. The capture groups are
|
||||||
|
* plain `(...)` because ucode regexes go through POSIX regcomp(), which has
|
||||||
|
* no `(?:...)` non-capturing group; nothing here reads the captures, so the
|
||||||
|
* extra ones are harmless. */
|
||||||
|
const IPV4_CIDR_RE = /^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\/(3[0-2]|[12]?[0-9]))?$/;
|
||||||
|
|
||||||
|
export function isValidIPv4(s) {
|
||||||
|
if (!s || type(s) !== 'string')
|
||||||
|
return false;
|
||||||
|
return !!match(s, IPV4_CIDR_RE);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ipv4_to_nftarr(list) {
|
||||||
|
if (isEmpty(list))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
for (let ip in list) {
|
||||||
|
if (isValidIPv4(ip))
|
||||||
|
push(out, ip);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* MAC address: six colon-separated hex octets, case-insensitive. */
|
||||||
|
const MAC_RE = /^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$/;
|
||||||
|
|
||||||
|
export function isValidMAC(s) {
|
||||||
|
if (!s || type(s) !== 'string')
|
||||||
|
return false;
|
||||||
|
return !!match(s, MAC_RE);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function mac_to_nftarr(list) {
|
||||||
|
if (isEmpty(list))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
for (let m in list) {
|
||||||
|
if (isValidMAC(m))
|
||||||
|
push(out, m);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Linux interface names: alnum, dot, underscore, hyphen. Anything that
|
||||||
|
* would survive `ip link show` parsing is accepted here. */
|
||||||
|
const IFACE_RE = /^[A-Za-z0-9._-]+$/;
|
||||||
|
|
||||||
|
export function isValidIface(s) {
|
||||||
|
if (!s || type(s) !== 'string')
|
||||||
|
return false;
|
||||||
|
return !!match(s, IFACE_RE);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function iface_to_nftarr(list) {
|
||||||
|
if (isEmpty(list))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
for (let i in list) {
|
||||||
|
if (isValidIface(i))
|
||||||
|
push(out, i);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Routing-port validator. UCI delivers routing_port as a comma-separated
|
||||||
|
* string ('22,53,80,443,...'), so we split first and then validate each
|
||||||
|
* piece as a single port. The old template did `split(routing_port, ',')`
|
||||||
|
* and `join(', ', ...)` directly: an entry like '7890; reject' or '22,foo'
|
||||||
|
* would land verbatim in an nft inet_service set. */
|
||||||
|
const PORT_RE = /^[0-9]+$/;
|
||||||
|
|
||||||
|
export function isValidPort(s) {
|
||||||
|
if (!s || type(s) !== 'string')
|
||||||
|
return false;
|
||||||
|
if (!match(s, PORT_RE))
|
||||||
|
return false;
|
||||||
|
const n = +s;
|
||||||
|
/* Port 0 is not a port: nft rejects `th dport 0` and `redirect to :0`, and a
|
||||||
|
* ruleset that carries one is rolled back as a whole by fw4. The callers
|
||||||
|
* use this as "the value is usable", so 0 has to be a rejection. */
|
||||||
|
return n >= 1 && n <= 65535;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ports_to_nftarr(s) {
|
||||||
|
if (isEmpty(s) || type(s) !== 'string')
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let out = [];
|
||||||
|
for (let p in split(s, ',')) {
|
||||||
|
const t = trim(p);
|
||||||
|
if (isValidPort(t))
|
||||||
|
push(out, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
return isEmpty(out) ? null : `{ ${join(', ', uniq(out))} }`;
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
#
|
||||||
|
# Copyright (C) 2025 ImmortalWrt.org
|
||||||
|
#
|
||||||
|
# Single source of truth for every fw4 chain and set owned by homeproxy.
|
||||||
|
#
|
||||||
|
# * root/etc/homeproxy/scripts/firewall_post.ut declares these objects and
|
||||||
|
# decides which of them actually exist for the current proxy mode.
|
||||||
|
# * root/etc/init.d/homeproxy flushes and deletes them on stop.
|
||||||
|
#
|
||||||
|
# When you add or remove a chain/set in firewall_post.ut, update this list in
|
||||||
|
# the same commit -- tests/ucode/test_fw4_names.sh compares both sides and
|
||||||
|
# fails when they drift.
|
||||||
|
#
|
||||||
|
# Each object is cleaned up independently: the cleanup loop deletes one at a
|
||||||
|
# time so that a chain/set which does not exist in the current mode cannot
|
||||||
|
# abort the whole batch. A single `nft -f` batch is atomic and would roll back
|
||||||
|
# every preceding command in that case.
|
||||||
|
|
||||||
|
HP_FW4_CHAINS="
|
||||||
|
homeproxy_output_redir
|
||||||
|
homeproxy_redirect
|
||||||
|
homeproxy_redirect_proxy
|
||||||
|
homeproxy_redirect_proxy_port
|
||||||
|
homeproxy_redirect_lanac
|
||||||
|
homeproxy_mangle_prerouting
|
||||||
|
homeproxy_mangle_output
|
||||||
|
homeproxy_mangle_tproxy
|
||||||
|
homeproxy_mangle_tproxy_port
|
||||||
|
homeproxy_mangle_lanac
|
||||||
|
homeproxy_mangle_mark
|
||||||
|
homeproxy_mangle_tun
|
||||||
|
homeproxy_mangle_tun_mark
|
||||||
|
"
|
||||||
|
|
||||||
|
HP_FW4_SETS="
|
||||||
|
homeproxy_local_addr_v4
|
||||||
|
homeproxy_local_addr_v6
|
||||||
|
homeproxy_gfw_list_v4
|
||||||
|
homeproxy_gfw_list_v6
|
||||||
|
homeproxy_mainland_addr_v4
|
||||||
|
homeproxy_mainland_addr_v6
|
||||||
|
homeproxy_wan_proxy_addr_v4
|
||||||
|
homeproxy_wan_proxy_addr_v6
|
||||||
|
homeproxy_wan_direct_addr_v4
|
||||||
|
homeproxy_wan_direct_addr_v6
|
||||||
|
homeproxy_routing_port
|
||||||
|
"
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#!/usr/bin/ucode
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* scripts/generate_client.uc: process-level entry point.
|
||||||
|
*
|
||||||
|
* The actual client generator is generator/client.uc; this script is the
|
||||||
|
* thin entry point that init.d/homeproxy execve's. Responsibilities:
|
||||||
|
*
|
||||||
|
* 1. Load UCI into the HomeProxyConfig domain model.
|
||||||
|
* 2. Resolve the GenerationContext env: the WAN resolver (ubus) and the two
|
||||||
|
* domain-resource lists (fs). This is the process-level boundary, which
|
||||||
|
* is why it lives here and not under generator/.
|
||||||
|
* 3. Call generate(dm, env) to get the sing-box JSON object.
|
||||||
|
* 4. Write atomically: candidate tmp -> `sing-box check` -> mv to live.
|
||||||
|
*
|
||||||
|
* The check is here (and not in the runtime, and not in the generator)
|
||||||
|
* because the architecture guide mandates the generator produce
|
||||||
|
* "atomic write candidate + serialization" and the runtime is already
|
||||||
|
* structured around "a failing generation leaves the previous file in
|
||||||
|
* place" (see runtime/config.sh's hp_ensure_live). Having the shell do
|
||||||
|
* the check keeps that contract single-sourced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { connect } from 'ubus';
|
||||||
|
import { mkdtemp, readfile, writefile } from 'fs';
|
||||||
|
|
||||||
|
import { Loader } from './config/loader.uc';
|
||||||
|
import { generate } from './generator/client.uc';
|
||||||
|
import { removeBlankAttrs, HP_DIR, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||||
|
|
||||||
|
/* Resolve the GenerationContext inputs. This is the only impure step on the
|
||||||
|
* client generation path, and it is deliberately here rather than under
|
||||||
|
* generator/:
|
||||||
|
*
|
||||||
|
* - wan_dns is the upstream the default-dns server detours to. ubus may be
|
||||||
|
* unreachable (no ubusd, a dev host, no WAN lease); build_context() then
|
||||||
|
* applies the same mode-dependent public fallback the pre-split generator
|
||||||
|
* used, so an unresolved value stays safe rather than fatal.
|
||||||
|
* - direct_domain_list / proxy_domain_list are the two files the resource
|
||||||
|
* updater maintains. Custom mode ignores both, so they are not read
|
||||||
|
* there - matching the pre-split read exactly.
|
||||||
|
*
|
||||||
|
* The extra parentheses around the ubus call keep the ?. chain guarded when
|
||||||
|
* connect() returns null. */
|
||||||
|
function resolve_env(dm) {
|
||||||
|
const routing_mode = dm.general.routing_mode || 'bypass_mainland_china';
|
||||||
|
const ubus = connect();
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
wan_dns: (ubus?.call('network.interface', 'status', {'interface': 'wan'}))?.['dns-server']?.[0],
|
||||||
|
direct_domain_list: [],
|
||||||
|
proxy_domain_list: []
|
||||||
|
};
|
||||||
|
|
||||||
|
if (routing_mode !== 'custom') {
|
||||||
|
const direct_list_raw = readfile(HP_DIR + '/resources/direct_list.txt');
|
||||||
|
env.direct_domain_list = direct_list_raw ? split(trim(direct_list_raw), /[\r\n]/) : [];
|
||||||
|
|
||||||
|
const proxy_list_raw = readfile(HP_DIR + '/resources/proxy_list.txt');
|
||||||
|
env.proxy_domain_list = proxy_list_raw ? split(trim(proxy_list_raw), /[\r\n]/) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return env;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dm = Loader.load(UCICONFIG_DIR);
|
||||||
|
const config = removeBlankAttrs(generate(dm, resolve_env(dm)));
|
||||||
|
|
||||||
|
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||||
|
|
||||||
|
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||||
|
*
|
||||||
|
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||||
|
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||||
|
* the same file, and `sing-box check` could be validating a file the other run
|
||||||
|
* was still writing - the winner then installed a half-written config.
|
||||||
|
*
|
||||||
|
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||||
|
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||||
|
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||||
|
* the "all shell args quoted" rule is the machine-checkable
|
||||||
|
* invariant, not a comment about today's safety. */
|
||||||
|
const work_dir = mkdtemp();
|
||||||
|
const tmp = work_dir + '/sing-box-c.json';
|
||||||
|
|
||||||
|
/* writefile() returns null on failure, and ignoring that turned a full disk or
|
||||||
|
* a permission error into a later "sing-box check failed", which points at the
|
||||||
|
* wrong thing entirely. */
|
||||||
|
if (writefile(tmp, sprintf('%.J\n', config)) == null) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
die('failed to write the generated client configuration to ' + tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-c.json') !== 0) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The generated config carries every node credential - passwords, UUIDs,
|
||||||
|
* private keys - and writefile() has no mode argument, so it lands with the
|
||||||
|
* process umask (world-readable at the usual 022). sing-box runs as its own
|
||||||
|
* user and reads the file directly, so 0600 is enough. */
|
||||||
|
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-c.json');
|
||||||
|
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/ucode
|
||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* scripts/generate_server.uc: process-level entry point.
|
||||||
|
*
|
||||||
|
* Mirror of scripts/generate_client.uc for the server instance. The
|
||||||
|
* server generator (generator/server.uc) returns null when no inbound
|
||||||
|
* is enabled; the shell then exits 1 so init.d can detect
|
||||||
|
* "server disabled" without a separate UCI check.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { mkdtemp, writefile } from 'fs';
|
||||||
|
import { Loader } from './config/loader.uc';
|
||||||
|
import { generate_server } from './generator/server.uc';
|
||||||
|
import { removeBlankAttrs, RUN_DIR, shellQuote, UCICONFIG_DIR } from './homeproxy.uc';
|
||||||
|
|
||||||
|
const dm = Loader.load(UCICONFIG_DIR);
|
||||||
|
const config = generate_server(dm);
|
||||||
|
if (!config)
|
||||||
|
exit(1);
|
||||||
|
|
||||||
|
const cleaned = removeBlankAttrs(config);
|
||||||
|
system('mkdir -p ' + shellQuote(RUN_DIR));
|
||||||
|
|
||||||
|
/* A private scratch directory rather than a fixed `<out>.tmp`.
|
||||||
|
*
|
||||||
|
* Two generation runs can still overlap (a LuCI apply while the cron entry
|
||||||
|
* reloads, or a manual and a triggered reload). With a fixed name both wrote
|
||||||
|
* the same file, and `sing-box check` could be validating a file the other run
|
||||||
|
* was still writing - the winner then installed a half-written config.
|
||||||
|
*
|
||||||
|
* mkdtemp() is this package's existing primitive for that (executeCommand()
|
||||||
|
* uses it) and gives a 0700 directory under /tmp. The path here comes from
|
||||||
|
* mkdtemp() so it is safe today, but it goes through shellQuote() anyway:
|
||||||
|
* the "all shell args quoted" rule is the machine-checkable
|
||||||
|
* invariant, not a comment about today's safety. */
|
||||||
|
const work_dir = mkdtemp();
|
||||||
|
const tmp = work_dir + '/sing-box-s.json';
|
||||||
|
|
||||||
|
/* Same reason as the client path: a failed write used to be reported as a
|
||||||
|
* failed `sing-box check`. */
|
||||||
|
if (writefile(tmp, sprintf('%.J\n', cleaned)) == null) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
die('failed to write the generated server configuration to ' + tmp);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (system('sing-box check --config ' + shellQuote(tmp)) !== 0) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (system('mv -f ' + shellQuote(tmp) + ' ' + shellQuote(RUN_DIR) + '/sing-box-s.json') !== 0) {
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The server config carries inbound credentials and the REALITY/ECH key
|
||||||
|
* material, and writefile() has no mode argument, so it lands with the
|
||||||
|
* process umask. 0600 matches what sing-box needs to read it as its own
|
||||||
|
* user. */
|
||||||
|
system('chmod 600 ' + shellQuote(RUN_DIR) + '/sing-box-s.json');
|
||||||
|
|
||||||
|
system('rm -rf ' + shellQuote(work_dir));
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* generator/client.uc: client-side orchestrator.
|
||||||
|
*
|
||||||
|
* This file is the only place the client generator still knows the
|
||||||
|
* overall shape. Each section builder lives in its own module under
|
||||||
|
* generator/; this file glues them together.
|
||||||
|
*
|
||||||
|
* generate(dm, env)
|
||||||
|
* -> build_context(dm, env)
|
||||||
|
* -> build_dns
|
||||||
|
* -> build_inbounds
|
||||||
|
* -> build_outbounds (mutates direct_overrides)
|
||||||
|
* -> build_route
|
||||||
|
* -> build_user_rulesets (custom mode only)
|
||||||
|
* -> build_http_clients
|
||||||
|
* -> attachSchema, attachExperimental
|
||||||
|
* -> final config object (caller writes + sing-box check)
|
||||||
|
*
|
||||||
|
* Every section builder takes (config, ctx, ...) where `ctx` carries
|
||||||
|
* the routing-mode- and proxy-mode-derived scalars (main_node,
|
||||||
|
* dns_server, proxy_mode, ...) plus the mutable accumulators (outbounds,
|
||||||
|
* endpoints, ...) the builders append to. The orchestrator owns them
|
||||||
|
* and threads them through, which is what kept the original
|
||||||
|
* generate_client.uc at 1240 lines: a 1100-line config blob with a
|
||||||
|
* single closure scope.
|
||||||
|
*
|
||||||
|
* The output is the final config object: no `$schema`, no atomic write,
|
||||||
|
* no `sing-box check`. The CLI shell (`scripts/generate_client.uc`)
|
||||||
|
* is responsible for those - per the architecture guide, the generator
|
||||||
|
* must not double as the runner.
|
||||||
|
*
|
||||||
|
* It is also responsible for resolving `env` (the WAN DNS via ubus and the
|
||||||
|
* two resource lists) and passing it in. This module imports no ubus and no
|
||||||
|
* fs: `generate(dm, env)` is a pure function, so the same arguments always
|
||||||
|
* produce the same bytes. tests/arch-guard.sh guard 27 keeps it that way.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { isEmpty, strToInt, RUN_DIR } from '../homeproxy.uc';
|
||||||
|
|
||||||
|
import { build_context } from './context.uc';
|
||||||
|
import { build_dns } from './dns.uc';
|
||||||
|
import { build_inbounds } from './inbound.uc';
|
||||||
|
import { build_outbounds } from './outbound.uc';
|
||||||
|
import { build_route } from './route.uc';
|
||||||
|
import { build_user_rulesets, build_http_clients } from './ruleset.uc';
|
||||||
|
import { attachSchema, attachExperimental } from './common.uc';
|
||||||
|
|
||||||
|
/* --- public entry ------------------------------------------------------ */
|
||||||
|
|
||||||
|
/* Build the sing-box client config object. Pure function: takes the
|
||||||
|
* HomeProxyConfig from the Loader plus the GenerationContext inputs the CLI
|
||||||
|
* resolved (`env`), and returns the JSON object the caller writes and
|
||||||
|
* `sing-box check`s. No UCI, no file I/O, no ubus, no procd. */
|
||||||
|
export function generate(dm, env) {
|
||||||
|
const ctx = build_context(dm, env);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
log: {
|
||||||
|
disabled: false,
|
||||||
|
level: ctx.log_level,
|
||||||
|
output: RUN_DIR + '/sing-box-c.log',
|
||||||
|
timestamp: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isEmpty(ctx.ntp_server))
|
||||||
|
config.ntp = {
|
||||||
|
enabled: true,
|
||||||
|
server: ctx.ntp_server,
|
||||||
|
detour: 'direct-out',
|
||||||
|
domain_resolver: 'default-dns',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* `direct_overrides` is the single side effect that survives the
|
||||||
|
* split: a direct node with override_address / override_port
|
||||||
|
* records the override here so build_route can emit the
|
||||||
|
* route-options action later. The orchestrator owns the map and
|
||||||
|
* passes it to both builders. */
|
||||||
|
const direct_overrides = {};
|
||||||
|
|
||||||
|
build_dns(config, dm, ctx);
|
||||||
|
build_inbounds(config, ctx);
|
||||||
|
|
||||||
|
/* direct_outbounds live on config.outbounds regardless of mode:
|
||||||
|
* the DNS path always references them, and a custom-mode config
|
||||||
|
* without any direct-out/block-out would not have a fallback for
|
||||||
|
* a routing rule that emits a missing tag. Initialised AFTER
|
||||||
|
* build_dns / build_inbounds so the JSON key order is
|
||||||
|
* log, ntp, dns, inbounds, outbounds, ... matching the
|
||||||
|
* pre-refactor generator. The builder appends to the same array
|
||||||
|
* (no reassignment), which preserves the key order that
|
||||||
|
* removeBlankAttrs() emits and keeps golden snapshots stable. */
|
||||||
|
config.outbounds = [
|
||||||
|
{
|
||||||
|
type: 'direct',
|
||||||
|
tag: 'direct-out',
|
||||||
|
routing_mark: strToInt(ctx.self_mark)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'block',
|
||||||
|
tag: 'block-out'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
config.endpoints = [];
|
||||||
|
|
||||||
|
build_outbounds(config, dm, ctx, direct_overrides);
|
||||||
|
|
||||||
|
build_route(config, dm, ctx, direct_overrides);
|
||||||
|
|
||||||
|
/* User-defined rulesets are custom-mode only; passing an empty
|
||||||
|
* dm.routing.rulesets through build_user_rulesets is a no-op
|
||||||
|
* because every cfg in it is filtered by !cfg.enabled. */
|
||||||
|
if (ctx.routing_mode === 'custom')
|
||||||
|
build_user_rulesets(config.route.rule_set, dm, ctx);
|
||||||
|
|
||||||
|
const http_clients = build_http_clients(config.route.rule_set, dm, ctx);
|
||||||
|
if (length(http_clients))
|
||||||
|
config.http_clients = http_clients;
|
||||||
|
|
||||||
|
/* A direct node used as the main node yields a main-out with no fields of
|
||||||
|
* its own: generate_outbound() turns its override_address / override_port
|
||||||
|
* into a route action rather than outbound fields, leaving a bare
|
||||||
|
* {"type":"direct","tag":"main-out"}. sing-box refuses to detour into
|
||||||
|
* such an outbound and the service never starts:
|
||||||
|
*
|
||||||
|
* FATAL start service: start dns/tcp[main-dns]: detour to an empty
|
||||||
|
* direct outbound makes no sense
|
||||||
|
* FATAL start service: initialize rule-set[0]: ... detour to an empty
|
||||||
|
* direct outbound makes no sense
|
||||||
|
*
|
||||||
|
* Dropping the detour is the equivalent behaviour - the query or the
|
||||||
|
* rule-set download leaves through the host's own network, which is what a
|
||||||
|
* direct outbound does - and mirrors the `ctx.self_mark ? 'direct-out' :
|
||||||
|
* null` that build_dns already applies to the other DNS servers. Only
|
||||||
|
* main-out is touched: direct-out is deliberate and carries a routing
|
||||||
|
* mark.
|
||||||
|
*
|
||||||
|
* This has to run after build_http_clients(): the rule-set downloads
|
||||||
|
* reach main-out through an http_client, not through a detour of their
|
||||||
|
* own. */
|
||||||
|
let bare_main_out = false;
|
||||||
|
for (let i = 0; i < length(config.outbounds); i++) {
|
||||||
|
const ob = config.outbounds[i];
|
||||||
|
/* build_outbounds leaves a null in the array for a node it could not
|
||||||
|
* emit (a pruned urltest candidate); skip those. */
|
||||||
|
if (ob && ob.tag === 'main-out' && ob.type === 'direct' && length(keys(ob)) === 2)
|
||||||
|
bare_main_out = true;
|
||||||
|
}
|
||||||
|
if (bare_main_out) {
|
||||||
|
for (let i = 0; i < length(config.dns.servers); i++) {
|
||||||
|
const s = config.dns.servers[i];
|
||||||
|
if (s && s.detour === 'main-out')
|
||||||
|
delete s.detour;
|
||||||
|
}
|
||||||
|
for (let i = 0; i < length(config.http_clients || []); i++) {
|
||||||
|
const c = config.http_clients[i];
|
||||||
|
if (c && c.detour === 'main-out')
|
||||||
|
delete c.detour;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEmpty(config.route.rule_set))
|
||||||
|
config.route.rule_set = null;
|
||||||
|
if (isEmpty(config.endpoints))
|
||||||
|
config.endpoints = null;
|
||||||
|
|
||||||
|
attachExperimental(config, ctx.routing_mode, ctx.dns_store_dns);
|
||||||
|
attachSchema(config);
|
||||||
|
|
||||||
|
return config;
|
||||||
|
};
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-2.0-only
|
||||||
|
*
|
||||||
|
* generator/common.uc: shared helpers used by every generator module.
|
||||||
|
*
|
||||||
|
* Every "select a tag from a UCI-style reference" function lives in this
|
||||||
|
* file because the same selector logic (a routing_node name, an
|
||||||
|
* array-of-names, a direct/block/default marker, a ruleset id list) is
|
||||||
|
* reached by the DNS section, the route section, the outbound/endpoint
|
||||||
|
* builder and the ruleset section. Duplicating them per-module would
|
||||||
|
* drift; consolidating them here keeps the convention "one place to
|
||||||
|
* change a tag shape".
|
||||||
|
*
|
||||||
|
* The Loader owns UCI; nothing in here reads UCI directly. The orchestrator
|
||||||
|
* passes `dm` for the routing_node lookups that need to resolve a name into
|
||||||
|
* the underlying node id.
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import { isEmpty } from '../homeproxy.uc';
|
||||||
|
|
||||||
|
import { ConfigQuery } from '../config/model.uc';
|
||||||
|
|
||||||
|
/* parse_port() lives in homeproxy.uc. The Adapter layer needs it
|
||||||
|
* too (EndpointFactory builds the WireGuard endpoint) and an adapter must
|
||||||
|
* not import from generator/, so the util moved to the module both layers
|
||||||
|
* already share. Callers import it from '../homeproxy.uc' now. */
|
||||||
|
|
||||||
|
/* Resolve a UCI outbound-style reference (string or array of strings)
|
||||||
|
* into the sing-box tag the generator should emit. The arrays carry
|
||||||
|
* either 'block-out' / 'direct-out' sentinels or routing_node section
|
||||||
|
* names; a single string is the same thing with one entry.
|
||||||
|
*
|
||||||
|
* `dm` is needed for the routing_node -> node id lookup; we deliberately
|
||||||
|
* do not read `dm.routing.nodes` until we know we need it (the common
|
||||||
|
* case is the sentinel). */
|
||||||
|
export function get_outbound(cfg, dm) {
|
||||||
|
if (isEmpty(cfg))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (type(cfg) === 'array') {
|
||||||
|
let outbounds = [];
|
||||||
|
for (let i in cfg)
|
||||||
|
push(outbounds, get_outbound(i, dm));
|
||||||
|
return outbounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (cfg) {
|
||||||
|
case 'block-out':
|
||||||
|
case 'direct-out':
|
||||||
|
return cfg;
|
||||||
|
default:
|
||||||
|
const rn = ConfigQuery.find_by_name(dm.routing.nodes, cfg);
|
||||||
|
if (!rn || isEmpty(rn.node))
|
||||||
|
die(sprintf("%s's node is missing, please check your configuration.", cfg));
|
||||||
|
else if (rn.node === 'urltest')
|
||||||
|
return 'cfg-' + cfg + '-out';
|
||||||
|
else
|
||||||
|
return 'cfg-' + rn.node + '-out';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Find a loaded section by name, treating `enabled` as a gate. The Loader
|
||||||
|
* normalises `enabled` on every list section; a section with the field unset
|
||||||
|
* is enabled (boolean sections default to 0 in UCI, but a dns_server/ruleset
|
||||||
|
* without the option is a valid, enabled row).
|
||||||
|
*
|
||||||
|
* Declared before its callers on purpose: ucode resolves function references
|
||||||
|
* at call time from what has already been evaluated, so a `function` statement
|
||||||
|
* placed after get_resolver() fails with "access to undeclared variable". */
|
||||||
|
function find_enabled(items, name) {
|
||||||
|
const item = ConfigQuery.find_by_name(items || [], name);
|
||||||
|
if (!item)
|
||||||
|
return null;
|
||||||
|
if (item.enabled === false || item.enabled === '0')
|
||||||
|
return null;
|
||||||
|
return item;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Resolve a UCI resolver reference (a dns_server section name or one of
|
||||||
|
* the sentinels) into a sing-box resolver tag.
|
||||||
|
*
|
||||||
|
* `dm` is required for the same reason get_outbound() needs it: a reference to
|
||||||
|
* a section that was deleted or disabled produces `cfg-<name>-dns`, which
|
||||||
|
* nothing defines - sing-box then rejects the whole configuration with
|
||||||
|
* "dns server not found", the reload silently keeps the previous one, and the
|
||||||
|
* user only sees that their change did not take. Fail with the name instead. */
|
||||||
|
export function get_resolver(cfg, dm) {
|
||||||
|
if (isEmpty(cfg))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
switch (cfg) {
|
||||||
|
case 'default-dns':
|
||||||
|
case 'system-dns':
|
||||||
|
return cfg;
|
||||||
|
default:
|
||||||
|
if (dm && !find_enabled((dm.dns || {}).servers, cfg))
|
||||||
|
die(sprintf("the DNS server '%s' does not exist or is disabled; check the rule that refers to it.", cfg));
|
||||||
|
return 'cfg-' + cfg + '-dns';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Resolve a UCI ruleset reference list (array of ruleset section names)
|
||||||
|
* into the corresponding sing-box rule_set tag list. Same existence gate as
|
||||||
|
* get_resolver(): ruleset.uc skips disabled entries, so a reference to one
|
||||||
|
* would leave a dangling `cfg-<name>-rule` tag behind. */
|
||||||
|
export function get_ruleset(cfg, dm) {
|
||||||
|
if (isEmpty(cfg))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
let rules = [];
|
||||||
|
for (let i in cfg) {
|
||||||
|
if (isEmpty(i))
|
||||||
|
continue;
|
||||||
|
if (dm && !find_enabled((dm.routing || {}).rulesets, i))
|
||||||
|
die(sprintf("the rule-set '%s' does not exist or is disabled; check the rule that refers to it.", i));
|
||||||
|
push(rules, 'cfg-' + i + '-rule');
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Resolve the direct-node destination override that the route builder
|
||||||
|
* needs for a `direct` routing_node target. The override is recorded
|
||||||
|
* earlier by generate_outbound() (see outbound.uc) when a direct node
|
||||||
|
* carries override_address / override_port; this helper just looks it
|
||||||
|
* up. Returns null when the target is not a direct node or no override
|
||||||
|
* is configured. */
|
||||||
|
export function get_direct_override(outbound_selector, dm, direct_overrides) {
|
||||||
|
if (type(outbound_selector) === 'array' || isEmpty(outbound_selector))
|
||||||
|
return null;
|
||||||
|
|
||||||
|
switch (outbound_selector) {
|
||||||
|
case 'direct-out':
|
||||||
|
case 'block-out':
|
||||||
|
return null;
|
||||||
|
default:
|
||||||
|
const rn = ConfigQuery.find_by_name(dm.routing.nodes, outbound_selector);
|
||||||
|
const node = rn && rn.node;
|
||||||
|
return (!isEmpty(node) && node !== 'urltest') ? (direct_overrides[node] || null) : null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* True when `tag` (the sing-box outbound tag) belongs to a direct outbound.
|
||||||
|
* Used by the http_clients builder to drop the detour field on a pure-TUN
|
||||||
|
* setup: sing-box 1.14 rejects detouring to a direct outbound that has
|
||||||
|
* nothing to detour through. */
|
||||||
|
export function isDirectOutboundTag(tag, dm) {
|
||||||
|
if (isEmpty(tag) || tag === 'block-out')
|
||||||
|
return false;
|
||||||
|
if (tag === 'direct-out')
|
||||||
|
return true;
|
||||||
|
|
||||||
|
/* The caller passes the tag the generator emits - get_outbound() builds
|
||||||
|
* `cfg-<node>-out` - not the routing_node section name, so that wrapper has
|
||||||
|
* to be undone before the lookup. Without this the two queries below were
|
||||||
|
* given a name that matches neither table, and the function returned false
|
||||||
|
* for every routing_node: build_http_clients() then kept a `detour` on a
|
||||||
|
* rule-set download in pure TUN mode, where sing-box refuses it ("detour to
|
||||||
|
* an empty direct outbound makes no sense") and rejects the whole
|
||||||
|
* configuration. */
|
||||||
|
const unwrapped = match(tag, /^cfg-(.+)-out$/);
|
||||||
|
const name = unwrapped ? unwrapped[1] : tag;
|
||||||
|
|
||||||
|
const rn = ConfigQuery.find_by_name(dm.routing.nodes, name);
|
||||||
|
const node_name = (rn && rn.node) || name;
|
||||||
|
const node = ConfigQuery.node_by_id(dm, node_name);
|
||||||
|
return !!(node && node.type === 'direct');
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Append the sing-box JSON $schema field. Kept here because the value is
|
||||||
|
* fixed and the two generators used to spell it the same way; if it ever
|
||||||
|
* changes, both should pick up the new URL automatically. */
|
||||||
|
export function attachSchema(config) {
|
||||||
|
config['$schema'] = 'https://sing-box.sagernet.org/schema.json';
|
||||||
|
return config;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Attach the experimental cache_file block when one of the routing modes
|
||||||
|
* that needs it is active. Routing-mode gating stays here because the
|
||||||
|
* block is the same regardless of mode; only the condition differs. */
|
||||||
|
export function attachExperimental(config, routing_mode, dns_store_dns) {
|
||||||
|
if (routing_mode in ['bypass_mainland_china', 'custom']) {
|
||||||
|
config.experimental = {
|
||||||
|
cache_file: {
|
||||||
|
enabled: true,
|
||||||
|
path: '/etc/homeproxy/cache.db',
|
||||||
|
store_dns: (dns_store_dns === '1') ? true : null
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
};
|
||||||
Loaded 100 of 673 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user