mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-11 04:37:16 +00:00
1456658028
* feat(sub): add Happ client integration, routing presets, and app management Implement comprehensive Happ proxy client integration according to official developer specifications. - Fix header emission on disabled routing and hidden settings to send explicit '0' headers rather than omitting, allowing Happ clients to reset cached settings. - Add support for 'happ://routing/off' deeplink in routing validation. - Preserve '?serverDescription=' query parameters in link fragments without escaping to support Happ server subtitles across VMess, VLESS, Trojan and SS. - Add Happ application management headers: ProviderID, New-Url, Fallback-Url, Sub-Info banners, Sub-Expire notifications, No-Limit mode, hardware ID enforcement, TUN modes/types, route exclusions, APNS exclusions, and per-app proxy settings. - Add curated routing presets (Iran Bypass, China Direct, AdBlock, Global) and interactive visual rule generator in frontend settings. - Synchronize all 13 translation locales with native Persian, Russian, and Chinese translations. * fix(sub): keep Happ header overrides behind the auto-detect opt-in The Routing-Enable/Hide-Settings off values were emitted on the User-Agent alone, so every panel that upgraded would push "Routing-Enable: 0" — documented by happ.su as disabling routing globally — to every Happ client without the operator enabling anything. They now ride subHappAutoDetect like every other Happ header. Two further mismatches against the vendor spec: - serverDescription was written as a key of the VMess base64 JSON object. happ.su documents it as a "#Title?serverDescription=<base64>" link parameter or a JSON "meta" entry, so the caption never reached Happ while every other VMess consumer received an unknown key. Dropped rather than moved: emitting the documented form is unsafe here because our own parser base64-decodes the whole VMess body (internal/util/link/outbound.go). - The TUN Mode dropdown stored the literal "default", forwarded as "Tun-Mode: default", where happ.su documents system|gvisor only. It now stores the unset value so no header is sent. TUN Type "default" is a documented value and is unchanged. Each fix carries a test that fails without it.
184 lines
7.8 KiB
Go
184 lines
7.8 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
// N1 — externalProxyToEndpoint maps the scalar fields and carries the source
|
|
// entry so delegated TLS application reproduces the legacy presence-tracked
|
|
// overrides (absent key never clobbers an upstream value).
|
|
func TestExternalProxyToEndpoint(t *testing.T) {
|
|
ep := map[string]any{
|
|
"forceTls": "tls",
|
|
"dest": "cdn.example.com",
|
|
"port": float64(8443),
|
|
"remark": "R",
|
|
"sni": "s.example.com",
|
|
}
|
|
e := externalProxyToEndpoint(ep)
|
|
if e.Address != "cdn.example.com" {
|
|
t.Fatalf("Address = %q, want cdn.example.com", e.Address)
|
|
}
|
|
if e.Port != 8443 {
|
|
t.Fatalf("Port = %d, want 8443", e.Port)
|
|
}
|
|
if e.ForceTls != "tls" {
|
|
t.Fatalf("ForceTls = %q, want tls", e.ForceTls)
|
|
}
|
|
if e.Remark != "R" {
|
|
t.Fatalf("Remark = %q, want R", e.Remark)
|
|
}
|
|
if e.ep == nil {
|
|
t.Fatalf("ep not carried; delegated TLS application would lose the source entry")
|
|
}
|
|
// Delegation preserves the sni override and does not invent absent fields.
|
|
params := map[string]string{}
|
|
applyEndpointTLSParams(e, params, "tls")
|
|
if params["sni"] != "s.example.com" {
|
|
t.Fatalf("delegated sni = %q, want s.example.com", params["sni"])
|
|
}
|
|
if _, ok := params["fp"]; ok {
|
|
t.Fatalf("absent fingerprint must not be set, got fp=%q", params["fp"])
|
|
}
|
|
}
|
|
|
|
// N2 — inboundDefaultEndpoint reproduces the no-externalProxy default: resolved
|
|
// address + inbound port, forceTls "same", empty remark, no source entry.
|
|
func TestInboundDefaultEndpoint(t *testing.T) {
|
|
in := &model.Inbound{Listen: "198.51.100.7", Port: 8080}
|
|
s := &SubService{}
|
|
e := s.inboundDefaultEndpoint(in)
|
|
if e.Address != "198.51.100.7" {
|
|
t.Fatalf("Address = %q, want 198.51.100.7", e.Address)
|
|
}
|
|
if e.Port != 8080 {
|
|
t.Fatalf("Port = %d, want 8080", e.Port)
|
|
}
|
|
if e.ForceTls != "same" {
|
|
t.Fatalf("ForceTls = %q, want same", e.ForceTls)
|
|
}
|
|
if e.Remark != "" {
|
|
t.Fatalf("Remark = %q, want empty", e.Remark)
|
|
}
|
|
if e.ep != nil {
|
|
t.Fatalf("default endpoint must not carry a source externalProxy entry")
|
|
}
|
|
}
|
|
|
|
// N3 — buildEndpointLinks renders the param-form path: one link per endpoint,
|
|
// TLS override applied for tls, fields stripped + security overridden for none,
|
|
// joined by "\n", in order.
|
|
func TestBuildEndpointLinks_ParamForm(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
params := map[string]string{"type": "tcp", "security": "tls", "sni": "base.sni", "fp": "chrome"}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "tls", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
|
}
|
|
got := s.buildEndpointLinks(eps, params, "tls",
|
|
func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
|
|
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
|
|
)
|
|
want := "vless://uid@a.example.com:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +
|
|
"vless://uid@b.example.com:80?security=none&type=tcp#ib-B-user"
|
|
if got != want {
|
|
t.Fatalf("N3 mismatch.\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
// N4 — buildEndpointVmessLinks renders the object-form path: base obj cloned per
|
|
// endpoint, add/port/tls rewritten, sni override applied, none-strip honored.
|
|
func TestBuildEndpointVmessLinks(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
baseObj := map[string]any{
|
|
"v": "2", "add": "base.example.com", "port": 443, "type": "none",
|
|
"id": "uid", "scy": "auto", "net": "tcp",
|
|
"tls": "tls", "sni": "base.sni", "alpn": "h2", "fp": "chrome",
|
|
}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "sni": "a.sni"}),
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
|
|
}
|
|
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
|
want := "vmess://ewogICJhZGQiOiAiYS5leGFtcGxlLmNvbSIsCiAgImFscG4iOiAiaDIiLAogICJmcCI6ICJjaHJvbWUiLAogICJpZCI6ICJ1aWQiLAogICJuZXQiOiAidGNwIiwKICAicG9ydCI6IDg0NDMsCiAgInBzIjogImliLUEtdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAic25pIjogImEuc25pIiwKICAidGxzIjogInRscyIsCiAgInR5cGUiOiAibm9uZSIsCiAgInYiOiAiMiIKfQ==\n" +
|
|
"vmess://ewogICJhZGQiOiAiYi5leGFtcGxlLmNvbSIsCiAgImlkIjogInVpZCIsCiAgIm5ldCI6ICJ0Y3AiLAogICJwb3J0IjogODAsCiAgInBzIjogImliLUItdXNlciIsCiAgInNjeSI6ICJhdXRvIiwKICAidGxzIjogIm5vbmUiLAogICJ0eXBlIjogIm5vbmUiLAogICJ2IjogIjIiCn0="
|
|
if got != want {
|
|
t.Fatalf("N4 mismatch.\n got: %q\nwant: %q", got, want)
|
|
}
|
|
}
|
|
|
|
// happ.su documents serverDescription as a "#title?serverDescription=<base64>"
|
|
// link parameter, never a key of the VMess object, so nothing may leak into it.
|
|
func TestBuildEndpointVmessLinks_HostServerDescription(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "none"}
|
|
host := &model.Host{Address: "a.example.com", Port: 8443, ServerDescription: "Berlin premium"}
|
|
eps := []ShareEndpoint{externalProxyToEndpoint(hostToExternalProxyMap(host, "a.example.com", 8443))}
|
|
|
|
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
|
|
if err != nil {
|
|
t.Fatalf("decode vmess link: %v", err)
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
t.Fatalf("unmarshal vmess object: %v", err)
|
|
}
|
|
if obj["add"] != "a.example.com" {
|
|
t.Fatalf("host endpoint not applied: add = %v", obj["add"])
|
|
}
|
|
if value, ok := obj["serverDescription"]; ok {
|
|
t.Fatalf("VMess object carries serverDescription = %v; it is not a VMess object key", value)
|
|
}
|
|
}
|
|
|
|
// N5 — a host's Final Mask is appended to the inbound's own fm param (#5831).
|
|
func TestBuildEndpointLinks_HostFinalMaskMerge(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
params := map[string]string{"type": "tcp", "security": "tls", "fm": `{"tcp":[{"type":"sudoku"}]}`}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"tcp":[{"type":"fragment"}]}`}),
|
|
}
|
|
got := s.buildEndpointLinks(eps, params, "tls",
|
|
func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
|
|
func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
|
|
)
|
|
wantFm := "fm=" + url.QueryEscape(`{"tcp":[{"type":"sudoku"},{"type":"fragment"}]}`)
|
|
if !strings.Contains(got, wantFm) {
|
|
t.Fatalf("host finalMask not merged into the fm param.\n got: %q\nwant substring: %q", got, wantFm)
|
|
}
|
|
}
|
|
|
|
// N6 — same for the VMess object form: the host mask lands in obj["fm"].
|
|
func TestBuildEndpointVmessLinks_HostFinalMask(t *testing.T) {
|
|
s := &SubService{}
|
|
in := &model.Inbound{Remark: "ib"}
|
|
baseObj := map[string]any{"v": "2", "add": "base.example.com", "port": 443, "type": "none", "id": "uid", "scy": "auto", "net": "tcp", "tls": "tls"}
|
|
eps := []ShareEndpoint{
|
|
externalProxyToEndpoint(map[string]any{"forceTls": "same", "dest": "a.example.com", "port": float64(8443), "remark": "A", "isHost": true, "finalMask": `{"udp":[{"type":"salamander"}]}`}),
|
|
}
|
|
got := s.buildEndpointVmessLinks(eps, baseObj, in, "user", "tcp")
|
|
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(got, "vmess://"))
|
|
if err != nil {
|
|
t.Fatalf("decode vmess link: %v", err)
|
|
}
|
|
var obj map[string]any
|
|
if err := json.Unmarshal(raw, &obj); err != nil {
|
|
t.Fatalf("unmarshal vmess obj: %v", err)
|
|
}
|
|
if fm, _ := obj["fm"].(string); fm != `{"udp":[{"type":"salamander"}]}` {
|
|
t.Fatalf("vmess fm = %q, want the host mask", obj["fm"])
|
|
}
|
|
}
|