mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-15 07:40:59 +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:
@@ -3,15 +3,13 @@ package controller
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/middleware"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// HostController exposes CRUD + ordering for Host override endpoints under
|
||||
// /panel/api/hosts. Thin HTTP layer over HostService; mirrors NodeController.
|
||||
type HostController struct {
|
||||
hostService service.HostService
|
||||
}
|
||||
@@ -24,15 +22,16 @@ func NewHostController(g *gin.RouterGroup) *HostController {
|
||||
|
||||
func (a *HostController) initRouter(g *gin.RouterGroup) {
|
||||
g.GET("/list", a.list)
|
||||
g.GET("/get/:id", a.get)
|
||||
g.GET("/get/:groupId", a.get)
|
||||
g.GET("/byInbound/:inboundId", a.byInbound)
|
||||
g.GET("/tags", a.tags)
|
||||
|
||||
g.POST("/add", a.add)
|
||||
g.POST("/update/:id", a.update)
|
||||
g.POST("/del/:id", a.del)
|
||||
g.POST("/setEnable/:id", a.setEnable)
|
||||
g.POST("/update/:groupId", a.update)
|
||||
g.POST("/del/:groupId", a.del)
|
||||
g.POST("/setEnable/:groupId", a.setEnable)
|
||||
g.POST("/reorder", a.reorder)
|
||||
g.POST("/bulk/add", a.add)
|
||||
g.POST("/bulk/setEnable", a.bulkSetEnable)
|
||||
g.POST("/bulk/del", a.bulkDel)
|
||||
}
|
||||
@@ -47,12 +46,8 @@ func (a *HostController) list(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) get(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, err := a.hostService.GetHost(id)
|
||||
groupId := c.Param("groupId")
|
||||
h, err := a.hostService.GetHostGroup(groupId)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.obtain"), err)
|
||||
return
|
||||
@@ -84,11 +79,11 @@ func (a *HostController) tags(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) add(c *gin.Context) {
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
created, err := a.hostService.AddHost(h)
|
||||
created, err := a.hostService.AddHostGroup(req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.add"), err)
|
||||
return
|
||||
@@ -97,16 +92,12 @@ func (a *HostController) add(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) update(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
h, ok := middleware.BindAndValidate[model.Host](c)
|
||||
groupId := c.Param("groupId")
|
||||
req, ok := middleware.BindJSONAndValidate[entity.HostGroup](c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
updated, err := a.hostService.UpdateHost(id, h)
|
||||
updated, err := a.hostService.UpdateHostGroup(groupId, req)
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
@@ -115,12 +106,8 @@ func (a *HostController) update(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) del(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHost(id); err != nil {
|
||||
groupId := c.Param("groupId")
|
||||
if err := a.hostService.DeleteHostGroup(groupId); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
@@ -128,11 +115,7 @@ func (a *HostController) del(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (a *HostController) setEnable(c *gin.Context) {
|
||||
id, err := strconv.Atoi(c.Param("id"))
|
||||
if err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "get"), err)
|
||||
return
|
||||
}
|
||||
groupId := c.Param("groupId")
|
||||
body := struct {
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}{}
|
||||
@@ -140,7 +123,7 @@ func (a *HostController) setEnable(c *gin.Context) {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostEnable(id, body.Enable); err != nil {
|
||||
if err := a.hostService.SetHostGroupEnable(groupId, body.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -149,13 +132,13 @@ func (a *HostController) setEnable(c *gin.Context) {
|
||||
|
||||
func (a *HostController) reorder(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.ReorderHosts(req.Ids); err != nil {
|
||||
if err := a.hostService.ReorderHostGroups(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -164,14 +147,14 @@ func (a *HostController) reorder(c *gin.Context) {
|
||||
|
||||
func (a *HostController) bulkSetEnable(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
Enable bool `json:"enable" form:"enable"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.SetHostsEnable(req.Ids, req.Enable); err != nil {
|
||||
if err := a.hostService.SetHostsGroupEnable(req.Ids, req.Enable); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.update"), err)
|
||||
return
|
||||
}
|
||||
@@ -180,13 +163,13 @@ func (a *HostController) bulkSetEnable(c *gin.Context) {
|
||||
|
||||
func (a *HostController) bulkDel(c *gin.Context) {
|
||||
var req struct {
|
||||
Ids []int `json:"ids" form:"ids"`
|
||||
Ids []string `json:"ids" form:"ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
if err := a.hostService.DeleteHosts(req.Ids); err != nil {
|
||||
if err := a.hostService.DeleteHostsGroup(req.Ids); err != nil {
|
||||
jsonMsg(c, I18nWeb(c, "pages.hosts.toasts.delete"), err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-contrib/sessions"
|
||||
@@ -17,12 +16,11 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/web/entity"
|
||||
)
|
||||
|
||||
func newHostTestDB(t *testing.T) {
|
||||
t.Helper()
|
||||
// I18nWeb logs a warning when the localizer is absent (as in tests); the
|
||||
// logger must be initialised so that warning does not nil-panic.
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
gin.SetMode(gin.TestMode)
|
||||
dbDir := t.TempDir()
|
||||
@@ -62,8 +60,6 @@ func doHostReq(t *testing.T, engine *gin.Engine, method, path string, body any)
|
||||
return env
|
||||
}
|
||||
|
||||
// TestHostController_AddListGetDelete exercises the CRUD round-trip and asserts
|
||||
// the {success,msg,obj} envelope convention through the registered routes.
|
||||
func TestHostController_AddListGetDelete(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
@@ -74,53 +70,83 @@ func TestHostController_AddListGetDelete(t *testing.T) {
|
||||
t.Fatalf("seed inbound: %v", err)
|
||||
}
|
||||
|
||||
// add
|
||||
add := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/add", map[string]any{
|
||||
"inboundId": ib.Id, "remark": "h1", "address": "h1.example.com", "port": 8443,
|
||||
"inboundIds": []int{ib.Id}, "remark": "h1", "hosts": []string{"h1.example.com"}, "port": 8443,
|
||||
})
|
||||
if !add.Success {
|
||||
t.Fatalf("add not successful: %s", add.Msg)
|
||||
}
|
||||
var created model.Host
|
||||
var created []*model.Host
|
||||
if err := json.Unmarshal(add.Obj, &created); err != nil {
|
||||
t.Fatalf("decode created host: %v", err)
|
||||
t.Fatalf("decode created hosts: %v", err)
|
||||
}
|
||||
if created.Id == 0 || created.Remark != "h1" {
|
||||
t.Fatalf("created host = %+v", created)
|
||||
if len(created) != 1 || created[0].GroupId == "" || created[0].Remark != "h1" {
|
||||
t.Fatalf("created hosts = %+v", created)
|
||||
}
|
||||
groupId := created[0].GroupId
|
||||
|
||||
// list
|
||||
list := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts []model.Host
|
||||
if err := json.Unmarshal(list.Obj, &hosts); err != nil {
|
||||
var groups []entity.HostGroup
|
||||
if err := json.Unmarshal(list.Obj, &groups); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
if len(hosts) != 1 || hosts[0].Id != created.Id {
|
||||
t.Fatalf("list = %+v, want one host id=%d", hosts, created.Id)
|
||||
if len(groups) != 1 || groups[0].GroupId != groupId {
|
||||
t.Fatalf("list = %+v, want one group groupId=%s", groups, groupId)
|
||||
}
|
||||
|
||||
// get
|
||||
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+itoa(created.Id), nil)
|
||||
get := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
if !get.Success {
|
||||
t.Fatalf("get not successful: %s", get.Msg)
|
||||
}
|
||||
|
||||
// del
|
||||
del := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/del/"+itoa(created.Id), nil)
|
||||
if !del.Success {
|
||||
t.Fatalf("del not successful: %s", del.Msg)
|
||||
update := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/update/"+groupId, map[string]any{
|
||||
"inboundIds": []int{ib.Id}, "remark": "h1-updated", "hosts": []string{"h1.example.com"}, "port": 8443,
|
||||
})
|
||||
if !update.Success {
|
||||
t.Fatalf("update not successful: %s", update.Msg)
|
||||
}
|
||||
get2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
var group2 entity.HostGroup
|
||||
_ = json.Unmarshal(get2.Obj, &group2)
|
||||
if group2.Remark != "h1-updated" {
|
||||
t.Fatalf("update did not change remark: %s", group2.Remark)
|
||||
}
|
||||
|
||||
setEn := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/setEnable", map[string]any{
|
||||
"ids": []string{groupId}, "enable": false,
|
||||
})
|
||||
if !setEn.Success {
|
||||
t.Fatalf("bulk/setEnable not successful: %s", setEn.Msg)
|
||||
}
|
||||
get3 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/get/"+groupId, nil)
|
||||
var group3 entity.HostGroup
|
||||
_ = json.Unmarshal(get3.Obj, &group3)
|
||||
if !group3.IsDisabled {
|
||||
t.Fatalf("bulk/setEnable did not disable host group")
|
||||
}
|
||||
|
||||
add2 := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/add", map[string]any{
|
||||
"inboundIds": []int{ib.Id}, "remark": "h2", "hosts": []string{"h2.example.com"}, "port": 8443,
|
||||
})
|
||||
var created2 []*model.Host
|
||||
_ = json.Unmarshal(add2.Obj, &created2)
|
||||
groupId2 := created2[0].GroupId
|
||||
|
||||
bulkDel := doHostReq(t, engine, http.MethodPost, "/panel/api/hosts/bulk/del", map[string]any{
|
||||
"ids": []string{groupId, groupId2},
|
||||
})
|
||||
if !bulkDel.Success {
|
||||
t.Fatalf("bulk/del not successful: %s", bulkDel.Msg)
|
||||
}
|
||||
|
||||
list2 := doHostReq(t, engine, http.MethodGet, "/panel/api/hosts/list", nil)
|
||||
var hosts2 []model.Host
|
||||
_ = json.Unmarshal(list2.Obj, &hosts2)
|
||||
if len(hosts2) != 0 {
|
||||
t.Fatalf("after delete, list = %+v, want empty", hosts2)
|
||||
var groups2 []entity.HostGroup
|
||||
_ = json.Unmarshal(list2.Obj, &groups2)
|
||||
if len(groups2) != 0 {
|
||||
t.Fatalf("after delete, list = %+v, want empty", groups2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostController_AuthInherited mirrors production wiring: the hosts group is
|
||||
// nested under the api group guarded by checkAPIAuth, so an unauthenticated XHR
|
||||
// to a hosts route is rejected (401) — the auth is inherited, not re-declared.
|
||||
func TestHostController_AuthInherited(t *testing.T) {
|
||||
newHostTestDB(t)
|
||||
engine := gin.New()
|
||||
@@ -140,7 +166,3 @@ func TestHostController_AuthInherited(t *testing.T) {
|
||||
t.Fatalf("unauthenticated hosts/list = %d, want 401 (auth inherited)", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func itoa(i int) string {
|
||||
return strconv.Itoa(i)
|
||||
}
|
||||
|
||||
+119
-101
@@ -1,4 +1,3 @@
|
||||
// Package entity defines data structures and entities used by the web layer of the 3x-ui panel.
|
||||
package entity
|
||||
|
||||
import (
|
||||
@@ -11,100 +10,91 @@ import (
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
)
|
||||
|
||||
// Msg represents a standard API response message with success status, message text, and optional data object.
|
||||
type Msg struct {
|
||||
Success bool `json:"success"` // Indicates if the operation was successful
|
||||
Msg string `json:"msg"` // Response message text
|
||||
Obj any `json:"obj"` // Optional data object
|
||||
Success bool `json:"success"`
|
||||
Msg string `json:"msg"`
|
||||
Obj any `json:"obj"`
|
||||
}
|
||||
|
||||
// AllSetting contains all configuration settings for the 3x-ui panel including web server, Telegram bot, and subscription settings.
|
||||
type AllSetting struct {
|
||||
// Web server settings
|
||||
WebListen string `json:"webListen" form:"webListen"` // Web server listen IP address
|
||||
WebDomain string `json:"webDomain" form:"webDomain"` // Web server domain for domain validation
|
||||
WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"` // Web server port number
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"` // Path to SSL certificate file for web server
|
||||
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"` // Path to SSL private key file for web server
|
||||
WebBasePath string `json:"webBasePath" form:"webBasePath"` // Base path for web panel URLs
|
||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"` // Session maximum age in minutes (cap at one year)
|
||||
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"` // Trusted reverse proxy IPs/CIDRs for forwarded headers
|
||||
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"` // Xray outbound tag for the panel's own outbound HTTP (update checks/downloads, Telegram, geo updates, outbound-subscription fetches)
|
||||
WebListen string `json:"webListen" form:"webListen"`
|
||||
WebDomain string `json:"webDomain" form:"webDomain"`
|
||||
WebPort int `json:"webPort" form:"webPort" validate:"gte=1,lte=65535"`
|
||||
WebCertFile string `json:"webCertFile" form:"webCertFile"`
|
||||
WebKeyFile string `json:"webKeyFile" form:"webKeyFile"`
|
||||
WebBasePath string `json:"webBasePath" form:"webBasePath"`
|
||||
SessionMaxAge int `json:"sessionMaxAge" form:"sessionMaxAge" validate:"gte=1,lte=525600"`
|
||||
TrustedProxyCIDRs string `json:"trustedProxyCIDRs" form:"trustedProxyCIDRs"`
|
||||
PanelOutbound string `json:"panelOutbound" form:"panelOutbound"`
|
||||
|
||||
// UI settings
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"` // Number of items per page in lists (0 disables pagination)
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"` // Expiration warning threshold in days
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"` // Traffic warning threshold percentage
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"` // Subscription remark template ({{VAR}} tokens) rendered per client
|
||||
Datepicker string `json:"datepicker" form:"datepicker"` // Date picker format
|
||||
PageSize int `json:"pageSize" form:"pageSize" validate:"gte=0,lte=1000"`
|
||||
ExpireDiff int `json:"expireDiff" form:"expireDiff" validate:"gte=0"`
|
||||
TrafficDiff int `json:"trafficDiff" form:"trafficDiff" validate:"gte=0,lte=100"`
|
||||
RemarkTemplate string `json:"remarkTemplate" form:"remarkTemplate"`
|
||||
Datepicker string `json:"datepicker" form:"datepicker"`
|
||||
|
||||
// Telegram bot settings
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"` // Enable Telegram bot notifications
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"` // Telegram bot token
|
||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"` // Proxy URL for Telegram bot
|
||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"` // Custom API server for Telegram bot
|
||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"` // Telegram chat ID for notifications
|
||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"` // Cron schedule for Telegram notifications
|
||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"` // Enable database backup via Telegram
|
||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"` // CPU usage threshold for alerts (percent)
|
||||
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"` // Memory usage threshold for alerts (percent)
|
||||
TgLang string `json:"tgLang" form:"tgLang"` // Telegram bot language
|
||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"` // Comma-separated event types to send via Telegram
|
||||
TgBotEnable bool `json:"tgBotEnable" form:"tgBotEnable"`
|
||||
TgBotToken string `json:"tgBotToken" form:"tgBotToken"`
|
||||
TgBotProxy string `json:"tgBotProxy" form:"tgBotProxy"`
|
||||
TgBotAPIServer string `json:"tgBotAPIServer" form:"tgBotAPIServer"`
|
||||
TgBotChatId string `json:"tgBotChatId" form:"tgBotChatId"`
|
||||
TgRunTime string `json:"tgRunTime" form:"tgRunTime"`
|
||||
TgBotBackup bool `json:"tgBotBackup" form:"tgBotBackup"`
|
||||
TgCpu int `json:"tgCpu" form:"tgCpu" validate:"gte=0,lte=100"`
|
||||
TgMemory int `json:"tgMemory" form:"tgMemory" validate:"gte=0,lte=100"`
|
||||
TgLang string `json:"tgLang" form:"tgLang"`
|
||||
TgEnabledEvents string `json:"tgEnabledEvents" form:"tgEnabledEvents"`
|
||||
|
||||
// Email (SMTP) notification settings
|
||||
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"` // Enable email notifications
|
||||
SmtpHost string `json:"smtpHost" form:"smtpHost"` // SMTP server host
|
||||
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"` // SMTP server port
|
||||
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"` // SMTP username
|
||||
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"` // SMTP password
|
||||
SmtpTo string `json:"smtpTo" form:"smtpTo"` // Comma-separated recipient emails
|
||||
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"` // SMTP encryption: none, starttls, tls
|
||||
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"` // Comma-separated event types to send via email
|
||||
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"` // CPU threshold for email notifications
|
||||
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"` // Memory threshold for email notifications
|
||||
SmtpEnable bool `json:"smtpEnable" form:"smtpEnable"`
|
||||
SmtpHost string `json:"smtpHost" form:"smtpHost"`
|
||||
SmtpPort int `json:"smtpPort" form:"smtpPort" validate:"gte=1,lte=65535"`
|
||||
SmtpUsername string `json:"smtpUsername" form:"smtpUsername"`
|
||||
SmtpPassword string `json:"smtpPassword" form:"smtpPassword"`
|
||||
SmtpTo string `json:"smtpTo" form:"smtpTo"`
|
||||
SmtpEncryptionType string `json:"smtpEncryptionType" form:"smtpEncryptionType"`
|
||||
SmtpEnabledEvents string `json:"smtpEnabledEvents" form:"smtpEnabledEvents"`
|
||||
SmtpCpu int `json:"smtpCpu" form:"smtpCpu" validate:"gte=0,lte=100"`
|
||||
SmtpMemory int `json:"smtpMemory" form:"smtpMemory" validate:"gte=0,lte=100"`
|
||||
|
||||
// Security settings
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"` // Time zone location
|
||||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"` // Enable two-factor authentication
|
||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"` // Two-factor authentication token
|
||||
TimeLocation string `json:"timeLocation" form:"timeLocation"`
|
||||
TwoFactorEnable bool `json:"twoFactorEnable" form:"twoFactorEnable"`
|
||||
TwoFactorToken string `json:"twoFactorToken" form:"twoFactorToken"`
|
||||
|
||||
// Subscription server settings
|
||||
SubEnable bool `json:"subEnable" form:"subEnable"` // Enable subscription server
|
||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"` // Enable JSON subscription endpoint
|
||||
SubTitle string `json:"subTitle" form:"subTitle"` // Subscription title
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"` // Subscription support URL
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"` // Subscription profile URL
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"` // Subscription announce
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"` // Enable routing for subscription
|
||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"` // Subscription global routing rules (Only for Happ)
|
||||
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"` // Enable routing injection for the Incy client
|
||||
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"` // Incy routing deep-link injected into the subscription body (Only for Incy)
|
||||
SubListen string `json:"subListen" form:"subListen"` // Subscription server listen IP
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"` // Subscription server port
|
||||
SubPath string `json:"subPath" form:"subPath"` // Base path for subscription URLs
|
||||
SubDomain string `json:"subDomain" form:"subDomain"` // Domain for subscription server validation
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"` // SSL certificate file for subscription server
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` // SSL private key file for subscription server
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` // Subscription update interval in minutes
|
||||
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"` // Enable external traffic reporting
|
||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"` // URI for external traffic reporting
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // Restart Xray when clients are auto-disabled by expiry/traffic limit
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"` // Encrypt subscription responses
|
||||
SubURI string `json:"subURI" form:"subURI"` // Subscription server URI
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"` // Path for JSON subscription endpoint
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"` // JSON subscription server URI
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"` // Enable Clash/Mihomo subscription endpoint
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"` // Path for Clash/Mihomo subscription endpoint
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"` // Clash/Mihomo subscription server URI
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"` // Enable global routing rules for Clash/Mihomo
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"` // Clash/Mihomo global routing rules
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"` // JSON subscription mux configuration
|
||||
SubEnable bool `json:"subEnable" form:"subEnable"`
|
||||
SubJsonEnable bool `json:"subJsonEnable" form:"subJsonEnable"`
|
||||
SubTitle string `json:"subTitle" form:"subTitle"`
|
||||
SubSupportUrl string `json:"subSupportUrl" form:"subSupportUrl"`
|
||||
SubProfileUrl string `json:"subProfileUrl" form:"subProfileUrl"`
|
||||
SubAnnounce string `json:"subAnnounce" form:"subAnnounce"`
|
||||
SubEnableRouting bool `json:"subEnableRouting" form:"subEnableRouting"`
|
||||
SubRoutingRules string `json:"subRoutingRules" form:"subRoutingRules"`
|
||||
SubIncyEnableRouting bool `json:"subIncyEnableRouting" form:"subIncyEnableRouting"`
|
||||
SubIncyRoutingRules string `json:"subIncyRoutingRules" form:"subIncyRoutingRules"`
|
||||
SubListen string `json:"subListen" form:"subListen"`
|
||||
SubPort int `json:"subPort" form:"subPort" validate:"gte=1,lte=65535"`
|
||||
SubPath string `json:"subPath" form:"subPath"`
|
||||
SubDomain string `json:"subDomain" form:"subDomain"`
|
||||
SubCertFile string `json:"subCertFile" form:"subCertFile"`
|
||||
SubKeyFile string `json:"subKeyFile" form:"subKeyFile"`
|
||||
SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"`
|
||||
ExternalTrafficInformEnable bool `json:"externalTrafficInformEnable" form:"externalTrafficInformEnable"`
|
||||
ExternalTrafficInformURI string `json:"externalTrafficInformURI" form:"externalTrafficInformURI"`
|
||||
RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"`
|
||||
SubEncrypt bool `json:"subEncrypt" form:"subEncrypt"`
|
||||
SubURI string `json:"subURI" form:"subURI"`
|
||||
SubJsonPath string `json:"subJsonPath" form:"subJsonPath"`
|
||||
SubJsonURI string `json:"subJsonURI" form:"subJsonURI"`
|
||||
SubClashEnable bool `json:"subClashEnable" form:"subClashEnable"`
|
||||
SubClashPath string `json:"subClashPath" form:"subClashPath"`
|
||||
SubClashURI string `json:"subClashURI" form:"subClashURI"`
|
||||
SubClashEnableRouting bool `json:"subClashEnableRouting" form:"subClashEnableRouting"`
|
||||
SubClashRules string `json:"subClashRules" form:"subClashRules"`
|
||||
SubJsonMux string `json:"subJsonMux" form:"subJsonMux"`
|
||||
SubJsonRules string `json:"subJsonRules" form:"subJsonRules"`
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"` // JSON subscription global finalmask (tcp/udp masks + quicParams)
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"` // Absolute path to a folder containing a custom subscription page template
|
||||
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"` // Hide server settings in happ subscription (Only for Happ)
|
||||
SubJsonFinalMask string `json:"subJsonFinalMask" form:"subJsonFinalMask"`
|
||||
SubThemeDir string `json:"subThemeDir" form:"subThemeDir"`
|
||||
SubHideSettings bool `json:"subHideSettings" form:"subHideSettings"`
|
||||
|
||||
// LDAP settings
|
||||
LdapEnable bool `json:"ldapEnable" form:"ldapEnable"`
|
||||
LdapHost string `json:"ldapHost" form:"ldapHost"`
|
||||
LdapPort int `json:"ldapPort" form:"ldapPort" validate:"gte=0,lte=65535"`
|
||||
@@ -114,28 +104,22 @@ type AllSetting struct {
|
||||
LdapPassword string `json:"ldapPassword" form:"ldapPassword"`
|
||||
LdapBaseDN string `json:"ldapBaseDN" form:"ldapBaseDN"`
|
||||
LdapUserFilter string `json:"ldapUserFilter" form:"ldapUserFilter"`
|
||||
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"` // e.g., mail or uid
|
||||
LdapUserAttr string `json:"ldapUserAttr" form:"ldapUserAttr"`
|
||||
LdapVlessField string `json:"ldapVlessField" form:"ldapVlessField"`
|
||||
LdapSyncCron string `json:"ldapSyncCron" form:"ldapSyncCron"`
|
||||
// Generic flag configuration
|
||||
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
|
||||
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
|
||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
||||
// JSON subscription routing rules
|
||||
LdapFlagField string `json:"ldapFlagField" form:"ldapFlagField"`
|
||||
LdapTruthyValues string `json:"ldapTruthyValues" form:"ldapTruthyValues"`
|
||||
LdapInvertFlag bool `json:"ldapInvertFlag" form:"ldapInvertFlag"`
|
||||
LdapInboundTags string `json:"ldapInboundTags" form:"ldapInboundTags"`
|
||||
LdapAutoCreate bool `json:"ldapAutoCreate" form:"ldapAutoCreate"`
|
||||
LdapAutoDelete bool `json:"ldapAutoDelete" form:"ldapAutoDelete"`
|
||||
LdapDefaultTotalGB int `json:"ldapDefaultTotalGB" form:"ldapDefaultTotalGB" validate:"gte=0"`
|
||||
LdapDefaultExpiryDays int `json:"ldapDefaultExpiryDays" form:"ldapDefaultExpiryDays" validate:"gte=0"`
|
||||
LdapDefaultLimitIP int `json:"ldapDefaultLimitIP" form:"ldapDefaultLimitIP" validate:"gte=0"`
|
||||
|
||||
// WARP
|
||||
WarpUpdateInterval int `json:"warpUpdateInterval" form:"warpUpdateInterval" validate:"gte=0"`
|
||||
}
|
||||
|
||||
// AllSettingView is the browser-safe settings read model. Secret values
|
||||
// are redacted from the embedded write model and represented by presence
|
||||
// flags so the UI can show configured/not configured state.
|
||||
type AllSettingView struct {
|
||||
AllSetting
|
||||
|
||||
@@ -148,7 +132,6 @@ type AllSettingView struct {
|
||||
HasSmtpPassword bool `json:"hasSmtpPassword"`
|
||||
}
|
||||
|
||||
// CheckValid validates all settings in the AllSetting struct, checking IP addresses, ports, SSL certificates, and other configuration values.
|
||||
func pathHasForbiddenChar(s string) bool {
|
||||
for _, r := range s {
|
||||
if r == '\\' || r == ' ' || r < 0x20 || r == 0x7f {
|
||||
@@ -260,3 +243,38 @@ func (s *AllSetting) CheckValid() error {
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type HostGroup struct {
|
||||
GroupId string `json:"groupId"`
|
||||
InboundIds []int `json:"inboundIds" validate:"required,min=1"`
|
||||
Hosts []string `json:"hosts" validate:"omitempty"`
|
||||
|
||||
SortOrder int `json:"sortOrder"`
|
||||
Remark string `json:"remark" validate:"required,max=256"`
|
||||
ServerDescription string `json:"serverDescription" validate:"omitempty,max=64"`
|
||||
IsDisabled bool `json:"isDisabled"`
|
||||
IsHidden bool `json:"isHidden"`
|
||||
Tags []string `json:"tags"`
|
||||
Port int `json:"port" validate:"gte=0,lte=65535"`
|
||||
Security string `json:"security" validate:"omitempty,oneof=same tls none reality"`
|
||||
Sni string `json:"sni"`
|
||||
HostHeader string `json:"hostHeader"`
|
||||
Path string `json:"path"`
|
||||
Alpn []string `json:"alpn"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
OverrideSniFromAddress bool `json:"overrideSniFromAddress"`
|
||||
KeepSniBlank bool `json:"keepSniBlank"`
|
||||
PinnedPeerCertSha256 []string `json:"pinnedPeerCertSha256"`
|
||||
VerifyPeerCertByName string `json:"verifyPeerCertByName"`
|
||||
AllowInsecure bool `json:"allowInsecure"`
|
||||
EchConfigList string `json:"echConfigList"`
|
||||
MuxParams string `json:"muxParams"`
|
||||
SockoptParams string `json:"sockoptParams"`
|
||||
FinalMask string `json:"finalMask"`
|
||||
VlessRoute string `json:"vlessRoute"`
|
||||
ExcludeFromSubTypes []string `json:"excludeFromSubTypes"`
|
||||
NodeGuids []string `json:"nodeGuids"`
|
||||
MihomoIpVersion string `json:"mihomoIpVersion" validate:"omitempty,oneof=dual ipv4 ipv6 ipv4-prefer ipv6-prefer"`
|
||||
MihomoX25519 bool `json:"mihomoX25519"`
|
||||
ShuffleHost bool `json:"shuffleHost"`
|
||||
}
|
||||
|
||||
+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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "ملاحظة",
|
||||
"serverDescription": "الوصف",
|
||||
"inbound": "الوارد",
|
||||
"inbound": "الواردات",
|
||||
"address": "العنوان",
|
||||
"port": "المنفذ",
|
||||
"endpoint": "النهاية",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"tags": "وسوم",
|
||||
"nodeGuids": "النودز",
|
||||
"excludeFromSubTypes": "استبعاد من الصيغ",
|
||||
"verifyPeerCertByName": "التحقق من شهادة النظير بالاسم"
|
||||
"verifyPeerCertByName": "التحقق من شهادة النظير بالاسم",
|
||||
"inheritAddress": "يرث العنوان"
|
||||
},
|
||||
"hints": {
|
||||
"address": "اتركه فارغاً ليرث عنوان الوارد نفسه.",
|
||||
|
||||
@@ -1016,7 +1016,7 @@
|
||||
"fields": {
|
||||
"remark": "Remark",
|
||||
"serverDescription": "Description",
|
||||
"inbound": "Inbound",
|
||||
"inbound": "Inbounds",
|
||||
"address": "Address",
|
||||
"port": "Port",
|
||||
"endpoint": "Endpoint",
|
||||
@@ -1043,7 +1043,8 @@
|
||||
"shuffleHost": "Shuffle host",
|
||||
"tags": "Tags",
|
||||
"nodeGuids": "Nodes",
|
||||
"excludeFromSubTypes": "Exclude from formats"
|
||||
"excludeFromSubTypes": "Exclude from formats",
|
||||
"inheritAddress": "Inherits"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Leave blank to inherit the inbound's own address.",
|
||||
@@ -1098,9 +1099,9 @@
|
||||
"toasts": {
|
||||
"list": "Failed to load hosts",
|
||||
"obtain": "Failed to load host",
|
||||
"add": "Add host",
|
||||
"update": "Update host",
|
||||
"delete": "Delete host",
|
||||
"add": "Host added successfully",
|
||||
"update": "Host updated successfully",
|
||||
"delete": "Host deleted successfully",
|
||||
"badTag": "Invalid tag",
|
||||
"badVlessRoute": "Enter a single number between 0 and 65535"
|
||||
}
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Notas",
|
||||
"serverDescription": "Descripción",
|
||||
"inbound": "Inbound",
|
||||
"inbound": "Inbounds",
|
||||
"address": "Dirección",
|
||||
"port": "Puerto",
|
||||
"endpoint": "Punto final",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "Barajar host",
|
||||
"tags": "Etiquetas",
|
||||
"nodeGuids": "Nodos",
|
||||
"excludeFromSubTypes": "Excluir de formatos"
|
||||
"excludeFromSubTypes": "Excluir de formatos",
|
||||
"inheritAddress": "Hereda dirección"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Déjalo en blanco para heredar la dirección propia del inbound.",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "نام",
|
||||
"serverDescription": "توضیحات",
|
||||
"inbound": "اینباند",
|
||||
"inbound": "اینباندها",
|
||||
"address": "آدرس",
|
||||
"port": "پورت",
|
||||
"endpoint": "نقطه پایانی",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "درهمسازی میزبان",
|
||||
"tags": "برچسبها",
|
||||
"nodeGuids": "نودها",
|
||||
"excludeFromSubTypes": "حذف از فرمتها"
|
||||
"excludeFromSubTypes": "حذف از فرمتها",
|
||||
"inheritAddress": "ارثبری آدرس"
|
||||
},
|
||||
"hints": {
|
||||
"address": "برای ارثبری آدرس خودِ اینباند خالی بگذارید.",
|
||||
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"tags": "Tag",
|
||||
"nodeGuids": "Node",
|
||||
"excludeFromSubTypes": "Kecualikan dari format",
|
||||
"verifyPeerCertByName": "Verifikasi sertifikat peer berdasarkan nama"
|
||||
"verifyPeerCertByName": "Verifikasi sertifikat peer berdasarkan nama",
|
||||
"inheritAddress": "Warisi alamat"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Biarkan kosong untuk mewarisi alamat inbound itu sendiri.",
|
||||
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "ホストをシャッフル",
|
||||
"tags": "タグ",
|
||||
"nodeGuids": "ノード",
|
||||
"excludeFromSubTypes": "形式から除外"
|
||||
"excludeFromSubTypes": "形式から除外",
|
||||
"inheritAddress": "アドレス継承"
|
||||
},
|
||||
"hints": {
|
||||
"address": "空欄にするとインバウンド自身のアドレスを継承します。",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Observação",
|
||||
"serverDescription": "Descrição",
|
||||
"inbound": "Entrada",
|
||||
"inbound": "Entradas",
|
||||
"address": "Endereço",
|
||||
"port": "Porta",
|
||||
"endpoint": "Endpoint",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "Embaralhar host",
|
||||
"tags": "Tags",
|
||||
"nodeGuids": "Nós",
|
||||
"excludeFromSubTypes": "Excluir dos formatos"
|
||||
"excludeFromSubTypes": "Excluir dos formatos",
|
||||
"inheritAddress": "Herda endereço"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Deixe em branco para herdar o próprio endereço da entrada.",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Примечание",
|
||||
"serverDescription": "Описание",
|
||||
"inbound": "Входящее",
|
||||
"inbound": "Входящие",
|
||||
"address": "Адрес",
|
||||
"port": "Порт",
|
||||
"endpoint": "Конечная точка",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "Перемешивать хост",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Узлы",
|
||||
"excludeFromSubTypes": "Исключить из форматов"
|
||||
"excludeFromSubTypes": "Исключить из форматов",
|
||||
"inheritAddress": "Наследует адрес"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Оставьте пустым, чтобы унаследовать собственный адрес входящего.",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Açıklama",
|
||||
"serverDescription": "Tanım",
|
||||
"inbound": "Gelen Bağlantı",
|
||||
"inbound": "Gelen Bağlantılar",
|
||||
"address": "Adres",
|
||||
"port": "Port",
|
||||
"endpoint": "Uç Nokta",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"tags": "Etiketler",
|
||||
"nodeGuids": "Düğümler",
|
||||
"excludeFromSubTypes": "Formatlardan hariç tut",
|
||||
"verifyPeerCertByName": "Peer sertifikasını ada göre doğrula"
|
||||
"verifyPeerCertByName": "Peer sertifikasını ada göre doğrula",
|
||||
"inheritAddress": "Adresi devralır"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Gelen bağlantının kendi adresini devralmak için boş bırakın.",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Примітка",
|
||||
"serverDescription": "Опис",
|
||||
"inbound": "Вхідний",
|
||||
"inbound": "Вхідні",
|
||||
"address": "Адреса",
|
||||
"port": "Порт",
|
||||
"endpoint": "Кінцева точка",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "Перемішувати host",
|
||||
"tags": "Теги",
|
||||
"nodeGuids": "Вузли",
|
||||
"excludeFromSubTypes": "Виключити з форматів"
|
||||
"excludeFromSubTypes": "Виключити з форматів",
|
||||
"inheritAddress": "Успадковує адресу"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Залиште порожнім, щоб успадкувати власну адресу вхідного.",
|
||||
|
||||
@@ -1894,7 +1894,7 @@
|
||||
"fields": {
|
||||
"remark": "Ghi chú",
|
||||
"serverDescription": "Mô tả",
|
||||
"inbound": "Inbound",
|
||||
"inbound": "Inbounds",
|
||||
"address": "Địa chỉ",
|
||||
"port": "Cổng",
|
||||
"endpoint": "Endpoint",
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "Xáo trộn host",
|
||||
"tags": "Tag",
|
||||
"nodeGuids": "Nút",
|
||||
"excludeFromSubTypes": "Loại trừ khỏi định dạng"
|
||||
"excludeFromSubTypes": "Loại trừ khỏi định dạng",
|
||||
"inheritAddress": "Kế thừa địa chỉ"
|
||||
},
|
||||
"hints": {
|
||||
"address": "Để trống để kế thừa địa chỉ của chính inbound.",
|
||||
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "随机打乱 Host",
|
||||
"tags": "标签",
|
||||
"nodeGuids": "节点",
|
||||
"excludeFromSubTypes": "从格式中排除"
|
||||
"excludeFromSubTypes": "从格式中排除",
|
||||
"inheritAddress": "继承地址"
|
||||
},
|
||||
"hints": {
|
||||
"address": "留空则继承入站自身的地址。",
|
||||
|
||||
@@ -1921,7 +1921,8 @@
|
||||
"shuffleHost": "隨機排序 Host",
|
||||
"tags": "標籤",
|
||||
"nodeGuids": "節點",
|
||||
"excludeFromSubTypes": "從格式中排除"
|
||||
"excludeFromSubTypes": "從格式中排除",
|
||||
"inheritAddress": "繼承地址"
|
||||
},
|
||||
"hints": {
|
||||
"address": "留空以繼承入站本身的地址。",
|
||||
|
||||
Reference in New Issue
Block a user