mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-28 00:24:19 +00:00
cc65f37164
Subscriptions resolved a node-managed inbound's address to the node's panel address unconditionally, so an inbound bound to a specific public IP advertised an endpoint clients could not reach. The shareAddrStrategy field added in #5162 only applied to panel share/QR links by design. resolveInboundAddress now follows the same order as the panel's link builder: 'listen' prefers a routable bind, 'custom' prefers shareAddr, and the default 'node' keeps the existing node-first behavior, so output is unchanged for inbounds that never set the field. Applies to raw, JSON, and Clash subscriptions, which all resolve through this path. Help text in all locales updated to drop the 'subscriptions are not affected' caveat.
1062 lines
32 KiB
Go
1062 lines
32 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
)
|
|
|
|
func TestSubscriptionExpiryFromClient(t *testing.T) {
|
|
const now = int64(1_700_000_000_000)
|
|
const oneDayMs = int64(86_400_000)
|
|
if got := subscriptionExpiryFromClient(now, 0); got != 0 {
|
|
t.Fatalf("zero expiry should stay zero, got %d", got)
|
|
}
|
|
if got := subscriptionExpiryFromClient(now, 1_700_000_000_000); got != 1_700_000_000_000 {
|
|
t.Fatalf("positive expiry should pass through, got %d", got)
|
|
}
|
|
if got := subscriptionExpiryFromClient(now, -oneDayMs); got != now+oneDayMs {
|
|
t.Fatalf("delayed-start expiry should be now+|value|, got %d, want %d", got, now+oneDayMs)
|
|
}
|
|
if a, b := subscriptionExpiryFromClient(now, -oneDayMs), subscriptionExpiryFromClient(now, -oneDayMs); a != b {
|
|
t.Fatalf("same now+value should be deterministic across calls, got %d vs %d (#4545 review)", a, b)
|
|
}
|
|
}
|
|
|
|
func TestFindClientIndex(t *testing.T) {
|
|
clients := []model.Client{
|
|
{Email: "a@example.com"},
|
|
{Email: "b@example.com"},
|
|
{Email: "c@example.com"},
|
|
}
|
|
if got := findClientIndex(clients, "b@example.com"); got != 1 {
|
|
t.Fatalf("findClientIndex middle = %d, want 1", got)
|
|
}
|
|
if got := findClientIndex(clients, "a@example.com"); got != 0 {
|
|
t.Fatalf("findClientIndex first = %d, want 0", got)
|
|
}
|
|
if got := findClientIndex(clients, "missing@example.com"); got != -1 {
|
|
t.Fatalf("findClientIndex missing = %d, want -1", got)
|
|
}
|
|
if got := findClientIndex(nil, "x"); got != -1 {
|
|
t.Fatalf("findClientIndex on nil slice = %d, want -1", got)
|
|
}
|
|
}
|
|
|
|
func TestIsRoutableHost(t *testing.T) {
|
|
routable := []string{"example.com", "sub.example.com", "10.0.0.1", "192.168.1.5", "1.2.3.4", "2001:db8::1"}
|
|
for _, v := range routable {
|
|
if !isRoutableHost(v) {
|
|
t.Fatalf("isRoutableHost(%q) = false, want true", v)
|
|
}
|
|
}
|
|
notRoutable := []string{"", "0.0.0.0", "::", "::0", "127.0.0.1", "127.0.0.2", "::1", "[::1]"}
|
|
for _, v := range notRoutable {
|
|
if isRoutableHost(v) {
|
|
t.Fatalf("isRoutableHost(%q) = true, want false", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestListenIsInternalOnly(t *testing.T) {
|
|
// Reachable only from the same host -> a fallback child here must be
|
|
// projected through its master.
|
|
internalOnly := []string{"127.0.0.1", "127.0.0.2", "::1", "[::1]", "@fallback", "/run/x.sock"}
|
|
for _, v := range internalOnly {
|
|
if !listenIsInternalOnly(v) {
|
|
t.Fatalf("listenIsInternalOnly(%q) = false, want true", v)
|
|
}
|
|
}
|
|
// Directly reachable on its own port -> never projected, even if a stale
|
|
// fallback rule names it as a child (#4987).
|
|
reachable := []string{"", "0.0.0.0", "::", "::0", "1.2.3.4", "10.0.0.5", "192.168.1.10", "vpn.example.com"}
|
|
for _, v := range reachable {
|
|
if listenIsInternalOnly(v) {
|
|
t.Fatalf("listenIsInternalOnly(%q) = true, want false", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResolveInboundAddress(t *testing.T) {
|
|
const reqHost = "sub.example.com"
|
|
|
|
// A routable bind Listen (a real IP or hostname the operator set as the
|
|
// inbound's advertised endpoint) becomes the link's connect host.
|
|
t.Run("routable listen is advertised as the link host", func(t *testing.T) {
|
|
s := &SubService{address: reqHost}
|
|
for _, listen := range []string{"1.2.3.4", "10.0.0.5", "192.168.1.10", "203.0.113.7", "vpn.example.com"} {
|
|
ib := &model.Inbound{Listen: listen}
|
|
if got := s.resolveInboundAddress(ib); got != listen {
|
|
t.Fatalf("listen %q: address = %q, want %q (advertised listen)", listen, got, listen)
|
|
}
|
|
}
|
|
})
|
|
|
|
// A loopback/wildcard bind or a unix-domain-socket listen is a
|
|
// server-side detail and must never leak into the link host.
|
|
t.Run("non-routable listen falls back to subscriber host", func(t *testing.T) {
|
|
s := &SubService{address: reqHost}
|
|
for _, listen := range []string{"", "0.0.0.0", "::", "::0", "127.0.0.1", "::1", "@fallback", "/run/x.sock"} {
|
|
ib := &model.Inbound{Listen: listen}
|
|
if got := s.resolveInboundAddress(ib); got != reqHost {
|
|
t.Fatalf("listen %q: address = %q, want %q (subscriber host, not bind detail)", listen, got, reqHost)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("node-managed inbound uses the node address", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "1.2.3.4"}
|
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
|
t.Fatalf("node-managed address = %q, want node7.example.com", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node id with no known node falls back to subscriber host", func(t *testing.T) {
|
|
id := 9
|
|
s := &SubService{address: reqHost, nodesByID: map[int]*model.Node{}}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0"}
|
|
if got := s.resolveInboundAddress(ib); got != reqHost {
|
|
t.Fatalf("unknown-node address = %q, want subscriber host %q", got, reqHost)
|
|
}
|
|
})
|
|
|
|
// Per-inbound share address strategy (#5208): subscriptions follow the
|
|
// same order as the panel's share/QR links.
|
|
t.Run("listen strategy prefers the bind over the node address", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "listen"}
|
|
if got := s.resolveInboundAddress(ib); got != "203.0.113.7" {
|
|
t.Fatalf("listen-strategy address = %q, want the bind 203.0.113.7", got)
|
|
}
|
|
})
|
|
|
|
t.Run("listen strategy falls back to node address on a wildcard bind", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "0.0.0.0", ShareAddrStrategy: "listen"}
|
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
|
t.Fatalf("listen-strategy wildcard address = %q, want node7.example.com", got)
|
|
}
|
|
})
|
|
|
|
t.Run("custom strategy uses the share address", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "custom", ShareAddr: "edge.example.com"}
|
|
if got := s.resolveInboundAddress(ib); got != "edge.example.com" {
|
|
t.Fatalf("custom-strategy address = %q, want edge.example.com", got)
|
|
}
|
|
})
|
|
|
|
t.Run("custom strategy with empty share address falls back to node", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, ShareAddrStrategy: "custom"}
|
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
|
t.Fatalf("custom-strategy fallback address = %q, want node7.example.com", got)
|
|
}
|
|
})
|
|
|
|
t.Run("node strategy keeps the pre-strategy order", func(t *testing.T) {
|
|
id := 7
|
|
s := &SubService{
|
|
address: reqHost,
|
|
nodesByID: map[int]*model.Node{7: {Id: 7, Address: "node7.example.com"}},
|
|
}
|
|
ib := &model.Inbound{NodeID: &id, Listen: "203.0.113.7", ShareAddrStrategy: "node"}
|
|
if got := s.resolveInboundAddress(ib); got != "node7.example.com" {
|
|
t.Fatalf("node-strategy address = %q, want node7.example.com", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUnmarshalStreamSettings(t *testing.T) {
|
|
got := unmarshalStreamSettings(`{"network":"ws","wsSettings":{"path":"/api"}}`)
|
|
if got["network"] != "ws" {
|
|
t.Fatalf("network = %v, want ws", got["network"])
|
|
}
|
|
ws, ok := got["wsSettings"].(map[string]any)
|
|
if !ok || ws["path"] != "/api" {
|
|
t.Fatalf("wsSettings = %v, want map with path=/api", got["wsSettings"])
|
|
}
|
|
}
|
|
|
|
func TestUnmarshalStreamSettings_InvalidJSON(t *testing.T) {
|
|
if got := unmarshalStreamSettings("not json"); got != nil {
|
|
t.Fatalf("invalid JSON should produce nil map, got %#v", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_StringValue(t *testing.T) {
|
|
headers := map[string]any{"Host": "example.com"}
|
|
if got := searchHost(headers); got != "example.com" {
|
|
t.Fatalf("searchHost = %q, want example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_CaseInsensitiveKey(t *testing.T) {
|
|
headers := map[string]any{"host": "example.com"}
|
|
if got := searchHost(headers); got != "example.com" {
|
|
t.Fatalf("searchHost = %q, want example.com", got)
|
|
}
|
|
headers2 := map[string]any{"HOST": "example.com"}
|
|
if got := searchHost(headers2); got != "example.com" {
|
|
t.Fatalf("searchHost uppercase = %q, want example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_ArrayValue(t *testing.T) {
|
|
headers := map[string]any{"Host": []any{"first.example.com", "second.example.com"}}
|
|
if got := searchHost(headers); got != "first.example.com" {
|
|
t.Fatalf("searchHost array = %q, want first.example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_EmptyArray(t *testing.T) {
|
|
headers := map[string]any{"Host": []any{}}
|
|
if got := searchHost(headers); got != "" {
|
|
t.Fatalf("searchHost empty array = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_NoHostKey(t *testing.T) {
|
|
headers := map[string]any{"X-Other": "value"}
|
|
if got := searchHost(headers); got != "" {
|
|
t.Fatalf("searchHost no host = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchHost_NotAMap(t *testing.T) {
|
|
if got := searchHost("not a map"); got != "" {
|
|
t.Fatalf("searchHost non-map = %q, want empty", got)
|
|
}
|
|
if got := searchHost(nil); got != "" {
|
|
t.Fatalf("searchHost nil = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchKey_FoundAtTopLevel(t *testing.T) {
|
|
data := map[string]any{"foo": 42, "bar": "x"}
|
|
got, ok := searchKey(data, "foo")
|
|
if !ok {
|
|
t.Fatal("expected to find foo")
|
|
}
|
|
if got != 42 {
|
|
t.Fatalf("got %v, want 42", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchKey_FoundInNested(t *testing.T) {
|
|
data := map[string]any{
|
|
"outer": map[string]any{
|
|
"inner": map[string]any{
|
|
"target": "hit",
|
|
},
|
|
},
|
|
}
|
|
got, ok := searchKey(data, "target")
|
|
if !ok {
|
|
t.Fatal("expected to find target in nested map")
|
|
}
|
|
if got != "hit" {
|
|
t.Fatalf("got %v, want hit", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchKey_FoundInsideArray(t *testing.T) {
|
|
data := map[string]any{
|
|
"list": []any{
|
|
map[string]any{"other": 1},
|
|
map[string]any{"needle": "found"},
|
|
},
|
|
}
|
|
got, ok := searchKey(data, "needle")
|
|
if !ok {
|
|
t.Fatal("expected to find needle in array element")
|
|
}
|
|
if got != "found" {
|
|
t.Fatalf("got %v, want found", got)
|
|
}
|
|
}
|
|
|
|
func TestSearchKey_NotFound(t *testing.T) {
|
|
data := map[string]any{"foo": "bar"}
|
|
if _, ok := searchKey(data, "missing"); ok {
|
|
t.Fatal("expected ok=false for missing key")
|
|
}
|
|
}
|
|
|
|
func TestSearchKey_OnScalar(t *testing.T) {
|
|
if _, ok := searchKey(42, "anything"); ok {
|
|
t.Fatal("expected ok=false searching on a scalar")
|
|
}
|
|
}
|
|
|
|
func TestBuildXhttpExtra_IncludesClientSideFieldsWhenPresent(t *testing.T) {
|
|
extra := buildXhttpExtra(map[string]any{
|
|
"path": "/xhttp",
|
|
"host": "example.com",
|
|
"mode": "packet-up",
|
|
"xPaddingBytes": "100-1000",
|
|
"uplinkHTTPMethod": "GET",
|
|
"uplinkChunkSize": float64(4096),
|
|
"noGRPCHeader": true,
|
|
"scMinPostsIntervalMs": "20-40",
|
|
"xmux": map[string]any{
|
|
"maxConcurrency": "16-32",
|
|
"hMaxRequestTimes": "600-900",
|
|
"hMaxReusableSecs": "1800-3000",
|
|
"hKeepAlivePeriod": float64(15),
|
|
},
|
|
"downloadSettings": map[string]any{
|
|
"network": "xhttp",
|
|
},
|
|
"headers": map[string]any{
|
|
"Host": "ignored.example.com",
|
|
"X-Forwarded": "1",
|
|
"X-Test-Empty": "",
|
|
},
|
|
})
|
|
|
|
if extra["path"] != nil || extra["host"] != nil {
|
|
t.Fatalf("path/host should stay top-level, got extra %#v", extra)
|
|
}
|
|
for _, key := range []string{
|
|
"xPaddingBytes",
|
|
"uplinkHTTPMethod",
|
|
"uplinkChunkSize",
|
|
"noGRPCHeader",
|
|
"scMinPostsIntervalMs",
|
|
"xmux",
|
|
"downloadSettings",
|
|
} {
|
|
if _, ok := extra[key]; !ok {
|
|
t.Fatalf("extra missing %q: %#v", key, extra)
|
|
}
|
|
}
|
|
if _, ok := extra["mode"]; ok {
|
|
t.Fatalf("mode should stay as a top-level query parameter, got extra %#v", extra)
|
|
}
|
|
|
|
headers, ok := extra["headers"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("headers = %#v, want map", extra["headers"])
|
|
}
|
|
if _, ok := headers["Host"]; ok {
|
|
t.Fatalf("headers should not include Host: %#v", headers)
|
|
}
|
|
if headers["X-Forwarded"] != "1" {
|
|
t.Fatalf("headers[X-Forwarded] = %#v, want 1", headers["X-Forwarded"])
|
|
}
|
|
}
|
|
|
|
func TestBuildXhttpExtra_LeavesDefaultClientSideFieldsOut(t *testing.T) {
|
|
extra := buildXhttpExtra(map[string]any{
|
|
"uplinkHTTPMethod": "",
|
|
"uplinkChunkSize": float64(0),
|
|
"noGRPCHeader": false,
|
|
"xmux": map[string]any{},
|
|
"downloadSettings": map[string]any{},
|
|
})
|
|
if extra != nil {
|
|
t.Fatalf("default-only xhttp extra = %#v, want nil", extra)
|
|
}
|
|
}
|
|
|
|
func TestCloneStringMap(t *testing.T) {
|
|
src := map[string]string{"a": "1", "b": "2"}
|
|
dst := cloneStringMap(src)
|
|
if len(dst) != len(src) {
|
|
t.Fatalf("clone length = %d, want %d", len(dst), len(src))
|
|
}
|
|
for k, v := range src {
|
|
if dst[k] != v {
|
|
t.Fatalf("clone[%q] = %q, want %q", k, dst[k], v)
|
|
}
|
|
}
|
|
dst["a"] = "changed"
|
|
if src["a"] == "changed" {
|
|
t.Fatal("modifying clone leaked into source")
|
|
}
|
|
}
|
|
|
|
func TestCloneStringMap_Empty(t *testing.T) {
|
|
dst := cloneStringMap(map[string]string{})
|
|
if dst == nil {
|
|
t.Fatal("clone of empty map should not be nil")
|
|
}
|
|
if len(dst) != 0 {
|
|
t.Fatalf("clone of empty map should be empty, got %v", dst)
|
|
}
|
|
}
|
|
|
|
func TestGetHostFromXFH_HostOnly(t *testing.T) {
|
|
got, err := getHostFromXFH("example.com")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "example.com" {
|
|
t.Fatalf("got %q, want example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestGetHostFromXFH_HostWithPort(t *testing.T) {
|
|
got, err := getHostFromXFH("example.com:8443")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "example.com" {
|
|
t.Fatalf("got %q, want example.com", got)
|
|
}
|
|
}
|
|
|
|
func TestGetHostFromXFH_IPv6WithPort(t *testing.T) {
|
|
got, err := getHostFromXFH("[2606:4700::1111]:443")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "2606:4700::1111" {
|
|
t.Fatalf("got %q, want 2606:4700::1111", got)
|
|
}
|
|
}
|
|
|
|
func TestGetHostFromXFH_BadHostPort(t *testing.T) {
|
|
if _, err := getHostFromXFH("example.com:8443:9999"); err == nil {
|
|
t.Fatal("expected error for malformed host:port")
|
|
}
|
|
}
|
|
|
|
func TestReadPositiveInt(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in any
|
|
wantVal int
|
|
wantOk bool
|
|
}{
|
|
{"int_positive", int(5), 5, true},
|
|
{"int_zero", int(0), 0, false},
|
|
{"int_negative", int(-3), -3, false},
|
|
{"int32_positive", int32(7), 7, true},
|
|
{"int64_positive", int64(99), 99, true},
|
|
{"float64_positive", float64(12), 12, true},
|
|
{"float64_zero", float64(0.0), 0, false},
|
|
{"float64_negative", float64(-1.5), -1, false},
|
|
{"float32_positive", float32(3), 3, true},
|
|
{"string", "not a number", 0, false},
|
|
{"nil", nil, 0, false},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
gotVal, gotOk := readPositiveInt(c.in)
|
|
if gotVal != c.wantVal || gotOk != c.wantOk {
|
|
t.Fatalf("readPositiveInt(%v) = (%d, %v), want (%d, %v)", c.in, gotVal, gotOk, c.wantVal, c.wantOk)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSetStringParam(t *testing.T) {
|
|
p := map[string]string{"existing": "value"}
|
|
|
|
setStringParam(p, "new", "hello")
|
|
if p["new"] != "hello" {
|
|
t.Fatalf("missing key after set: %v", p)
|
|
}
|
|
|
|
setStringParam(p, "existing", "")
|
|
if _, ok := p["existing"]; ok {
|
|
t.Fatalf("empty value should delete the key, got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestSetIntParam(t *testing.T) {
|
|
p := map[string]string{"existing": "10"}
|
|
|
|
setIntParam(p, "n", 42)
|
|
if p["n"] != "42" {
|
|
t.Fatalf("set positive int: got %v", p)
|
|
}
|
|
|
|
setIntParam(p, "existing", 0)
|
|
if _, ok := p["existing"]; ok {
|
|
t.Fatalf("zero value should delete the key, got %v", p)
|
|
}
|
|
|
|
p["other"] = "5"
|
|
setIntParam(p, "other", -1)
|
|
if _, ok := p["other"]; ok {
|
|
t.Fatalf("negative value should delete the key, got %v", p)
|
|
}
|
|
}
|
|
|
|
func TestSetStringField(t *testing.T) {
|
|
f := map[string]any{"existing": "value"}
|
|
|
|
setStringField(f, "new", "hello")
|
|
if f["new"] != "hello" {
|
|
t.Fatalf("missing key after set: %v", f)
|
|
}
|
|
|
|
setStringField(f, "existing", "")
|
|
if _, ok := f["existing"]; ok {
|
|
t.Fatalf("empty value should delete the key, got %v", f)
|
|
}
|
|
}
|
|
|
|
func TestSetIntField(t *testing.T) {
|
|
f := map[string]any{"existing": 10}
|
|
|
|
setIntField(f, "n", 7)
|
|
if f["n"] != 7 {
|
|
t.Fatalf("set positive int: got %v", f)
|
|
}
|
|
|
|
setIntField(f, "existing", 0)
|
|
if _, ok := f["existing"]; ok {
|
|
t.Fatalf("zero value should delete the key, got %v", f)
|
|
}
|
|
}
|
|
|
|
func TestBuildVmessLink(t *testing.T) {
|
|
obj := map[string]any{
|
|
"v": "2",
|
|
"ps": "remark",
|
|
"add": "example.com",
|
|
"port": 443,
|
|
"net": "tcp",
|
|
}
|
|
link := buildVmessLink(obj)
|
|
if !strings.HasPrefix(link, "vmess://") {
|
|
t.Fatalf("missing vmess:// prefix: %q", link)
|
|
}
|
|
payload := strings.TrimPrefix(link, "vmess://")
|
|
decoded, err := base64.StdEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
t.Fatalf("base64 decode failed: %v", err)
|
|
}
|
|
var roundTrip map[string]any
|
|
if err := json.Unmarshal(decoded, &roundTrip); err != nil {
|
|
t.Fatalf("decoded payload is not JSON: %v\n%s", err, decoded)
|
|
}
|
|
if roundTrip["add"] != "example.com" {
|
|
t.Fatalf("round-trip add = %v, want example.com", roundTrip["add"])
|
|
}
|
|
if roundTrip["ps"] != "remark" {
|
|
t.Fatalf("round-trip ps = %v, want remark", roundTrip["ps"])
|
|
}
|
|
}
|
|
|
|
func TestCloneVmessShareObj_CopiesEverythingByDefault(t *testing.T) {
|
|
base := map[string]any{
|
|
"v": "2",
|
|
"sni": "example.com",
|
|
"alpn": "h2",
|
|
"fp": "chrome",
|
|
"net": "tcp",
|
|
}
|
|
out := cloneVmessShareObj(base, "tls")
|
|
for _, key := range []string{"sni", "alpn", "fp", "net", "v"} {
|
|
if _, ok := out[key]; !ok {
|
|
t.Fatalf("expected key %q to be preserved when security=tls, got %v", key, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCloneVmessShareObj_NoneStripsTLSOnlyKeys(t *testing.T) {
|
|
base := map[string]any{
|
|
"v": "2",
|
|
"sni": "example.com",
|
|
"alpn": "h2",
|
|
"fp": "chrome",
|
|
"net": "tcp",
|
|
}
|
|
out := cloneVmessShareObj(base, "none")
|
|
for _, key := range []string{"sni", "alpn", "fp"} {
|
|
if _, ok := out[key]; ok {
|
|
t.Fatalf("security=none should strip %q, got %v", key, out)
|
|
}
|
|
}
|
|
if out["v"] != "2" || out["net"] != "tcp" {
|
|
t.Fatalf("non-TLS keys should remain, got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSParams_UsesProxyDomainAndOverrides(t *testing.T) {
|
|
params := map[string]string{
|
|
"security": "tls",
|
|
"sni": "origin.example.com",
|
|
"fp": "firefox",
|
|
"alpn": "h2",
|
|
}
|
|
ep := map[string]any{
|
|
"dest": "proxy.example.com",
|
|
"sni": "tls.example.com",
|
|
"fingerprint": "chrome",
|
|
"alpn": []any{"h3", "h2"},
|
|
}
|
|
|
|
applyExternalProxyTLSParams(ep, params, "tls")
|
|
|
|
if params["sni"] != "tls.example.com" {
|
|
t.Fatalf("sni = %q, want tls.example.com", params["sni"])
|
|
}
|
|
if params["fp"] != "chrome" {
|
|
t.Fatalf("fp = %q, want chrome", params["fp"])
|
|
}
|
|
if params["alpn"] != "h3,h2" {
|
|
t.Fatalf("alpn = %q, want h3,h2", params["alpn"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSParams_PreservesUpstreamSNI(t *testing.T) {
|
|
// External-proxy entry has no SNI of its own; its dest must not
|
|
// clobber the upstream tlsSettings.serverName already written into
|
|
// params. Regression: the dest fallback used to overwrite "222" with
|
|
// "111" whenever an operator set forceTls=same and left the proxy's
|
|
// SNI field blank.
|
|
params := map[string]string{"security": "tls", "sni": "real.example.com"}
|
|
ep := map[string]any{"dest": "proxy.example.com"}
|
|
|
|
applyExternalProxyTLSParams(ep, params, "tls")
|
|
|
|
if params["sni"] != "real.example.com" {
|
|
t.Fatalf("sni = %q, want upstream sni preserved (real.example.com)", params["sni"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSParams_ExplicitSNIOverridesUpstream(t *testing.T) {
|
|
params := map[string]string{"security": "tls", "sni": "real.example.com"}
|
|
ep := map[string]any{"dest": "proxy.example.com", "sni": "edge.example.com"}
|
|
|
|
applyExternalProxyTLSParams(ep, params, "tls")
|
|
|
|
if params["sni"] != "edge.example.com" {
|
|
t.Fatalf("sni = %q, want edge.example.com", params["sni"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxy_ECHPropagates(t *testing.T) {
|
|
const ech = "ech-config-base64"
|
|
|
|
t.Run("url params", func(t *testing.T) {
|
|
params := map[string]string{"security": "tls"}
|
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
|
applyExternalProxyTLSParams(ep, params, "tls")
|
|
if params["ech"] != ech {
|
|
t.Fatalf("ech param = %q, want %q", params["ech"], ech)
|
|
}
|
|
})
|
|
|
|
t.Run("vmess obj", func(t *testing.T) {
|
|
obj := map[string]any{}
|
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
|
applyExternalProxyTLSObj(ep, obj, "tls")
|
|
if obj["ech"] != ech {
|
|
t.Fatalf("ech obj = %v, want %q", obj["ech"], ech)
|
|
}
|
|
})
|
|
|
|
t.Run("json stream settings", func(t *testing.T) {
|
|
stream := map[string]any{"security": "tls", "tlsSettings": map[string]any{}}
|
|
ep := map[string]any{"dest": "proxy.example.com", "echConfigList": ech}
|
|
applyExternalProxyTLSToStream(ep, stream, "tls")
|
|
settings, _ := stream["tlsSettings"].(map[string]any)["settings"].(map[string]any)
|
|
if settings["echConfigList"] != ech {
|
|
t.Fatalf("echConfigList = %v, want %q", settings["echConfigList"], ech)
|
|
}
|
|
})
|
|
|
|
t.Run("non-tls security drops ech", func(t *testing.T) {
|
|
params := map[string]string{}
|
|
ep := map[string]any{"echConfigList": ech}
|
|
applyExternalProxyTLSParams(ep, params, "none")
|
|
if _, ok := params["ech"]; ok {
|
|
t.Fatalf("ech must not be set when security != tls")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSToStream_DoesNotLeakAcrossProxies(t *testing.T) {
|
|
stream := map[string]any{
|
|
"security": "tls",
|
|
"tlsSettings": map[string]any{
|
|
"serverName": "upstream.example.com",
|
|
},
|
|
}
|
|
proxies := []map[string]any{
|
|
{"dest": "a.example.com", "sni": "a-sni.example.com", "fingerprint": "chrome", "alpn": []any{"h3"}},
|
|
{"dest": "b.example.com"},
|
|
}
|
|
|
|
results := make([]map[string]any, 0, len(proxies))
|
|
for _, ep := range proxies {
|
|
working := cloneStreamForExternalProxy(stream)
|
|
applyExternalProxyTLSToStream(ep, working, "tls")
|
|
ts := working["tlsSettings"].(map[string]any)
|
|
snapshot := map[string]any{
|
|
"serverName": ts["serverName"],
|
|
"fingerprint": ts["fingerprint"],
|
|
"alpn": ts["alpn"],
|
|
}
|
|
results = append(results, snapshot)
|
|
}
|
|
|
|
if results[0]["serverName"] != "a-sni.example.com" || results[0]["fingerprint"] != "chrome" {
|
|
t.Fatalf("proxy A snapshot = %v", results[0])
|
|
}
|
|
// Proxy B has no SNI of its own — the upstream tlsSettings serverName
|
|
// must remain in place (no dest fallback) and no fingerprint/alpn
|
|
// must leak from proxy A.
|
|
if results[1]["serverName"] != "upstream.example.com" {
|
|
t.Fatalf("proxy B serverName = %v, want upstream.example.com preserved", results[1]["serverName"])
|
|
}
|
|
if results[1]["fingerprint"] != nil {
|
|
t.Fatalf("proxy B should inherit no fingerprint, got %v (leaked from A)", results[1]["fingerprint"])
|
|
}
|
|
if results[1]["alpn"] != nil {
|
|
t.Fatalf("proxy B should inherit no alpn, got %v (leaked from A)", results[1]["alpn"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSParams_SetsPinnedPeerCert(t *testing.T) {
|
|
params := map[string]string{"security": "tls"}
|
|
ep := map[string]any{
|
|
"dest": "proxy.example.com",
|
|
"pinnedPeerCertSha256": []any{"aa11", "bb22"},
|
|
}
|
|
|
|
applyExternalProxyTLSParams(ep, params, "tls")
|
|
|
|
if params["pcs"] != "aa11,bb22" {
|
|
t.Fatalf("pcs = %q, want aa11,bb22", params["pcs"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSObj_SetsPinnedPeerCert(t *testing.T) {
|
|
obj := map[string]any{"tls": "tls"}
|
|
ep := map[string]any{
|
|
"dest": "proxy.example.com",
|
|
"pinnedPeerCertSha256": []any{"aa11"},
|
|
}
|
|
|
|
applyExternalProxyTLSObj(ep, obj, "tls")
|
|
|
|
if obj["pcs"] != "aa11" {
|
|
t.Fatalf("pcs = %v, want aa11", obj["pcs"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSToStream_SetsPinnedPeerCert(t *testing.T) {
|
|
stream := map[string]any{
|
|
"security": "tls",
|
|
"tlsSettings": map[string]any{"serverName": "upstream.example.com"},
|
|
}
|
|
ep := map[string]any{"dest": "edge.example.com", "pinnedPeerCertSha256": []any{"aa11", "bb22"}}
|
|
|
|
working := cloneStreamForExternalProxy(stream)
|
|
applyExternalProxyTLSToStream(ep, working, "tls")
|
|
|
|
ts := working["tlsSettings"].(map[string]any)
|
|
settings, _ := ts["settings"].(map[string]any)
|
|
pins, ok := settings["pinnedPeerCertSha256"].([]any)
|
|
if !ok || len(pins) != 2 || pins[0] != "aa11" || pins[1] != "bb22" {
|
|
t.Fatalf("pinnedPeerCertSha256 = %v, want [aa11 bb22]", settings["pinnedPeerCertSha256"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyHysteriaParams_PinIsHexNormalized(t *testing.T) {
|
|
// base64 SHA-256 pin must come out as bare lowercase hex for Hysteria's
|
|
// pinSHA256, which other (pcs) protocols leave untouched.
|
|
params := map[string]string{"security": "tls", "sni": "server.example.com"}
|
|
ep := map[string]any{
|
|
"dest": "edge.example.com",
|
|
"pinnedPeerCertSha256": []any{"yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ="},
|
|
}
|
|
|
|
applyExternalProxyHysteriaParams(ep, params)
|
|
|
|
if params["pinSHA256"] != "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4" {
|
|
t.Fatalf("pinSHA256 = %q, want hex-normalized pin", params["pinSHA256"])
|
|
}
|
|
if _, ok := params["pcs"]; ok {
|
|
t.Fatalf("pcs must not be set for Hysteria, got %v", params)
|
|
}
|
|
if params["sni"] != "server.example.com" {
|
|
t.Fatalf("sni = %q, want inbound sni preserved (no override for Hysteria)", params["sni"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyHysteriaParams_NoPinLeavesMainPin(t *testing.T) {
|
|
params := map[string]string{"security": "tls", "pinSHA256": "deadbeef"}
|
|
ep := map[string]any{"dest": "edge.example.com"}
|
|
|
|
applyExternalProxyHysteriaParams(ep, params)
|
|
|
|
if params["pinSHA256"] != "deadbeef" {
|
|
t.Fatalf("pinSHA256 = %q, want main pin preserved when proxy has none", params["pinSHA256"])
|
|
}
|
|
}
|
|
|
|
func TestApplyExternalProxyTLSParams_DoesNotApplyForNone(t *testing.T) {
|
|
params := map[string]string{
|
|
"security": "none",
|
|
"sni": "origin.example.com",
|
|
}
|
|
ep := map[string]any{
|
|
"dest": "proxy.example.com",
|
|
"fingerprint": "chrome",
|
|
"alpn": []any{"h3"},
|
|
}
|
|
|
|
applyExternalProxyTLSParams(ep, params, "none")
|
|
|
|
if params["sni"] != "origin.example.com" {
|
|
t.Fatalf("sni should not change for security=none, got %q", params["sni"])
|
|
}
|
|
if _, ok := params["fp"]; ok {
|
|
t.Fatalf("fp should not be set for security=none, got %v", params)
|
|
}
|
|
if _, ok := params["alpn"]; ok {
|
|
t.Fatalf("alpn should not be set for security=none, got %v", params)
|
|
}
|
|
}
|
|
|
|
func TestExtractKcpShareFields_Defaults(t *testing.T) {
|
|
stream := map[string]any{}
|
|
got := extractKcpShareFields(stream)
|
|
if got.headerType != "none" {
|
|
t.Fatalf("default headerType = %q, want none", got.headerType)
|
|
}
|
|
if got.seed != "" || got.mtu != 0 || got.tti != 0 {
|
|
t.Fatalf("default kcpShareFields should be zero except headerType, got %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractKcpShareFields_ReadsAllFields(t *testing.T) {
|
|
stream := map[string]any{
|
|
"kcpSettings": map[string]any{
|
|
"header": map[string]any{"type": "wechat-video"},
|
|
"seed": "secret-seed",
|
|
"mtu": float64(1350),
|
|
"tti": float64(50),
|
|
},
|
|
}
|
|
got := extractKcpShareFields(stream)
|
|
if got.headerType != "wechat-video" {
|
|
t.Fatalf("headerType = %q, want wechat-video", got.headerType)
|
|
}
|
|
if got.seed != "secret-seed" {
|
|
t.Fatalf("seed = %q, want secret-seed", got.seed)
|
|
}
|
|
if got.mtu != 1350 {
|
|
t.Fatalf("mtu = %d, want 1350", got.mtu)
|
|
}
|
|
if got.tti != 50 {
|
|
t.Fatalf("tti = %d, want 50", got.tti)
|
|
}
|
|
}
|
|
|
|
func TestExtractKcpShareFields_FinalMaskLegacyHeader(t *testing.T) {
|
|
stream := map[string]any{
|
|
"finalmask": map[string]any{
|
|
"udp": []any{
|
|
map[string]any{
|
|
"type": "mkcp-legacy",
|
|
"settings": map[string]any{"header": "wechat", "value": ""},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
got := extractKcpShareFields(stream)
|
|
if got.headerType != "wechat-video" {
|
|
t.Fatalf("headerType = %q, want wechat-video", got.headerType)
|
|
}
|
|
if got.seed != "" {
|
|
t.Fatalf("seed = %q, want empty for header mask", got.seed)
|
|
}
|
|
}
|
|
|
|
func TestExtractKcpShareFields_FinalMaskLegacySeed(t *testing.T) {
|
|
stream := map[string]any{
|
|
"finalmask": map[string]any{
|
|
"udp": []any{
|
|
map[string]any{
|
|
"type": "mkcp-legacy",
|
|
"settings": map[string]any{"header": "", "value": "obfs-pass"},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
got := extractKcpShareFields(stream)
|
|
if got.headerType != "none" {
|
|
t.Fatalf("headerType = %q, want none for empty-header legacy mask", got.headerType)
|
|
}
|
|
if got.seed != "obfs-pass" {
|
|
t.Fatalf("seed = %q, want obfs-pass", got.seed)
|
|
}
|
|
}
|
|
|
|
func TestKcpShareFields_ApplyToParams(t *testing.T) {
|
|
params := map[string]string{}
|
|
kcpShareFields{headerType: "wechat-video", seed: "s", mtu: 1350, tti: 50}.applyToParams(params)
|
|
if params["headerType"] != "wechat-video" {
|
|
t.Fatalf("headerType param = %q", params["headerType"])
|
|
}
|
|
if params["seed"] != "s" {
|
|
t.Fatalf("seed param = %q", params["seed"])
|
|
}
|
|
if params["mtu"] != "1350" {
|
|
t.Fatalf("mtu param = %q", params["mtu"])
|
|
}
|
|
if params["tti"] != "50" {
|
|
t.Fatalf("tti param = %q", params["tti"])
|
|
}
|
|
}
|
|
|
|
func TestKcpShareFields_ApplyToParams_NoneHeaderNotAdded(t *testing.T) {
|
|
params := map[string]string{}
|
|
kcpShareFields{headerType: "none"}.applyToParams(params)
|
|
if _, ok := params["headerType"]; ok {
|
|
t.Fatalf("headerType=none should not be added, got %v", params)
|
|
}
|
|
}
|
|
|
|
func TestMarshalFinalMask_EmptyReturnsFalse(t *testing.T) {
|
|
if _, ok := marshalFinalMask(map[string]any{}); ok {
|
|
t.Fatal("expected ok=false for empty finalmask")
|
|
}
|
|
if _, ok := marshalFinalMask(nil); ok {
|
|
t.Fatal("expected ok=false for nil finalmask")
|
|
}
|
|
}
|
|
|
|
func TestMarshalFinalMask_WithContent(t *testing.T) {
|
|
fm := map[string]any{
|
|
"tcp": []any{
|
|
map[string]any{"type": "fragment"},
|
|
},
|
|
}
|
|
out, ok := marshalFinalMask(fm)
|
|
if !ok {
|
|
t.Fatal("expected ok=true for finalmask with valid tcp mask")
|
|
}
|
|
if !strings.Contains(out, `"tcp"`) {
|
|
t.Fatalf("marshaled finalmask missing tcp key: %s", out)
|
|
}
|
|
if !strings.Contains(out, "fragment") {
|
|
t.Fatalf("marshaled finalmask missing mask type: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestMarshalFinalMask_UnknownTypeIsDropped(t *testing.T) {
|
|
fm := map[string]any{
|
|
"tcp": []any{
|
|
map[string]any{"type": "not-a-real-mask"},
|
|
},
|
|
}
|
|
if _, ok := marshalFinalMask(fm); ok {
|
|
t.Fatal("unknown mask types should be dropped, leaving nothing to marshal")
|
|
}
|
|
}
|
|
|
|
func TestHasFinalMaskContent(t *testing.T) {
|
|
if hasFinalMaskContent(nil) {
|
|
t.Fatal("nil should not count as content")
|
|
}
|
|
if hasFinalMaskContent(map[string]any{}) {
|
|
t.Fatal("empty map should not count as content")
|
|
}
|
|
if !hasFinalMaskContent(map[string]any{"x": 1}) {
|
|
t.Fatal("non-empty map should count as content")
|
|
}
|
|
}
|
|
|
|
func TestHysteriaPinHex(t *testing.T) {
|
|
const hexPin = "c847dd2395d0978c0780b8201c4b289a8b281597d47c275f2d77d3f96d8de9c4"
|
|
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
// Std base64 (xray-core's native TLS format / the panel generate button)
|
|
// must be re-encoded to the hex form Hysteria2 clients expect (#4818).
|
|
{"std base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT+W2N6cQ=", hexPin},
|
|
// A manually pasted hex fingerprint passes through (lowercased).
|
|
{"hex passthrough", hexPin, hexPin},
|
|
{"uppercase hex lowercased", strings.ToUpper(hexPin), hexPin},
|
|
// openssl x509 -fingerprint -sha256 emits colon-separated hex.
|
|
{"colon hex stripped", "C8:47:DD:23:95:D0:97:8C:07:80:B8:20:1C:4B:28:9A:8B:28:15:97:D4:7C:27:5F:2D:77:D3:F9:6D:8D:E9:C4", hexPin},
|
|
{"surrounding whitespace trimmed", " " + hexPin + " ", hexPin},
|
|
// URL-safe base64 with the same 32 bytes decodes identically.
|
|
{"url-safe base64", "yEfdI5XQl4wHgLggHEsomosoFZfUfCdfLXfT-W2N6cQ=", hexPin},
|
|
// Garbage that is neither valid hex nor a 32-byte base64 is left as-is
|
|
// rather than silently dropped.
|
|
{"unrecognized passthrough", "not-a-pin", "not-a-pin"},
|
|
{"empty", "", ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := hysteriaPinHex(tc.in); got != tc.want {
|
|
t.Fatalf("hysteriaPinHex(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestHysteriaHopPorts(t *testing.T) {
|
|
withHop := func(ports any) map[string]any {
|
|
return map[string]any{
|
|
"finalmask": map[string]any{
|
|
"quicParams": map[string]any{
|
|
"udpHop": map[string]any{"ports": ports, "interval": "5-10"},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
cases := []struct {
|
|
name string
|
|
stream map[string]any
|
|
want string
|
|
}{
|
|
{"range", withHop("20000-50000"), "20000-50000"},
|
|
{"trimmed", withHop(" 443,20000-50000 "), "443,20000-50000"},
|
|
{"empty string", withHop(""), ""},
|
|
{"non-string", withHop(float64(443)), ""},
|
|
{"no udpHop", map[string]any{"finalmask": map[string]any{"quicParams": map[string]any{}}}, ""},
|
|
{"no finalmask", map[string]any{}, ""},
|
|
{"nil stream", nil, ""},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := hysteriaHopPorts(tc.stream); got != tc.want {
|
|
t.Fatalf("hysteriaHopPorts() = %q, want %q", got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|