mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-25 15:25:01 +03:00
2c18cbe1cd
Deleting the only host a client was bound to blanked that client on every inbound. Once the host row was gone, the binding could no longer say which inbound it guarded, so it failed closed everywhere: a client on inbounds A and B, bound to one host on A, lost B's link the moment that host was deleted, and nothing in the panel hinted at it. Every host delete (host groups, the externalProxy sync, inbound deletion, the depleted-inbound sweep and the orphan prune) now goes through database.DeleteHosts, which keeps each deleted host's inbound in host_tombstones in the same transaction. A binding to a deleted host closes only that inbound; ids that resolve to no host at all still fail closed everywhere. The Postgres Host step now runs the deletion cases as well.
313 lines
12 KiB
Go
313 lines
12 KiB
Go
package sub
|
|
|
|
import (
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/goccy/go-yaml"
|
|
"gorm.io/gorm"
|
|
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/testpg"
|
|
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
|
)
|
|
|
|
func TestSubHosts_Postgres(t *testing.T) {
|
|
if os.Getenv("XUI_DB_TYPE") != "postgres" || os.Getenv("XUI_DB_DSN") == "" {
|
|
t.Skip("requires a disposable PostgreSQL database")
|
|
}
|
|
for _, scenario := range []struct {
|
|
name string
|
|
run func(*testing.T)
|
|
}{
|
|
{"global", TestSub_GlobalHostOrder},
|
|
{"filters", TestSub_HostOrderFiltersAndFallbacks},
|
|
{"base64", TestSub_HostOrderBase64AndInfoNode},
|
|
{"balancer", TestSub_HostOrderBalancerSlots},
|
|
{"deleted binding", TestSub_ClientHostRule_DeletedHostClosesOnlyItsInbound},
|
|
} {
|
|
t.Run(scenario.name, func(t *testing.T) {
|
|
cleanup, err := testpg.IsolatePackage("host_order")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(cleanup)
|
|
scenario.run(t)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSub_HostOrderBalancerSlots(t *testing.T) {
|
|
seedSubDB(t)
|
|
setHostSortMode(t, "hosts-first")
|
|
a := seedSubInbound(t, "order-sub", "a", 46910, 0, wsTLSStream)
|
|
b := seedSubInbound(t, "order-sub", "b", 46911, 10, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
|
|
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "c", SortOrder: 2, Remark: "C", Address: "c.example.com"})
|
|
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
|
|
bal := seedSubBalancer(t, &model.SubBalancer{Remark: "auto", Strategy: "random", InboundIds: []int{a.Id, b.Id}, SortOrder: 5, Enabled: true})
|
|
body, _, err := NewSubJsonService("", "", "", "", NewSubService("")).GetJson("order-sub", "sub.example.com", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
docs := parseSubJsonDocs(t, body)
|
|
var remarks []string
|
|
for _, doc := range docs {
|
|
if doc["remarks"] == "auto" {
|
|
remarks = append(remarks, "balancer")
|
|
continue
|
|
}
|
|
outbounds := doc["outbounds"].([]any)
|
|
settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
|
|
remarks = append(remarks, settings["address"].(string))
|
|
}
|
|
if !reflect.DeepEqual(remarks, []string{"b.example.com", "a.example.com", "balancer", "c.example.com"}) {
|
|
t.Fatalf("endpoint and balancer slots: %v", remarks)
|
|
}
|
|
prefix := fmt.Sprintf("bal-%d-vless", bal.Id)
|
|
wantTags := []string{prefix, prefix + "-2", prefix + "-3", "direct", "block"}
|
|
if got := docOutboundTags(docs[2]); !reflect.DeepEqual(got, wantTags) {
|
|
t.Fatalf("balancer tags: %v, want %v", got, wantTags)
|
|
}
|
|
}
|
|
|
|
func setHostSortMode(t *testing.T, mode string) {
|
|
t.Helper()
|
|
row := &model.Setting{Key: "subHostSortMode"}
|
|
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: mode}).FirstOrCreate(row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestSub_HostOrderFiltersAndFallbacks(t *testing.T) {
|
|
seedSubDB(t)
|
|
setHostSortMode(t, "hosts-first")
|
|
ib := seedSubInbound(t, "order-sub", "eligible", 46900, 10, wsTLSStream)
|
|
seedSubInbound(t, "order-sub", "legacy", 46901, -10, `{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"legacy.sni"},"externalProxy":[{"forceTls":"tls","dest":"legacy.example.com","port":443}]}`)
|
|
noHosts := seedSubInbound(t, "order-sub", "no-hosts", 46902, -20, wsTLSStream)
|
|
if err := database.GetDB().Model(noHosts).Update("listen", "198.51.100.20").Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
foreign := seedSubInbound(t, "other-sub", "foreign", 46903, -40, wsTLSStream)
|
|
excluded := seedSubInbound(t, "order-sub", "excluded", 46904, -30, wsTLSStream)
|
|
if err := database.GetDB().Model(excluded).Update("exclude_from_sub", true).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, h := range []model.Host{
|
|
{InboundId: ib.Id, GroupId: "main", SortOrder: 0, Address: "a.example.com"},
|
|
{InboundId: ib.Id, GroupId: "off", SortOrder: -5, Address: "off.example.com", IsDisabled: true},
|
|
{InboundId: ib.Id, GroupId: "format", SortOrder: -1, Address: "format.example.com", ExcludeFromSubTypes: []string{"clash"}},
|
|
{InboundId: ib.Id, GroupId: "hidden", SortOrder: 1, Address: "hidden.example.com", IsHidden: true},
|
|
{InboundId: foreign.Id, GroupId: "foreign", SortOrder: -10, Address: "foreign.example.com"},
|
|
{InboundId: excluded.Id, GroupId: "excluded", SortOrder: -20, Address: "excluded.example.com"},
|
|
} {
|
|
row := h
|
|
seedHost(t, &row)
|
|
}
|
|
db := database.GetDB()
|
|
queries := 0
|
|
const callback = "test:6894_hosts"
|
|
if err := db.Callback().Query().Before("gorm:query").Register(callback, func(tx *gorm.DB) {
|
|
if tx.Statement.Table == "hosts" {
|
|
queries++
|
|
}
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = db.Callback().Query().Remove(callback) })
|
|
for _, format := range []string{"raw", "clash", "json"} {
|
|
queries = 0
|
|
want := []string{"format.example.com", "a.example.com", "hidden.example.com", "198.51.100.20", "legacy.example.com"}
|
|
if format == "clash" {
|
|
want = want[1:]
|
|
}
|
|
if got := renderedHostAddresses(t, format, NewSubService("")); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("%s: %v, want %v", format, got, want)
|
|
}
|
|
if queries != 1 {
|
|
t.Fatalf("%s queried hosts %d times, want 1", format, queries)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSub_HostOrderBase64AndInfoNode(t *testing.T) {
|
|
seedSubDB(t)
|
|
setHostSortMode(t, "hosts-first")
|
|
a := seedSubInbound(t, "order-sub", "a", 46905, 0, wsTLSStream)
|
|
b := seedSubInbound(t, "order-sub", "b", 46906, 10, wsTLSStream)
|
|
seedHost(t, &model.Host{InboundId: a.Id, GroupId: "a", SortOrder: 1, Remark: "A", Address: "a.example.com"})
|
|
seedHost(t, &model.Host{InboundId: b.Id, GroupId: "b", SortOrder: 0, Remark: "B", Address: "b.example.com"})
|
|
row := &model.Setting{Key: "subInfoNodeEnable"}
|
|
if err := database.GetDB().Where("key = ?", row.Key).Assign(model.Setting{Value: "true"}).FirstOrCreate(row).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
router := newSubscriptionTestRouter(subscriptionTestRouterConfig{})
|
|
response := httptest.NewRecorder()
|
|
router.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "http://sub.example.com/sub/order-sub", nil))
|
|
if response.Code != http.StatusOK {
|
|
t.Fatalf("HTTP status %d: %s", response.Code, response.Body.String())
|
|
}
|
|
decoded, err := base64.StdEncoding.DecodeString(response.Body.String())
|
|
if err != nil {
|
|
t.Fatalf("base64 subscription: %v: %s", err, response.Body.String())
|
|
}
|
|
lines := strings.Split(strings.TrimSpace(string(decoded)), "\n")
|
|
if len(lines) != 3 || !strings.HasPrefix(lines[0], "socks://127.0.0.1") || !strings.Contains(lines[1], "b.example.com") || !strings.Contains(lines[2], "a.example.com") {
|
|
t.Fatalf("encoded order/info node: %q", lines)
|
|
}
|
|
}
|
|
|
|
func renderedHostAddresses(t *testing.T, format string, base *SubService) []string {
|
|
t.Helper()
|
|
var addresses []string
|
|
switch format {
|
|
case "raw":
|
|
links, _, _, _, err := base.GetSubs("order-sub", "sub.example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, line := range splitLinkLines(strings.Join(links, "\n")) {
|
|
u, err := url.Parse(line)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
addresses = append(addresses, u.Hostname())
|
|
}
|
|
case "clash":
|
|
body, _, err := NewSubClashService(false, "", false, base).GetClash("order-sub", "sub.example.com")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var doc struct {
|
|
Proxies []struct{ Server string } `yaml:"proxies"`
|
|
}
|
|
if err := yaml.Unmarshal([]byte(body), &doc); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, proxy := range doc.Proxies {
|
|
addresses = append(addresses, proxy.Server)
|
|
}
|
|
case "json":
|
|
body, _, err := NewSubJsonService("", "", "", "", base).GetJson("order-sub", "sub.example.com", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var docs []struct {
|
|
Outbounds []struct {
|
|
Settings struct {
|
|
Address string `json:"address"`
|
|
} `json:"settings"`
|
|
} `json:"outbounds"`
|
|
}
|
|
if body == "" {
|
|
break
|
|
}
|
|
if err := json.Unmarshal([]byte(body), &docs); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, doc := range docs {
|
|
if len(doc.Outbounds) == 0 || doc.Outbounds[0].Settings.Address == "" {
|
|
t.Fatalf("JSON proxy shape: %s", body)
|
|
}
|
|
addresses = append(addresses, doc.Outbounds[0].Settings.Address)
|
|
}
|
|
}
|
|
return addresses
|
|
}
|
|
|
|
func TestSub_GlobalHostOrder(t *testing.T) {
|
|
seedSubDB(t)
|
|
first := seedSubInbound(t, "order-sub", "first", 46894, -10, wsTLSStream)
|
|
second := seedSubInbound(t, "order-sub", "second", 46895, 20, wsTLSStream)
|
|
seedSubInbound(t, "order-sub", "fallback", 46896, -20, wsTLSStream)
|
|
for _, host := range []model.Host{
|
|
{GroupId: "a", InboundId: first.Id, SortOrder: 1, Remark: "A", Address: "a.example.com"},
|
|
{GroupId: "b", InboundId: second.Id, SortOrder: 0, Remark: "B", Address: "b.example.com"},
|
|
{GroupId: "c", InboundId: second.Id, SortOrder: 2, Remark: "C", Address: "c.example.com"},
|
|
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
|
|
{GroupId: "shared", InboundId: first.Id, SortOrder: 1, Remark: "Z", Address: "shared2.example.com"},
|
|
{GroupId: "shared", InboundId: second.Id, SortOrder: 1, Remark: "Z", Address: "shared1.example.com"},
|
|
} {
|
|
h := host
|
|
seedHost(t, &h)
|
|
}
|
|
base := NewSubService("")
|
|
assertAll := func(want []string) {
|
|
t.Helper()
|
|
for _, format := range []string{"raw", "clash", "json"} {
|
|
if got := renderedHostAddresses(t, format, base); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("%s addresses = %v, want %v", format, got, want)
|
|
}
|
|
}
|
|
}
|
|
assertAll([]string{"203.0.113.5", "a.example.com", "shared1.example.com", "shared2.example.com", "b.example.com", "shared1.example.com", "c.example.com"})
|
|
setHostSortMode(t, "hosts-first")
|
|
assertAll([]string{"b.example.com", "a.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "c.example.com", "203.0.113.5"})
|
|
if err := (&service.HostService{}).ReorderHostGroups([]string{"c", "b", "shared", "a"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertAll([]string{"c.example.com", "b.example.com", "shared1.example.com", "shared2.example.com", "shared1.example.com", "a.example.com", "203.0.113.5"})
|
|
setHostSortMode(t, "inbound-first")
|
|
assertAll([]string{"203.0.113.5", "shared1.example.com", "shared2.example.com", "a.example.com", "c.example.com", "b.example.com", "shared1.example.com"})
|
|
}
|
|
|
|
// The hosts-first order renders each host on its own, and a CIDR host must still expand into its
|
|
// random addresses there, as it does in inbound order, on every format.
|
|
func TestSub_HostOrderExpandsCIDRHosts(t *testing.T) {
|
|
seedSubDB(t)
|
|
setHostSortMode(t, "hosts-first")
|
|
ib := seedSubInbound(t, "order-sub", "cidr", 46910, 0, wsTLSStream)
|
|
seedHost(t, &model.Host{GroupId: "cidr", InboundId: ib.Id, Remark: "R", Address: "198.51.100.0/24", RandomCount: 3})
|
|
_, cidr, _ := net.ParseCIDR("198.51.100.0/24")
|
|
for _, format := range []string{"raw", "clash", "json"} {
|
|
got := renderedHostAddresses(t, format, NewSubService(""))
|
|
if len(got) != 3 {
|
|
t.Fatalf("%s addresses = %v, want 3 random addresses from the range", format, got)
|
|
}
|
|
for _, addr := range got {
|
|
if ip := net.ParseIP(addr); ip == nil || !cidr.Contains(ip) {
|
|
t.Fatalf("%s address %q is not inside 198.51.100.0/24", format, addr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// A client bound to host rules must get only those hosts in the hosts-first order too, on every
|
|
// format, and a binding whose hosts are all gone must render nothing rather than every host.
|
|
func TestSub_HostOrderRespectsClientHostBindings(t *testing.T) {
|
|
seedSubDB(t)
|
|
setHostSortMode(t, "hosts-first")
|
|
ib := seedSubInbound(t, "order-sub", "bound", 46920, 0, wsTLSStream)
|
|
seedHost(t, &model.Host{GroupId: "a", InboundId: ib.Id, SortOrder: 0, Remark: "A", Address: "a.example.com"})
|
|
b := seedHost(t, &model.Host{GroupId: "b", InboundId: ib.Id, SortOrder: 1, Remark: "B", Address: "b.example.com"})
|
|
bind := func(ids ...int) {
|
|
t.Helper()
|
|
if err := database.GetDB().Model(&model.ClientRecord{}).Where("sub_id = ?", "order-sub").
|
|
Update("client_host_rule_ids", model.EncodeClientHostRuleIds(ids)).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
bind(b.Id)
|
|
for _, format := range []string{"raw", "clash", "json"} {
|
|
if got := renderedHostAddresses(t, format, NewSubService("")); !reflect.DeepEqual(got, []string{"b.example.com"}) {
|
|
t.Fatalf("%s addresses = %v, want only the bound host", format, got)
|
|
}
|
|
}
|
|
bind(999999)
|
|
for _, format := range []string{"raw", "clash", "json"} {
|
|
if got := renderedHostAddresses(t, format, NewSubService("")); len(got) != 0 {
|
|
t.Fatalf("%s addresses = %v, want none for a binding whose hosts are gone", format, got)
|
|
}
|
|
}
|
|
}
|