diff --git a/internal/database/db.go b/internal/database/db.go index 1d5f3e815..00ae09b51 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -14,6 +14,7 @@ import ( "os/exec" "path" "path/filepath" + "reflect" "runtime" "slices" "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 // or malformed streamSettings, or one without externalProxy entries, is a no-op. func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings string) (int, error) { - if strings.TrimSpace(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 - } + imports, _ := externalProxyHosts(inboundId, streamSettings) created := 0 - for i, raw := range eps { - ep, ok := raw.(map[string]any) - if !ok { - continue - } - if err := tx.Create(externalProxyEntryToHost(inboundId, i, ep)).Error; err != nil { + for _, host := range imports { + if err := tx.Create(host).Error; err != nil { return created, err } created++ @@ -842,6 +829,140 @@ func CreateHostsFromExternalProxy(tx *gorm.DB, inboundId int, streamSettings str 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 { security, _ := ep["forceTls"].(string) switch security { diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index cbc67870d..12c2f4d51 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -2047,6 +2047,9 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err := s.updateClientTraffics(tx, oldInbound, inbound); err != nil { 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 { diff --git a/internal/web/service/inbound_update_external_proxy_test.go b/internal/web/service/inbound_update_external_proxy_test.go new file mode 100644 index 000000000..e6256de39 --- /dev/null +++ b/internal/web/service/inbound_update_external_proxy_test.go @@ -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) + } + } +}