mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 14:07:13 +00:00
feat(hosts): bulk-add multiple hosts to multiple inbounds (#5677)
* feat(hosts): bulk-add multiple hosts to multiple inbounds Allow users to select multiple inbound IDs and enter multiple host addresses (with optional per-host port override) in a single form submission. - Add BulkAddHostReq entity and POST /panel/api/hosts/bulk/add endpoint - Add AddHostsBulk service with GORM transaction safety - Add parseHostAndPort helper (IPv4, bracketed/bracketless IPv6, port) - Update HostFormModal to multi-select inbounds and tag-input hosts - Wire bulkCreate mutation in HostsPage with existing-host suggestions - Register endpoint in api-docs/endpoints.ts and regenerate OpenAPI/Zod * feat(hosts): group override records by group_id and support group editing * fix: import Popover in HostList * fix: use messageApi in HostFormModal * fix(hosts): resolve 4 bugs found in host-group code review - fix(schema): allow empty hosts array in BulkAddHostSchema so users can save a host without an address (inherits inbound endpoint). The old .min(1) was never enforced at runtime since the schema is only used for type inference, but the type was incorrect. - fix(service): validate new inbound IDs in UpdateHostGroup before deleting old rows, matching the same check already present in AddHostGroup. Prevents orphaned host rows when an invalid inbound ID is supplied on edit. - fix(service): replace full-table scan in GetHostsByInbound with two targeted queries (DISTINCT group_id WHERE inbound_id=?, then WHERE group_id IN ?) to avoid loading every host in the DB. - fix(mutations): remove unused createMut / create export from useHostMutations. The /hosts/add endpoint is identical to /hosts/bulk/add; only bulkCreate is used by the UI. * fix(hosts): address code review feedback (optimize bulk inserts, add validation tests, and remove comments) * fix(fmt): apply gofumpt formatting to model.go and db.go The previous merge commit incorrectly applied gofmt (tab-aligned) to these files. The repository's golangci config requires gofumpt+goimports which produces space-aligned struct fields. This commit restores the correct gofumpt formatting that matches upstream/main. * chore(frontend): regenerate API schemas and update lockfile * fix * refactor(hosts): dedupe host-group service and tidy frontend AddHostGroup and UpdateHostGroup shared an identical ~35-field model.Host construction and hand-rolled transaction boilerplate (tx.Begin plus a committed flag plus a deferred recover/rollback). Extract buildHostRows, validateInboundsExist and formatHostAddr, and run every mutation through db.Transaction. groupHosts collapses its duplicated address/port formatting and create/append fork into one path using slices.Contains. Behavior-preserving: host.go drops ~90 lines with the existing service/controller tests green. Frontend: drop the Partial union and two as-casts in HostsPage.onSave (the modal always passes a full BulkAddHostValues), and remove the movable index map in HostList in favor of the table render index arg. --------- Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Sanaei <ho3ein.sanaei@gmail.com>
This commit is contained in:
+300
-88
@@ -1,115 +1,301 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/random"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HostService manages Host rows (override endpoints attached to an inbound).
|
||||
// Mirrors the empty-struct + database.GetDB() shape of ClientService.
|
||||
type HostService struct{}
|
||||
|
||||
// GetHosts returns every host, grouped by inbound then ordered by sort_order.
|
||||
func (s *HostService) GetHosts() ([]*model.Host, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error
|
||||
return hosts, err
|
||||
func formatHostAddr(addr string, port int) string {
|
||||
if port <= 0 {
|
||||
return addr
|
||||
}
|
||||
if strings.Contains(addr, ":") {
|
||||
return "[" + addr + "]:" + strconv.Itoa(port)
|
||||
}
|
||||
return addr + ":" + strconv.Itoa(port)
|
||||
}
|
||||
|
||||
// GetHostsByInbound returns one inbound's hosts ordered by sort_order then id.
|
||||
func (s *HostService) GetHostsByInbound(inboundId int) ([]*model.Host, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Where("inbound_id = ?", inboundId).Order("sort_order asc, id asc").Find(&hosts).Error
|
||||
return hosts, err
|
||||
func newHostGroup(h *model.Host, groupId string) *entity.HostGroup {
|
||||
return &entity.HostGroup{
|
||||
GroupId: groupId,
|
||||
InboundIds: []int{},
|
||||
Hosts: []string{},
|
||||
SortOrder: h.SortOrder,
|
||||
Remark: h.Remark,
|
||||
ServerDescription: h.ServerDescription,
|
||||
IsDisabled: h.IsDisabled,
|
||||
IsHidden: h.IsHidden,
|
||||
Tags: h.Tags,
|
||||
Port: h.Port,
|
||||
Security: h.Security,
|
||||
Sni: h.Sni,
|
||||
HostHeader: h.HostHeader,
|
||||
Path: h.Path,
|
||||
Alpn: h.Alpn,
|
||||
Fingerprint: h.Fingerprint,
|
||||
OverrideSniFromAddress: h.OverrideSniFromAddress,
|
||||
KeepSniBlank: h.KeepSniBlank,
|
||||
PinnedPeerCertSha256: h.PinnedPeerCertSha256,
|
||||
VerifyPeerCertByName: h.VerifyPeerCertByName,
|
||||
AllowInsecure: h.AllowInsecure,
|
||||
EchConfigList: h.EchConfigList,
|
||||
MuxParams: h.MuxParams,
|
||||
SockoptParams: h.SockoptParams,
|
||||
FinalMask: h.FinalMask,
|
||||
VlessRoute: h.VlessRoute,
|
||||
ExcludeFromSubTypes: h.ExcludeFromSubTypes,
|
||||
NodeGuids: h.NodeGuids,
|
||||
MihomoIpVersion: h.MihomoIpVersion,
|
||||
MihomoX25519: h.MihomoX25519,
|
||||
ShuffleHost: h.ShuffleHost,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HostService) GetHost(id int) (*model.Host, error) {
|
||||
host := &model.Host{}
|
||||
if err := database.GetDB().First(host, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
func groupHosts(hosts []*model.Host) []*entity.HostGroup {
|
||||
groupsMap := make(map[string]*entity.HostGroup)
|
||||
var orderedGroupIds []string
|
||||
|
||||
// AddHost creates a host after confirming its inbound exists (no hard FK).
|
||||
func (s *HostService) AddHost(host *model.Host) (*model.Host, error) {
|
||||
db := database.GetDB()
|
||||
var count int64
|
||||
if err := db.Model(&model.Inbound{}).Where("id = ?", host.InboundId).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count == 0 {
|
||||
return nil, common.NewError("inbound not found")
|
||||
}
|
||||
host.Id = 0
|
||||
if err := db.Create(host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return host, nil
|
||||
}
|
||||
for _, h := range hosts {
|
||||
gId := h.GroupId
|
||||
if gId == "" {
|
||||
gId = "fallback_" + strconv.Itoa(h.Id)
|
||||
}
|
||||
|
||||
// UpdateHost overwrites a host's content. InboundId and SortOrder are immutable
|
||||
// here — the inbound is fixed at creation and ordering is owned by ReorderHosts.
|
||||
func (s *HostService) UpdateHost(id int, host *model.Host) (*model.Host, error) {
|
||||
db := database.GetDB()
|
||||
existing := &model.Host{}
|
||||
if err := db.First(existing, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
host.Id = id
|
||||
host.InboundId = existing.InboundId
|
||||
host.SortOrder = existing.SortOrder
|
||||
host.CreatedAt = existing.CreatedAt
|
||||
if err := db.Save(host).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.GetHost(id)
|
||||
}
|
||||
g, exists := groupsMap[gId]
|
||||
if !exists {
|
||||
g = newHostGroup(h, gId)
|
||||
groupsMap[gId] = g
|
||||
orderedGroupIds = append(orderedGroupIds, gId)
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHost(id int) error {
|
||||
return database.GetDB().Delete(&model.Host{}, id).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostEnable(id int, enable bool) error {
|
||||
return database.GetDB().Model(&model.Host{}).Where("id = ?", id).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostsEnable(ids []int, enable bool) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(&model.Host{}).Where("id IN ?", ids).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHosts(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("id IN ?", ids).Delete(&model.Host{}).Error
|
||||
}
|
||||
|
||||
// ReorderHosts assigns sort_order by the position of each id in ids, in a single
|
||||
// transaction (driver-safe on SQLite and Postgres).
|
||||
func (s *HostService) ReorderHosts(ids []int) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
tx := database.GetDB().Begin()
|
||||
for i, id := range ids {
|
||||
if err := tx.Model(&model.Host{}).Where("id = ?", id).Update("sort_order", i).Error; err != nil {
|
||||
tx.Rollback()
|
||||
return err
|
||||
if !slices.Contains(g.InboundIds, h.InboundId) {
|
||||
g.InboundIds = append(g.InboundIds, h.InboundId)
|
||||
}
|
||||
hostStr := formatHostAddr(h.Address, h.Port)
|
||||
if !slices.Contains(g.Hosts, hostStr) {
|
||||
g.Hosts = append(g.Hosts, hostStr)
|
||||
}
|
||||
if h.SortOrder < g.SortOrder {
|
||||
g.SortOrder = h.SortOrder
|
||||
}
|
||||
}
|
||||
return tx.Commit().Error
|
||||
|
||||
res := make([]*entity.HostGroup, 0, len(orderedGroupIds))
|
||||
for _, gId := range orderedGroupIds {
|
||||
res = append(res, groupsMap[gId])
|
||||
}
|
||||
|
||||
sort.SliceStable(res, func(i, j int) bool {
|
||||
if res[i].SortOrder != res[j].SortOrder {
|
||||
return res[i].SortOrder < res[j].SortOrder
|
||||
}
|
||||
return res[i].Remark < res[j].Remark
|
||||
})
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func buildHostRows(groupId string, req *entity.HostGroup) []*model.Host {
|
||||
hostsToProcess := req.Hosts
|
||||
if len(hostsToProcess) == 0 {
|
||||
hostsToProcess = []string{""}
|
||||
}
|
||||
var rows []*model.Host
|
||||
for _, hostStr := range hostsToProcess {
|
||||
addr, port := parseHostAndPort(hostStr, req.Port)
|
||||
for _, inboundId := range req.InboundIds {
|
||||
rows = append(rows, &model.Host{
|
||||
GroupId: groupId,
|
||||
InboundId: inboundId,
|
||||
SortOrder: req.SortOrder,
|
||||
Remark: req.Remark,
|
||||
ServerDescription: req.ServerDescription,
|
||||
IsDisabled: req.IsDisabled,
|
||||
IsHidden: req.IsHidden,
|
||||
Tags: req.Tags,
|
||||
Address: addr,
|
||||
Port: port,
|
||||
Security: req.Security,
|
||||
Sni: req.Sni,
|
||||
HostHeader: req.HostHeader,
|
||||
Path: req.Path,
|
||||
Alpn: req.Alpn,
|
||||
Fingerprint: req.Fingerprint,
|
||||
OverrideSniFromAddress: req.OverrideSniFromAddress,
|
||||
KeepSniBlank: req.KeepSniBlank,
|
||||
PinnedPeerCertSha256: req.PinnedPeerCertSha256,
|
||||
VerifyPeerCertByName: req.VerifyPeerCertByName,
|
||||
AllowInsecure: req.AllowInsecure,
|
||||
EchConfigList: req.EchConfigList,
|
||||
MuxParams: req.MuxParams,
|
||||
SockoptParams: req.SockoptParams,
|
||||
FinalMask: req.FinalMask,
|
||||
VlessRoute: req.VlessRoute,
|
||||
ExcludeFromSubTypes: req.ExcludeFromSubTypes,
|
||||
NodeGuids: req.NodeGuids,
|
||||
MihomoIpVersion: req.MihomoIpVersion,
|
||||
MihomoX25519: req.MihomoX25519,
|
||||
ShuffleHost: req.ShuffleHost,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func validateInboundsExist(tx *gorm.DB, inboundIds []int) error {
|
||||
for _, inboundId := range inboundIds {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Inbound{}).Where("id = ?", inboundId).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return common.NewError("inbound not found")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *HostService) GetHosts() ([]*entity.HostGroup, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Order("inbound_id asc, sort_order asc, id asc").Find(&hosts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groupHosts(hosts), nil
|
||||
}
|
||||
|
||||
func (s *HostService) GetHostsByInbound(inboundId int) ([]*entity.HostGroup, error) {
|
||||
var groupIds []string
|
||||
if err := database.GetDB().Model(&model.Host{}).Where("inbound_id = ?", inboundId).Distinct().Pluck("group_id", &groupIds).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(groupIds) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var hosts []*model.Host
|
||||
if err := database.GetDB().Where("group_id IN ?", groupIds).Order("sort_order asc, id asc").Find(&hosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return groupHosts(hosts), nil
|
||||
}
|
||||
|
||||
func (s *HostService) GetHostGroup(groupId string) (*entity.HostGroup, error) {
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Where("group_id = ?", groupId).Order("sort_order asc, id asc").Find(&hosts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(hosts) == 0 {
|
||||
return nil, common.NewError("host group not found")
|
||||
}
|
||||
grouped := groupHosts(hosts)
|
||||
if len(grouped) == 0 {
|
||||
return nil, common.NewError("host group not found")
|
||||
}
|
||||
return grouped[0], nil
|
||||
}
|
||||
|
||||
func (s *HostService) AddHostGroup(req *entity.HostGroup) ([]*model.Host, error) {
|
||||
groupId := req.GroupId
|
||||
if groupId == "" {
|
||||
groupId = random.NumLower(16)
|
||||
}
|
||||
created := buildHostRows(groupId, req)
|
||||
|
||||
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(created) > 0 {
|
||||
return tx.Create(&created).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *HostService) UpdateHostGroup(groupId string, req *entity.HostGroup) ([]*model.Host, error) {
|
||||
created := buildHostRows(groupId, req)
|
||||
|
||||
err := database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
var count int64
|
||||
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
return common.NewError("host group not found")
|
||||
}
|
||||
if err := validateInboundsExist(tx, req.InboundIds); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Where("group_id = ?", groupId).Delete(&model.Host{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(created) > 0 {
|
||||
return tx.Create(&created).Error
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHostGroup(groupId string) error {
|
||||
return database.GetDB().Where("group_id = ?", groupId).Delete(&model.Host{}).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostGroupEnable(groupId string, enable bool) error {
|
||||
return database.GetDB().Model(&model.Host{}).Where("group_id = ?", groupId).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) SetHostsGroupEnable(groupIds []string, enable bool) error {
|
||||
if len(groupIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Model(&model.Host{}).Where("group_id IN ?", groupIds).Update("is_disabled", !enable).Error
|
||||
}
|
||||
|
||||
func (s *HostService) DeleteHostsGroup(groupIds []string) error {
|
||||
if len(groupIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Where("group_id IN ?", groupIds).Delete(&model.Host{}).Error
|
||||
}
|
||||
|
||||
func (s *HostService) ReorderHostGroups(groupIds []string) error {
|
||||
if len(groupIds) == 0 {
|
||||
return nil
|
||||
}
|
||||
return database.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
for i, groupId := range groupIds {
|
||||
if err := tx.Model(&model.Host{}).Where("group_id = ?", groupId).Update("sort_order", i).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetAllTags returns the distinct, sorted set of tags across all hosts.
|
||||
func (s *HostService) GetAllTags() ([]string, error) {
|
||||
hosts, err := s.GetHosts()
|
||||
var hosts []*model.Host
|
||||
err := database.GetDB().Find(&hosts).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -128,3 +314,29 @@ func (s *HostService) GetAllTags() ([]string, error) {
|
||||
sort.Strings(out)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseHostAndPort(hostStr string, defaultPort int) (string, int) {
|
||||
hostStr = strings.TrimSpace(hostStr)
|
||||
if hostStr == "" {
|
||||
return "", defaultPort
|
||||
}
|
||||
if strings.Count(hostStr, ":") > 1 && !strings.Contains(hostStr, "[") {
|
||||
return hostStr, defaultPort
|
||||
}
|
||||
lastColon := strings.LastIndex(hostStr, ":")
|
||||
if lastColon != -1 && lastColon < len(hostStr)-1 {
|
||||
pStr := hostStr[lastColon+1:]
|
||||
if p, err := strconv.Atoi(pStr); err == nil && p >= 0 && p <= 65535 {
|
||||
addr := hostStr[:lastColon]
|
||||
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
|
||||
addr = addr[1 : len(addr)-1]
|
||||
}
|
||||
return addr, p
|
||||
}
|
||||
}
|
||||
addr := hostStr
|
||||
if strings.HasPrefix(addr, "[") && strings.HasSuffix(addr, "]") {
|
||||
addr = addr[1 : len(addr)-1]
|
||||
}
|
||||
return addr, defaultPort
|
||||
}
|
||||
|
||||
@@ -5,25 +5,28 @@ import (
|
||||
|
||||
"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 mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *model.Host {
|
||||
func mkHost(t *testing.T, svc *HostService, inboundId int, remark string, order int) *entity.HostGroup {
|
||||
t.Helper()
|
||||
h, err := svc.AddHost(&model.Host{
|
||||
InboundId: inboundId,
|
||||
Remark: remark,
|
||||
SortOrder: order,
|
||||
Address: remark + ".example.com",
|
||||
Port: 8443,
|
||||
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||
InboundIds: []int{inboundId},
|
||||
Remark: remark,
|
||||
SortOrder: order,
|
||||
Hosts: []string{remark + ".example.com"},
|
||||
Port: 8443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddHost %s: %v", remark, err)
|
||||
t.Fatalf("AddHostGroup %s: %v", remark, err)
|
||||
}
|
||||
return h
|
||||
g, err := svc.GetHostGroup(created[0].GroupId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostGroup %s: %v", remark, err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// TestAddHost_GetHostsByInbound: create persists; query returns by inbound,
|
||||
// ordered by sort_order then id.
|
||||
func TestAddHost_GetHostsByInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
@@ -38,24 +41,22 @@ func TestAddHost_GetHostsByInbound(t *testing.T) {
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].Id != h2.Id || got[1].Id != h1.Id {
|
||||
t.Fatalf("order = [%d,%d], want [%d,%d] (sort_order asc)", got[0].Id, got[1].Id, h2.Id, h1.Id)
|
||||
if got[0].GroupId != h2.GroupId || got[1].GroupId != h1.GroupId {
|
||||
t.Fatalf("order = [%s,%s], want [%s,%s] (sort_order asc)", got[0].GroupId, got[1].GroupId, h2.GroupId, h1.GroupId)
|
||||
}
|
||||
if got[0].Address != "a.example.com" {
|
||||
t.Fatalf("address not persisted: %q", got[0].Address)
|
||||
if got[0].Hosts[0] != "a.example.com:8443" {
|
||||
t.Fatalf("address not persisted: %q", got[0].Hosts[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddHost_RejectsUnknownInbound: a host whose inbound does not exist is refused.
|
||||
func TestAddHost_RejectsUnknownInbound(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: 99999, Remark: "x"}); err == nil {
|
||||
if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{99999}, Remark: "x", Hosts: []string{"test.com"}}); err == nil {
|
||||
t.Fatalf("expected error adding host to unknown inbound")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReorderHosts: reorder updates sort_order and re-query reflects new order.
|
||||
func TestReorderHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
@@ -64,22 +65,21 @@ func TestReorderHosts(t *testing.T) {
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 0)
|
||||
h3 := mkHost(t, svc, ib.Id, "h3", 0)
|
||||
|
||||
want := []int{h3.Id, h1.Id, h2.Id}
|
||||
if err := svc.ReorderHosts(want); err != nil {
|
||||
t.Fatalf("ReorderHosts: %v", err)
|
||||
want := []string{h3.GroupId, h1.GroupId, h2.GroupId}
|
||||
if err := svc.ReorderHostGroups(want); err != nil {
|
||||
t.Fatalf("ReorderHostGroups: %v", err)
|
||||
}
|
||||
got, _ := svc.GetHostsByInbound(ib.Id)
|
||||
for i, h := range got {
|
||||
if h.Id != want[i] {
|
||||
t.Fatalf("position %d = %d, want %d", i, h.Id, want[i])
|
||||
for i, g := range got {
|
||||
if g.GroupId != want[i] {
|
||||
t.Fatalf("position %d = %s, want %s", i, g.GroupId, want[i])
|
||||
}
|
||||
if h.SortOrder != i {
|
||||
t.Fatalf("host %d sort_order = %d, want %d", h.Id, h.SortOrder, i)
|
||||
if g.SortOrder != i {
|
||||
t.Fatalf("host %s sort_order = %d, want %d", g.GroupId, g.SortOrder, i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSetHostEnableAndBulk: per-row and bulk enable/disable toggles persist.
|
||||
func TestSetHostEnableAndBulk(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
@@ -87,32 +87,31 @@ func TestSetHostEnableAndBulk(t *testing.T) {
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 1)
|
||||
|
||||
if err := svc.SetHostEnable(h1.Id, false); err != nil {
|
||||
t.Fatalf("SetHostEnable: %v", err)
|
||||
if err := svc.SetHostGroupEnable(h1.GroupId, false); err != nil {
|
||||
t.Fatalf("SetHostGroupEnable: %v", err)
|
||||
}
|
||||
if g, _ := svc.GetHost(h1.Id); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("h1 should be disabled after SetHostEnable(false)")
|
||||
if g, _ := svc.GetHostGroup(h1.GroupId); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("h1 should be disabled after SetHostGroupEnable(false)")
|
||||
}
|
||||
|
||||
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, true); err != nil {
|
||||
t.Fatalf("SetHostsEnable(true): %v", err)
|
||||
if err := svc.SetHostsGroupEnable([]string{h1.GroupId, h2.GroupId}, true); err != nil {
|
||||
t.Fatalf("SetHostsGroupEnable(true): %v", err)
|
||||
}
|
||||
for _, id := range []int{h1.Id, h2.Id} {
|
||||
if g, _ := svc.GetHost(id); g == nil || g.IsDisabled {
|
||||
t.Fatalf("host %d should be enabled", id)
|
||||
for _, gid := range []string{h1.GroupId, h2.GroupId} {
|
||||
if g, _ := svc.GetHostGroup(gid); g == nil || g.IsDisabled {
|
||||
t.Fatalf("host %s should be enabled", gid)
|
||||
}
|
||||
}
|
||||
if err := svc.SetHostsEnable([]int{h1.Id, h2.Id}, false); err != nil {
|
||||
t.Fatalf("SetHostsEnable(false): %v", err)
|
||||
if err := svc.SetHostsGroupEnable([]string{h1.GroupId, h2.GroupId}, false); err != nil {
|
||||
t.Fatalf("SetHostsGroupEnable(false): %v", err)
|
||||
}
|
||||
for _, id := range []int{h1.Id, h2.Id} {
|
||||
if g, _ := svc.GetHost(id); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("host %d should be disabled", id)
|
||||
for _, gid := range []string{h1.GroupId, h2.GroupId} {
|
||||
if g, _ := svc.GetHostGroup(gid); g == nil || !g.IsDisabled {
|
||||
t.Fatalf("host %s should be disabled", gid)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteHosts: bulk delete removes exactly the named rows.
|
||||
func TestDeleteHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
@@ -121,27 +120,24 @@ func TestDeleteHosts(t *testing.T) {
|
||||
h2 := mkHost(t, svc, ib.Id, "h2", 1)
|
||||
h3 := mkHost(t, svc, ib.Id, "h3", 2)
|
||||
|
||||
if err := svc.DeleteHosts([]int{h1.Id, h3.Id}); err != nil {
|
||||
t.Fatalf("DeleteHosts: %v", err)
|
||||
if err := svc.DeleteHostsGroup([]string{h1.GroupId, h3.GroupId}); err != nil {
|
||||
t.Fatalf("DeleteHostsGroup: %v", err)
|
||||
}
|
||||
got, _ := svc.GetHostsByInbound(ib.Id)
|
||||
if len(got) != 1 || got[0].Id != h2.Id {
|
||||
t.Fatalf("remaining = %v, want only h2 (%d)", got, h2.Id)
|
||||
if len(got) != 1 || got[0].GroupId != h2.GroupId {
|
||||
t.Fatalf("remaining = %v, want only h2 (%s)", got, h2.GroupId)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteInboundCascadesHosts: deleting an inbound deletes its hosts.
|
||||
func TestDeleteInboundCascadesHosts(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
inboundSvc := &InboundService{}
|
||||
// Disabled local inbound so DelInbound skips the runtime push.
|
||||
ib := &model.Inbound{Tag: "casc", Enable: false, Port: 4443, Protocol: model.VLESS, Settings: `{"clients":[]}`}
|
||||
if err := database.GetDB().Create(ib).Error; err != nil {
|
||||
t.Fatalf("create inbound: %v", err)
|
||||
}
|
||||
mkHost(t, svc, ib.Id, "h1", 0)
|
||||
mkHost(t, svc, ib.Id, "h2", 1)
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
|
||||
if _, err := inboundSvc.DelInbound(ib.Id); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
@@ -150,18 +146,20 @@ func TestDeleteInboundCascadesHosts(t *testing.T) {
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("hosts not cascaded on inbound delete, len = %d", len(got))
|
||||
}
|
||||
if _, err := svc.GetHostGroup(h1.GroupId); err == nil {
|
||||
t.Fatalf("expected group to be deleted after cascading")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetAllTags: distinct, sorted tags across all hosts.
|
||||
func TestGetAllTags(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h1", Tags: []string{"EU", "CDN"}}); err != nil {
|
||||
t.Fatalf("AddHost: %v", err)
|
||||
if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{ib.Id}, Remark: "h1", Hosts: []string{"h1.com"}, Tags: []string{"EU", "CDN"}}); err != nil {
|
||||
t.Fatalf("AddHostGroup: %v", err)
|
||||
}
|
||||
if _, err := svc.AddHost(&model.Host{InboundId: ib.Id, Remark: "h2", Tags: []string{"CDN", "FAST"}}); err != nil {
|
||||
t.Fatalf("AddHost: %v", err)
|
||||
if _, err := svc.AddHostGroup(&entity.HostGroup{InboundIds: []int{ib.Id}, Remark: "h2", Hosts: []string{"h2.com"}, Tags: []string{"CDN", "FAST"}}); err != nil {
|
||||
t.Fatalf("AddHostGroup: %v", err)
|
||||
}
|
||||
tags, err := svc.GetAllTags()
|
||||
if err != nil {
|
||||
@@ -177,3 +175,193 @@ func TestGetAllTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddHostsGroup(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib1 := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
ib2 := mkInbound(t, 80, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
req := &entity.HostGroup{
|
||||
InboundIds: []int{ib1.Id, ib2.Id},
|
||||
Hosts: []string{"h1.com", "h2.com:443", "[2001:db8::1]:80"},
|
||||
Remark: "BulkRemark",
|
||||
Port: 8443,
|
||||
Security: "same",
|
||||
}
|
||||
|
||||
created, err := svc.AddHostGroup(req)
|
||||
if err != nil {
|
||||
t.Fatalf("AddHostGroup: %v", err)
|
||||
}
|
||||
|
||||
if len(created) != 6 {
|
||||
t.Fatalf("expected 6 created hosts, got %d", len(created))
|
||||
}
|
||||
|
||||
got1, _ := svc.GetHostsByInbound(ib1.Id)
|
||||
if len(got1) != 1 {
|
||||
t.Fatalf("expected 1 group for inbound 1, got %d", len(got1))
|
||||
}
|
||||
|
||||
g := got1[0]
|
||||
if g.Remark != "BulkRemark" {
|
||||
t.Errorf("expected remark BulkRemark, got %s", g.Remark)
|
||||
}
|
||||
|
||||
var foundH2Port443 bool
|
||||
var foundIPv6Port80 bool
|
||||
var foundH1DefaultPort8443 bool
|
||||
|
||||
for _, hostStr := range g.Hosts {
|
||||
if hostStr == "h2.com:443" {
|
||||
foundH2Port443 = true
|
||||
}
|
||||
if hostStr == "[2001:db8::1]:80" {
|
||||
foundIPv6Port80 = true
|
||||
}
|
||||
if hostStr == "h1.com:8443" {
|
||||
foundH1DefaultPort8443 = true
|
||||
}
|
||||
}
|
||||
|
||||
if !foundH2Port443 {
|
||||
t.Error("missing custom port override host h2.com:443")
|
||||
}
|
||||
if !foundIPv6Port80 {
|
||||
t.Error("missing IPv6 host with port override [2001:db8::1]:80")
|
||||
}
|
||||
if !foundH1DefaultPort8443 {
|
||||
t.Error("missing default port fallback host h1.com:8443")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostAndPort_IPv6EdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
defaultPort int
|
||||
wantAddr string
|
||||
wantPort int
|
||||
}{
|
||||
{"2001:db8::1", 8443, "2001:db8::1", 8443},
|
||||
{"[2001:db8::1]:80", 8443, "2001:db8::1", 80},
|
||||
{"h1.com:443", 8443, "h1.com", 443},
|
||||
{"h1.com", 8443, "h1.com", 8443},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
addr, port := parseHostAndPort(tc.input, tc.defaultPort)
|
||||
if addr != tc.wantAddr || port != tc.wantPort {
|
||||
t.Errorf("parseHostAndPort(%q, %d) = (%q, %d); want (%q, %d)",
|
||||
tc.input, tc.defaultPort, addr, port, tc.wantAddr, tc.wantPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseHostAndPort_AdversarialStressCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
input string
|
||||
defaultPort int
|
||||
wantAddr string
|
||||
wantPort int
|
||||
}{
|
||||
{"", 8443, "", 8443},
|
||||
{" ", 8443, "", 8443},
|
||||
{"h1.com: ", 8443, "h1.com:", 8443},
|
||||
{"h1.com: -1", 8443, "h1.com: -1", 8443},
|
||||
{"h1.com:-1", 8443, "h1.com:-1", 8443},
|
||||
{"h1.com:0", 8443, "h1.com", 0},
|
||||
{"h1.com:65535", 8443, "h1.com", 65535},
|
||||
{"h1.com:65536", 8443, "h1.com:65536", 8443},
|
||||
{"h1.com:80a", 8443, "h1.com:80a", 8443},
|
||||
{"h1.com:123:456", 8443, "h1.com:123:456", 8443},
|
||||
{"[2001:db8::1]", 8443, "2001:db8::1", 8443},
|
||||
{"[2001:db8::1]:80", 8443, "2001:db8::1", 80},
|
||||
{"2001:db8::1", 8443, "2001:db8::1", 8443},
|
||||
{"[2001:db8::1]:65536", 8443, "[2001:db8::1]:65536", 8443},
|
||||
{"[]:80", 8443, "", 80},
|
||||
{"[:]::80", 8443, "[:]:", 80},
|
||||
{"h1.com:", 8443, "h1.com:", 8443},
|
||||
{"h1.com:123:", 8443, "h1.com:123:", 8443},
|
||||
{" h1.com : 80 ", 8443, "h1.com : 80", 8443},
|
||||
{" [2001:db8::1]:80 ", 8443, "2001:db8::1", 80},
|
||||
{"[2001:db8::1]:+80", 8443, "2001:db8::1", 80},
|
||||
{"[2001:db8::1]:080", 8443, "2001:db8::1", 80},
|
||||
{"[2001:db8::1]80", 8443, "[2001:db8::1]80", 8443},
|
||||
{"[::1]", 8443, "::1", 8443},
|
||||
{"[2001:db8::1", 8443, "[2001:db8:", 1},
|
||||
{"[2001:db8::1]:-80", 8443, "[2001:db8::1]:-80", 8443},
|
||||
{"h1.com:443:80", 8443, "h1.com:443:80", 8443},
|
||||
{"[2001:db8::1]::80", 8443, "[2001:db8::1]:", 80},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
addr, port := parseHostAndPort(tc.input, tc.defaultPort)
|
||||
if addr != tc.wantAddr || port != tc.wantPort {
|
||||
t.Errorf("parseHostAndPort(%q, %d) = (%q, %d); want (%q, %d)",
|
||||
tc.input, tc.defaultPort, addr, port, tc.wantAddr, tc.wantPort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddHostGroup_OptionalAddress(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
|
||||
created, err := svc.AddHostGroup(&entity.HostGroup{
|
||||
InboundIds: []int{ib.Id},
|
||||
Remark: "OptionalAddressHost",
|
||||
Hosts: nil,
|
||||
Port: 8443,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddHostGroup with nil Hosts failed: %v", err)
|
||||
}
|
||||
|
||||
if len(created) != 1 {
|
||||
t.Fatalf("expected 1 host created, got %d", len(created))
|
||||
}
|
||||
|
||||
g, err := svc.GetHostGroup(created[0].GroupId)
|
||||
if err != nil {
|
||||
t.Fatalf("GetHostGroup failed: %v", err)
|
||||
}
|
||||
|
||||
if len(g.Hosts) != 1 || g.Hosts[0] != ":8443" {
|
||||
t.Fatalf("expected Hosts list to contain default port fallback ':8443', got %v", g.Hosts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateHostGroup_ValidateBeforeDelete(t *testing.T) {
|
||||
setupBulkDB(t)
|
||||
svc := &HostService{}
|
||||
ib := mkInbound(t, 443, model.VLESS, `{"clients":[]}`)
|
||||
h1 := mkHost(t, svc, ib.Id, "h1", 0)
|
||||
|
||||
req := &entity.HostGroup{
|
||||
InboundIds: []int{99999},
|
||||
Remark: "h1-updated",
|
||||
Hosts: []string{"h1.com"},
|
||||
}
|
||||
if _, err := svc.UpdateHostGroup(h1.GroupId, req); err == nil {
|
||||
t.Fatalf("expected error updating host group with invalid inbound")
|
||||
}
|
||||
|
||||
got, err := svc.GetHostGroup(h1.GroupId)
|
||||
if err != nil {
|
||||
t.Fatalf("original host group should not be deleted: %v", err)
|
||||
}
|
||||
if got.Remark != "h1" {
|
||||
t.Fatalf("original host group remark changed: %s", got.Remark)
|
||||
}
|
||||
|
||||
req.InboundIds = []int{ib.Id}
|
||||
if _, err := svc.UpdateHostGroup(h1.GroupId, req); err != nil {
|
||||
t.Fatalf("valid update failed: %v", err)
|
||||
}
|
||||
got2, _ := svc.GetHostGroup(h1.GroupId)
|
||||
if got2.Remark != "h1-updated" {
|
||||
t.Fatalf("remark not updated: %s", got2.Remark)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user