mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 07:15:01 +03:00
bc1b750e00
Host rules applied per inbound only, so every client of an inbound got the same subscription endpoints (#6618). A client can now be bound to one or more host rules, and its raw, JSON and Clash subscriptions render only those hosts, in either subscription order; an unbound client is unchanged. A binding whose hosts are deleted, disabled or excluded from the format fails closed: the client gets no links there but stays in traffic accounting. Host edits keep row ids, so bindings survive them; nil leaves a stored binding alone and an empty list clears it.
1651 lines
65 KiB
Go
1651 lines
65 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/dbtest"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/xray"
|
|
)
|
|
|
|
func seedSubDB(t *testing.T) {
|
|
t.Helper()
|
|
dbDir := t.TempDir()
|
|
t.Setenv("XUI_DB_FOLDER", dbDir)
|
|
dbtest.InitDB(t, filepath.Join(dbDir, "x-ui.db"))
|
|
}
|
|
|
|
// seedSubInbound creates a VLESS inbound with one client wired into the
|
|
// normalized clients/client_inbounds tables so getInboundsBySubId resolves it.
|
|
func seedSubInbound(t *testing.T, subId, tag string, port, subSortIndex int, stream string) *model.Inbound {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
uuid := "11111111-2222-4333-8444-" + fmt.Sprintf("%012d", port)
|
|
email := tag + "@e"
|
|
settings := fmt.Sprintf(`{"clients":[{"id":%q,"email":%q,"subId":%q,"enable":true}],"decryption":"none"}`, uuid, email, subId)
|
|
ib := &model.Inbound{
|
|
UserId: 1, Tag: tag, Enable: true, Listen: "203.0.113.5", Port: port,
|
|
Protocol: model.VLESS, Remark: tag, Settings: settings, StreamSettings: stream,
|
|
SubSortIndex: subSortIndex,
|
|
}
|
|
if err := db.Create(ib).Error; err != nil {
|
|
t.Fatalf("seed inbound %s: %v", tag, err)
|
|
}
|
|
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("seed client %s: %v", email, err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
|
t.Fatalf("seed client_inbound %s: %v", email, err)
|
|
}
|
|
return ib
|
|
}
|
|
|
|
func seedHost(t *testing.T, h *model.Host) *model.Host {
|
|
t.Helper()
|
|
if err := database.GetDB().Create(h).Error; err != nil {
|
|
t.Fatalf("seed host: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
const wsTLSStream = `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"}}`
|
|
|
|
// #1 — an inbound with no hosts renders identically to the legacy path: a single
|
|
// link from the inbound's own address. Mutation-checks the zero-hosts fallback.
|
|
func TestSub_ZeroHosts_IdenticalOutput(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "z", 4431, 1, `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`)
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Fatalf("links = %d, want 1", len(links))
|
|
}
|
|
if !strings.Contains(links[0], "203.0.113.5:4431") {
|
|
t.Fatalf("zero-hosts link should use the inbound address: %s", links[0])
|
|
}
|
|
if strings.Contains(links[0], "\n") {
|
|
t.Fatalf("zero-hosts must be a single link: %s", links[0])
|
|
}
|
|
}
|
|
|
|
// #2 — N enabled hosts render N links, ordered by sort_order, each carrying its
|
|
// own address/port/sni and host-header/path override.
|
|
func TestSub_NHosts_EmitsNLinksOrdered(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "n", 4432, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", Sni: "b.sni", HostHeader: "b.host", Path: "/b"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 2096, Security: "tls", Sni: "a.sni", HostHeader: "a.host", Path: "/a"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("want 2 host links, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(parts[0], "a.cdn.com:2096") || !strings.Contains(parts[0], "sni=a.sni") ||
|
|
!strings.Contains(parts[0], "host=a.host") || !strings.Contains(parts[0], "path=%2Fa") {
|
|
t.Fatalf("host A link (sort_order 1) wrong: %s", parts[0])
|
|
}
|
|
if !strings.Contains(parts[1], "b.cdn.com:8443") || !strings.Contains(parts[1], "sni=b.sni") ||
|
|
!strings.Contains(parts[1], "host=b.host") || !strings.Contains(parts[1], "path=%2Fb") {
|
|
t.Fatalf("host B link (sort_order 2) wrong: %s", parts[1])
|
|
}
|
|
}
|
|
|
|
// #3 — a disabled host is omitted; the inbound falls back to its legacy link.
|
|
func TestSub_DisabledHostSkipped(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "d", 4433, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "OFF", Address: "off.cdn.com", Port: 8443, IsDisabled: true})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "off.cdn.com") {
|
|
t.Fatalf("disabled host must not render: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "203.0.113.5:4433") {
|
|
t.Fatalf("with only a disabled host, the inbound's own link should render: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #4 — when both hosts and a legacy externalProxy are set, hosts win and the
|
|
// externalProxy entry is ignored.
|
|
func TestSub_HostAndExternalProxy_Precedence(t *testing.T) {
|
|
seedSubDB(t)
|
|
stream := `{"network":"ws","security":"tls","wsSettings":{"path":"/base","host":"base.host"},"tlsSettings":{"serverName":"base.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.cdn.com","port":7443,"remark":"L"}]}`
|
|
ib := seedSubInbound(t, "s1", "p", 4434, 1, stream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "host.cdn.com:8443") {
|
|
t.Fatalf("host should win: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "legacy.cdn.com") {
|
|
t.Fatalf("externalProxy must be ignored when hosts exist: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #5 — hosts that share a remark but differ in address/port are NOT deduped:
|
|
// distinct hosts produce distinct links. Mutation-checks the (absent) dedup.
|
|
func TestSub_NHosts_NoDedup(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "dd", 4435, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "SAME", Address: "one.cdn.com", Port: 8443, Security: "tls"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "SAME", Address: "two.cdn.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
parts := strings.Split(joined, "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("two distinct hosts must yield two links, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(joined, "one.cdn.com") || !strings.Contains(joined, "two.cdn.com") {
|
|
t.Fatalf("both distinct host addresses must appear: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #6 — host sort_order composes with inbound SubSortIndex: inbounds order by
|
|
// SubSortIndex, hosts within an inbound by sort_order.
|
|
func TestSub_HostSortComposesWithSubSortIndex(t *testing.T) {
|
|
seedSubDB(t)
|
|
// inbound "second" has a higher SubSortIndex so it must come after "first".
|
|
ibFirst := seedSubInbound(t, "s1", "first", 4436, 1, wsTLSStream)
|
|
ibSecond := seedSubInbound(t, "s1", "second", 4437, 2, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ibSecond.Id, SortOrder: 1, Remark: "S", Address: "second-host.com", Port: 8443, Security: "tls"})
|
|
seedHost(t, &model.Host{InboundId: ibFirst.Id, SortOrder: 1, Remark: "F", Address: "first-host.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
firstAt := strings.Index(joined, "first-host.com")
|
|
secondAt := strings.Index(joined, "second-host.com")
|
|
if firstAt < 0 || secondAt < 0 {
|
|
t.Fatalf("both inbound hosts should render: %s", joined)
|
|
}
|
|
if firstAt > secondAt {
|
|
t.Fatalf("inbound order must follow SubSortIndex (first before second): %s", joined)
|
|
}
|
|
}
|
|
|
|
// #7 — host overrides apply AFTER projectThroughFallbackMaster: the host's
|
|
// address/sni win over the projected master stream.
|
|
func TestSub_HostOverFallback(t *testing.T) {
|
|
seedSubDB(t)
|
|
db := database.GetDB()
|
|
master := &model.Inbound{
|
|
UserId: 1, Tag: "master", Enable: true, Listen: "203.0.113.9", Port: 9443,
|
|
Protocol: model.VLESS, Remark: "master",
|
|
Settings: `{"clients":[],"decryption":"none"}`,
|
|
StreamSettings: `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"master.sni"}}`,
|
|
}
|
|
if err := db.Create(master).Error; err != nil {
|
|
t.Fatalf("seed master: %v", err)
|
|
}
|
|
// child listens internal-only so projection triggers.
|
|
child := seedSubInbound(t, "s1", "child", 4438, 1, `{"network":"tcp","security":"none"}`)
|
|
child.Listen = "127.0.0.1"
|
|
if err := db.Model(&model.Inbound{}).Where("id = ?", child.Id).Update("listen", "127.0.0.1").Error; err != nil {
|
|
t.Fatalf("set child listen: %v", err)
|
|
}
|
|
if err := db.Create(&model.InboundFallback{MasterId: master.Id, ChildId: child.Id}).Error; err != nil {
|
|
t.Fatalf("seed fallback: %v", err)
|
|
}
|
|
seedHost(t, &model.Host{InboundId: child.Id, SortOrder: 1, Remark: "H", Address: "host.cdn.com", Port: 8443, Security: "tls", Sni: "host.sni"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "host.cdn.com:8443") || !strings.Contains(joined, "sni=host.sni") {
|
|
t.Fatalf("host override must win over fallback master: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "203.0.113.9") || strings.Contains(joined, "sni=master.sni") {
|
|
t.Fatalf("master endpoint/sni must be overridden by the host: %s", joined)
|
|
}
|
|
}
|
|
|
|
// #8 — a client only gets hosts for inbounds it is actually on (the
|
|
// clients ⋈ client_inbounds ⋈ inbounds join), never arbitrary inbounds.
|
|
func TestSub_HostsResolveViaClientInbounds(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedSubInbound(t, "s1", "mine", 4439, 1, wsTLSStream) // client on s1
|
|
other := seedSubInbound(t, "s2", "other", 4440, 1, wsTLSStream) // client on s2 only
|
|
seedHost(t, &model.Host{InboundId: other.Id, SortOrder: 1, Remark: "X", Address: "other-host.com", Port: 8443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "other-host.com") {
|
|
t.Fatalf("host on an inbound the client is not on must not appear: %s", joined)
|
|
}
|
|
}
|
|
|
|
// allowInsecure renders as allowInsecure=1 in the raw link and
|
|
// skip-cert-verify: true in the Clash proxy.
|
|
func TestSub_HostAllowInsecure(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "ai", 4450, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "AI", Address: "ai.cdn.com", Port: 8443, Security: "tls", AllowInsecure: true})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(links, "\n"), "allowInsecure=1") {
|
|
t.Fatalf("raw link should carry allowInsecure=1: %s", strings.Join(links, "\n"))
|
|
}
|
|
|
|
clash := NewSubClashService(false, "", false, NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "skip-cert-verify: true") {
|
|
t.Fatalf("clash proxy should carry skip-cert-verify: true:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// A host's Host header and path reach the Clash and JSON renderers even when
|
|
// the inbound's own ws settings leave them empty (#5944).
|
|
func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "hh", 4457, 1,
|
|
`{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
|
|
HostHeader: "cdn.example.com", Path: "/ws-path",
|
|
})
|
|
|
|
clash := NewSubClashService(false, "", false, NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "Host: cdn.example.com") {
|
|
t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
|
|
}
|
|
if !strings.Contains(yaml, "path: /ws-path") {
|
|
t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
|
|
}
|
|
|
|
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
|
|
t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A host's Final Mask reaches the raw share link as the fm param, merged with
|
|
// any inbound-level mask (#5831).
|
|
func TestSub_HostFinalMask_RawLink(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "fmh", 4455, 1,
|
|
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`)
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}`
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm.cdn.com", Port: 8443, Security: "tls",
|
|
FinalMask: finalMask,
|
|
})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) == 0 {
|
|
t.Fatal("GetSubs returned no links")
|
|
}
|
|
link, err := url.Parse(strings.Split(links[0], "\n")[0])
|
|
if err != nil {
|
|
t.Fatalf("parse raw link: %v", err)
|
|
}
|
|
var finalmask map[string]any
|
|
if err := json.Unmarshal([]byte(link.Query().Get("fm")), &finalmask); err != nil {
|
|
t.Fatalf("unmarshal fm query param: %v", err)
|
|
}
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 2 {
|
|
t.Fatalf("tcp mask count = %d, want existing + host mask: %#v", len(tcp), finalmask)
|
|
}
|
|
fragment, _ := tcp[1].(map[string]any)
|
|
settings, _ := fragment["settings"].(map[string]any)
|
|
if settings["length"] != "25-30" || settings["delay"] != "10-30" {
|
|
t.Fatalf("legacy ranges = (%v, %v), want last per-segment values", settings["length"], settings["delay"])
|
|
}
|
|
if len(settings["lengths"].([]any)) != 5 || len(settings["delays"].([]any)) != 5 {
|
|
t.Fatalf("per-segment ranges changed: %#v", settings)
|
|
}
|
|
}
|
|
|
|
func TestSub_HostFinalMaskJSONAddsLegacyFragmentRanges(t *testing.T) {
|
|
seedSubDB(t)
|
|
baseStream := `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"},"finalmask":{"tcp":[{"type":"sudoku","settings":{"password":"p"}}]}}`
|
|
ib := seedSubInbound(t, "s1", "fmj", 4456, 1, baseStream)
|
|
finalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["5-10","10-15","15-20","20-25","25-30"],"delays":["10-20","5-20","5-25","15-25","10-30"],"maxSplit":"10-15"}}]}`
|
|
host := seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "FM", Address: "fm-json.cdn.com", Port: 8444, Security: "tls",
|
|
FinalMask: finalMask,
|
|
})
|
|
|
|
globalFinalMask := `{"tcp":[{"type":"fragment","settings":{"packets":"tlshello","lengths":["31-40"],"delays":[]}}]}`
|
|
out, _, err := NewSubJsonService("", "", globalFinalMask, "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
var config map[string]any
|
|
if err := json.Unmarshal([]byte(out), &config); err != nil {
|
|
t.Fatalf("unmarshal JSON subscription: %v", err)
|
|
}
|
|
outbounds, _ := config["outbounds"].([]any)
|
|
if len(outbounds) == 0 {
|
|
t.Fatalf("JSON subscription has no outbounds: %s", out)
|
|
}
|
|
outbound, _ := outbounds[0].(map[string]any)
|
|
stream, _ := outbound["streamSettings"].(map[string]any)
|
|
finalmask, _ := stream["finalmask"].(map[string]any)
|
|
tcp, _ := finalmask["tcp"].([]any)
|
|
if len(tcp) != 3 {
|
|
t.Fatalf("tcp mask count = %d, want base + global + host masks: %#v", len(tcp), finalmask)
|
|
}
|
|
globalFragment, _ := tcp[1].(map[string]any)
|
|
globalSettings, _ := globalFragment["settings"].(map[string]any)
|
|
if globalSettings["length"] != "31-40" {
|
|
t.Fatalf("global legacy length = %v, want 31-40", globalSettings["length"])
|
|
}
|
|
if _, exists := globalSettings["delay"]; exists {
|
|
t.Fatalf("empty global delays must not emit a fallback: %#v", globalSettings)
|
|
}
|
|
fragment, _ := tcp[2].(map[string]any)
|
|
settings, _ := fragment["settings"].(map[string]any)
|
|
if settings["length"] != "25-30" || settings["delay"] != "10-30" {
|
|
t.Fatalf("legacy ranges = (%v, %v), want the final per-segment ranges", settings["length"], settings["delay"])
|
|
}
|
|
if got := settings["lengths"].([]any); len(got) != 5 || got[4] != "25-30" {
|
|
t.Fatalf("per-segment lengths changed: %#v", settings["lengths"])
|
|
}
|
|
if got := settings["delays"].([]any); len(got) != 5 || got[4] != "10-30" {
|
|
t.Fatalf("per-segment delays changed: %#v", settings["delays"])
|
|
}
|
|
if got := settings["maxSplit"]; got != "10-15" {
|
|
t.Fatalf("maxSplit = %v, want 10-15", got)
|
|
}
|
|
|
|
var storedHost model.Host
|
|
if err := database.GetDB().First(&storedHost, host.Id).Error; err != nil {
|
|
t.Fatalf("reload host: %v", err)
|
|
}
|
|
if storedHost.FinalMask != finalMask {
|
|
t.Fatalf("stored host FinalMask changed: %s", storedHost.FinalMask)
|
|
}
|
|
var storedInbound model.Inbound
|
|
if err := database.GetDB().First(&storedInbound, ib.Id).Error; err != nil {
|
|
t.Fatalf("reload inbound: %v", err)
|
|
}
|
|
if storedInbound.StreamSettings != baseStream {
|
|
t.Fatalf("stored inbound StreamSettings changed: %s", storedInbound.StreamSettings)
|
|
}
|
|
}
|
|
|
|
// A host's sockoptParams is injected into the JSON output stream (sockopt is
|
|
// stripped from the base stream, re-added per host).
|
|
func TestSub_HostSockoptJSON(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "so", 4460, 1,
|
|
`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/x","mode":"auto"},"tlsSettings":{"serverName":"base.sni"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "SO", Address: "so.cdn.com", Port: 8443, Security: "tls",
|
|
SockoptParams: `{"tcpFastOpen":true}`,
|
|
})
|
|
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "sockopt") || !strings.Contains(out, "tcpFastOpen") {
|
|
t.Fatalf("json should include the host sockopt:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A host's muxParams override the JSON outbound's mux.
|
|
func TestSub_HostMuxJSON(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "mx", 4470, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "MX", Address: "mx.cdn.com", Port: 8443, Security: "tls",
|
|
MuxParams: `{"enabled":true,"concurrency":8}`,
|
|
})
|
|
js := NewSubJsonService("", "", "", "", NewSubService(""))
|
|
out, _, err := js.GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "concurrency") {
|
|
t.Fatalf("json should include the host mux override:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A reality host overrides SNI + fingerprint while inheriting pbk/sid from the
|
|
// inbound (reality keys can't be host-supplied).
|
|
func TestSub_HostRealitySniOverride(t *testing.T) {
|
|
seedSubDB(t)
|
|
realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
|
ib := seedSubInbound(t, "s1", "rl", 4490, 1, realityStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "RL", Address: "rl.cdn.com", Port: 8443,
|
|
Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
|
|
})
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "rl.cdn.com:8443") || !strings.Contains(joined, "security=reality") {
|
|
t.Fatalf("reality host base wrong: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "sni=host.reality.com") || !strings.Contains(joined, "fp=firefox") {
|
|
t.Fatalf("reality host sni/fp override not applied: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "sni=base.reality.com") {
|
|
t.Fatalf("base reality sni must be overridden: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, "pbk=PBK") || !strings.Contains(joined, "sid=abcd") {
|
|
t.Fatalf("reality pbk/sid must be inherited from the inbound: %s", joined)
|
|
}
|
|
}
|
|
|
|
// A reality host's SNI reaches JSON and Clash as serverName only: xray refuses a
|
|
// reality client that also carries serverNames (#6690).
|
|
func TestSub_HostRealitySniJSONAndClash(t *testing.T) {
|
|
seedSubDB(t)
|
|
realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
|
ib := seedSubInbound(t, "s1", "rlj", 4491, 1, realityStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "RLJ", Address: "rl.cdn.com", Port: 8443,
|
|
Security: "reality", Sni: "host.reality.com",
|
|
})
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
|
t.Fatalf("a single-config subscription should be one JSON object: %v\n%s", err, out)
|
|
}
|
|
reality := proxyRealitySettings(t, doc)
|
|
if got := reality["serverName"]; got != "host.reality.com" {
|
|
t.Fatalf("json serverName = %v, want the host's SNI host.reality.com", got)
|
|
}
|
|
if names, leaked := reality["serverNames"]; leaked {
|
|
t.Fatalf("server-side serverNames %v leaked into the json reality client:\n%s", names, out)
|
|
}
|
|
|
|
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "servername: host.reality.com") {
|
|
t.Fatalf("clash proxy should carry the host's SNI:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// A reality host's fingerprint must reach JSON and Clash like it reaches the raw
|
|
// link; otherwise one host gets fp=firefox in vless:// but chrome in Clash.
|
|
func TestSub_HostRealityFingerprintJSONAndClash(t *testing.T) {
|
|
seedSubDB(t)
|
|
realityStream := `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
|
ib := seedSubInbound(t, "s1", "rlf", 4492, 1, realityStream)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "RLF", Address: "rl.cdn.com", Port: 8443,
|
|
Security: "reality", Sni: "host.reality.com", Fingerprint: "firefox",
|
|
})
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
var doc map[string]any
|
|
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
|
t.Fatalf("a single-config subscription should be one JSON object: %v\n%s", err, out)
|
|
}
|
|
if got := proxyRealitySettings(t, doc)["fingerprint"]; got != "firefox" {
|
|
t.Fatalf("json reality fingerprint = %v, want the host's firefox", got)
|
|
}
|
|
|
|
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "client-fingerprint: firefox") {
|
|
t.Fatalf("clash proxy should carry the host's fingerprint:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// Hosts of one inbound render from per-host copies of the same stream; a host
|
|
// that leaves an override blank must get the inbound's value, not the previous host's.
|
|
func TestSub_HostBlankOverridesDoNotLeakBetweenHosts(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
stream string
|
|
first model.Host
|
|
clashWant map[string]string
|
|
jsonField func(stream map[string]any) any
|
|
jsonWant any
|
|
}{
|
|
{
|
|
name: "reality fingerprint and sni",
|
|
stream: mihomoRealityStream,
|
|
first: model.Host{Security: "reality", Sni: "a.reality.com", Fingerprint: "firefox"},
|
|
clashWant: map[string]string{"client-fingerprint": "chrome", "servername": "base.reality.com"},
|
|
jsonField: func(stream map[string]any) any {
|
|
rs, _ := stream["realitySettings"].(map[string]any)
|
|
return rs["fingerprint"]
|
|
},
|
|
jsonWant: "chrome",
|
|
},
|
|
{
|
|
name: "ws host header and path",
|
|
stream: wsTLSStream,
|
|
first: model.Host{Security: "tls", HostHeader: "a.host", Path: "/a"},
|
|
clashWant: map[string]string{"ws-opts.path": "/base", "ws-opts.headers.Host": "base.host"},
|
|
jsonField: func(stream map[string]any) any {
|
|
ws, _ := stream["wsSettings"].(map[string]any)
|
|
return ws["path"]
|
|
},
|
|
jsonWant: "/base",
|
|
},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "lk", 4494, 1, tc.stream)
|
|
first := tc.first
|
|
first.InboundId, first.SortOrder, first.Remark, first.Address, first.Port = ib.Id, 0, "A", "a.cdn.com", 443
|
|
seedHost(t, &first)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "B", Address: "b.cdn.com", Port: 443, Security: tc.first.Security})
|
|
|
|
out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
second := clashProxyByServer(t, out, "b.cdn.com")
|
|
for field, want := range tc.clashWant {
|
|
if got := lookupPath(second, field); got != want {
|
|
t.Errorf("host B clash %s = %v, want the inbound's %q", field, got, want)
|
|
}
|
|
}
|
|
|
|
out, _, err = NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
var configs []map[string]any
|
|
if err := json.Unmarshal([]byte(out), &configs); err != nil || len(configs) != 2 {
|
|
t.Fatalf("want one JSON config per host, got err=%v:\n%s", err, out)
|
|
}
|
|
if got := tc.jsonField(proxyStreamSettings(t, configs[1])); got != tc.jsonWant {
|
|
t.Errorf("host B json value = %v, want the inbound's %v", got, tc.jsonWant)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func clashProxyByServer(t *testing.T, out, server string) map[string]any {
|
|
t.Helper()
|
|
var config struct {
|
|
Proxies []map[string]any `yaml:"proxies"`
|
|
}
|
|
if err := yaml.Unmarshal([]byte(out), &config); err != nil {
|
|
t.Fatalf("decode Clash YAML: %v\n%s", err, out)
|
|
}
|
|
for _, proxy := range config.Proxies {
|
|
if proxy["server"] == server {
|
|
return proxy
|
|
}
|
|
}
|
|
t.Fatalf("no clash proxy for %s in:\n%s", server, out)
|
|
return nil
|
|
}
|
|
|
|
func lookupPath(m map[string]any, path string) any {
|
|
var cur any = m
|
|
for _, key := range strings.Split(path, ".") {
|
|
next, _ := cur.(map[string]any)
|
|
cur = next[key]
|
|
}
|
|
return cur
|
|
}
|
|
|
|
func proxyStreamSettings(t *testing.T, doc map[string]any) map[string]any {
|
|
t.Helper()
|
|
outbounds, _ := doc["outbounds"].([]any)
|
|
for _, ob := range outbounds {
|
|
outbound, _ := ob.(map[string]any)
|
|
if outbound["tag"] == "proxy" {
|
|
stream, _ := outbound["streamSettings"].(map[string]any)
|
|
return stream
|
|
}
|
|
}
|
|
t.Fatalf("no proxy outbound in %v", doc)
|
|
return nil
|
|
}
|
|
|
|
const mihomoRealityStream = `{"network":"tcp","security":"reality","tcpSettings":{"header":{"type":"none"}},"realitySettings":{"serverNames":["base.reality.com"],"shortIds":["abcd"],"settings":{"publicKey":"PBK","fingerprint":"chrome"}}}`
|
|
|
|
// seedMihomoX25519Hosts puts a default host and a "Mihomo X25519" host on one
|
|
// reality inbound, so each test proves the toggle is per host, not per inbound.
|
|
func seedMihomoX25519Hosts(t *testing.T) {
|
|
t.Helper()
|
|
ib := seedSubInbound(t, "s1", "mx", 4493, 1, mihomoRealityStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "PQ", Address: "pq.cdn.com", Port: 443})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "CLASSIC", Address: "classic.cdn.com", Port: 443, MihomoX25519: true})
|
|
}
|
|
|
|
// Servers on Xray-core < 26.9.8 do not need ML-KEM, and a two-segment ML-KEM
|
|
// ClientHello is what some DPI drops; the host toggle must turn it off in Clash.
|
|
func TestSub_HostMihomoX25519Clash(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedMihomoX25519Hosts(t)
|
|
|
|
out, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
var config struct {
|
|
Proxies []struct {
|
|
Server string `yaml:"server"`
|
|
RealityOpts struct {
|
|
MLKEM *bool `yaml:"support-x25519mlkem768"`
|
|
} `yaml:"reality-opts"`
|
|
} `yaml:"proxies"`
|
|
}
|
|
if err := yaml.Unmarshal([]byte(out), &config); err != nil {
|
|
t.Fatalf("decode Clash YAML: %v\n%s", err, out)
|
|
}
|
|
want := map[string]bool{"pq.cdn.com": true, "classic.cdn.com": false}
|
|
for _, proxy := range config.Proxies {
|
|
mlkem, ok := want[proxy.Server]
|
|
if !ok {
|
|
continue
|
|
}
|
|
if got := proxy.RealityOpts.MLKEM; got == nil || *got != mlkem {
|
|
t.Errorf("%s: support-x25519mlkem768 = %s, want %v\n%s", proxy.Server, fmtOptBool(got), mlkem, out)
|
|
}
|
|
delete(want, proxy.Server)
|
|
}
|
|
if len(want) != 0 {
|
|
t.Fatalf("missing host proxies %v in:\n%s", want, out)
|
|
}
|
|
}
|
|
|
|
func fmtOptBool(b *bool) string {
|
|
if b == nil {
|
|
return "absent"
|
|
}
|
|
return fmt.Sprint(*b)
|
|
}
|
|
|
|
// Mihomo also reads the ML-KEM hint from imported vless:// links (#6712), so a
|
|
// "Mihomo X25519" host must not carry it there either.
|
|
func TestSub_HostMihomoX25519RawLink(t *testing.T) {
|
|
seedSubDB(t)
|
|
seedMihomoX25519Hosts(t)
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
var pq, classic string
|
|
for _, link := range strings.Split(strings.Join(links, "\n"), "\n") {
|
|
switch {
|
|
case strings.Contains(link, "@pq.cdn.com:443"):
|
|
pq = link
|
|
case strings.Contains(link, "@classic.cdn.com:443"):
|
|
classic = link
|
|
}
|
|
}
|
|
if !strings.Contains(pq, "support-x25519mlkem768=true") {
|
|
t.Fatalf("default host must keep the ML-KEM hint: %q", pq)
|
|
}
|
|
if classic == "" || strings.Contains(classic, "support-x25519mlkem768") {
|
|
t.Fatalf("Mihomo X25519 host must drop the ML-KEM hint: %q", classic)
|
|
}
|
|
}
|
|
|
|
func proxyRealitySettings(t *testing.T, doc map[string]any) map[string]any {
|
|
t.Helper()
|
|
outbounds, _ := doc["outbounds"].([]any)
|
|
for _, ob := range outbounds {
|
|
outbound, _ := ob.(map[string]any)
|
|
if outbound["tag"] != "proxy" {
|
|
continue
|
|
}
|
|
stream, _ := outbound["streamSettings"].(map[string]any)
|
|
reality, ok := stream["realitySettings"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("proxy outbound has no realitySettings: %v", outbound)
|
|
}
|
|
return reality
|
|
}
|
|
t.Fatalf("no proxy outbound in %v", doc)
|
|
return nil
|
|
}
|
|
|
|
// #9 — ExcludeFromSubTypes is honored per format: a host excluded from clash is
|
|
// absent from GetClash but present in the raw GetSubs output.
|
|
func TestSub_ExcludeFromSubTypes(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "x", 4441, 1, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "clashless.cdn.com", Port: 8443, Security: "tls", ExcludeFromSubTypes: []string{"clash"}})
|
|
|
|
sub := NewSubService("")
|
|
links, _, _, _, err := sub.GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if !strings.Contains(strings.Join(links, "\n"), "clashless.cdn.com") {
|
|
t.Fatalf("host not excluded from raw should appear in GetSubs")
|
|
}
|
|
|
|
clash := NewSubClashService(false, "", false, NewSubService(""))
|
|
yaml, _, err := clash.GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if strings.Contains(yaml, "clashless.cdn.com") {
|
|
t.Fatalf("host excluded from clash must not appear in GetClash:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// A host that forces plain TLS over a Reality inbound must not leave the
|
|
// Reality identity behind: pbk/sid/spx and the Reality dest sni describe a
|
|
// handshake the endpoint no longer performs.
|
|
func TestSub_HostTlsOverRealityDropsRealityParams(t *testing.T) {
|
|
seedSubDB(t)
|
|
reality := `{"network":"tcp","security":"reality","realitySettings":{"serverNames":["master-dest.example.com"],"publicKey":"MASTERPBK","shortIds":["ab12"],"fingerprint":"chrome"}}`
|
|
ib := seedSubInbound(t, "s1", "reality-in", 4461, 1, reality)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "H", Address: "edge.example.com", Port: 443, Security: "tls"})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if !strings.Contains(joined, "security=tls") {
|
|
t.Fatalf("host forces tls, link must say so: %s", joined)
|
|
}
|
|
for _, leaked := range []string{
|
|
"pbk=",
|
|
"sid=",
|
|
"spx=",
|
|
"support-x25519mlkem768=",
|
|
"sni=master-dest.example.com",
|
|
} {
|
|
if strings.Contains(joined, leaked) {
|
|
t.Fatalf("reality parameter %q survived a tls host override: %s", leaked, joined)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A host's cipher suites override the inbound's own in the JSON subscription,
|
|
// while a host that leaves the field blank inherits them.
|
|
func TestSub_HostCipherSuitesJSON(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "cs", 4462, 1,
|
|
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni","cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "CS", Address: "cs.cdn.com", Port: 8443, Security: "tls",
|
|
CipherSuites: "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256",
|
|
})
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 1, Remark: "INHERIT", Address: "inh.cdn.com", Port: 8443, Security: "tls",
|
|
})
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, `"cipherSuites": "TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) &&
|
|
!strings.Contains(out, `"cipherSuites":"TLS_AES_256_GCM_SHA384:TLS_AES_128_GCM_SHA256"`) {
|
|
t.Fatalf("json tlsSettings should carry the host's cipher suites:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, `"cipherSuites": "TLS_CHACHA20_POLY1305_SHA256"`) &&
|
|
!strings.Contains(out, `"cipherSuites":"TLS_CHACHA20_POLY1305_SHA256"`) {
|
|
t.Fatalf("a host with no cipher suites should inherit the inbound's:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// Xray reads a client's TLS verification fields at the top of tlsSettings; a
|
|
// nested "settings" map is panel-only shape and xray silently ignores it.
|
|
func TestSub_HostTLSVerificationJSONAtXrayLevel(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "ech", 4461, 1,
|
|
`{"network":"xhttp","security":"tls","xhttpSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni","settings":{"fingerprint":"chrome"}}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 0, Remark: "ECH", Address: "ech.cdn.com", Port: 443, Security: "tls",
|
|
EchConfigList: "cloudflare-ech.com+udp://1.1.1.1", VerifyPeerCertByName: "cert.example.com",
|
|
PinnedPeerCertSha256: []string{"aa11", "bb22"}, AllowInsecure: true,
|
|
})
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
var config map[string]any
|
|
if err := json.Unmarshal([]byte(out), &config); err != nil {
|
|
t.Fatalf("unmarshal JSON subscription: %v", err)
|
|
}
|
|
outbounds, _ := config["outbounds"].([]any)
|
|
if len(outbounds) == 0 {
|
|
t.Fatalf("JSON subscription has no outbounds: %s", out)
|
|
}
|
|
outbound, _ := outbounds[0].(map[string]any)
|
|
stream, _ := outbound["streamSettings"].(map[string]any)
|
|
tls, _ := stream["tlsSettings"].(map[string]any)
|
|
want := map[string]any{
|
|
"serverName": "base.sni",
|
|
"fingerprint": "chrome",
|
|
"echConfigList": "cloudflare-ech.com+udp://1.1.1.1",
|
|
"verifyPeerCertByName": "cert.example.com",
|
|
"pinnedPeerCertSha256": "aa11,bb22",
|
|
}
|
|
for key, value := range want {
|
|
if tls[key] != value {
|
|
t.Errorf("tlsSettings.%s = %#v, want %#v", key, tls[key], value)
|
|
}
|
|
}
|
|
for _, key := range []string{"settings", "allowInsecure"} {
|
|
if _, ok := tls[key]; ok {
|
|
t.Errorf("tlsSettings.%s must not reach xray: %#v", key, tls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSub_HostCIDR_RawLinks(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "cidr-raw", 4462, 1,
|
|
`{"network":"tcp","security":"tls","tlsSettings":{"serverName":"sni.example.com"}}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id,
|
|
SortOrder: 0,
|
|
Remark: "IPv6Node",
|
|
Address: "2a01:4f8:1c1b:729::/64",
|
|
Port: 8443,
|
|
Security: "tls",
|
|
RandomCount: 3,
|
|
})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("len(parts) = %d, want 3", len(parts))
|
|
}
|
|
|
|
_, ipNet, _ := net.ParseCIDR("2a01:4f8:1c1b:729::/64")
|
|
seenIPs := make(map[string]struct{}, len(parts))
|
|
|
|
for i, link := range parts {
|
|
wantRemark := fmt.Sprintf("IPv6Node%%20-%%20%d", i+1)
|
|
if !strings.Contains(link, wantRemark) {
|
|
t.Errorf("link[%d] does not contain expected remark %q: %s", i, wantRemark, link)
|
|
}
|
|
|
|
atIdx := strings.Index(link, "@")
|
|
colonIdx := strings.LastIndex(link, ":8443")
|
|
if atIdx == -1 || colonIdx == -1 || colonIdx <= atIdx {
|
|
t.Fatalf("malformed link host: %s", link)
|
|
}
|
|
rawAddr := strings.Trim(link[atIdx+1:colonIdx], "[]")
|
|
parsed := net.ParseIP(rawAddr)
|
|
if parsed == nil || !ipNet.Contains(parsed) {
|
|
t.Errorf("link[%d] IP %q not inside 2a01:4f8:1c1b:729::/64", i, rawAddr)
|
|
}
|
|
if _, dup := seenIPs[rawAddr]; dup {
|
|
t.Errorf("duplicate IP %q emitted across links", rawAddr)
|
|
}
|
|
seenIPs[rawAddr] = struct{}{}
|
|
}
|
|
}
|
|
|
|
func TestSub_HostCIDR_SingleCountKeepsBaseRemark(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "cidr-single", 4463, 1,
|
|
`{"network":"tcp","security":"none"}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id,
|
|
SortOrder: 0,
|
|
Remark: "SingleCIDR",
|
|
Address: "198.51.100.0/24",
|
|
Port: 8080,
|
|
Security: "none",
|
|
RandomCount: 1,
|
|
})
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 1 {
|
|
t.Fatalf("len(parts) = %d, want 1", len(parts))
|
|
}
|
|
if strings.Contains(parts[0], "SingleCIDR - 1") {
|
|
t.Errorf("count=1 must not append index suffix: %s", parts[0])
|
|
}
|
|
if !strings.Contains(parts[0], "SingleCIDR") {
|
|
t.Errorf("link does not contain base remark: %s", parts[0])
|
|
}
|
|
}
|
|
|
|
func TestSub_HostCIDR_ClashAndJson(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "cidr-clash", 4464, 1,
|
|
`{"network":"tcp","security":"none"}`)
|
|
seedHost(t, &model.Host{
|
|
InboundId: ib.Id,
|
|
SortOrder: 0,
|
|
Remark: "V4Node",
|
|
Address: "203.0.113.0/24",
|
|
Port: 8080,
|
|
Security: "none",
|
|
RandomCount: 2,
|
|
})
|
|
|
|
clashYaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(clashYaml, "V4Node - 1") || !strings.Contains(clashYaml, "V4Node - 2") {
|
|
t.Errorf("Clash YAML should contain numbered host proxies: %s", clashYaml)
|
|
}
|
|
|
|
jsonDoc, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(jsonDoc, "V4Node - 1") || !strings.Contains(jsonDoc, "V4Node - 2") {
|
|
t.Errorf("JSON config should contain numbered host outbounds: %s", jsonDoc)
|
|
}
|
|
}
|
|
|
|
// seedSubClient adds a second client sharing subId to an already-seeded
|
|
// inbound, so per-client host bindings can be compared within one sub.
|
|
func seedSubClient(t *testing.T, ib *model.Inbound, subId, email string) {
|
|
t.Helper()
|
|
db := database.GetDB()
|
|
uuid := "22222222-3333-4444-8555-" + fmt.Sprintf("%012d", ib.Id)
|
|
client := &model.ClientRecord{Email: email, SubID: subId, UUID: uuid, Enable: true}
|
|
if err := db.Create(client).Error; err != nil {
|
|
t.Fatalf("seed client %s: %v", email, err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ib.Id}).Error; err != nil {
|
|
t.Fatalf("seed client_inbound %s: %v", email, err)
|
|
}
|
|
}
|
|
|
|
const tcpTLSStream = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"base.sni"}}`
|
|
|
|
// A bound host with blank address and port must use the inbound's own, even after an earlier
|
|
// client rewrote the shared inbound's Listen/Port while rendering its endpoints.
|
|
func TestSub_JsonBoundHostBlankAddressIgnoresEarlierClient(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "mv", 4491, 1, tcpTLSStream)
|
|
// Host A has a real address and sorts last, so the unbound client (lower
|
|
// id, rendered first) leaves the shared inbound pointing at a.cdn.com:443.
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls"})
|
|
// Host B is override-only: blank address and port, so the bound client's
|
|
// endpoint must inherit the inbound's own address and port.
|
|
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 0, Remark: "B", Security: "tls", Sni: "b.sni"})
|
|
seedSubClient(t, ib, "s1", "bound@e")
|
|
|
|
if err := database.GetDB().Model(&model.ClientRecord{}).Where("email = ?", "bound@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostB.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rule: %v", err)
|
|
}
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
type config struct {
|
|
Remarks string `json:"remarks"`
|
|
Outbounds []struct {
|
|
Tag string `json:"tag"`
|
|
Settings struct {
|
|
Address string `json:"address"`
|
|
Port int `json:"port"`
|
|
} `json:"settings"`
|
|
} `json:"outbounds"`
|
|
}
|
|
var docs []config
|
|
if err := json.Unmarshal([]byte(out), &docs); err != nil {
|
|
t.Fatalf("unmarshal json sub: %v\n%s", err, out)
|
|
}
|
|
var got struct {
|
|
address string
|
|
port int
|
|
}
|
|
for _, doc := range docs {
|
|
if !strings.Contains(doc.Remarks, "bound@e") {
|
|
continue
|
|
}
|
|
for _, ob := range doc.Outbounds {
|
|
if ob.Tag == "proxy" {
|
|
got.address, got.port = ob.Settings.Address, ob.Settings.Port
|
|
}
|
|
}
|
|
}
|
|
if got.address == "" {
|
|
t.Fatalf("no proxy outbound found for the bound client:\n%s", out)
|
|
}
|
|
if got.address != ib.Listen || got.port != ib.Port {
|
|
t.Fatalf("override-only host resolved to %s:%d, want the inbound's own %s:%d",
|
|
got.address, got.port, ib.Listen, ib.Port)
|
|
}
|
|
}
|
|
|
|
// A client bound to one host renders only that host's link with its SNI,
|
|
// while an unbound client on the same inbound still fans out to every host.
|
|
func TestSub_ClientHostRule_SingleBinding(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "cb", 443, 1, tcpTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
|
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
|
seedSubClient(t, ib, "s1", "other@e")
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "cb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostB.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rule: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 3 {
|
|
t.Fatalf("bound client renders 1 link, unbound renders 2, got %d: %v", len(parts), parts)
|
|
}
|
|
bound := 0
|
|
for _, l := range parts {
|
|
if strings.Contains(l, "b.cdn.com:443") {
|
|
bound++
|
|
if !strings.Contains(l, "sni=b.sni") {
|
|
t.Fatalf("bound link must carry the bound host SNI: %s", l)
|
|
}
|
|
}
|
|
}
|
|
if bound != 2 {
|
|
t.Fatalf("want 2 links via b.cdn.com (1 bound + 1 unbound fan-out), got %d: %v", bound, parts)
|
|
}
|
|
aLinks := 0
|
|
for _, l := range parts {
|
|
if strings.Contains(l, "a.cdn.com:443") {
|
|
aLinks++
|
|
if strings.Contains(l, "sni=b.sni") {
|
|
t.Fatalf("unbound host-A link must not carry host-B SNI: %s", l)
|
|
}
|
|
}
|
|
}
|
|
if aLinks != 1 {
|
|
t.Fatalf("want exactly 1 link via a.cdn.com (unbound fan-out), got %d: %v", aLinks, parts)
|
|
}
|
|
}
|
|
|
|
// A client bound to several host rules renders one link per bound host,
|
|
// and only those hosts — a third inbound host stays out of its links.
|
|
func TestSub_ClientHostRule_MultipleBindings(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "mb", 444, 1, tcpTLSStream)
|
|
hostA := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
|
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 3, Remark: "C", Address: "c.cdn.com", Port: 443, Security: "tls", Sni: "c.sni"})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "mb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id, hostB.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
parts := strings.Split(joined, "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("two bound hosts must yield two links, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(joined, "sni=a.sni") || !strings.Contains(joined, "sni=b.sni") {
|
|
t.Fatalf("each bound host SNI must appear: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "c.cdn.com") {
|
|
t.Fatalf("unbound host must not render for the bound client: %s", joined)
|
|
}
|
|
}
|
|
|
|
// A deleted binding is skipped; with no valid binding left the client fails closed with zero
|
|
// links (no fallback, no dummy link) but stays in traffic accounting.
|
|
func TestSub_ClientHostRule_DeletedFallsBack(t *testing.T) {
|
|
t.Run("deleted and valid binding renders only the valid host", func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "fb", 445, 1, tcpTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
|
hostB := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "fb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{9999, hostB.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 1 {
|
|
t.Fatalf("deleted id skipped, want only the valid host's link, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(parts[0], "b.cdn.com:443") || !strings.Contains(parts[0], "sni=b.sni") {
|
|
t.Fatalf("valid bound host must render with its SNI: %s", parts[0])
|
|
}
|
|
if strings.Contains(parts[0], "a.cdn.com") {
|
|
t.Fatalf("unbound host must not render for the bound client: %s", parts[0])
|
|
}
|
|
})
|
|
|
|
t.Run("deleted-only binding fails closed with zero links", func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "fb", 445, 1, tcpTLSStream)
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 443, Security: "tls", Sni: "b.sni"})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "fb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{9999})).Error; err != nil {
|
|
t.Fatalf("bind host rule: %v", err)
|
|
}
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "fb@e", Up: 100, Down: 200, Enable: true}).Error; err != nil {
|
|
t.Fatalf("seed traffic: %v", err)
|
|
}
|
|
|
|
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Fatalf("deleted-only binding must emit zero links, got %d: %v", len(links), links)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "a.cdn.com") || strings.Contains(joined, "b.cdn.com") {
|
|
t.Fatalf("fail-closed must not fall back to inbound hosts: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "socks://") || strings.Contains(joined, "127.0.0.1") {
|
|
t.Fatalf("fail-closed must not emit a dummy link: %s", joined)
|
|
}
|
|
if traffic.Up != 100 || traffic.Down != 200 {
|
|
t.Fatalf("usage = up %d/down %d, want 100/200: closed client stays in accounting", traffic.Up, traffic.Down)
|
|
}
|
|
if !traffic.Enable {
|
|
t.Fatalf("traffic.Enable = false, want true: closed client stays enabled")
|
|
}
|
|
})
|
|
|
|
// A deleted host id must not silence an inbound without the client's other bound hosts:
|
|
// relevance comes from rows that still exist, fail-closed only when every bound id is gone.
|
|
t.Run("deleted id on one inbound does not silence an unrelated inbound", func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ibA := seedSubInbound(t, "s1", "xa", 4471, 1, tcpTLSStream)
|
|
ibB := seedSubInbound(t, "s1", "xb", 4472, 2, tcpTLSStream)
|
|
|
|
// Put the client on both inbounds under one email.
|
|
db := database.GetDB()
|
|
var client model.ClientRecord
|
|
if err := db.Where("email = ?", "xb@e").First(&client).Error; err != nil {
|
|
t.Fatalf("load client xb@e: %v", err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: client.Id, InboundId: ibA.Id}).Error; err != nil {
|
|
t.Fatalf("link client to inbound A: %v", err)
|
|
}
|
|
// Host A lives on ibA; bind it plus a deleted id 9999.
|
|
hostA := seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 443, Security: "tls", Sni: "a.sni"})
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "xb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id, 9999})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
// Inbound B has no bound host and none of the ids map to it, so its
|
|
// legacy fallback link must survive; inbound A still renders host A.
|
|
if !strings.Contains(joined, "a.cdn.com:443") {
|
|
t.Fatalf("inbound A's bound host must render: %s", joined)
|
|
}
|
|
if !strings.Contains(joined, fmt.Sprintf("203.0.113.5:%d", ibB.Port)) {
|
|
t.Fatalf("deleted id must not silence inbound B's fallback link: %s", joined)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A client bound to a disabled host fails closed on every format (no links, outbounds or proxies)
|
|
// but stays in traffic accounting, so quota and expiry headers stay correct.
|
|
func TestSub_ClientHostRule_DisabledFailsClosed(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "dis", 447, 1, tcpTLSStream)
|
|
host := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "D", Address: "dis.cdn.com", Port: 443, Security: "tls", Sni: "dis.sni"})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "dis@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
if err := db.Model(&model.Host{}).Where("id = ?", host.Id).Update("is_disabled", true).Error; err != nil {
|
|
t.Fatalf("disable host: %v", err)
|
|
}
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "dis@e", Up: 100, Down: 200, Enable: true}).Error; err != nil {
|
|
t.Fatalf("seed traffic: %v", err)
|
|
}
|
|
|
|
t.Run("raw", func(t *testing.T) {
|
|
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Fatalf("disabled binding must emit zero links, got %d: %v", len(links), links)
|
|
}
|
|
joined := strings.Join(links, "\n")
|
|
if strings.Contains(joined, "dis.cdn.com") || strings.Contains(joined, "203.0.113.5") {
|
|
t.Fatalf("fail-closed must not fall back: %s", joined)
|
|
}
|
|
if strings.Contains(joined, "socks://") || strings.Contains(joined, "127.0.0.1") {
|
|
t.Fatalf("fail-closed must not emit a dummy link: %s", joined)
|
|
}
|
|
if traffic.Up != 100 || traffic.Down != 200 {
|
|
t.Fatalf("usage = up %d/down %d, want 100/200", traffic.Up, traffic.Down)
|
|
}
|
|
})
|
|
|
|
t.Run("json", func(t *testing.T) {
|
|
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if strings.TrimSpace(out) != "" {
|
|
t.Fatalf("disabled binding must emit zero outbounds, got:\n%s", out)
|
|
}
|
|
if !strings.HasPrefix(header, "upload=100; download=200; ") {
|
|
t.Fatalf("header = %q, want usage prefix %q", header, "upload=100; download=200; ")
|
|
}
|
|
if strings.Contains(out, "dis.cdn.com") {
|
|
t.Fatalf("fail-closed json must not contain the disabled host:\n%s", out)
|
|
}
|
|
})
|
|
|
|
t.Run("clash", func(t *testing.T) {
|
|
out, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if strings.TrimSpace(out) != "" {
|
|
t.Fatalf("disabled binding must emit zero proxies, got:\n%s", out)
|
|
}
|
|
if !strings.HasPrefix(header, "upload=100; download=200; ") {
|
|
t.Fatalf("header = %q, want usage prefix %q", header, "upload=100; download=200; ")
|
|
}
|
|
if strings.Contains(out, "dis.cdn.com") {
|
|
t.Fatalf("fail-closed clash must not contain the disabled host:\n%s", out)
|
|
}
|
|
})
|
|
|
|
t.Run("panel links", func(t *testing.T) {
|
|
if got := NewLinkProvider().LinksForClient("req.example.com", ib, "dis@e"); len(got) != 0 {
|
|
t.Fatalf("LinksForClient must emit zero links, got %v", got)
|
|
}
|
|
svc := NewSubService("")
|
|
svc.PrepareForRequest("req.example.com")
|
|
if got := svc.inboundLinks(ib); len(got) != 0 {
|
|
t.Fatalf("inboundLinks must emit zero links, got %v", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// A host excluded from one format fails closed only there: that format emits nothing but keeps
|
|
// accounting, while the other formats still render the host.
|
|
func TestSub_ClientHostRule_FormatExcludedFailsClosed(t *testing.T) {
|
|
formats := []string{"raw", "json", "clash"}
|
|
for _, format := range formats {
|
|
t.Run(format, func(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "exc", 448, 1, tcpTLSStream)
|
|
host := seedHost(t, &model.Host{
|
|
InboundId: ib.Id, SortOrder: 1, Remark: "X", Address: "exc.cdn.com", Port: 443,
|
|
Security: "tls", Sni: "exc.sni", ExcludeFromSubTypes: []string{format},
|
|
})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "exc@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: "exc@e", Up: 300, Down: 400, Enable: true}).Error; err != nil {
|
|
t.Fatalf("seed traffic: %v", err)
|
|
}
|
|
|
|
switch format {
|
|
case "raw":
|
|
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Fatalf("format-excluded binding must emit zero links, got %d: %v", len(links), links)
|
|
}
|
|
if traffic.Up != 300 || traffic.Down != 400 {
|
|
t.Fatalf("usage = up %d/down %d, want 300/400", traffic.Up, traffic.Down)
|
|
}
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "exc.cdn.com") {
|
|
t.Fatalf("json (not excluded) must still render the host:\n%s", out)
|
|
}
|
|
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if !strings.Contains(yaml, "exc.cdn.com") {
|
|
t.Fatalf("clash (not excluded) must still render the host:\n%s", yaml)
|
|
}
|
|
case "json":
|
|
out, header, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if strings.TrimSpace(out) != "" {
|
|
t.Fatalf("format-excluded binding must emit zero outbounds, got:\n%s", out)
|
|
}
|
|
if !strings.HasPrefix(header, "upload=300; download=400; ") {
|
|
t.Fatalf("header = %q, want usage prefix", header)
|
|
}
|
|
links, _, _, traffic, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 || !strings.Contains(links[0], "exc.cdn.com") {
|
|
t.Fatalf("raw (not excluded) must still render the host: %v", links)
|
|
}
|
|
if traffic.Up != 300 || traffic.Down != 400 {
|
|
t.Fatalf("usage = up %d/down %d, want 300/400", traffic.Up, traffic.Down)
|
|
}
|
|
case "clash":
|
|
yaml, header, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if strings.TrimSpace(yaml) != "" {
|
|
t.Fatalf("format-excluded binding must emit zero proxies, got:\n%s", yaml)
|
|
}
|
|
if !strings.HasPrefix(header, "upload=300; download=400; ") {
|
|
t.Fatalf("header = %q, want usage prefix", header)
|
|
}
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
if len(links) != 1 || !strings.Contains(links[0], "exc.cdn.com") {
|
|
t.Fatalf("raw (not excluded) must still render the host: %v", links)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// The bound host's SNI wins in the JSON subscription too, and unbound
|
|
// inbound hosts stay out of the bound client's configs.
|
|
func TestSub_ClientHostRule_JSONSNI(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "jb", 446, 1, tcpTLSStream)
|
|
host := seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 443, Security: "tls", Sni: "bound.sni"})
|
|
seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "O", Address: "o.cdn.com", Port: 443, Security: "tls", Sni: "other.sni"})
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "jb@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{host.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rules: %v", err)
|
|
}
|
|
|
|
out, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(out, "bound.sni") {
|
|
t.Fatalf("json config must carry the bound host SNI:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "j.cdn.com") {
|
|
t.Fatalf("json config must dial the bound host address:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "other.sni") || strings.Contains(out, "o.cdn.com") {
|
|
t.Fatalf("json config must not contain the unbound host:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// A host bound on inbound A must not leak onto inbound B for a client on
|
|
// both: A renders only the bound host, B keeps its own endpoint.
|
|
func TestSub_ClientHostRule_ScopedToInbound(t *testing.T) {
|
|
seedSubDB(t)
|
|
db := database.GetDB()
|
|
ibA := seedSubInbound(t, "s1", "scope-a", 443, 1, tcpTLSStream)
|
|
var rec model.ClientRecord
|
|
if err := db.Where("email = ?", "scope-a@e").First(&rec).Error; err != nil {
|
|
t.Fatalf("load client record: %v", err)
|
|
}
|
|
ibB := &model.Inbound{
|
|
UserId: 1, Tag: "scope-b", Enable: true, Listen: "203.0.113.5", Port: 8443,
|
|
Protocol: model.VLESS, Remark: "scope-b", Settings: ibA.Settings,
|
|
StreamSettings: tcpTLSStream, SubSortIndex: 2,
|
|
}
|
|
if err := db.Create(ibB).Error; err != nil {
|
|
t.Fatalf("seed inbound B: %v", err)
|
|
}
|
|
if err := db.Create(&model.ClientInbound{ClientId: rec.Id, InboundId: ibB.Id}).Error; err != nil {
|
|
t.Fatalf("attach client to inbound B: %v", err)
|
|
}
|
|
hostA := seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 1, Remark: "HA", Address: "host-a.cdn.com", Port: 9443, Security: "tls", Sni: "host-a.sni"})
|
|
seedHost(t, &model.Host{InboundId: ibA.Id, SortOrder: 2, Remark: "HA2", Address: "host-a2.cdn.com", Port: 9443, Security: "tls", Sni: "host-a2.sni"})
|
|
if err := db.Model(&model.ClientRecord{}).Where("id = ?", rec.Id).
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{hostA.Id})).Error; err != nil {
|
|
t.Fatalf("bind host rule: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 2 {
|
|
t.Fatalf("one link per inbound, got %d: %v", len(parts), parts)
|
|
}
|
|
joined := strings.Join(parts, "\n")
|
|
if got := strings.Count(joined, "host-a.cdn.com"); got != 1 {
|
|
t.Fatalf("host-a address appears %d times, want once (inbound A only):\n%s", got, joined)
|
|
}
|
|
if got := strings.Count(joined, "host-a.sni"); got != 1 {
|
|
t.Fatalf("host-a SNI appears %d times, want once (inbound A only):\n%s", got, joined)
|
|
}
|
|
if strings.Contains(joined, "host-a2.cdn.com") || strings.Contains(joined, "host-a2.sni") {
|
|
t.Fatalf("unbound host-a2 must not render for the bound client:\n%s", joined)
|
|
}
|
|
plain := ""
|
|
for _, l := range parts {
|
|
if strings.Contains(l, "203.0.113.5:8443") {
|
|
plain = l
|
|
}
|
|
}
|
|
if plain == "" {
|
|
t.Fatalf("inbound B must keep its own endpoint 203.0.113.5:8443:\n%s", joined)
|
|
}
|
|
if strings.Contains(plain, "host-a.cdn.com") || strings.Contains(plain, "host-a.sni") {
|
|
t.Fatalf("inbound B link leaked host A:\n%s", plain)
|
|
}
|
|
|
|
jsOut, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if got := strings.Count(jsOut, "host-a.cdn.com"); got != 1 {
|
|
t.Fatalf("json host-a address appears %d times, want once:\n%s", got, jsOut)
|
|
}
|
|
if got := strings.Count(jsOut, "host-a.sni"); got != 1 {
|
|
t.Fatalf("json host-a SNI appears %d times, want once:\n%s", got, jsOut)
|
|
}
|
|
if strings.Contains(jsOut, "host-a2.cdn.com") || strings.Contains(jsOut, "host-a2.sni") {
|
|
t.Fatalf("json must not contain the unbound host-a2:\n%s", jsOut)
|
|
}
|
|
if !strings.Contains(jsOut, "203.0.113.5") {
|
|
t.Fatalf("json must keep inbound B on its own address:\n%s", jsOut)
|
|
}
|
|
|
|
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if got := strings.Count(yaml, "host-a.cdn.com"); got != 1 {
|
|
t.Fatalf("clash host-a server appears %d times, want once:\n%s", got, yaml)
|
|
}
|
|
if strings.Contains(yaml, "host-a2.cdn.com") {
|
|
t.Fatalf("clash must not contain the unbound host-a2:\n%s", yaml)
|
|
}
|
|
if !strings.Contains(yaml, "203.0.113.5") {
|
|
t.Fatalf("clash must keep inbound B on its own server:\n%s", yaml)
|
|
}
|
|
}
|
|
|
|
// Binding survives a host-rule edit: UpdateHostGroup keeps row ids, so
|
|
// raw/JSON/Clash still render only the bound host with the new SNI.
|
|
func TestSub_ClientHostRule_SurvivesGroupEdit(t *testing.T) {
|
|
seedSubDB(t)
|
|
ib := seedSubInbound(t, "s1", "edit", 4451, 1, tcpTLSStream)
|
|
hostSvc := &service.HostService{}
|
|
created, err := hostSvc.AddHostGroup(&entity.HostGroup{
|
|
InboundIds: []int{ib.Id},
|
|
Hosts: []string{"a.cdn.com:443", "b.cdn.com:443"},
|
|
Remark: "orig",
|
|
Port: 443,
|
|
Security: "tls",
|
|
Sni: "old.sni",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("AddHostGroup: %v", err)
|
|
}
|
|
if len(created) != 2 {
|
|
t.Fatalf("created = %d rows, want 2", len(created))
|
|
}
|
|
boundId := 0
|
|
for _, h := range created {
|
|
if h.Address == "b.cdn.com" {
|
|
boundId = h.Id
|
|
}
|
|
}
|
|
if boundId == 0 {
|
|
t.Fatalf("bound host row not found in %v", created)
|
|
}
|
|
|
|
db := database.GetDB()
|
|
if err := db.Model(&model.ClientRecord{}).Where("email = ?", "edit@e").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds([]int{boundId})).Error; err != nil {
|
|
t.Fatalf("bind host rule: %v", err)
|
|
}
|
|
|
|
g, err := hostSvc.GetHostGroup(created[0].GroupId)
|
|
if err != nil {
|
|
t.Fatalf("GetHostGroup: %v", err)
|
|
}
|
|
g.Remark = "renamed"
|
|
g.Sni = "new.sni"
|
|
if _, err := hostSvc.UpdateHostGroup(g.GroupId, g); err != nil {
|
|
t.Fatalf("UpdateHostGroup: %v", err)
|
|
}
|
|
|
|
links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetSubs: %v", err)
|
|
}
|
|
parts := strings.Split(strings.Join(links, "\n"), "\n")
|
|
if len(parts) != 1 {
|
|
t.Fatalf("edited binding must still render 1 link, got %d: %v", len(parts), parts)
|
|
}
|
|
if !strings.Contains(parts[0], "b.cdn.com:443") || !strings.Contains(parts[0], "sni=new.sni") {
|
|
t.Fatalf("bound link must carry the edited host with new SNI: %s", parts[0])
|
|
}
|
|
if strings.Contains(parts[0], "a.cdn.com") || strings.Contains(parts[0], "sni=old.sni") {
|
|
t.Fatalf("edited binding must not fall back to the unbound host: %s", parts[0])
|
|
}
|
|
|
|
jsOut, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("s1", "req.example.com", false)
|
|
if err != nil {
|
|
t.Fatalf("GetJson: %v", err)
|
|
}
|
|
if !strings.Contains(jsOut, "b.cdn.com") || !strings.Contains(jsOut, "new.sni") {
|
|
t.Fatalf("json must carry the edited bound host with new SNI:\n%s", jsOut)
|
|
}
|
|
if strings.Contains(jsOut, "a.cdn.com") || strings.Contains(jsOut, "old.sni") {
|
|
t.Fatalf("json must not contain the unbound host or old SNI:\n%s", jsOut)
|
|
}
|
|
|
|
yaml, _, err := NewSubClashService(false, "", false, NewSubService("")).GetClash("s1", "req.example.com")
|
|
if err != nil {
|
|
t.Fatalf("GetClash: %v", err)
|
|
}
|
|
if got := strings.Count(yaml, "b.cdn.com"); got != 1 {
|
|
t.Fatalf("clash bound host appears %d times, want once:\n%s", got, yaml)
|
|
}
|
|
if !strings.Contains(yaml, "new.sni") {
|
|
t.Fatalf("clash must carry the new SNI:\n%s", yaml)
|
|
}
|
|
if strings.Contains(yaml, "a.cdn.com") || strings.Contains(yaml, "old.sni") {
|
|
t.Fatalf("clash must not contain the unbound host or old SNI:\n%s", yaml)
|
|
}
|
|
}
|