mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-10-24 14:55:02 +03:00
fix(hosts): carry an inbound update's externalProxy change onto its imported hosts (#6831)
Subscriptions build an inbound's links from its Host rows, and those rows are imported from streamSettings.externalProxy only when the inbound is added. An update that moved the external proxy returned 200, but every subscription kept handing out the old address, port and security. An update now re-syncs only the externalProxy entries it changed onto hosts that are still exactly as imported. Unchanged entries are set aside first, so a host the admin edited, disabled, deleted or shared is never re-imported next to the admin's version, and a stream without the externalProxy key leaves hosts alone.
This commit is contained in:
+138
-17
@@ -14,6 +14,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"path"
|
"path"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -817,24 +818,10 @@ func mtprotoInboundClientEmail(remark string, used map[string]struct{}) string {
|
|||||||
// first run, so a freshly imported inbound must be converted here instead. Blank
|
// first run, so a freshly imported inbound must be converted here instead. Blank
|
||||||
// or malformed streamSettings, or one without externalProxy entries, is a no-op.
|
// or malformed streamSettings, or one without externalProxy entries, is a no-op.
|
||||||
func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) {
|
func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) {
|
||||||
if strings.TrimSpace(streamSettings) == "" {
|
imports, _ := externalProxyHosts(inboundId, streamSettings)
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
var stream map[string]any
|
|
||||||
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
eps, ok := stream["externalProxy"].([]any)
|
|
||||||
if !ok || len(eps) == 0 {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
created := 0
|
created := 0
|
||||||
for i, raw := range eps {
|
for _, host := range imports {
|
||||||
ep, ok := raw.(map[string]any)
|
if err := tx.Create(host).Error; err != nil {
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil {
|
|
||||||
return created, err
|
return created, err
|
||||||
}
|
}
|
||||||
created++
|
created++
|
||||||
@@ -842,6 +829,140 @@ func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings str
|
|||||||
return created, nil
|
return created, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SyncHostsFromExternalProxy carries the entries an inbound update changed onto the hosts
|
||||||
|
// still exactly as imported; a stream without the externalProxy key leaves hosts alone.
|
||||||
|
func SyncHostsFromExternalProxy(tx *gorm.DB, inboundId int, oldStreamSettings, newStreamSettings string) error {
|
||||||
|
newImports, listed := externalProxyHosts(inboundId, newStreamSettings)
|
||||||
|
if !listed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
oldImports, _ := externalProxyHosts(inboundId, oldStreamSettings)
|
||||||
|
gone, added := changedImports(oldImports, newImports)
|
||||||
|
if len(gone) == 0 && len(added) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
untouched, err := untouchedImportedHosts(tx, inboundId, oldImports)
|
||||||
|
if err != nil || untouched == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i, old := range gone {
|
||||||
|
host := untouched[old]
|
||||||
|
if host == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i >= len(added) {
|
||||||
|
if err := tx.Delete(host).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added[i].Id, added[i].GroupId, added[i].SortOrder, added[i].CreatedAt = host.Id, host.GroupId, host.SortOrder, host.CreatedAt
|
||||||
|
if err := tx.Save(added[i]).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, host := range added[min(len(gone), len(added)):] {
|
||||||
|
if err := tx.Create(host).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// changedImports drops the entries both lists share and returns, in list order, the old
|
||||||
|
// positions that changed or went away and the new entries that changed or appeared.
|
||||||
|
func changedImports(oldImports, newImports []*model.Host) ([]int, []*model.Host) {
|
||||||
|
kept := make([]bool, len(oldImports))
|
||||||
|
var added []*model.Host
|
||||||
|
for _, next := range newImports {
|
||||||
|
match := -1
|
||||||
|
for i, prev := range oldImports {
|
||||||
|
if !kept[i] && sameImportedHost(prev, next) {
|
||||||
|
match = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match < 0 {
|
||||||
|
added = append(added, next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept[match] = true
|
||||||
|
}
|
||||||
|
var gone []int
|
||||||
|
for i := range oldImports {
|
||||||
|
if !kept[i] {
|
||||||
|
gone = append(gone, i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return gone, added
|
||||||
|
}
|
||||||
|
|
||||||
|
// untouchedImportedHosts pairs each old import with a row still equal to it and alone in its
|
||||||
|
// group (nil where the admin edited, shared or deleted it); nil when no row qualifies.
|
||||||
|
func untouchedImportedHosts(tx *gorm.DB, inboundId int, imports []*model.Host) ([]*model.Host, error) {
|
||||||
|
var stored []*model.Host
|
||||||
|
if err := tx.Where("inbound_id = ?", inboundId).Order("sort_order asc, id asc").Find(&stored).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
paired := make([]*model.Host, len(imports))
|
||||||
|
found := false
|
||||||
|
for i, want := range imports {
|
||||||
|
for _, host := range stored {
|
||||||
|
if slices.Contains(paired, host) || !sameImportedHost(host, want) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var groupRows int64
|
||||||
|
if err := tx.Model(&model.Host{}).Where("group_id = ?", host.GroupId).Count(&groupRows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if groupRows == 1 {
|
||||||
|
paired[i], found = host, true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return paired, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// externalProxyHosts maps each entry to the host an import creates; the bool reports whether
|
||||||
|
// the stream carries an externalProxy list at all, an empty one included.
|
||||||
|
func externalProxyHosts(inboundId int, streamSettings string) ([]*model.Host, bool) {
|
||||||
|
if strings.TrimSpace(streamSettings) == "" {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
var stream map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
eps, listed := stream["externalProxy"].([]any)
|
||||||
|
var hosts []*model.Host
|
||||||
|
for i, raw := range eps {
|
||||||
|
if ep, ok := raw.(map[string]any); ok {
|
||||||
|
hosts = append(hosts, externalProxyEntryToHost(inboundId, i, ep))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hosts, listed
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameImportedHost compares every field but row identity and placement, so a host the
|
||||||
|
// admin changed in any way no longer counts as the import; empty lists match nil.
|
||||||
|
func sameImportedHost(a, b *model.Host) bool {
|
||||||
|
return reflect.DeepEqual(importedHostFields(*a), importedHostFields(*b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func importedHostFields(h model.Host) model.Host {
|
||||||
|
h.Id, h.GroupId, h.SortOrder, h.CreatedAt, h.UpdatedAt = 0, "", 0, 0, 0
|
||||||
|
for _, list := range []*[]string{&h.Tags, &h.Alpn, &h.PinnedPeerCertSha256, &h.ExcludeFromSubTypes, &h.NodeGuids} {
|
||||||
|
if len(*list) == 0 {
|
||||||
|
*list = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
|
func externalProxyEntryToHost(inboundId, index int, ep map[string]any) *model.Host {
|
||||||
security, _ := ep["forceTls"].(string)
|
security, _ := ep["forceTls"].(string)
|
||||||
switch security {
|
switch security {
|
||||||
|
|||||||
@@ -2047,6 +2047,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
|||||||
if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
|
if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := database.SyncHostsFromExternalProxy(tx, inbound.Id, running.StreamSettings, inbound.StreamSettings); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure created_at and updated_at exist in inbound.Settings clients
|
// Ensure created_at and updated_at exist in inbound.Settings clients
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,317 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||||
|
)
|
||||||
|
|
||||||
|
func addExternalProxyInbound(t *testing.T, tag string, port int, stream string) *model.Inbound {
|
||||||
|
t.Helper()
|
||||||
|
created, _, err := (&InboundService{}).AddInbound(&model.Inbound{
|
||||||
|
UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS,
|
||||||
|
StreamSettings: stream,
|
||||||
|
Settings: `{"clients":[{"id":"6df5616b-ebfd-4186-86d5-4bce29fe8805","email":"` + tag + `","subId":"s-` + tag + `","enable":true}],"decryption":"none","encryption":"none"}`,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddInbound %s: %v", tag, err)
|
||||||
|
}
|
||||||
|
return created
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateStreamSettings(t *testing.T, inbound *model.Inbound, stream string) {
|
||||||
|
t.Helper()
|
||||||
|
update := *inbound
|
||||||
|
update.StreamSettings = stream
|
||||||
|
if _, _, err := (&InboundService{}).UpdateInbound(&update); err != nil {
|
||||||
|
t.Fatalf("UpdateInbound: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostsByGroup(t *testing.T, where string, args ...any) map[string]model.Host {
|
||||||
|
t.Helper()
|
||||||
|
var hosts []model.Host
|
||||||
|
if err := database.GetDB().Where(where, args...).Find(&hosts).Error; err != nil {
|
||||||
|
t.Fatalf("load hosts: %v", err)
|
||||||
|
}
|
||||||
|
byGroup := make(map[string]model.Host, len(hosts))
|
||||||
|
for _, h := range hosts {
|
||||||
|
if _, dup := byGroup[h.GroupId]; dup {
|
||||||
|
t.Fatalf("two rows of group %q in %v", h.GroupId, hosts)
|
||||||
|
}
|
||||||
|
byGroup[h.GroupId] = h
|
||||||
|
}
|
||||||
|
return byGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
func hostRows(t *testing.T, where string, args ...any) []model.Host {
|
||||||
|
t.Helper()
|
||||||
|
var hosts []model.Host
|
||||||
|
if err := database.GetDB().Where(where, args...).Order("id asc").Find(&hosts).Error; err != nil {
|
||||||
|
t.Fatalf("load hosts: %v", err)
|
||||||
|
}
|
||||||
|
return hosts
|
||||||
|
}
|
||||||
|
|
||||||
|
func onlyHost(t *testing.T, inboundId int) model.Host {
|
||||||
|
t.Helper()
|
||||||
|
var hosts []model.Host
|
||||||
|
if err := database.GetDB().Where("inbound_id = ?", inboundId).Find(&hosts).Error; err != nil {
|
||||||
|
t.Fatalf("load hosts: %v", err)
|
||||||
|
}
|
||||||
|
if len(hosts) != 1 {
|
||||||
|
t.Fatalf("hosts = %+v, want exactly one", hosts)
|
||||||
|
}
|
||||||
|
return hosts[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscriptions build links from hosts, so a host imported from the old
|
||||||
|
// externalProxy kept handing out its port and security after the update.
|
||||||
|
func TestUpdateInbound_ExternalProxyChangeRetargetsImportedHost(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
created := addExternalProxyInbound(t, "edge-tls", 2053, `{"network":"ws","security":"tls",
|
||||||
|
"tlsSettings":{"certificates":[{"certificateFile":"/etc/ssl/edge.pem","keyFile":"/etc/ssl/edge.key"}]},
|
||||||
|
"wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"same","dest":"edge.example","port":2053}]}`)
|
||||||
|
before := onlyHost(t, created.Id)
|
||||||
|
|
||||||
|
updateStreamSettings(t, created, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"tls","dest":"edge.example","port":443,"sni":"edge.example","alpn":["http/1.1"],"fingerprint":"chrome"}]}`)
|
||||||
|
|
||||||
|
after := onlyHost(t, created.Id)
|
||||||
|
if after.Id != before.Id || after.GroupId != before.GroupId {
|
||||||
|
t.Fatalf("host replaced (id %d group %q -> id %d group %q), want it updated in place so its group id stays valid",
|
||||||
|
before.Id, before.GroupId, after.Id, after.GroupId)
|
||||||
|
}
|
||||||
|
if after.Address != "edge.example" || after.Port != 443 || after.Security != "tls" ||
|
||||||
|
after.Sni != "edge.example" || after.Fingerprint != "chrome" || !slices.Equal(after.Alpn, []string{"http/1.1"}) {
|
||||||
|
t.Fatalf("host = %+v, want edge.example:443 tls, sni edge.example, alpn [http/1.1], fingerprint chrome", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only hosts still exactly as imported follow externalProxy; an imported host
|
||||||
|
// the admin edited and a host the admin created are theirs and stay as they are.
|
||||||
|
func TestUpdateInbound_ExternalProxyChangeKeepsAdminHosts(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
hostSvc := &HostService{}
|
||||||
|
created := addExternalProxyInbound(t, "edge-mixed", 8080, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[
|
||||||
|
{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"},
|
||||||
|
{"forceTls":"tls","dest":"b.cdn.example","port":8443,"remark":"B"},
|
||||||
|
{"forceTls":"none","dest":"c.cdn.example","port":80,"remark":"C"}
|
||||||
|
]}`)
|
||||||
|
imported := map[string]model.Host{}
|
||||||
|
for _, h := range hostsByGroup(t, "inbound_id = ?", created.Id) {
|
||||||
|
imported[h.Remark] = h
|
||||||
|
}
|
||||||
|
if len(imported) != 3 {
|
||||||
|
t.Fatalf("imported hosts = %+v, want A, B and C", imported)
|
||||||
|
}
|
||||||
|
|
||||||
|
edited, err := hostSvc.GetHostGroup(imported["B"].GroupId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostGroup B: %v", err)
|
||||||
|
}
|
||||||
|
edited.Sni = "edited.example"
|
||||||
|
if _, err := hostSvc.UpdateHostGroup(edited.GroupId, edited); err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup B: %v", err)
|
||||||
|
}
|
||||||
|
manual, err := hostSvc.AddHostGroup(&entity.HostGroup{
|
||||||
|
InboundIds: []int{created.Id}, Remark: "manual", Hosts: []string{"manual.example"}, Port: 443, Security: "tls",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("AddHostGroup manual: %v", err)
|
||||||
|
}
|
||||||
|
beforeUpdate := hostsByGroup(t, "inbound_id = ?", created.Id)
|
||||||
|
|
||||||
|
updateStreamSettings(t, created, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"tls","dest":"a2.cdn.example","port":443,"remark":"A2"}]}`)
|
||||||
|
|
||||||
|
got := hostsByGroup(t, "inbound_id = ?", created.Id)
|
||||||
|
if len(got) != 3 {
|
||||||
|
t.Fatalf("hosts = %+v, want A retargeted, edited B and the manual host (C removed)", got)
|
||||||
|
}
|
||||||
|
a := got[imported["A"].GroupId]
|
||||||
|
if a.Address != "a2.cdn.example" || a.Port != 443 || a.Remark != "A2" {
|
||||||
|
t.Fatalf("host A = %+v, want it retargeted to a2.cdn.example:443", a)
|
||||||
|
}
|
||||||
|
if _, ok := got[imported["C"].GroupId]; ok {
|
||||||
|
t.Fatal("untouched import C survived although its externalProxy entry was removed")
|
||||||
|
}
|
||||||
|
for _, groupId := range []string{edited.GroupId, manual[0].GroupId} {
|
||||||
|
want := beforeUpdate[groupId]
|
||||||
|
if h := got[groupId]; h.Id != want.Id || h.Address != want.Address || h.Port != want.Port || h.Sni != want.Sni || h.Remark != want.Remark {
|
||||||
|
t.Fatalf("admin host %q = %+v, want it untouched: %+v", groupId, h, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changing one entry must not re-import another that stayed the same: the admin's
|
||||||
|
// disable, delete, edit or share of that entry's host would come back as a duplicate.
|
||||||
|
func TestUpdateInbound_ExternalProxyChangeLeavesUnchangedEntriesAlone(t *testing.T) {
|
||||||
|
const before = `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},"externalProxy":[
|
||||||
|
{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"},
|
||||||
|
{"forceTls":"tls","dest":"b.cdn.example","port":8443,"remark":"B"}]}`
|
||||||
|
const after = `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},"externalProxy":[
|
||||||
|
{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"},
|
||||||
|
{"forceTls":"tls","dest":"b.cdn.example","port":443,"remark":"B"}]}`
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
takeA func(t *testing.T, svc *HostService, a *entity.HostGroup, otherInbound int)
|
||||||
|
wantA int
|
||||||
|
}{
|
||||||
|
{"disabled", func(t *testing.T, svc *HostService, a *entity.HostGroup, _ int) {
|
||||||
|
if err := svc.SetHostGroupEnable(a.GroupId, false); err != nil {
|
||||||
|
t.Fatalf("SetHostGroupEnable: %v", err)
|
||||||
|
}
|
||||||
|
}, 1},
|
||||||
|
{"deleted", func(t *testing.T, svc *HostService, a *entity.HostGroup, _ int) {
|
||||||
|
if err := svc.DeleteHostGroup(a.GroupId); err != nil {
|
||||||
|
t.Fatalf("DeleteHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
}, 0},
|
||||||
|
{"edited", func(t *testing.T, svc *HostService, a *entity.HostGroup, _ int) {
|
||||||
|
a.Remark = "renamed"
|
||||||
|
if _, err := svc.UpdateHostGroup(a.GroupId, a); err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
}, 1},
|
||||||
|
{"shared", func(t *testing.T, svc *HostService, a *entity.HostGroup, otherInbound int) {
|
||||||
|
a.InboundIds = append(a.InboundIds, otherInbound)
|
||||||
|
if _, err := svc.UpdateHostGroup(a.GroupId, a); err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
}, 1},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
hostSvc := &HostService{}
|
||||||
|
created := addExternalProxyInbound(t, "edge-pair", 8083, before)
|
||||||
|
other := addExternalProxyInbound(t, "edge-pair-other", 8084, `{"network":"ws","security":"none","wsSettings":{"path":"/other"}}`)
|
||||||
|
imported := map[string]model.Host{}
|
||||||
|
for _, h := range hostsByGroup(t, "inbound_id = ?", created.Id) {
|
||||||
|
imported[h.Remark] = h
|
||||||
|
}
|
||||||
|
a, err := hostSvc.GetHostGroup(imported["A"].GroupId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostGroup A: %v", err)
|
||||||
|
}
|
||||||
|
c.takeA(t, hostSvc, a, other.Id)
|
||||||
|
kept := hostRows(t, "address = ?", "a.cdn.example")
|
||||||
|
|
||||||
|
updateStreamSettings(t, created, after)
|
||||||
|
|
||||||
|
got := hostsByGroup(t, "inbound_id = ?", created.Id)
|
||||||
|
if len(got) != 1+c.wantA {
|
||||||
|
t.Fatalf("hosts = %+v, want B plus %d admin-owned A row(s), and no re-imported A", got, c.wantA)
|
||||||
|
}
|
||||||
|
if b := got[imported["B"].GroupId]; b.Address != "b.cdn.example" || b.Port != 443 {
|
||||||
|
t.Fatalf("host B = %+v, want it retargeted to b.cdn.example:443", b)
|
||||||
|
}
|
||||||
|
if rows := hostRows(t, "address = ?", "a.cdn.example"); !reflect.DeepEqual(rows, kept) {
|
||||||
|
t.Fatalf("A rows = %+v, want the admin's rows untouched: %+v", rows, kept)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leftover entries pair up by position only after unchanged ones are set aside; otherwise
|
||||||
|
// removing or inserting an entry ahead of an admin-owned host shifts every pairing after it.
|
||||||
|
func TestUpdateInbound_ExternalProxyShiftLeavesAdminHostAlone(t *testing.T) {
|
||||||
|
const entryA = `{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"}`
|
||||||
|
const entryB = `{"forceTls":"tls","dest":"b.cdn.example","port":8443,"remark":"B"}`
|
||||||
|
const entryC = `{"forceTls":"tls","dest":"c.cdn.example","port":8443,"remark":"C"}`
|
||||||
|
stream := func(entries ...string) string {
|
||||||
|
return `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},"externalProxy":[` + strings.Join(entries, ",") + `]}`
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
after string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"removed ahead", stream(entryB), []string{"B edited"}},
|
||||||
|
{"inserted ahead", stream(entryC, entryA, entryB), []string{"A", "B edited", "C"}},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
hostSvc := &HostService{}
|
||||||
|
created := addExternalProxyInbound(t, "edge-shift", 8086, stream(entryA, entryB))
|
||||||
|
imported := map[string]model.Host{}
|
||||||
|
for _, h := range hostsByGroup(t, "inbound_id = ?", created.Id) {
|
||||||
|
imported[h.Remark] = h
|
||||||
|
}
|
||||||
|
edited, err := hostSvc.GetHostGroup(imported["B"].GroupId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostGroup B: %v", err)
|
||||||
|
}
|
||||||
|
edited.Remark = "B edited"
|
||||||
|
if _, err := hostSvc.UpdateHostGroup(edited.GroupId, edited); err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup B: %v", err)
|
||||||
|
}
|
||||||
|
kept := hostRows(t, "group_id = ?", edited.GroupId)
|
||||||
|
|
||||||
|
updateStreamSettings(t, created, c.after)
|
||||||
|
|
||||||
|
var remarks []string
|
||||||
|
for _, h := range hostsByGroup(t, "inbound_id = ?", created.Id) {
|
||||||
|
remarks = append(remarks, h.Remark)
|
||||||
|
}
|
||||||
|
slices.Sort(remarks)
|
||||||
|
if !slices.Equal(remarks, c.want) {
|
||||||
|
t.Fatalf("host remarks = %q, want %q: B once as the admin left it, no import moved onto another entry's host", remarks, c.want)
|
||||||
|
}
|
||||||
|
if rows := hostRows(t, "group_id = ?", edited.GroupId); !reflect.DeepEqual(rows, kept) {
|
||||||
|
t.Fatalf("edited B rows = %+v, want them untouched: %+v", rows, kept)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The form drops externalProxy when it switches to a protocol with its own stream
|
||||||
|
// defaults; that must not delete hosts the operator can no longer see the source of.
|
||||||
|
func TestUpdateInbound_StreamWithoutExternalProxyKeepsImportedHosts(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
created := addExternalProxyInbound(t, "edge-switch", 8085, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"}]}`)
|
||||||
|
before := onlyHost(t, created.Id)
|
||||||
|
|
||||||
|
updateStreamSettings(t, created, `{"network":"tcp","security":"none","tcpSettings":{}}`)
|
||||||
|
|
||||||
|
if after := onlyHost(t, created.Id); !reflect.DeepEqual(after, before) {
|
||||||
|
t.Fatalf("host = %+v, want it untouched by a stream with no externalProxy key: %+v", after, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A host group the admin spread over a second inbound is theirs even when its
|
||||||
|
// fields still match the import: retargeting one row would split the group.
|
||||||
|
func TestUpdateInbound_ExternalProxyChangeKeepsSharedHostGroup(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
hostSvc := &HostService{}
|
||||||
|
first := addExternalProxyInbound(t, "edge-shared", 8081, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"tls","dest":"a.cdn.example","port":8443,"remark":"A"}]}`)
|
||||||
|
second := addExternalProxyInbound(t, "edge-other", 8082, `{"network":"ws","security":"none","wsSettings":{"path":"/other"}}`)
|
||||||
|
group, err := hostSvc.GetHostGroup(onlyHost(t, first.Id).GroupId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
group.InboundIds = []int{first.Id, second.Id}
|
||||||
|
if _, err := hostSvc.UpdateHostGroup(group.GroupId, group); err != nil {
|
||||||
|
t.Fatalf("UpdateHostGroup: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStreamSettings(t, first, `{"network":"ws","security":"none","wsSettings":{"path":"/ws"},
|
||||||
|
"externalProxy":[{"forceTls":"tls","dest":"a2.cdn.example","port":443,"remark":"A2"}]}`)
|
||||||
|
|
||||||
|
for _, inboundId := range []int{first.Id, second.Id} {
|
||||||
|
if h := onlyHost(t, inboundId); h.GroupId != group.GroupId || h.Address != "a.cdn.example" || h.Port != 8443 {
|
||||||
|
t.Fatalf("inbound %d host = %+v, want the shared group %q left on a.cdn.example:8443", inboundId, h, group.GroupId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user