diff --git a/frontend/src/models/setting.ts b/frontend/src/models/setting.ts index 616cb1f..4a1f162 100644 --- a/frontend/src/models/setting.ts +++ b/frontend/src/models/setting.ts @@ -34,7 +34,6 @@ export class AllSetting { subPort = 2096; subPath = '/sub/'; subDomain = ''; - restartXrayOnClientDisable = true; subCertFile = ''; subKeyFile = ''; subUpdates = 12; diff --git a/frontend/src/pages/settings/GeneralTab.tsx b/frontend/src/pages/settings/GeneralTab.tsx index 415b6c3..7e84283 100644 --- a/frontend/src/pages/settings/GeneralTab.tsx +++ b/frontend/src/pages/settings/GeneralTab.tsx @@ -178,17 +178,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp /> - - updateSetting({ restartXrayOnClientDisable: v })} - /> - - CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT)) - AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT)) - AND (total <= 0 OR up + ? + down + ? < total) THEN enable::boolean - ELSE false - END` - } - return `CASE - WHEN ? THEN enable - WHEN (expiry_time <> CAST(? AS BIGINT) OR total <> CAST(? AS BIGINT)) - AND (expiry_time <= 0 OR expiry_time > CAST(? AS BIGINT)) - AND (total <= 0 OR up + ? + down + ? < total) THEN enable - ELSE 0 - END` -} - -// ClientTrafficExpiryMergeExpr: placeholder nodeExpiry once. Master absolute is -// kept; CAST avoids Postgres int4 inference on ms timestamps. -func ClientTrafficExpiryMergeExpr() string { - return `CASE - WHEN expiry_time > 0 THEN expiry_time - ELSE CAST(? AS BIGINT) - END` -} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index e9b9443..51fc2f7 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -183,13 +183,12 @@ type HistoryOfSeeders struct { const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000 const ( - ApiScopeAdmin = "admin" - ApiScopeMonitor = "monitor" - ApiScopeNodeSync = "node-sync" + ApiScopeAdmin = "admin" + ApiScopeMonitor = "monitor" ) func IsKnownApiScope(s string) bool { - return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync + return s == ApiScopeAdmin || s == ApiScopeMonitor } type ApiToken struct { diff --git a/internal/web/controller/api.go b/internal/web/controller/api.go index 113bbb6..4c569d3 100644 --- a/internal/web/controller/api.go +++ b/internal/web/controller/api.go @@ -35,18 +35,6 @@ func NewAPIController(g *gin.RouterGroup) *APIController { } func (a *APIController) checkAPIAuth(c *gin.Context) { - // A verified client certificate (a completed mTLS handshake) authenticates - // the caller, equivalent to a valid bearer token. api_authed must be set so - // the CSRF middleware lets cert-authed mutations through. - if c.Request.TLS != nil && len(c.Request.TLS.VerifiedChains) > 0 { - if u, err := a.userService.GetFirstUser(); err == nil { - session.SetAPIAuthUser(c, u) - } - c.Set("api_authed", true) - c.Set("api_token_scope", model.ApiScopeNodeSync) - c.Next() - return - } auth := c.GetHeader("Authorization") if after, ok := strings.CutPrefix(auth, "Bearer "); ok { tok := after @@ -79,33 +67,7 @@ var monitorScopeAllow = map[string]struct{}{ "/nodes/history/:id/:metric/:bucket": {}, } -// nodeSyncScopeAllow is the node-sync route/method allowlist relative to -// /panel/api; Gin patterns prevent concrete parameters broadening authority. -var nodeSyncScopeAllow = map[string]map[string]struct{}{ - "/server/status": {http.MethodGet: {}}, - "/inbounds/list": {http.MethodGet: {}}, - "/inbounds/add": {http.MethodPost: {}}, - "/inbounds/del/:id": {http.MethodPost: {}}, - "/inbounds/update/:id": {http.MethodPost: {}}, - "/clients/add": {http.MethodPost: {}}, - "/clients/del/:email": {http.MethodPost: {}}, - "/clients/:email/detach": {http.MethodPost: {}}, - "/clients/update/:email": {http.MethodPost: {}}, - "/server/getWebCertFiles": {http.MethodGet: {}}, - "/server/descendants": {http.MethodGet: {}}, - "/clients/resetTraffic/:email": {http.MethodPost: {}}, - "/inbounds/resetAllTraffics": {http.MethodPost: {}}, - "/inbounds/:id/resetTraffic": {http.MethodPost: {}}, - "/clients/onlinesByGuid": {http.MethodPost: {}}, - "/clients/onlines": {http.MethodPost: {}}, - "/clients/lastOnline": {http.MethodPost: {}}, - "/inbounds/pushClientTraffics": {http.MethodPost: {}}, - "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}}, - "/clients/clientIpsByGuid": {http.MethodPost: {}}, - "/hosts/list": {http.MethodGet: {}}, -} - -// enforceTokenScope applies explicit allowlists to monitor and node-sync tokens. +// enforceTokenScope applies the explicit allowlist to monitor tokens. // Admin tokens and session-login users retain their existing behavior. func (a *APIController) enforceTokenScope(c *gin.Context) { scopeVal, ok := c.Get("api_token_scope") @@ -131,13 +93,6 @@ func (a *APIController) enforceTokenScope(c *gin.Context) { c.Next() return } - case model.ApiScopeNodeSync: - if methods, allowed := nodeSyncScopeAllow[rel]; allowed { - if _, allowedMethod := methods[c.Request.Method]; allowedMethod { - c.Next() - return - } - } default: deny() return diff --git a/internal/web/controller/api_auth_test.go b/internal/web/controller/api_auth_test.go index 526adb3..c09f503 100644 --- a/internal/web/controller/api_auth_test.go +++ b/internal/web/controller/api_auth_test.go @@ -2,12 +2,10 @@ package controller import ( "crypto/tls" - "crypto/x509" "net/http" "net/http/cookiejar" "net/http/httptest" "path/filepath" - "reflect" "testing" "github.com/gin-contrib/sessions" @@ -110,100 +108,9 @@ func TestCheckAPIAuth_BearerSuccess(t *testing.T) { } } -// TestCheckAPIAuth_AcceptsVerifiedClientCert ensures verified mTLS authenticates -// as node-sync rather than bypassing scope checks as admin. -func TestCheckAPIAuth_AcceptsVerifiedClientCert(t *testing.T) { - engine, _ := newAPIAuthTestEngine(t) - - req := httptest.NewRequest(http.MethodGet, "/panel/api/server/status", nil) - req.TLS = &tls.ConnectionState{ - VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, - } - w := httptest.NewRecorder() - engine.ServeHTTP(w, req) - - if w.Code != http.StatusOK { - t.Fatalf("status = %d, want 200; body=%s", w.Code, w.Body.String()) - } - if got := w.Body.String(); got != `{"api_authed":true,"scope":"node-sync"}` { - t.Fatalf("body = %s, want node-sync scope", got) - } - - forbidden := httptest.NewRequest(http.MethodPost, "/panel/api/server/updatePanel", nil) - forbidden.TLS = &tls.ConnectionState{ - VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, - } - w = httptest.NewRecorder() - engine.ServeHTTP(w, forbidden) - if w.Code != http.StatusForbidden { - t.Fatalf("updatePanel status = %d, want 403; body=%s", w.Code, w.Body.String()) - } -} - -func TestNodeSyncScopeAllowlistMatchesRemoteInventory(t *testing.T) { - expected := map[string]map[string]struct{}{ - "/server/status": {http.MethodGet: {}}, - "/inbounds/list": {http.MethodGet: {}}, - "/inbounds/add": {http.MethodPost: {}}, - "/inbounds/del/:id": {http.MethodPost: {}}, - "/inbounds/update/:id": {http.MethodPost: {}}, - "/clients/add": {http.MethodPost: {}}, - "/clients/del/:email": {http.MethodPost: {}}, - "/clients/:email/detach": {http.MethodPost: {}}, - "/clients/update/:email": {http.MethodPost: {}}, - "/server/getWebCertFiles": {http.MethodGet: {}}, - "/server/descendants": {http.MethodGet: {}}, - "/clients/resetTraffic/:email": {http.MethodPost: {}}, - "/inbounds/resetAllTraffics": {http.MethodPost: {}}, - "/inbounds/:id/resetTraffic": {http.MethodPost: {}}, - "/clients/onlinesByGuid": {http.MethodPost: {}}, - "/clients/onlines": {http.MethodPost: {}}, - "/clients/lastOnline": {http.MethodPost: {}}, - "/inbounds/pushClientTraffics": {http.MethodPost: {}}, - "/server/clientIps": {http.MethodGet: {}, http.MethodPost: {}}, - "/clients/clientIpsByGuid": {http.MethodPost: {}}, - "/hosts/list": {http.MethodGet: {}}, - } - if !reflect.DeepEqual(nodeSyncScopeAllow, expected) { - t.Fatalf("node-sync allowlist drift:\n got: %#v\nwant: %#v", nodeSyncScopeAllow, expected) - } - if _, ok := nodeSyncScopeAllow["/server/updatePanel"]; ok { - t.Fatal("node-sync must not include /server/updatePanel") - } -} - -func TestNodeSyncScopeUsesFullPathPatterns(t *testing.T) { - engine, _ := newAPIAuthTestEngine(t) - cases := []struct { - name string - method string - path string - want int - }{ - {"detach email parameter", http.MethodPost, "/panel/api/clients/alice@example.com/detach", http.StatusOK}, - {"reset inbound id parameter", http.MethodPost, "/panel/api/inbounds/42/resetTraffic", http.StatusOK}, - {"client IP by guid endpoint", http.MethodPost, "/panel/api/clients/clientIpsByGuid", http.StatusOK}, - {"update panel forbidden", http.MethodPost, "/panel/api/server/updatePanel", http.StatusForbidden}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - req := httptest.NewRequest(tc.method, tc.path, nil) - req.TLS = &tls.ConnectionState{ - VerifiedChains: [][]*x509.Certificate{{&x509.Certificate{}}}, - } - w := httptest.NewRecorder() - engine.ServeHTTP(w, req) - if w.Code != tc.want { - t.Fatalf("status = %d, want %d; body=%s", w.Code, tc.want, w.Body.String()) - } - }) - } -} - // TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough asserts a TLS request with no // verified client chain is NOT treated as authenticated (it falls through to the -// bearer/session paths) — so the cert branch can't accidentally authorize plain -// browser HTTPS. +// bearer/session paths) — a plain browser HTTPS connection must never authorize. func TestCheckAPIAuth_EmptyVerifiedChainsFallsThrough(t *testing.T) { engine, _ := newAPIAuthTestEngine(t) diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index f4f67dd..d5d3927 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -81,7 +81,6 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.DELETE("/hwids/:email/:id", a.deleteHwid) g.POST("/onlines", a.onlines) g.POST("/onlinesByGuid", a.onlinesByGuid) - g.POST("/clientIpsByGuid", a.clientIpsByGuid) g.POST("/activeInbounds", a.activeInbounds) g.POST("/lastOnline", a.lastOnline) } @@ -523,11 +522,6 @@ func (a *ClientController) getIps(c *gin.Context) { jsonObj(c, infos, err) } -func (a *ClientController) clientIpsByGuid(c *gin.Context) { - data, err := a.inboundService.GetClientIpsByGuid() - jsonObj(c, data, err) -} - func (a *ClientController) clearIps(c *gin.Context) { email := c.Param("email") if err := a.inboundService.ClearClientIps(email); err != nil { diff --git a/internal/web/controller/inbound.go b/internal/web/controller/inbound.go index 5d66240..33de62b 100644 --- a/internal/web/controller/inbound.go +++ b/internal/web/controller/inbound.go @@ -15,7 +15,6 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/session" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" - "github.com/mhsanaei/3x-ui/v3/internal/xray" "github.com/gin-gonic/gin" ) @@ -84,7 +83,6 @@ func (a *InboundController) initRouter(g *gin.RouterGroup) { g.POST("/resetAllTraffics", a.resetAllTraffics) g.POST("/import", a.importInbound) g.POST("/:id/fallbacks", a.setFallbacks) - g.POST("/pushClientTraffics", a.pushClientTraffics) } // getInbounds retrieves the list of inbounds for the logged-in user. @@ -474,24 +472,6 @@ func (a *InboundController) resetAllTraffics(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.resetAllTrafficSuccess"), nil) } -// pushClientTraffics receives a master panel's aggregated per-client usage -// (see InboundService.AcceptGlobalTraffic for the storage semantics). -func (a *InboundController) pushClientTraffics(c *gin.Context) { - var req struct { - MasterGuid string `json:"masterGuid"` - Traffics []*xray.ClientTraffic `json:"traffics"` - } - if err := c.ShouldBindJSON(&req); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - if err := a.inboundService.AcceptGlobalTraffic(req.MasterGuid, req.Traffics); err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonMsg(c, "success", nil) -} - // importInbound imports an inbound configuration from provided data. func (a *InboundController) importInbound(c *gin.Context) { inbound := &model.Inbound{} diff --git a/internal/web/controller/node.go b/internal/web/controller/node.go index 5856f18..823c33b 100644 --- a/internal/web/controller/node.go +++ b/internal/web/controller/node.go @@ -2,7 +2,6 @@ package controller import ( "context" - "errors" "fmt" "slices" "strconv" @@ -31,62 +30,15 @@ func NewNodeController(g *gin.RouterGroup) *NodeController { func (a *NodeController) initRouter(g *gin.RouterGroup) { g.GET("/list", a.list) g.GET("/get/:id", a.get) - g.GET("/webCert/:id", a.webCert) 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("/test", a.test) - g.POST("/certFingerprint", a.certFingerprint) - g.POST("/inbounds", a.inbounds) g.POST("/probe/:id", a.probe) g.POST("/updatePanel", a.updatePanel) g.GET("/history/:id/:metric/:bucket", a.history) - g.POST("/mtls/ca", a.mtlsCa) - g.POST("/mtls/trustCA", a.setMtlsTrustCA) - g.POST("/mtls/reloadClient", a.reloadMtlsClient) -} - -// reloadMtlsClient validates the credential currently stored by the master and -// closes cached mTLS pools so subsequent node requests present the new leaf. -func (a *NodeController) reloadMtlsClient(c *gin.Context) { - if err := a.nodeService.ReloadMasterMtlsClient(); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), err) - return - } - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.reloadMtls"), nil) -} - -// mtlsCa returns this panel's node-auth CA certificate (public) to paste into a -// node's mTLS trust setting. It lazily mints the CA + master client cert on -// first call. -func (a *NodeController) mtlsCa(c *gin.Context) { - caCert, err := a.nodeService.NodeMtlsCaCert() - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) - return - } - jsonObj(c, gin.H{"caCert": caCert}, nil) -} - -// setMtlsTrustCA stores the CA this panel trusts for incoming node-API client -// certificates (this panel acting as a node). An empty value disables it. -// Applied on the next panel restart. -func (a *NodeController) setMtlsTrustCA(c *gin.Context) { - var req struct { - CaCert string `json:"caCert" form:"caCert"` - } - if err := c.ShouldBind(&req); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err) - return - } - if err := a.nodeService.SetNodeMtlsTrustCA(req.CaCert); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), err) - return - } - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.saveMtls"), nil) } func (a *NodeController) list(c *gin.Context) { @@ -112,91 +64,44 @@ func (a *NodeController) get(c *gin.Context) { jsonObj(c, n, nil) } -// webCert returns the node's own web TLS certificate/key file paths so the -// inbound form's "Set Cert from Panel" can fill paths that exist on the node. -func (a *NodeController) webCert(c *gin.Context) { - id, err := strconv.Atoi(c.Param("id")) - if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) - return - } - files, err := a.nodeService.GetWebCertFiles(id) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) - return - } - jsonObj(c, files, nil) -} - -func (a *NodeController) ensureReachable(c *gin.Context, n *service.NodeMutationRequest, id int) error { - runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(id, n) - if err != nil { - return err - } - ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) - defer cancel() - if _, err := a.nodeService.Probe(ctx, runtimeNode); err != nil { - return errors.New(service.FriendlyProbeError(err.Error())) - } - return nil -} - func (a *NodeController) add(c *gin.Context) { n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) if !ok { return } - isAgent := n.NodeType == "agent" - // Agent nodes have no panel to probe; they authenticate via agent token. - if n.OutboundTag == "" && !isAgent { - if err := a.ensureReachable(c, n, 0); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) - return - } - } view, err := a.nodeService.CreateFromRequest(n) if err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } - if n.OutboundTag != "" { - if err := a.ensureReachable(c, n, view.Id); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) - return - } - } - // For agent nodes, issue an agent token bound to the new node and - // return it so the UI can display it (shown only once). - if isAgent { - view.NodeType = "agent" - nodeID := view.Id - token, agentID, err := issueAgentToken(&nodeID, n.Name) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) - return - } - // Probe-only (V2.1.0): preset the flag on the freshly issued - // agent row when the create form asked for it, so the very - // first config poll already serves no core payload. A failed - // preset only logs — the node and token are usable, and the - // flag can still be flipped from the edit form. - if n.ProbeOnly != nil && *n.ProbeOnly { - if err := database.GetDB().Model(&model.AgentNode{}). - Where("id = ?", agentID). - Update("probe_only", true).Error; err != nil { - logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err) - } else { - view.ProbeOnly = true - } - } - jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{ - "node": view, - "agentToken": token, - "nodeType": "agent", - }, nil) + // Every node is agent-managed: issue an agent token bound to the + // new node and return it so the UI can display it (shown only once). + view.NodeType = "agent" + nodeID := view.Id + token, agentID, err := issueAgentToken(&nodeID, n.Name) + if err != nil { + jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.add"), err) return } - jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, nil) + // Probe-only (V2.1.0): preset the flag on the freshly issued + // agent row when the create form asked for it, so the very + // first config poll already serves no core payload. A failed + // preset only logs — the node and token are usable, and the + // flag can still be flipped from the edit form. + if n.ProbeOnly != nil && *n.ProbeOnly { + if err := database.GetDB().Model(&model.AgentNode{}). + Where("id = ?", agentID). + Update("probe_only", true).Error; err != nil { + logger.Warningf("node add: preset probe_only for agent %d failed: %v", agentID, err) + } else { + view.ProbeOnly = true + } + } + jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), gin.H{ + "node": view, + "agentToken": token, + "nodeType": "agent", + }, nil) } func (a *NodeController) update(c *gin.Context) { @@ -209,30 +114,10 @@ func (a *NodeController) update(c *gin.Context) { if !ok { return } - old, err := a.nodeService.GetById(id) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) - return - } - // Agent nodes have no panel to probe and no panel API token; editing one - // must skip the reachability check exactly like creation does. - isAgent := n.NodeType == "agent" || a.nodeService.IsAgentManaged(id) - if !isAgent && n.OutboundTag == "" && old.OutboundTag == "" && (!n.ClearApiToken || n.Enable) { - if err := a.ensureReachable(c, n, id); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) - return - } - } if err := a.nodeService.UpdateFromRequest(id, n); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } - if n.OutboundTag != old.OutboundTag { - if err := a.ensureReachable(c, n, id); err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) - return - } - } jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil) } @@ -262,79 +147,13 @@ func (a *NodeController) setEnable(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } - n, err := a.nodeService.GetById(id) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) - return - } if err := a.nodeService.SetEnable(id, body.Enable); err != nil { jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), err) return } - if n.OutboundTag != "" { - } jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.update"), nil) } -func (a *NodeController) inbounds(c *gin.Context) { - n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) - if !ok { - return - } - runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.obtain"), err) - return - } - ctx, cancel := context.WithTimeout(c.Request.Context(), 10*time.Second) - defer cancel() - options, err := a.nodeService.GetRemoteInboundOptions(ctx, runtimeNode) - jsonObj(c, options, err) -} - -func (a *NodeController) test(c *gin.Context) { - n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) - if !ok { - return - } - runtimeNode, err := a.nodeService.RuntimeNodeFromRequest(n.Id, n) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) - defer cancel() - var patch service.HeartbeatPatch - if runtimeNode.OutboundTag != "" { - patch, err = a.nodeService.ProbeWithOutbound(ctx, runtimeNode, runtimeNode.OutboundTag) - } else { - patch, err = a.nodeService.Probe(ctx, runtimeNode) - } - jsonObj(c, patch.ToUI(err == nil), nil) -} - -func (a *NodeController) certFingerprint(c *gin.Context) { - n, ok := middleware.BindAndValidate[service.NodeMutationRequest](c) - if !ok { - return - } - runtimeNode, err := a.nodeService.NodeFromRequestForCertificate(n) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) - return - } - - ctx, cancel := context.WithTimeout(c.Request.Context(), 6*time.Second) - defer cancel() - fp, err := a.nodeService.FetchCertFingerprint(ctx, runtimeNode) - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.nodes.toasts.test"), err) - return - } - jsonObj(c, fp, nil) -} - func (a *NodeController) probe(c *gin.Context) { id, err := strconv.Atoi(c.Param("id")) if err != nil { diff --git a/internal/web/controller/node_credentials_writeonly_test.go b/internal/web/controller/node_credentials_writeonly_test.go deleted file mode 100644 index 1954470..0000000 --- a/internal/web/controller/node_credentials_writeonly_test.go +++ /dev/null @@ -1,241 +0,0 @@ -package controller - -import ( - "encoding/json" - "errors" - "net" - "net/http" - "net/http/httptest" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/gin-gonic/gin" - - "gorm.io/gorm" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/locale" -) - -func newNodeCredentialTestEngine(t *testing.T) *gin.Engine { - t.Helper() - gin.SetMode(gin.TestMode) - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - - engine := gin.New() - engine.Use(func(c *gin.Context) { - c.Set("I18n", func(_ locale.I18nType, key string, _ ...string) string { return key }) - c.Next() - }) - NewNodeController(engine.Group("/panel/api/nodes")) - return engine -} - -func TestNodeControllerResponsesDoNotLeakApiToken(t *testing.T) { - engine := newNodeCredentialTestEngine(t) - if err := database.GetDB().Create(&model.Node{ - Name: "stored-node", - Scheme: "https", - Address: "example.com", - Port: 2053, - BasePath: "/", - ApiToken: "stored-secret-token", - Enable: true, - }).Error; err != nil { - t.Fatalf("seed node: %v", err) - } - - for _, path := range []string{"/panel/api/nodes/list", "/panel/api/nodes/get/1"} { - w := httptest.NewRecorder() - engine.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) - if w.Code != http.StatusOK { - t.Fatalf("%s status = %d body=%s", path, w.Code, w.Body.String()) - } - body := w.Body.String() - if strings.Contains(body, "stored-secret-token") || strings.Contains(body, "apiToken") { - t.Fatalf("%s leaked api token: %s", path, body) - } - if !strings.Contains(body, `"hasApiToken":true`) { - t.Fatalf("%s did not expose credential presence: %s", path, body) - } - } -} - -func TestNodeControllerProbeReportsHeartbeatPersistenceFailure(t *testing.T) { - engine := newNodeCredentialTestEngine(t) - remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.6.0","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) - })) - defer remote.Close() - host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) - if err != nil { - t.Fatalf("split remote addr: %v", err) - } - port, err := strconv.Atoi(portString) - if err != nil { - t.Fatalf("parse remote port: %v", err) - } - node := &model.Node{Scheme: "http", Address: host, Port: port, BasePath: "/", Enable: true, AllowPrivateAddress: true} - if err := database.GetDB().Create(node).Error; err != nil { - t.Fatalf("seed node: %v", err) - } - - db := database.GetDB() - const callback = "test:fail_node_heartbeat_update" - errInjected := errors.New("injected heartbeat persistence failure") - if err := db.Callback().Update().Before("gorm:update").Register(callback, func(tx *gorm.DB) { - if tx.Statement != nil && tx.Statement.Table == "nodes" { - tx.AddError(errInjected) - } - }); err != nil { - t.Fatalf("register update callback: %v", err) - } - t.Cleanup(func() { - if err := db.Callback().Update().Remove(callback); err != nil { - t.Errorf("remove update callback: %v", err) - } - }) - - w := httptest.NewRecorder() - engine.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/panel/api/nodes/probe/"+strconv.Itoa(node.Id), nil)) - if !strings.Contains(w.Body.String(), `"success":false`) { - t.Fatalf("probe reported success despite heartbeat persistence failure: %s", w.Body.String()) - } - if !strings.Contains(w.Body.String(), errInjected.Error()) { - t.Fatalf("probe response omitted persistence error: %s", w.Body.String()) - } -} - -func TestNodeControllerAddAcceptsTokenButReturnsView(t *testing.T) { - engine := newNodeCredentialTestEngine(t) - - remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/panel/api/server/status" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer input-secret-token" { - t.Fatalf("Authorization = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) - })) - defer remote.Close() - host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) - if err != nil { - t.Fatalf("split remote addr: %v", err) - } - port, err := strconv.Atoi(portString) - if err != nil { - t.Fatalf("parse remote port: %v", err) - } - - payload := map[string]any{ - "name": "added-node", - "scheme": "http", - "address": host, - "port": port, - "basePath": "/", - "apiToken": "input-secret-token", - "enable": true, - "allowPrivateAddress": true, - } - raw, _ := json.Marshal(payload) - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/add", strings.NewReader(string(raw))) - req.Header.Set("Content-Type", "application/json") - engine.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("add status = %d body=%s", w.Code, w.Body.String()) - } - body := w.Body.String() - if strings.Contains(body, "input-secret-token") || strings.Contains(body, "apiToken") { - t.Fatalf("add response leaked api token: %s", body) - } - if !strings.Contains(body, `"hasApiToken":true`) { - t.Fatalf("add response did not expose credential presence: %s", body) - } - - var stored model.Node - if err := database.GetDB().Where("name = ?", "added-node").First(&stored).Error; err != nil { - t.Fatalf("load stored node: %v", err) - } - if stored.ApiToken != "input-secret-token" { - t.Fatalf("stored token = %q, want input-secret-token", stored.ApiToken) - } -} - -func TestNodeControllerUpdateBlankApiTokenKeepsStoredToken(t *testing.T) { - engine := newNodeCredentialTestEngine(t) - - remote := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/panel/api/server/status" { - t.Fatalf("unexpected path: %s", r.URL.Path) - } - if got := r.Header.Get("Authorization"); got != "Bearer stored-secret-token" { - t.Fatalf("Authorization = %q", got) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":1,"mem":{"current":1,"total":2},"xray":{"version":"1","state":"running"},"panelVersion":"v3.4.1","panelGuid":"guid","uptime":7,"netIO":{"up":3,"down":4}}}`)) - })) - defer remote.Close() - host, portString, err := net.SplitHostPort(strings.TrimPrefix(remote.URL, "http://")) - if err != nil { - t.Fatalf("split remote addr: %v", err) - } - port, err := strconv.Atoi(portString) - if err != nil { - t.Fatalf("parse remote port: %v", err) - } - node := &model.Node{ - Name: "stored-node", - Scheme: "http", - Address: host, - Port: port, - BasePath: "/", - ApiToken: "stored-secret-token", - Enable: true, - AllowPrivateAddress: true, - } - if err := database.GetDB().Create(node).Error; err != nil { - t.Fatalf("seed node: %v", err) - } - - payload := map[string]any{ - "name": "stored-node-renamed", - "scheme": "http", - "address": host, - "port": port, - "basePath": "/", - "apiToken": "", - "enable": true, - "allowPrivateAddress": true, - } - raw, _ := json.Marshal(payload) - w := httptest.NewRecorder() - req := httptest.NewRequest(http.MethodPost, "/panel/api/nodes/update/"+strconv.Itoa(node.Id), strings.NewReader(string(raw))) - req.Header.Set("Content-Type", "application/json") - engine.ServeHTTP(w, req) - if w.Code != http.StatusOK { - t.Fatalf("update status = %d body=%s", w.Code, w.Body.String()) - } - - var stored model.Node - if err := database.GetDB().Where("id = ?", node.Id).First(&stored).Error; err != nil { - t.Fatalf("load stored node: %v", err) - } - if stored.ApiToken != "stored-secret-token" { - t.Fatalf("blank update changed token to %q", stored.ApiToken) - } - if stored.Name != "stored-node-renamed" { - t.Fatalf("stored name = %q, want stored-node-renamed", stored.Name) - } -} diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index a70f4ba..12984a9 100644 --- a/internal/web/controller/server.go +++ b/internal/web/controller/server.go @@ -7,7 +7,6 @@ import ( "slices" "strconv" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/entity" "github.com/mhsanaei/3x-ui/v3/internal/web/global" @@ -43,17 +42,13 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.GET("/history/:metric/:bucket", a.getOverviewHistoryBucket) g.GET("/getPanelUpdateInfo", a.getPanelUpdateInfo) g.GET("/getUpdateStatus", a.getUpdateStatus) - g.GET("/getConfigJson", a.getConfigJson) g.GET("/getDb", a.getDb) g.GET("/getMigration", a.getMigration) g.GET("/getNewUUID", a.getNewUUID) - g.GET("/getWebCertFiles", a.getWebCertFiles) - g.GET("/descendants", a.descendants) g.GET("/getNewX25519Cert", a.getNewX25519Cert) g.GET("/getNewmldsa65", a.getNewmldsa65) g.GET("/getNewmlkem768", a.getNewmlkem768) g.GET("/getNewVlessEnc", a.getNewVlessEnc) - g.GET("/clientIps", a.getClientIps) g.GET("/fail2banStatus", a.getFail2banStatus) g.GET("/outboundDirectory", a.getOutboundDirectory) @@ -65,7 +60,6 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) { g.POST("/getRemoteCertHash", a.getRemoteCertHash) g.POST("/scanRealityTarget", a.scanRealityTarget) g.POST("/scanRealityTargets", a.scanRealityTargets) - g.POST("/clientIps", a.setClientIps) } // startTask registers the @2s ticker that refreshes server status and pushes @@ -164,16 +158,6 @@ func (a *ServerController) setUpdateChannel(c *gin.Context) { jsonMsg(c, I18nWeb(c, "pages.index.updateChannelChanged"), err) } -// getConfigJson retrieves the Xray configuration as JSON. -func (a *ServerController) getConfigJson(c *gin.Context) { - configJson, err := a.serverService.GetConfigJson() - if err != nil { - jsonMsg(c, I18nWeb(c, "pages.index.getConfigError"), err) - return - } - jsonObj(c, configJson, nil) -} - // getDb downloads the database file. func (a *ServerController) getDb(c *gin.Context) { db, err := a.serverService.GetDb() @@ -230,32 +214,6 @@ func (a *ServerController) importDB(c *gin.Context) { jsonObj(c, I18nWeb(c, "pages.index.importDatabaseSuccess"), nil) } -// descendants publishes read-only summaries of the nodes this panel manages so -// a parent panel can surface them as transitive sub-nodes in a chained -// topology. Called by the parent via the node's API token (#4983). -func (a *ServerController) descendants(c *gin.Context) { - data, err := (&service.NodeService{}).LocalDescendants() - jsonObj(c, data, err) -} - -// getWebCertFiles returns this panel's own web TLS certificate and key file -// paths. The central panel calls it on a node (via the node's API token) so -// "Set Cert from Panel" can fill a node-assigned inbound with paths that exist -// on the node's filesystem instead of the central panel's — see issue #4854. -func (a *ServerController) getWebCertFiles(c *gin.Context) { - certFile, err := a.settingService.GetCertFile() - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - keyFile, err := a.settingService.GetKeyFile() - if err != nil { - jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) - return - } - jsonObj(c, gin.H{"webCertFile": certFile, "webKeyFile": keyFile}, nil) -} - // getNewX25519Cert generates a new X25519 certificate. func (a *ServerController) getNewX25519Cert(c *gin.Context) { cert, err := a.serverService.GetNewX25519Cert() @@ -419,18 +377,3 @@ func (a *ServerController) getNewmlkem768(c *gin.Context) { } jsonObj(c, out, nil) } - -func (a *ServerController) getClientIps(c *gin.Context) { - ips, err := (&service.InboundService{}).GetAllInboundClientIps() - jsonObj(c, ips, err) -} - -func (a *ServerController) setClientIps(c *gin.Context) { - var ips []model.InboundClientIps - if err := c.ShouldBindJSON(&ips); err != nil { - jsonMsg(c, "invalid data", err) - return - } - err := (&service.InboundService{}).MergeInboundClientIps(ips) - jsonMsg(c, "Client IPs merged", err) -} diff --git a/internal/web/controller/setting_test.go b/internal/web/controller/setting_test.go index 5f00590..6ebb543 100644 --- a/internal/web/controller/setting_test.go +++ b/internal/web/controller/setting_test.go @@ -57,7 +57,7 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) { t.Fatalf("InitDB: %v", err) } t.Cleanup(func() { _ = database.CloseDB() }) - row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeNodeSync} + row := &model.ApiToken{Name: "route-scope", Token: crypto.HashTokenSHA256("token"), Enabled: true, Scope: model.ApiScopeMonitor} if err := database.GetDB().Create(row).Error; err != nil { t.Fatalf("seed token: %v", err) } @@ -86,4 +86,3 @@ func TestAPITokenMutationRoutesEnforceExpectedScope(t *testing.T) { t.Fatal("token was disabled by wrong scope") } } - diff --git a/internal/web/entity/entity.go b/internal/web/entity/entity.go index d2bf0ee..a82b52a 100644 --- a/internal/web/entity/entity.go +++ b/internal/web/entity/entity.go @@ -63,7 +63,6 @@ type AllSetting struct { SubCertFile string `json:"subCertFile" form:"subCertFile"` SubKeyFile string `json:"subKeyFile" form:"subKeyFile"` SubUpdates int `json:"subUpdates" form:"subUpdates" validate:"gte=0,lte=525600"` - RestartXrayOnClientDisable bool `json:"restartXrayOnClientDisable" form:"restartXrayOnClientDisable"` // TrafficStatsSwitches (ITEM6 §4.3.1): the whole-network traffic // accounting switches as one JSON string // ({inboundUplink,inboundDownlink,outboundUplink,outboundDownlink}), diff --git a/internal/web/job/node_heartbeat_job.go b/internal/web/job/node_heartbeat_job.go index 3bb0632..b144a8d 100644 --- a/internal/web/job/node_heartbeat_job.go +++ b/internal/web/job/node_heartbeat_job.go @@ -1,7 +1,6 @@ package job import ( - "context" "strconv" "sync" "time" @@ -80,40 +79,13 @@ func (j *NodeHeartbeatJob) Run() { } func (j *NodeHeartbeatJob) probeOne(n *model.Node) { - // Agent-managed nodes are heartbeated via the agent token, not the - // legacy panel probe. Skip the probe so we don't clobber the - // agent-driven online status — but do expire that status when the - // agent stops heartbeating, or an uninstalled agent would leave its - // node showing online forever. - var agentCount int64 - _ = database.GetDB().Model(&model.AgentNode{}).Where("node_id = ?", n.Id).Count(&agentCount).Error - if agentCount > 0 { - j.expireStaleAgentNode(n) - return - } - ctx, cancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout) - defer cancel() - prevStatus := n.Status - patch, err := j.nodeService.Probe(ctx, n) - if err != nil { - patch.Status = "offline" - } else { - patch.Status = "online" - } - if updErr := j.nodeService.UpdateHeartbeat(n.Id, patch); updErr != nil { - logger.Warning("node heartbeat: update node", n.Id, "failed:", updErr) - } - publishNodeTransition(n, prevStatus, patch) - // Learn the nodes this node manages so the panel can surface them as - // transitive sub-nodes (#4983). Fresh context — the probe budget above may - // be spent. Drop them when the node is unreachable. - if patch.Status == "online" { - dctx, dcancel := context.WithTimeout(context.Background(), nodeHeartbeatRequestTimeout) - j.nodeService.RefreshDescendants(dctx, n) - dcancel() - } else { - j.nodeService.ClearDescendants(n.Id) - } + // Every node is agent-managed: it is heartbeated via the agent + // token, never dialed. The job's only duty is to expire the + // agent-driven online status when the agent stops heartbeating, + // or an uninstalled agent would leave its node showing online + // forever. A node with no bound agent never checked in and is + // left alone. + j.expireStaleAgentNode(n) } // agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit diff --git a/internal/web/job/node_traffic_sync_agent_online_test.go b/internal/web/job/node_traffic_sync_agent_online_test.go deleted file mode 100644 index 917193e..0000000 --- a/internal/web/job/node_traffic_sync_agent_online_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package job - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" -) - -// REPRO (V1.0.2 fix 2): agent nodes are push-only — their online clients -// arrive via agent traffic reports (InboundService.SetAgentOnlineClients) -// and are keyed exactly where the node list looks them up (the service-level -// alignment is pinned by TestReproAgentNodeOnlineEmailsInNodeList). But the -// traffic sync job's pull loop treats every enabled+online node as a panel: -// for an agent node the snapshot fetch can never succeed (address is the -// literal placeholder "agent", there is no panel API behind it), and the -// fetch-failure branch calls ClearNodeOnlineClients — wiping the agent's -// pushed online state on every tick. The heartbeat job already skips -// agent-managed nodes for probing; the sync job must not pull them either. -func TestSyncOne_AgentNodeKeepsReportedOnlines(t *testing.T) { - setupIntegrationDB(t) - db := database.GetDB() - - node := &model.Node{ - Name: "agent-sync-node", - Address: "agent", - Port: 2053, - Enable: true, - Status: "online", - } - if err := db.Create(node).Error; err != nil { - t.Fatalf("create node: %v", err) - } - if err := db.Create(&model.AgentNode{ - TokenHash: "hash-agent-sync-node", - Name: "agent-sync-node", - NodeID: &node.Id, - }).Error; err != nil { - t.Fatalf("create agent binding: %v", err) - } - // Production form: the agent node actually hosts inbounds (without any - // node-bound inbound in the DB, NodeService.GetAll takes its early - // return and never fills per-node stats at all). - if err := db.Create(&model.Inbound{ - Tag: "agent-sync-in-1", Enable: true, Port: 46994, Protocol: model.VLESS, - Settings: `{"clients":[]}`, NodeID: &node.Id, - }).Error; err != nil { - t.Fatalf("create inbound: %v", err) - } - - inboundSvc := service.InboundService{} - inboundSvc.ClearNodeOnlineClients(node.Id) - t.Cleanup(func() { inboundSvc.ClearNodeOnlineClients(node.Id) }) - - inboundSvc.SetAgentOnlineClients(node.Id, []string{"agent-online@example.com"}) - if got := inboundSvc.GetOnlineClients(); len(got) != 1 { - t.Fatalf("precondition: online union = %v, want the agent-reported email", got) - } - - j := NewNodeTrafficSyncJob() - j.syncOne(runtime.NewManager(), node, false) - - byGuid := inboundSvc.GetOnlineClientsByGuid() - found := false - for _, emails := range byGuid { - for _, e := range emails { - if e == "agent-online@example.com" { - found = true - } - } - } - if !found { - t.Fatalf("agent onlines wiped by sync tick: byGuid = %v", byGuid) - } - - views, err := (&service.NodeService{}).GetNodeTreeView() - if err != nil { - t.Fatalf("GetNodeTreeView: %v", err) - } - for _, v := range views { - if v.Id == node.Id { - if len(v.OnlineEmails) != 1 || v.OnlineEmails[0] != "agent-online@example.com" { - t.Fatalf("node view online emails = %v, want [agent-online@example.com]", v.OnlineEmails) - } - } - } -} diff --git a/internal/web/job/node_traffic_sync_job.go b/internal/web/job/node_traffic_sync_job.go index 05ca60b..4b912ee 100644 --- a/internal/web/job/node_traffic_sync_job.go +++ b/internal/web/job/node_traffic_sync_job.go @@ -1,27 +1,16 @@ package job import ( - "context" - "strings" "sync" "time" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/util/common" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/web/service" "github.com/mhsanaei/3x-ui/v3/internal/web/websocket" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) const ( - nodeTrafficSyncConcurrency = 8 - nodeTrafficSyncRequestTimeout = 4 * time.Second - nodeReconcileTimeout = 30 * time.Second - nodeClientIpSyncInterval = 10 * time.Second - nodeClientIpSyncTimeout = 6 * time.Second - nodeGlobalPushInterval = 30 * time.Second // nodeInboundSpeedWindowMs is the poll window node-inbound speed deltas are // normalized to; it MUST match the dashboard's TRAFFIC_POLL_INTERVAL_S (5s), // the fixed divisor the frontend applies to turn a delta into a rate. @@ -34,20 +23,16 @@ type inboundSample struct { up, down, at int64 } +// NodeTrafficSyncJob is the fleet-wide traffic tick. In the agent era +// node traffic and online state arrive via agent reports, so there is +// nothing to pull per node; what remains is the fleet-wide duty set: +// the client depletion check, pruning the local online sets, deriving +// per-node-inbound speeds from the central counters, and the websocket +// broadcasts the dashboard consumes. type NodeTrafficSyncJob struct { - nodeService service.NodeService inboundService service.InboundService - settingService service.SettingService - xrayService service.XrayService running sync.Mutex structural atomicBool - ipSyncMu sync.Mutex - lastIpSync int64 - globalPushMu sync.Mutex - lastGlobalPush int64 - // noGuidIpEndpoint tracks nodes (by id) whose client-IP attribution endpoint - // returned 404, so an old-build node is noted once instead of every cycle. - noGuidIpEndpoint sync.Map // prevInboundTotals holds the previous poll's cumulative up/down (and the time // the counter last changed) per node inbound tag, so the next poll can derive // a per-inbound speed delta — node inbounds have no local Xray poll. Touched @@ -84,53 +69,6 @@ func (j *NodeTrafficSyncJob) Run() { } defer j.running.Unlock() - mgr := runtime.GetManager() - if mgr == nil { - return - } - - nodes, err := j.nodeService.GetAll() - if err != nil { - logger.Warning("node traffic sync: load nodes failed:", err) - return - } - if len(nodes) == 0 { - return - } - - // Decide once per tick whether this run also syncs client IPs, and stamp the - // clock before the loop so two back-to-back 5s ticks can't both qualify. - doIpSync := false - j.ipSyncMu.Lock() - if now := time.Now().Unix(); now-j.lastIpSync >= int64(nodeClientIpSyncInterval/time.Second) { - doIpSync = true - j.lastIpSync = now - } - j.ipSyncMu.Unlock() - - sem := make(chan struct{}, nodeTrafficSyncConcurrency) - var wg sync.WaitGroup - var activeMu sync.Mutex - var activeEmails []string - for _, n := range nodes { - if !n.Enable || n.Status != "online" { - continue - } - wg.Add(1) - sem <- struct{}{} - n := n - common.GoRecover("node-traffic-sync:"+n.Name, func() { - defer wg.Done() - defer func() { <-sem }() - if emails := j.syncOne(mgr, n, doIpSync); len(emails) > 0 { - activeMu.Lock() - activeEmails = append(activeEmails, emails...) - activeMu.Unlock() - } - }) - } - wg.Wait() - _, clientsDisabled, err := j.inboundService.AddTraffic(nil, nil) if err != nil { logger.Warning("node traffic sync: depletion check failed:", err) @@ -139,8 +77,6 @@ func (j *NodeTrafficSyncJob) Run() { j.structural.set() } - j.maybePushGlobals(mgr, nodes) - // Prune stale local-online entries (no local active emails or inbound tags // to add here — only the local xray poll feeds those) so a stopped local // xray's clients and inbounds still age out between traffic polls. @@ -157,6 +93,10 @@ func (j *NodeTrafficSyncJob) Run() { // Same snapshot-vs-delta split as the local traffic job: above the // threshold a full snapshot would be dropped by the hub's payload cap, so // send only the rows for clients online on the synced nodes this tick. + // Agent reports file online state into the online store rather than + // this tick's return, so the delta path keys off an empty active set + // exactly as it did while every node was agent-managed. + var activeEmails []string snapshot := true if total, countErr := j.inboundService.CountClientTraffics(); countErr != nil { logger.Warning("node traffic sync: count client traffics failed:", countErr) @@ -284,213 +224,3 @@ func (j *NodeTrafficSyncJob) nodeInboundSpeed() []*xray.Traffic { j.prevInboundTotals = next return deltas } - -// maybePushGlobals broadcasts this panel's aggregated per-client usage to its -// online nodes so each node can display the client's cross-panel total and -// enforce its quota locally (see InboundService.AcceptGlobalTraffic). Scoped -// per node to the clients that node actually hosts, and throttled — the -// aggregates only need to reach nodes on a human timescale, not every poll. -func (j *NodeTrafficSyncJob) maybePushGlobals(mgr *runtime.Manager, nodes []*model.Node) { - j.globalPushMu.Lock() - now := time.Now().Unix() - if now-j.lastGlobalPush < int64(nodeGlobalPushInterval/time.Second) { - j.globalPushMu.Unlock() - return - } - j.lastGlobalPush = now - j.globalPushMu.Unlock() - - masterGuid, err := j.settingService.GetPanelGuid() - if err != nil || masterGuid == "" { - return - } - - sem := make(chan struct{}, nodeTrafficSyncConcurrency) - var wg sync.WaitGroup - for _, n := range nodes { - if !n.Enable || n.Status != "online" { - continue - } - remote, err := mgr.RemoteFor(n) - if err != nil { - continue - } - traffics, err := j.inboundService.GetNodeClientTraffics(n.Id) - if err != nil { - logger.Warningf("node traffic sync: load globals for %s failed: %v", n.Name, err) - continue - } - if len(traffics) == 0 { - continue - } - wg.Add(1) - sem <- struct{}{} - n, remote, traffics := n, remote, traffics - common.GoRecover("node-global-push:"+n.Name, func() { - defer wg.Done() - defer func() { <-sem }() - ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout) - defer cancel() - if err := remote.PushGlobalClientTraffics(ctx, masterGuid, traffics); err != nil { - // An old-build node without the endpoint answers 404 — not worth a - // warning every cycle. - if strings.Contains(err.Error(), "HTTP 404") { - logger.Debugf("node traffic sync: node %s has no global-traffic endpoint (old build)", n.Name) - } else { - logger.Warningf("node traffic sync: push globals to %s failed: %v", n.Name, err) - } - } - }) - } - wg.Wait() -} - -// syncOne pulls one node's traffic snapshot and merges it. It returns the -// emails online on that node this tick, feeding the delta broadcast above the -// snapshot threshold; nil on any failure path. -func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node, doIpSync bool) []string { - // Agent-managed nodes are push-only: their traffic and online state - // arrive via agent reports (SetAgentOnlineClients), and there is no - // panel API to pull a snapshot from — the node address is the literal - // "agent" placeholder. Pulling one always fails, and the failure - // branch below clears the node's whole online subtree, wiping the - // state the agent just pushed on every 5s tick (the node list then - // shows agent nodes permanently dark while panel nodes, whose fetch - // succeeds and re-files a fresh tree in the same tick, are fine). - // The heartbeat job already treats agent nodes the same way: no - // probing, agent-driven state only. - if j.nodeService.IsAgentManaged(n.Id) { - return nil - } - rt, err := mgr.RemoteFor(n) - if err != nil { - logger.Warningf("node traffic sync: remote lookup failed for %s: %v", n.Name, err) - return nil - } - - justPushed := false - if n.ConfigDirty { - reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout) - reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n) - reconcileCancel() - if reconcileErr != nil { - // The dirty flag stays set so reconcile retries next tick, but traffic - // accounting must keep flowing: one rejected inbound used to starve the - // whole node's traffic/online sync forever (#5685). - logger.Warningf("node traffic sync: reconcile for %s failed, continuing with traffic pull: %v", n.Name, reconcileErr) - } else { - if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil { - logger.Warningf("node traffic sync: clear dirty for %s failed: %v", n.Name, clearErr) - } - j.structural.set() - // The snapshot below may still predate the push we just made, so its - // lagging lifecycle values must not merge back this tick (#6228). - justPushed = true - } - } - - ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout) - defer cancel() - - snap, err := rt.FetchTrafficSnapshot(ctx) - if err != nil { - logger.Warningf("node traffic sync: fetch from %s failed: %v", n.Name, err) - j.inboundService.ClearNodeOnlineClients(n.Id) - return nil - } - snap.ManagedAliases = rt.AdoptedInboundAliases() - syncCanAdopt := syncCanAdoptInbounds(n, snap.ManagedAliases) - service.FilterNodeSnapshot(n, snap) - _, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id) - if !dirty { - if pending, checkErr := j.inboundService.SnapshotHasUnadoptedInbounds(n.Id, snap); checkErr != nil { - logger.Warningf("node traffic sync: unadopted-inbound check for %s failed: %v", n.Name, checkErr) - } else if pending { - hostCtx, hostCancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout) - groups, hgErr := rt.FetchHostGroups(hostCtx) - hostCancel() - if hgErr != nil { - logger.Debugf("node traffic sync: fetch host groups from %s failed: %v", n.Name, hgErr) - } else { - snap.HostGroups = groups - } - } - } - changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty, justPushed) - if err != nil { - logger.Warningf("node traffic sync: merge for %s failed: %v", n.Name, err) - return nil - } - if changed { - j.structural.set() - } - if !dirty && n.InboundsAdoptedAt == 0 && syncCanAdopt { - if markErr := j.nodeService.MarkNodeInboundsAdopted(n.Id); markErr != nil { - logger.Warningf("node traffic sync: mark inbounds adopted for %s failed: %v", n.Name, markErr) - } - } - - active := make([]string, 0, len(snap.OnlineEmails)) - active = append(active, snap.OnlineEmails...) - for _, emails := range snap.OnlineTree { - active = append(active, emails...) - } - - if !doIpSync { - return active - } - - ipCtx, ipCancel := context.WithTimeout(context.Background(), nodeClientIpSyncTimeout) - defer ipCancel() - - nodeIps, err := rt.FetchAllClientIps(ipCtx) - if err == nil && len(nodeIps) > 0 { - if err := j.inboundService.MergeInboundClientIps(nodeIps); err != nil { - logger.Warningf("node traffic sync: merge client ips from %s failed: %v", n.Name, err) - } - } else if err != nil { - logger.Warningf("node traffic sync: fetch client ips from %s failed: %v", n.Name, err) - } - - masterIps, err := j.inboundService.GetAllInboundClientIps() - if err != nil { - logger.Warningf("node traffic sync: load client ips for push to %s failed: %v", n.Name, err) - return active - } - if len(masterIps) > 0 { - if err := rt.PushAllClientIps(ipCtx, masterIps); err != nil { - logger.Warningf("node traffic sync: push client ips to %s failed: %v", n.Name, err) - } - } - - // Per-node IP attribution: pull the node's guid-keyed subtree (its own - // observations plus any descendants) so the master can tell which node each - // IP is on. Old nodes without the endpoint return HTTP 404 every cycle — note - // it once per node (re-armed on recovery) instead of flooding the log. - if guidTrees, err := rt.FetchClientIpsByGuid(ipCtx); err != nil { - if strings.Contains(err.Error(), "HTTP 404") { - if _, seen := j.noGuidIpEndpoint.LoadOrStore(n.Id, true); !seen { - logger.Debugf("node traffic sync: node %s has no client-IP attribution endpoint (old build)", n.Name) - } - } else { - logger.Debugf("node traffic sync: fetch client ip attribution from %s failed: %v", n.Name, err) - } - } else { - j.noGuidIpEndpoint.Delete(n.Id) - if len(guidTrees) > 0 { - if err := j.inboundService.MergeClientIpsByGuid(n, guidTrees); err != nil { - logger.Warningf("node traffic sync: merge client ip attribution from %s failed: %v", n.Name, err) - } - } - } - return active -} - -// Whether this sync can perform the "first clean adoption" that -// InboundsAdoptedAt records (#6283). -func syncCanAdoptInbounds(n *model.Node, adoptedAliases []string) bool { - if n == nil || n.InboundSyncMode != "selected" { - return true - } - return len(n.InboundTags) > 0 || len(adoptedAliases) > 0 -} diff --git a/internal/web/job/node_traffic_sync_job_test.go b/internal/web/job/node_traffic_sync_job_test.go index c65c254..ec04e35 100644 --- a/internal/web/job/node_traffic_sync_job_test.go +++ b/internal/web/job/node_traffic_sync_job_test.go @@ -3,8 +3,6 @@ package job import ( "sync" "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) func TestAtomicBool_DefaultIsFalse(t *testing.T) { @@ -69,44 +67,3 @@ func TestAtomicBool_ConcurrentSettersExactlyOneTakeWins(t *testing.T) { t.Fatalf("expected exactly one reader to observe true, got %d", trueCount) } } - -// Regression (#6283): a node onboarded in selected mode with an empty tag -// list empties its snapshot via FilterNodeSnapshot before the merge sees it, -// so that sync adopts nothing and must not stamp InboundsAdoptedAt. -func TestSyncCanAdoptInbounds(t *testing.T) { - cases := []struct { - name string - node *model.Node - aliases []string - expected bool - }{ - {"all mode always adopts", &model.Node{InboundSyncMode: "all"}, nil, true}, - { - "selected with tags adopts", - &model.Node{InboundSyncMode: "selected", InboundTags: []string{"in-443-tcp"}}, - nil, - true, - }, - { - "selected empty with adopted alias adopts", - &model.Node{InboundSyncMode: "selected"}, - []string{"in-443-tcp"}, - true, - }, - { - // The reported bug: registering in selected mode and choosing - // tags afterwards stamped adoption while adopting nothing. - "selected empty with no aliases adopts nothing", - &model.Node{InboundSyncMode: "selected"}, - nil, - false, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := syncCanAdoptInbounds(c.node, c.aliases); got != c.expected { - t.Fatalf("syncCanAdoptInbounds(%+v, %v) = %v, want %v", c.node, c.aliases, got, c.expected) - } - }) - } -} diff --git a/internal/web/job/reap_sync_orphans_job.go b/internal/web/job/reap_sync_orphans_job.go deleted file mode 100644 index 4071fdf..0000000 --- a/internal/web/job/reap_sync_orphans_job.go +++ /dev/null @@ -1,27 +0,0 @@ -package job - -import ( - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/web/service" -) - -// ReapSyncOrphansJob hard-deletes the clients the node-snapshot sweep only -// soft-orphaned, once enough clean merges have agreed they are really gone. -type ReapSyncOrphansJob struct { - clientService service.ClientService -} - -func NewReapSyncOrphansJob() *ReapSyncOrphansJob { - return new(ReapSyncOrphansJob) -} - -func (j *ReapSyncOrphansJob) Run() { - reaped, err := j.clientService.ReapSyncOrphans() - if err != nil { - logger.Warning("reap sync orphans failed:", err) - return - } - if reaped > 0 { - logger.Infof("reap sync orphans: removed %d client(s)", reaped) - } -} diff --git a/internal/web/runtime/manager.go b/internal/web/runtime/manager.go index 8908af4..5f5d776 100644 --- a/internal/web/runtime/manager.go +++ b/internal/web/runtime/manager.go @@ -1,37 +1,32 @@ package runtime import ( - "errors" "sync" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) -type NodeEgressResolver interface { - NodeEgressProxyURL(nodeID int) string -} - +// Manager dispatches inbound/client writes to a Runtime. In the lite +// master every node is agent-managed and the master runs no local core, +// so the only production runtime is the no-op Local: writes land in the +// database and agent nodes converge by pulling their config. The +// override seams remain for tests that exercise the dispatch paths with +// a stub runtime. type Manager struct { local Runtime - mu sync.RWMutex - remotes map[int]*Remote - overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub - localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub - egressResolver NodeEgressResolver + mu sync.RWMutex + overrides map[int]Runtime // test-only: forces RuntimeFor to return a stub + localOverride Runtime // test-only: forces RuntimeFor(nil) to return a stub } func NewManager() *Manager { return &Manager{ - local: NewLocal(), - remotes: make(map[int]*Remote), + local: NewLocal(), } } -// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of building a -// real Remote. Test seam for exercising node-dispatch paths without a network -// node; pass nil rt to clear. +// SetRuntimeOverride makes RuntimeFor(nodeID) return rt instead of the +// local runtime. Test seam for exercising node-dispatch paths without a +// network node; pass nil rt to clear. func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) { m.mu.Lock() defer m.mu.Unlock() @@ -45,144 +40,32 @@ func (m *Manager) SetRuntimeOverride(nodeID int, rt Runtime) { m.overrides[nodeID] = rt } -// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the real -// local runtime. Test seam for exercising the local dispatch path (MTProto -// sidecar, local Xray) without a running child process; pass nil rt to clear. +// SetLocalRuntimeOverride makes RuntimeFor(nil) return rt instead of the +// real local runtime. Test seam for exercising the local dispatch path +// without a running child process; pass nil rt to clear. func (m *Manager) SetLocalRuntimeOverride(rt Runtime) { m.mu.Lock() defer m.mu.Unlock() m.localOverride = rt } -func (m *Manager) SetNodeEgressResolver(r NodeEgressResolver) { - m.mu.Lock() - defer m.mu.Unlock() - m.egressResolver = r -} - -func (m *Manager) NodeEgressProxyURL(nodeID int) string { +func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) { m.mu.RLock() defer m.mu.RUnlock() - if m.egressResolver == nil { - return "" - } - return m.egressResolver.NodeEgressProxyURL(nodeID) -} - -func (m *Manager) RuntimeFor(nodeID *int) (Runtime, error) { if nodeID == nil { - m.mu.RLock() if m.localOverride != nil { - rt := m.localOverride - m.mu.RUnlock() - return rt, nil + return m.localOverride, nil } - m.mu.RUnlock() return m.local, nil } - m.mu.RLock() if rt, ok := m.overrides[*nodeID]; ok { - m.mu.RUnlock() return rt, nil } - if rt, ok := m.remotes[*nodeID]; ok { - m.mu.RUnlock() - return rt, nil - } - m.mu.RUnlock() - - m.mu.Lock() - defer m.mu.Unlock() - if rt, ok := m.remotes[*nodeID]; ok { - return rt, nil - } - n, err := loadNode(*nodeID) - if err != nil { - return nil, err - } - if !n.Enable { - return nil, errors.New("node " + n.Name + " is disabled") - } - rt := NewRemote(n, m.egressResolver) - m.remotes[*nodeID] = rt - return rt, nil + return m.local, nil } func (m *Manager) Local() Runtime { return m.local } -func (m *Manager) RemoteFor(node *model.Node) (*Remote, error) { - if node == nil { - return nil, errors.New("node is nil") - } - m.mu.RLock() - if rt, ok := m.remotes[node.Id]; ok { - if sameRemoteIdentity(rt.node, node) { - m.mu.RUnlock() - return rt, nil - } - m.mu.RUnlock() - } else { - m.mu.RUnlock() - } - - m.mu.Lock() - defer m.mu.Unlock() - if rt, ok := m.remotes[node.Id]; ok { - if sameRemoteIdentity(rt.node, node) { - return rt, nil - } - } else { - rt := NewRemote(cloneRemoteNode(node), m.egressResolver) - m.remotes[node.Id] = rt - return rt, nil - } - rt := NewRemote(cloneRemoteNode(node), m.egressResolver) - m.remotes[node.Id] = rt - return rt, nil -} - -func cloneRemoteNode(n *model.Node) *model.Node { - if n == nil { - return nil - } - clone := *n - if n.InboundTags != nil { - clone.InboundTags = append([]string(nil), n.InboundTags...) - } - return &clone -} - -func sameRemoteIdentity(a, b *model.Node) bool { - if a == nil || b == nil { - return a == b - } - return a.Id == b.Id && - a.Scheme == b.Scheme && - a.Address == b.Address && - a.Port == b.Port && - a.BasePath == b.BasePath && - a.ApiToken == b.ApiToken && - a.AllowPrivateAddress == b.AllowPrivateAddress && - a.TlsVerifyMode == b.TlsVerifyMode && - a.PinnedCertSha256 == b.PinnedCertSha256 && - a.OutboundTag == b.OutboundTag -} - -func (m *Manager) InvalidateNode(nodeID int) { - m.mu.Lock() - defer m.mu.Unlock() - delete(m.remotes, nodeID) -} - -func loadNode(id int) (*model.Node, error) { - db := database.GetDB() - n := &model.Node{} - if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil { - return nil, err - } - return n, nil -} - var ( managerMu sync.RWMutex manager *Manager diff --git a/internal/web/runtime/manager_convergence_test.go b/internal/web/runtime/manager_convergence_test.go deleted file mode 100644 index 01fb9e9..0000000 --- a/internal/web/runtime/manager_convergence_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package runtime - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -func TestManagerRemoteForRefreshesChangedCredential(t *testing.T) { - m := NewManager() - first, err := m.RemoteFor(&model.Node{ - Id: 1, - Name: "node", - Scheme: "https", - Address: "node.example.com", - Port: 2053, - BasePath: "/", - ApiToken: "old-token", - }) - if err != nil { - t.Fatalf("first RemoteFor: %v", err) - } - second, err := m.RemoteFor(&model.Node{ - Id: 1, - Name: "node", - Scheme: "https", - Address: "node.example.com", - Port: 2053, - BasePath: "/", - ApiToken: "new-token", - }) - if err != nil { - t.Fatalf("second RemoteFor: %v", err) - } - if second == first { - t.Fatal("RemoteFor reused stale Remote after ApiToken changed") - } - if got := second.node.ApiToken; got != "new-token" { - t.Fatalf("cached Remote token = %q, want new-token", got) - } -} - -func TestManagerRemoteForIdentityFields(t *testing.T) { - base := model.Node{ - Id: 7, - Name: "node-a", - Remark: "old remark", - Scheme: "https", - Address: "node.example.com", - Port: 2053, - BasePath: "/", - ApiToken: "token", - AllowPrivateAddress: true, - TlsVerifyMode: "pin", - PinnedCertSha256: "sha", - OutboundTag: "warp", - Status: "online", - InboundCount: 1, - } - - cases := []struct { - name string - mutate func(*model.Node) - refresh bool - }{ - {"same", func(*model.Node) {}, false}, - {"name does not churn", func(n *model.Node) { n.Name = "renamed" }, false}, - {"remark does not churn", func(n *model.Node) { n.Remark = "new remark" }, false}, - {"status does not churn", func(n *model.Node) { n.Status = "offline" }, false}, - {"metrics do not churn", func(n *model.Node) { n.InboundCount = 99 }, false}, - {"scheme", func(n *model.Node) { n.Scheme = "http" }, true}, - {"address", func(n *model.Node) { n.Address = "other.example.com" }, true}, - {"port", func(n *model.Node) { n.Port = 8443 }, true}, - {"base path", func(n *model.Node) { n.BasePath = "/x/" }, true}, - {"api token", func(n *model.Node) { n.ApiToken = "next" }, true}, - {"allow private", func(n *model.Node) { n.AllowPrivateAddress = false }, true}, - {"tls verify mode", func(n *model.Node) { n.TlsVerifyMode = "skip" }, true}, - {"pin", func(n *model.Node) { n.PinnedCertSha256 = "other" }, true}, - {"outbound tag", func(n *model.Node) { n.OutboundTag = "direct" }, true}, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - m := NewManager() - firstNode := base - first, err := m.RemoteFor(&firstNode) - if err != nil { - t.Fatalf("first RemoteFor: %v", err) - } - - nextNode := base - tc.mutate(&nextNode) - second, err := m.RemoteFor(&nextNode) - if err != nil { - t.Fatalf("second RemoteFor: %v", err) - } - if gotRefresh := second != first; gotRefresh != tc.refresh { - t.Fatalf("refresh = %v, want %v", gotRefresh, tc.refresh) - } - }) - } -} - -func TestManagerRemoteForClonesInputNode(t *testing.T) { - m := NewManager() - n := &model.Node{ - Id: 9, - Scheme: "https", - Address: "node.example.com", - Port: 2053, - BasePath: "/", - ApiToken: "original", - } - rt, err := m.RemoteFor(n) - if err != nil { - t.Fatalf("RemoteFor: %v", err) - } - n.ApiToken = "mutated-after-cache" - if got := rt.node.ApiToken; got != "original" { - t.Fatalf("cached Remote observed caller mutation: %q", got) - } -} diff --git a/internal/web/runtime/reconcile_skip_test.go b/internal/web/runtime/reconcile_skip_test.go deleted file mode 100644 index 5be854c..0000000 --- a/internal/web/runtime/reconcile_skip_test.go +++ /dev/null @@ -1,319 +0,0 @@ -package runtime - -import ( - "context" - "net/http" - "net/http/httptest" - "strings" - "sync/atomic" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// TestReconcileInbound_SkipsUnchanged proves the delta-skip: a second reconcile -// of an unchanged inbound that the node still reports sends no push, while a -// content change or an absent-on-node inbound forces a fresh push. -func TestReconcileInbound_SkipsUnchanged(t *testing.T) { - var pushes atomic.Int32 - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost && strings.Contains(r.URL.Path, "/panel/api/inbounds/update/") { - pushes.Add(1) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-1", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - // Pre-seed the tag→id cache so resolveRemoteID needs no network round-trip. - r.cacheSet(ib.Tag, 7) - - // First reconcile: node doesn't report it yet → must push and record the fp. - if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed { - t.Fatalf("first reconcile: pushed=%v err=%v, want push", pushed, err) - } - if got := pushes.Load(); got != 1 { - t.Fatalf("after first reconcile pushes=%d, want 1", got) - } - - // Second reconcile: unchanged and present on node → skip. - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed { - t.Fatalf("second reconcile: pushed=%v err=%v, want skip", pushed, err) - } - if got := pushes.Load(); got != 1 { - t.Fatalf("unchanged reconcile pushed again: pushes=%d, want 1", got) - } - - // Content change → push again even though it's present on node. - ib.Settings = `{"clients":[{"email":"a@x"}]}` - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed { - t.Fatalf("changed reconcile: pushed=%v err=%v, want push", pushed, err) - } - if got := pushes.Load(); got != 2 { - t.Fatalf("changed reconcile pushes=%d, want 2", got) - } - - // Absent on node (e.g. node restarted/lost it) → re-push even if fp matches. - if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed { - t.Fatalf("absent-on-node reconcile: pushed=%v err=%v, want push", pushed, err) - } - if got := pushes.Load(); got != 3 { - t.Fatalf("absent-on-node reconcile pushes=%d, want 3", got) - } -} - -type nodeCallCounts struct { - adds atomic.Int32 - inboundUpdates atomic.Int32 - clientMutations atomic.Int32 -} - -func newCountingNodeServer(t *testing.T, clientsResp string) (*httptest.Server, *nodeCallCounts) { - t.Helper() - counts := &nodeCallCounts{} - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch { - case strings.Contains(r.URL.Path, "/panel/api/inbounds/add"): - counts.adds.Add(1) - case strings.Contains(r.URL.Path, "/panel/api/inbounds/update/"): - counts.inboundUpdates.Add(1) - case strings.Contains(r.URL.Path, "/panel/api/clients/"): - counts.clientMutations.Add(1) - _, _ = w.Write([]byte(clientsResp)) - return - } - _, _ = w.Write([]byte(`{"success":true}`)) - })) - t.Cleanup(srv.Close) - return srv, counts -} - -func perClientMutationCases(client model.Client) []struct { - name string - run func(*Remote, *model.Inbound) error -} { - return []struct { - name string - run func(*Remote, *model.Inbound) error - }{ - {"add", func(r *Remote, ib *model.Inbound) error { - return r.AddClient(context.Background(), ib, client) - }}, - {"delete", func(r *Remote, ib *model.Inbound) error { - return r.DeleteUser(context.Background(), ib, client.Email) - }}, - {"update", func(r *Remote, ib *model.Inbound) error { - return r.UpdateUser(context.Background(), ib, client.Email, client) - }}, - } -} - -// TestPerClientMutationsAloneDoNotSeedReconcileFingerprint: a per-client RPC -// only proves one client's slice converged, so without an explicit advance the -// dirty-reconcile backup must still send the full inbound. -func TestPerClientMutationsAloneDoNotSeedReconcileFingerprint(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true} - for _, tt := range perClientMutationCases(client) { - t.Run(tt.name, func(t *testing.T) { - counts.inboundUpdates.Store(0) - counts.clientMutations.Store(0) - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"id":"11111111-1111-1111-1111-111111111111","email":"a@x","subId":"s","enable":true}]}`} - r.cacheSet(ib.Tag, 7) - - if err := tt.run(r, ib); err != nil { - t.Fatalf("%s client mutation: %v", tt.name, err) - } - if got := counts.clientMutations.Load(); got != 1 { - t.Fatalf("%s client mutation requests=%d, want 1", tt.name, got) - } - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed { - t.Fatalf("%s reconcile after unadvanced mutation: pushed=%v err=%v, want full push", tt.name, pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 1 { - t.Fatalf("%s reconcile sent %d full inbound updates, want 1", tt.name, got) - } - }) - } -} - -// TestAdvancePushedInboundEnablesReconcileSkip: when the node provably held the -// pre-edit payload and every per-client push succeeded, advancing the -// fingerprint lets the next reconcile skip the redundant full push. -func TestAdvancePushedInboundEnablesReconcileSkip(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - client := model.Client{ID: "11111111-1111-1111-1111-111111111111", Email: "a@x", SubID: "s", Enable: true} - prevByOp := map[string]string{ - "add": `{"clients":[]}`, - "delete": `{"clients":[{"email":"a@x","enable":true}]}`, - "update": `{"clients":[{"email":"a@x","enable":false}]}`, - } - newByOp := map[string]string{ - "add": `{"clients":[{"email":"a@x","enable":true}]}`, - "delete": `{"clients":[]}`, - "update": `{"clients":[{"email":"a@x","enable":true}]}`, - } - for _, tt := range perClientMutationCases(client) { - t.Run(tt.name, func(t *testing.T) { - counts.inboundUpdates.Store(0) - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - prevIb := &model.Inbound{Tag: "in-adv-" + tt.name, Protocol: model.VLESS, Port: 443, Settings: prevByOp[tt.name]} - ib := &model.Inbound{Tag: prevIb.Tag, Protocol: model.VLESS, Port: 443, Settings: newByOp[tt.name]} - r.cacheSet(ib.Tag, 7) - r.recordPushedInbound(prevIb) - - if err := tt.run(r, ib); err != nil { - t.Fatalf("%s client mutation: %v", tt.name, err) - } - r.AdvancePushedInbound(prevIb, ib) - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed { - t.Fatalf("%s reconcile after advance: pushed=%v err=%v, want skip", tt.name, pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 0 { - t.Fatalf("%s reconcile sent %d full inbound updates, want 0", tt.name, got) - } - }) - } -} - -// TestAdvancePushedInboundRequiresMatchingPreviousFingerprint: if changes were -// folded to dirty (or an earlier push failed), the recorded fingerprint no -// longer matches the pre-edit payload; a later successful client push must not -// mask the pending reconcile. -func TestAdvancePushedInboundRequiresMatchingPreviousFingerprint(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - staleIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"folded@x"}]}`} - prevIb := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - ib := &model.Inbound{Tag: "in-stale", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`} - r.cacheSet(ib.Tag, 7) - r.recordPushedInbound(staleIb) - - if err := r.UpdateUser(context.Background(), ib, "a@x", model.Client{Email: "a@x"}); err != nil { - t.Fatalf("client mutation: %v", err) - } - r.AdvancePushedInbound(prevIb, ib) - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed { - t.Fatalf("reconcile with unproven pre-state: pushed=%v err=%v, want full push", pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 1 { - t.Fatalf("reconcile sent %d full inbound updates, want 1", got) - } -} - -// TestAdoptedSerializationChainKeepsReconcileSkip: after a push the node -// re-serializes settings its own way and the master adopts that form back into -// its DB; stamping the adopted payload keeps edit->advance->skip alive instead -// of degrading every edit to a full reconcile push. -func TestAdoptedSerializationChainKeepsReconcileSkip(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - pushForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[{"email":"a@x"}]}`} - adoptedForm := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"email\": \"a@x\"}]\n}"} - edited := &model.Inbound{Tag: "in-adopt", Protocol: model.VLESS, Port: 443, Settings: "{\n \"clients\": [{\"comment\": \"c\", \"email\": \"a@x\"}]\n}"} - r.cacheSet(pushForm.Tag, 7) - - r.recordPushedInbound(pushForm) - r.RecordAdoptedInbound(adoptedForm) - if err := r.UpdateUser(context.Background(), edited, "a@x", model.Client{Email: "a@x"}); err != nil { - t.Fatalf("client mutation: %v", err) - } - r.AdvancePushedInbound(adoptedForm, edited) - if pushed, err := r.ReconcileInbound(context.Background(), edited, true); err != nil || pushed { - t.Fatalf("reconcile after adopted-form advance: pushed=%v err=%v, want skip", pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 0 { - t.Fatalf("full inbound updates=%d, want 0", got) - } -} - -func TestDeleteUserNotFoundHandling(t *testing.T) { - t.Run("envelope not-found counts as already deleted", func(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":false,"msg":"client not found"}`) - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-delete-missing", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - r.cacheSet(ib.Tag, 7) - - if err := r.DeleteUser(context.Background(), ib, "missing@x"); err != nil { - t.Fatalf("DeleteUser missing client: %v", err) - } - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed { - t.Fatalf("reconcile after missing-client delete: pushed=%v err=%v, want full push", pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 1 { - t.Fatalf("reconcile sent %d full inbound updates, want 1", got) - } - }) - t.Run("http 404 from an old node stays an error", func(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.Contains(r.URL.Path, "/panel/api/clients/") { - http.Error(w, "404 page not found", http.StatusNotFound) - return - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-delete-404", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - r.cacheSet(ib.Tag, 7) - - err := r.DeleteUser(context.Background(), ib, "missing@x") - if err == nil || !strings.Contains(err.Error(), "HTTP 404") { - t.Fatalf("DeleteUser against old node = %v, want HTTP 404 error", err) - } - }) -} - -// TestDelInboundDropsReconcileFingerprint: deleting an inbound must forget its -// fingerprint so a later same-tag inbound with identical content is re-pushed. -func TestDelInboundDropsReconcileFingerprint(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-del", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - r.cacheSet(ib.Tag, 7) - - if pushed, err := r.ReconcileInbound(context.Background(), ib, false); err != nil || !pushed { - t.Fatalf("initial reconcile: pushed=%v err=%v, want push", pushed, err) - } - if err := r.DelInbound(context.Background(), ib); err != nil { - t.Fatalf("DelInbound: %v", err) - } - r.cacheSet(ib.Tag, 7) - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || !pushed { - t.Fatalf("reconcile after DelInbound: pushed=%v err=%v, want full push", pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 2 { - t.Fatalf("full inbound updates=%d, want 2", got) - } -} - -func TestUpdateInboundFallbackAddSeedsReconcileFingerprint(t *testing.T) { - srv, counts := newCountingNodeServer(t, `{"success":true}`) - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "in-add-fallback", Protocol: model.VLESS, Port: 443, Settings: `{"clients":[]}`} - - if err := r.UpdateInbound(context.Background(), ib, ib); err != nil { - t.Fatalf("UpdateInbound fallback add: %v", err) - } - if got := counts.adds.Load(); got != 1 { - t.Fatalf("fallback add requests=%d, want 1", got) - } - if pushed, err := r.ReconcileInbound(context.Background(), ib, true); err != nil || pushed { - t.Fatalf("reconcile after fallback add: pushed=%v err=%v, want skip", pushed, err) - } - if got := counts.inboundUpdates.Load(); got != 0 { - t.Fatalf("reconcile sent %d full inbound updates, want 0", got) - } -} diff --git a/internal/web/runtime/remote.go b/internal/web/runtime/remote.go deleted file mode 100644 index 0d889c6..0000000 --- a/internal/web/runtime/remote.go +++ /dev/null @@ -1,950 +0,0 @@ -package runtime - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "io" - "net" - "net/http" - "net/url" - "strconv" - "strings" - "sync" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" - "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" - "github.com/mhsanaei/3x-ui/v3/internal/web/entity" - "github.com/mhsanaei/3x-ui/v3/internal/xray" -) - -const remoteHTTPTimeout = 10 * time.Second - -// zstdMinBodyBytes is the smallest body worth compressing; below it the framing -// overhead can outweigh the savings. -const zstdMinBodyBytes = 1024 - -// maxRemoteResponseBytes caps a single node RPC's response body. It bounds the -// wire/decompressed size of one response — the real guard against a broken or -// hostile node streaming an unbounded body. It is NOT a process-wide memory -// bound: concurrent RPCs and the decoded JSON can each exceed it, so -// endpoint-specific caps and a concurrency budget remain follow-ups. Node -// responses (traffic snapshots, client-IP lists, inbound options) are JSON and -// stay well under it. -const maxRemoteResponseBytes = 64 << 20 // 64 MiB - -// errBodyDiagBytes bounds how much of a non-OK error body we read for a -// diagnostic snippet (and to let small-error connections be reused) without -// buffering a potentially huge or hostile error payload. -const errBodyDiagBytes = 8 << 10 // 8 KiB - -// errRemoteResponseTooLarge is returned when a node response exceeds the cap. -var errRemoteResponseTooLarge = errors.New("remote response exceeds size limit") - -// readCappedBody reads all of r but rejects bodies larger than limit, returning -// errRemoteResponseTooLarge. It reads at most limit+1 bytes so a body of exactly -// limit is accepted and the first oversize byte is detected without buffering -// more. -func readCappedBody(r io.Reader, limit int64) ([]byte, error) { - raw, err := io.ReadAll(io.LimitReader(r, limit+1)) - if err != nil { - return nil, err - } - if int64(len(raw)) > limit { - return nil, errRemoteResponseTooLarge - } - return raw, nil -} - -type envelope struct { - Success bool `json:"success"` - Msg string `json:"msg"` - Obj json.RawMessage `json:"obj"` -} - -// remoteAPIError is a node-panel envelope failure (HTTP 200, success=false), -// distinct from transport/HTTP-status errors so callers can trust its message. -type remoteAPIError struct{ msg string } - -func (e *remoteAPIError) Error() string { return "remote: " + e.msg } - -type Remote struct { - node *model.Node - - mu sync.RWMutex - remoteIDByTag map[string]int - adoptedAliases map[string]string - // pushedFP holds the fingerprint of the last inbound wire payload successfully - // pushed, keyed by panel-side tag, so reconcile can skip re-sending an - // unchanged inbound. Guarded by mu; dropped with the Remote on node config change. - pushedFP map[string]string - // supportsZstd is learned from the node's X-3x-Node-Caps response header; once - // seen, config pushes to this node are zstd-compressed. Old nodes never set - // it, so they keep receiving plain bodies (mixed-version safe). - supportsZstd bool - - // Per-node client honoring the TLS verify mode, built once and reused; a - // node config change drops the cached Remote so the next one rebuilds it. - clientOnce sync.Once - client *http.Client - clientErr error - - egressResolver NodeEgressResolver -} - -type RemoteInboundOption struct { - Id int `json:"id"` - Tag string `json:"tag"` - Remark string `json:"remark"` - Listen string `json:"listen"` - Protocol model.Protocol `json:"protocol"` - Port int `json:"port"` -} - -func NewRemote(n *model.Node, r NodeEgressResolver) *Remote { - return &Remote{ - node: n, - remoteIDByTag: make(map[string]int), - adoptedAliases: make(map[string]string), - pushedFP: make(map[string]string), - egressResolver: r, - } -} - -func (r *Remote) Name() string { return "node:" + r.node.Name } - -func (r *Remote) nodeSupportsZstd() bool { - r.mu.RLock() - defer r.mu.RUnlock() - return r.supportsZstd -} - -// recordCaps learns the node's capabilities from a response header so later -// pushes can use the negotiated envelope. -func (r *Remote) recordCaps(h http.Header) { - if !strings.Contains(h.Get(wirecodec.CapsHeader), wirecodec.CapZstd) { - return - } - r.mu.Lock() - r.supportsZstd = true - r.mu.Unlock() -} - -// httpClient lazily builds and caches the per-node client honoring the TLS -// verify mode, so Remote ops don't fall back to system CA on skip/pin (#5264). -func (r *Remote) httpClient() (*http.Client, error) { - r.clientOnce.Do(func() { - proxyURL := "" - if r.node.OutboundTag != "" && r.egressResolver != nil { - proxyURL = r.egressResolver.NodeEgressProxyURL(r.node.Id) - } - r.client, r.clientErr = HTTPClientForNode(r.node, proxyURL) - }) - return r.client, r.clientErr -} - -// isAgentPlaceholderAddress reports whether addr is the placeholder the -// node form used to store for agent nodes ("agent"). Agent nodes dial out -// to the master and expose no panel endpoint; the write path no longer -// stores the placeholder (see sanitizeAgentEndpoint in the node service), -// but rows written earlier still carry it, and "agent" is a syntactically -// valid hostname — without this guard baseURL would splice it into a -// dialable URL (https://agent:2053/...) and do() would fire real requests -// at whatever "agent" happens to resolve to. -func isAgentPlaceholderAddress(addr string) bool { - return strings.EqualFold(addr, "agent") -} - -func (r *Remote) baseURL() (string, error) { - addr, err := netsafe.NormalizeHost(r.node.Address) - if err != nil { - return "", err - } - if isAgentPlaceholderAddress(addr) { - return "", errors.New("node has no panel endpoint (agent-managed nodes cannot be dialed)") - } - scheme := r.node.Scheme - if scheme != "http" && scheme != "https" { - scheme = "https" - } - if r.node.Port <= 0 || r.node.Port > 65535 { - return "", fmt.Errorf("invalid node port %d", r.node.Port) - } - bp := r.node.BasePath - if !strings.HasSuffix(bp, "/") { - bp += "/" - } - u := &url.URL{ - Scheme: scheme, - Host: net.JoinHostPort(addr, strconv.Itoa(r.node.Port)), - Path: bp, - } - return u.String(), nil -} - -func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelope, error) { - // mtls nodes authenticate via the client certificate, so a bearer token is - // optional for them; every other mode still requires one. - if r.node.ApiToken == "" && r.node.TlsVerifyMode != "mtls" { - return nil, errors.New("node has no API token configured") - } - - base, err := r.baseURL() - if err != nil { - return nil, err - } - target := base + strings.TrimPrefix(path, "/") - - var ( - bodyBytes []byte - contentType string - ) - switch b := body.(type) { - case nil: - case url.Values: - bodyBytes = []byte(b.Encode()) - contentType = "application/x-www-form-urlencoded" - default: - buf, jerr := json.Marshal(b) - if jerr != nil { - return nil, fmt.Errorf("marshal body: %w", jerr) - } - bodyBytes = buf - contentType = "application/json" - } - - // Attach the integrity hash of the uncompressed body unconditionally (a new - // node verifies it, an old one ignores it), and zstd-compress only when the - // node advertised support and the body is worth it. - var ( - reqBody io.Reader - hashHex string - zstdEncoded bool - ) - if bodyBytes != nil { - hashHex = wirecodec.Sha256Hex(bodyBytes) - if len(bodyBytes) >= zstdMinBodyBytes && r.nodeSupportsZstd() { - bodyBytes = wirecodec.Compress(bodyBytes) - zstdEncoded = true - } - reqBody = bytes.NewReader(bodyBytes) - } - - cctx, cancel := context.WithTimeout(netsafe.ContextWithAllowPrivate(ctx, r.node.AllowPrivateAddress), remoteHTTPTimeout) - defer cancel() - req, err := http.NewRequestWithContext(cctx, method, target, reqBody) - if err != nil { - return nil, err - } - if r.node.ApiToken != "" { - token, err := nodetoken.Decrypt(r.node.Id, r.node.ApiToken) - if err != nil { - return nil, fmt.Errorf("decrypt node token: %w", err) - } - req.Header.Set("Authorization", "Bearer "+token) - } - req.Header.Set("Accept", "application/json") - if contentType != "" { - req.Header.Set("Content-Type", contentType) - } - if hashHex != "" { - req.Header.Set(wirecodec.HashHeader, hashHex) - } - if zstdEncoded { - req.Header.Set("Content-Encoding", wirecodec.EncodingZstd) - } - - client, err := r.httpClient() - if err != nil { - return nil, err - } - resp, err := client.Do(req) - if err != nil { - return nil, fmt.Errorf("%s %s: %w", method, path, err) - } - defer resp.Body.Close() - r.recordCaps(resp.Header) - - // Validate status before reading a success payload: a non-OK response's - // body is never used beyond a short diagnostic, so don't let a node force us - // to buffer a large body just to return an HTTP error. - if resp.StatusCode != http.StatusOK { - snippet, _ := io.ReadAll(io.LimitReader(resp.Body, errBodyDiagBytes)) - if msg := bytes.TrimSpace(snippet); len(msg) > 0 { - // %q quotes/escapes the untrusted node body so control characters or - // newlines in it can't garble or inject into the error/log output. - return nil, fmt.Errorf("%s %s: HTTP %d: %q", method, path, resp.StatusCode, msg) - } - return nil, fmt.Errorf("%s %s: HTTP %d", method, path, resp.StatusCode) - } - - // Fast-fail on an honestly-declared oversize body; the LimitReader below is - // the real guard since Content-Length is untrusted, may be absent, or is -1 - // under transparent decompression. - if resp.ContentLength > maxRemoteResponseBytes { - return nil, fmt.Errorf("%s %s: %w (content-length %d, cap %d)", method, path, errRemoteResponseTooLarge, resp.ContentLength, maxRemoteResponseBytes) - } - - raw, err := readCappedBody(resp.Body, maxRemoteResponseBytes) - if err != nil { - if errors.Is(err, errRemoteResponseTooLarge) { - return nil, fmt.Errorf("%s %s: %w (cap %d bytes)", method, path, err, maxRemoteResponseBytes) - } - return nil, fmt.Errorf("read body: %w", err) - } - - var env envelope - if err := json.Unmarshal(raw, &env); err != nil { - return nil, fmt.Errorf("decode envelope: %w", err) - } - if !env.Success { - return &env, &remoteAPIError{msg: env.Msg} - } - return &env, nil -} - -func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) { - if id, ok := r.cacheGetTag(tag); ok { - return id, nil - } - if err := r.refreshRemoteIDs(ctx); err != nil { - return 0, err - } - if id, ok := r.cacheGetTag(tag); ok { - return id, nil - } - return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name) -} - -// nodeInboundTagPrefix is the central-panel alias for an inbound on nodeID. -// Kept in sync with service.nodeTagPrefix (port_conflict.go); duplicated here -// so runtime does not import service. -func nodeInboundTagPrefix(nodeID int) string { - return fmt.Sprintf("n%d-", nodeID) -} - -// stripNodeInboundTagPrefix removes the central-only n- prefix before -// pushing an inbound to the node so Xray keeps its original tag and routing. -func stripNodeInboundTagPrefix(nodeID int, tag string) string { - if stripped, ok := strings.CutPrefix(tag, nodeInboundTagPrefix(nodeID)); ok { - return stripped - } - return tag -} - -// cacheGetTag looks up a remote inbound id by tag, tolerating an n- prefix -// that lives on only one of the two panels: the node may carry the bare tag -// while the central panel stores the prefixed form, or vice versa. -func (r *Remote) cacheGetTag(tag string) (int, bool) { - if id, ok := r.cacheGet(tag); ok { - return id, true - } - prefix := nodeInboundTagPrefix(r.node.Id) - if stripped, found := strings.CutPrefix(tag, prefix); found { - return r.cacheGet(stripped) - } - return r.cacheGet(prefix + tag) -} - -func (r *Remote) cacheGet(tag string) (int, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - id, ok := r.remoteIDByTag[tag] - return id, ok -} - -func (r *Remote) cacheSet(tag string, id int) { - r.mu.Lock() - defer r.mu.Unlock() - r.remoteIDByTag[tag] = id -} - -func (r *Remote) cacheDel(tag string) { - r.mu.Lock() - defer r.mu.Unlock() - delete(r.remoteIDByTag, tag) - delete(r.pushedFP, tag) -} - -func (r *Remote) ListRemoteTags(ctx context.Context) ([]string, error) { - if err := r.refreshRemoteIDs(ctx); err != nil { - return nil, err - } - r.mu.RLock() - defer r.mu.RUnlock() - tags := make([]string, 0, len(r.remoteIDByTag)) - for tag := range r.remoteIDByTag { - tags = append(tags, tag) - } - return tags, nil -} - -func (r *Remote) ListInboundOptions(ctx context.Context) ([]RemoteInboundOption, error) { - env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil) - if err != nil { - return nil, err - } - var list []RemoteInboundOption - if err := json.Unmarshal(env.Obj, &list); err != nil { - return nil, fmt.Errorf("decode inbound list: %w", err) - } - return list, nil -} - -func (r *Remote) refreshRemoteIDs(ctx context.Context) error { - env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil) - if err != nil { - return err - } - var list []struct { - Id int `json:"id"` - Tag string `json:"tag"` - } - if err := json.Unmarshal(env.Obj, &list); err != nil { - return fmt.Errorf("decode inbound list: %w", err) - } - next := make(map[string]int, len(list)) - for _, ib := range list { - if ib.Tag == "" { - continue - } - next[ib.Tag] = ib.Id - } - r.mu.Lock() - r.remoteIDByTag = next - r.mu.Unlock() - return nil -} - -func (r *Remote) AddInbound(ctx context.Context, ib *model.Inbound) error { - payload := wireInbound(ib, r.node.Id) - env, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/add", payload) - if err != nil { - return err - } - var created struct { - Id int `json:"id"` - Tag string `json:"tag"` - } - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &created); err == nil && created.Id > 0 && created.Tag != "" { - r.cacheSet(created.Tag, created.Id) - } - } - r.recordPushedInbound(ib) - return nil -} - -func (r *Remote) DelInbound(ctx context.Context, ib *model.Inbound) error { - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != nil { - logger.Warning("remote DelInbound: tag", ib.Tag, "not found on", r.node.Name) - return nil - } - if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/del/"+strconv.Itoa(id), nil); err != nil { - return err - } - r.cacheDel(ib.Tag) - return nil -} - -func (r *Remote) UpdateInbound(ctx context.Context, oldIb, newIb *model.Inbound) error { - id, err := r.resolveRemoteID(ctx, oldIb.Tag) - if err != nil { - return r.AddInbound(ctx, newIb) - } - payload := wireInbound(newIb, r.node.Id) - if _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/update/"+strconv.Itoa(id), payload); err != nil { - return err - } - if oldIb.Tag != newIb.Tag { - r.cacheDel(oldIb.Tag) - } - r.cacheSet(newIb.Tag, id) - r.recordPushedInbound(newIb) - return nil -} - -func (r *Remote) SetInboundSubSortIndex(ctx context.Context, ib *model.Inbound, index int) error { - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != nil { - return err - } - payload := url.Values{"subSortIndex": []string{strconv.Itoa(index)}} - _, err = r.do(ctx, http.MethodPost, "panel/api/inbounds/"+strconv.Itoa(id)+"/subSortIndex", payload) - return err -} - -// ReconcileInbound pushes ib only when its wire payload differs from the last -// successful push, or when the node no longer reports the tag (existsOnNode -// false) — a node that dropped/restarted must still be re-seeded. Returns -// whether a push actually happened. This turns a full-fleet reconcile from "send -// every inbound's full settings" into "send only what changed". -func (r *Remote) ReconcileInbound(ctx context.Context, ib *model.Inbound, existsOnNode bool) (bool, error) { - fp := wireFingerprint(wireInbound(ib, r.node.Id)) - if existsOnNode { - r.mu.RLock() - prev, ok := r.pushedFP[ib.Tag] - r.mu.RUnlock() - if ok && prev == fp { - return false, nil - } - } - if err := r.UpdateInbound(ctx, ib, ib); err != nil { - return false, err - } - return true, nil -} - -// recordPushedInbound stamps the fingerprint after a full-payload push — the -// only operation that proves the node holds the entire wire payload. -func (r *Remote) recordPushedInbound(ib *model.Inbound) { - fp := wireFingerprint(wireInbound(ib, r.node.Id)) - r.mu.Lock() - r.pushedFP[ib.Tag] = fp - r.mu.Unlock() -} - -// RecordAdoptedInbound stamps the exact payload fingerprint after the master -// adopts a node's settings serialization. -func (r *Remote) RecordAdoptedInbound(ib *model.Inbound) { - r.recordPushedInbound(ib) -} - -// AdoptInboundAlias records a deployed alias without mutating either panel. -// The runtime association is rediscovered after a master restart. -func (r *Remote) AdoptInboundAlias(ib *model.Inbound, remote RemoteInboundOption) { - r.mu.Lock() - r.remoteIDByTag[remote.Tag] = remote.Id - r.remoteIDByTag[ib.Tag] = remote.Id - r.adoptedAliases[ib.Tag] = remote.Tag - r.pushedFP[ib.Tag] = wireFingerprint(wireInbound(ib, r.node.Id)) - r.mu.Unlock() -} - -func (r *Remote) AdoptedInboundAliases() []string { - r.mu.RLock() - defer r.mu.RUnlock() - aliases := make([]string, 0, len(r.adoptedAliases)) - for _, alias := range r.adoptedAliases { - aliases = append(aliases, alias) - } - return aliases -} - -// AdvancePushedInbound moves the reconcile-skip fingerprint from an inbound's -// pre-edit payload to its post-edit payload once every per-client push for the -// edit succeeded. It advances only when the recorded fingerprint proves the -// node held the exact pre-edit state; otherwise the stale fingerprint stays and -// the next reconcile re-sends the full inbound. -func (r *Remote) AdvancePushedInbound(prevIb, ib *model.Inbound) { - prevFP := wireFingerprint(wireInbound(prevIb, r.node.Id)) - nextFP := wireFingerprint(wireInbound(ib, r.node.Id)) - r.mu.Lock() - if r.pushedFP[ib.Tag] == prevFP { - r.pushedFP[ib.Tag] = nextFP - } - r.mu.Unlock() -} - -// wireFingerprint hashes a wire payload so an unchanged inbound is cheap to detect. -func wireFingerprint(v url.Values) string { - sum := sha256.Sum256([]byte(v.Encode())) - return hex.EncodeToString(sum[:]) -} - -func (r *Remote) AddUser(ctx context.Context, ib *model.Inbound, _ map[string]any) error { - return r.UpdateInbound(ctx, ib, ib) -} - -func (r *Remote) RemoveUser(ctx context.Context, ib *model.Inbound, _ string) error { - return r.UpdateInbound(ctx, ib, ib) -} - -func (r *Remote) AddClient(ctx context.Context, ib *model.Inbound, client model.Client) error { - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != nil { - return fmt.Errorf("remote AddClient: resolve tag %q: %w", ib.Tag, err) - } - payload := map[string]any{ - "client": client, - "inboundIds": []int{id}, - } - if _, err := r.do(ctx, http.MethodPost, "panel/api/clients/add", payload); err != nil { - return err - } - return nil -} - -func (r *Remote) DeleteUser(ctx context.Context, ib *model.Inbound, email string) error { - if email == "" { - return nil - } - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != nil { - // Can't confirm the delete reached the node — surface it so the caller - // marks the node dirty and a reconcile converges, instead of silently - // dropping the delete and letting the next snapshot resurrect the client. - return fmt.Errorf("remote DeleteUser: resolve tag %q: %w", ib.Tag, err) - } - body := map[string]any{"inboundIds": []int{id}} - _, err = r.do(ctx, http.MethodPost, - "panel/api/clients/"+url.PathEscape(email)+"/detach", body) - if err == nil { - return nil - } - var apiErr *remoteAPIError - if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") { - return nil - } - return err -} - -func (r *Remote) DeleteClient(ctx context.Context, email string) error { - if email == "" { - return nil - } - _, err := r.do(ctx, http.MethodPost, - "panel/api/clients/del/"+url.PathEscape(email), nil) - if err == nil { - return nil - } - var apiErr *remoteAPIError - if errors.As(err, &apiErr) && strings.Contains(strings.ToLower(apiErr.msg), "not found") { - return nil - } - return err -} - -func (r *Remote) UpdateUser(ctx context.Context, ib *model.Inbound, oldEmail string, payload model.Client) error { - if oldEmail == "" { - oldEmail = payload.Email - } - id, err := r.resolveRemoteID(ctx, ib.Tag) - if err != nil { - return err - } - path := "panel/api/clients/update/" + url.PathEscape(oldEmail) + - "?inboundIds=" + strconv.Itoa(id) - if _, err := r.do(ctx, http.MethodPost, path, payload); err != nil { - return err - } - return nil -} - -func (r *Remote) RestartXray(ctx context.Context) error { - _, err := r.do(ctx, http.MethodPost, "panel/api/server/restartXrayService", nil) - return err -} - -// UpdatePanel asks the node to run its own official self-updater (update.sh) -// and restart onto the latest release. The node returns as soon as the job is -// launched; the new version surfaces on the next heartbeat. When dev is true the -// node is moved to the rolling dev channel instead of the latest stable release. -func (r *Remote) UpdatePanel(ctx context.Context, dev bool) error { - var body any - if dev { - body = url.Values{"dev": {"true"}} - } - _, err := r.do(ctx, http.MethodPost, "panel/api/server/updatePanel", body) - return err -} - -// WebCertFiles holds a node's own web TLS certificate and key file paths. -type WebCertFiles struct { - WebCertFile string `json:"webCertFile"` - WebKeyFile string `json:"webKeyFile"` -} - -// GetWebCertFiles fetches the node's own web TLS certificate/key file paths so -// the central panel can offer them as the "Set Cert from Panel" default for a -// node-assigned inbound — those paths exist on the node, the central panel's -// don't. See issue #4854. -func (r *Remote) GetWebCertFiles(ctx context.Context) (*WebCertFiles, error) { - env, err := r.do(ctx, http.MethodGet, "panel/api/server/getWebCertFiles", nil) - if err != nil { - return nil, err - } - var files WebCertFiles - if err := json.Unmarshal(env.Obj, &files); err != nil { - return nil, fmt.Errorf("decode web cert files: %w", err) - } - return &files, nil -} - -// GetDescendants fetches the node's read-only summaries of the nodes IT -// manages, so this panel can surface them as transitive sub-nodes in a chained -// topology (#4983). Best-effort: an old-build node without the endpoint returns -// an error the caller ignores. -func (r *Remote) GetDescendants(ctx context.Context) ([]model.NodeSummary, error) { - env, err := r.do(ctx, http.MethodGet, "panel/api/server/descendants", nil) - if err != nil { - return nil, err - } - var out []model.NodeSummary - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &out); err != nil { - return nil, fmt.Errorf("decode descendants: %w", err) - } - } - return out, nil -} - -func (r *Remote) ResetClientTraffic(ctx context.Context, _ *model.Inbound, email string) error { - _, err := r.do(ctx, http.MethodPost, - "panel/api/clients/resetTraffic/"+url.PathEscape(email), nil) - return err -} - -func (r *Remote) ResetAllTraffics(ctx context.Context) error { - _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/resetAllTraffics", nil) - return err -} - -func (r *Remote) ResetInboundTraffic(ctx context.Context, ib *model.Inbound) error { - _, err := r.do(ctx, http.MethodPost, fmt.Sprintf("panel/api/inbounds/%d/resetTraffic", ib.Id), nil) - return err -} - -type TrafficSnapshot struct { - Inbounds []*model.Inbound - OnlineEmails []string - ManagedAliases []string - // OnlineTree is the node's GUID-keyed online subtree (its own clients under - // its panelGuid plus every descendant under theirs). Preferred over the flat - // OnlineEmails so the master can attribute deeply nested clients to the real - // node across a chain (#4983). Empty when the node is an old build without - // the per-GUID endpoint — OnlineEmails is the fallback then. - OnlineTree map[string][]string - // ActiveInboundTree is the GUID-keyed subtree of inbound tags that carried - // traffic within the node's online grace window. Empty when the node is an - // old build without the endpoint; the master then falls back to email-only - // online attribution for that node. - ActiveInboundTree map[string][]string - LastOnlineMap map[string]int64 - // HostGroups carries the node's per-inbound host overrides (TLS/SNI/ - // fingerprint), fetched only when the snapshot holds a not-yet-adopted tag. - HostGroups []*entity.HostGroup -} - -// FetchHostGroups pulls the node's host overrides so a freshly adopted inbound -// keeps its subscription TLS/SNI/fingerprint settings on the master. -func (r *Remote) FetchHostGroups(ctx context.Context) ([]*entity.HostGroup, error) { - env, err := r.do(ctx, http.MethodGet, "panel/api/hosts/list", nil) - if err != nil { - return nil, err - } - var groups []*entity.HostGroup - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &groups); err != nil { - return nil, fmt.Errorf("decode host groups: %w", err) - } - } - return groups, nil -} - -func (r *Remote) FetchTrafficSnapshot(ctx context.Context) (*TrafficSnapshot, error) { - snap := &TrafficSnapshot{LastOnlineMap: map[string]int64{}} - - envList, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil) - if err != nil { - return nil, err - } - if err := json.Unmarshal(envList.Obj, &snap.Inbounds); err != nil { - return nil, fmt.Errorf("decode inbound list: %w", err) - } - - // Prefer the GUID-keyed subtree; fall back to the flat list only when the - // node is an old build without the per-GUID endpoint (#4983). - envTree, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlinesByGuid", nil) - if err == nil && len(envTree.Obj) > 0 { - _ = json.Unmarshal(envTree.Obj, &snap.OnlineTree) - } - if len(snap.OnlineTree) == 0 { - envOnlines, err := r.do(ctx, http.MethodPost, "panel/api/clients/onlines", nil) - if err != nil { - logger.Warning("remote", r.node.Name, "onlines fetch failed:", err) - } else if len(envOnlines.Obj) > 0 { - _ = json.Unmarshal(envOnlines.Obj, &snap.OnlineEmails) - } - } - - envLastOnline, err := r.do(ctx, http.MethodPost, "panel/api/clients/lastOnline", nil) - if err != nil { - logger.Warning("remote", r.node.Name, "lastOnline fetch failed:", err) - } else if len(envLastOnline.Obj) > 0 { - _ = json.Unmarshal(envLastOnline.Obj, &snap.LastOnlineMap) - } - - envActiveInbounds, err := r.do(ctx, http.MethodPost, "panel/api/clients/activeInbounds", nil) - if err != nil { - logger.Debugf("remote %s active inbounds fetch failed: %v", r.node.Name, err) - } else if len(envActiveInbounds.Obj) > 0 { - _ = json.Unmarshal(envActiveInbounds.Obj, &snap.ActiveInboundTree) - } - - return snap, nil -} - -// PushGlobalClientTraffics sends this panel's aggregated per-client usage to -// the node, tagged with this panel's GUID so the node keeps one row per -// pushing master. Display/enforcement input on the node only — the node never -// folds these into the counters it reports back, so this panel's (and any -// other master's) delta accounting over the node snapshot stays intact. -func (r *Remote) PushGlobalClientTraffics(ctx context.Context, masterGuid string, traffics []*xray.ClientTraffic) error { - payload := map[string]any{ - "masterGuid": masterGuid, - "traffics": traffics, - } - _, err := r.do(ctx, http.MethodPost, "panel/api/inbounds/pushClientTraffics", payload) - return err -} - -func wireInbound(ib *model.Inbound, remoteNodeID int) url.Values { - v := url.Values{} - v.Set("total", strconv.FormatInt(ib.Total, 10)) - v.Set("remark", ib.Remark) - v.Set("subSortIndex", strconv.Itoa(ib.SubSortIndex)) - v.Set("enable", strconv.FormatBool(ib.Enable)) - v.Set("expiryTime", strconv.FormatInt(ib.ExpiryTime, 10)) - v.Set("listen", ib.Listen) - v.Set("port", strconv.Itoa(ib.Port)) - v.Set("protocol", string(ib.Protocol)) - v.Set("settings", ib.Settings) - v.Set("streamSettings", sanitizeStreamSettingsForRemote(ib.StreamSettings)) - tag := ib.Tag - if remoteNodeID > 0 { - tag = stripNodeInboundTagPrefix(remoteNodeID, tag) - } - v.Set("tag", tag) - v.Set("sniffing", ib.Sniffing) - shareAddrStrategy := strings.TrimSpace(ib.ShareAddrStrategy) - switch shareAddrStrategy { - case "listen", "custom": - default: - shareAddrStrategy = "node" - } - v.Set("shareAddrStrategy", shareAddrStrategy) - v.Set("shareAddr", ib.ShareAddr) - v.Set("disableFlow", strconv.FormatBool(ib.DisableFlow)) - if ib.TrafficReset != "" { - v.Set("trafficReset", ib.TrafficReset) - } - if ib.TrafficResetDay > 0 { - v.Set("trafficResetDay", strconv.Itoa(ib.TrafficResetDay)) - } - return v -} - -// sanitizeStreamSettingsForRemote strips file-based TLS certificate paths -// from the StreamSettings before sending to a remote node, but ONLY when -// inline certificate content (certificate / key) is also present in the same -// entry. In that case the file paths are redundant and stripping them avoids -// confusion when the central panel's local paths don't exist on the remote. -// -// When a certificate entry contains ONLY file paths (no inline content) the -// paths are left untouched: the user explicitly entered paths that exist on -// the remote node's filesystem, and removing them would leave Xray with TLS -// configured but no certificate, causing Xray to crash on the remote node. -func sanitizeStreamSettingsForRemote(streamSettings string) string { - if streamSettings == "" { - return streamSettings - } - - var stream map[string]any - if err := json.Unmarshal([]byte(streamSettings), &stream); err != nil { - return streamSettings - } - - tlsSettings, ok := stream["tlsSettings"].(map[string]any) - if !ok { - return streamSettings - } - - certificates, ok := tlsSettings["certificates"].([]any) - if !ok { - return streamSettings - } - - changed := false - for _, cert := range certificates { - c, ok := cert.(map[string]any) - if !ok { - continue - } - // Only strip file paths when inline content is present so that the - // remote Xray still has a valid certificate to use. - hasCertFile := c["certificateFile"] != nil && c["certificateFile"] != "" - hasKeyFile := c["keyFile"] != nil && c["keyFile"] != "" - hasCertInline := isNonEmptySlice(c["certificate"]) - hasKeyInline := isNonEmptySlice(c["key"]) - if hasCertFile && hasCertInline { - delete(c, "certificateFile") - changed = true - } - if hasKeyFile && hasKeyInline { - delete(c, "keyFile") - changed = true - } - } - - if !changed { - return streamSettings - } - out, err := json.Marshal(stream) - if err != nil { - return streamSettings - } - return string(out) -} - -// isNonEmptySlice reports whether v is a non-nil, non-empty JSON array value. -func isNonEmptySlice(v any) bool { - s, ok := v.([]any) - return ok && len(s) > 0 -} - -func (r *Remote) FetchAllClientIps(ctx context.Context) ([]model.InboundClientIps, error) { - env, err := r.do(ctx, http.MethodGet, "panel/api/server/clientIps", nil) - if err != nil { - return nil, err - } - var ips []model.InboundClientIps - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &ips); err != nil { - return nil, fmt.Errorf("decode client ips: %w", err) - } - } - return ips, nil -} - -func (r *Remote) PushAllClientIps(ctx context.Context, ips []model.InboundClientIps) error { - _, err := r.do(ctx, http.MethodPost, "panel/api/server/clientIps", ips) - return err -} - -// FetchClientIpsByGuid pulls the node's per-node IP attribution subtree -// (guid -> email -> observed IPs). Unlike FetchAllClientIps (the flat union the -// master also pushes back), this preserves which physical node each IP is on. -// Returns an empty map for older nodes that lack the endpoint. -func (r *Remote) FetchClientIpsByGuid(ctx context.Context) (map[string]map[string][]model.ClientIpEntry, error) { - env, err := r.do(ctx, http.MethodPost, "panel/api/clients/clientIpsByGuid", nil) - if err != nil { - return nil, err - } - out := map[string]map[string][]model.ClientIpEntry{} - if len(env.Obj) > 0 { - if err := json.Unmarshal(env.Obj, &out); err != nil { - return nil, fmt.Errorf("decode client ips by guid: %w", err) - } - } - return out, nil -} diff --git a/internal/web/runtime/remote_envelope_test.go b/internal/web/runtime/remote_envelope_test.go deleted file mode 100644 index 207b73a..0000000 --- a/internal/web/runtime/remote_envelope_test.go +++ /dev/null @@ -1,92 +0,0 @@ -package runtime - -import ( - "context" - "io" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/util/wirecodec" -) - -// TestRemoteSendsEnvelopeWhenNodeAdvertisesCap: once a node has advertised the -// zstd capability (via a response header on any prior call), a large push is -// sent zstd-compressed with an X-Config-Sha256 of the *uncompressed* body. -func TestRemoteSendsEnvelopeWhenNodeAdvertisesCap(t *testing.T) { - var capturedEnc, capturedHash string - var capturedBody []byte - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set(wirecodec.CapsHeader, wirecodec.CapZstd) // advertise on every response - if r.Method == http.MethodPost { - capturedEnc = r.Header.Get("Content-Encoding") - capturedHash = r.Header.Get(wirecodec.HashHeader) - capturedBody, _ = io.ReadAll(r.Body) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - - // Prime: a prior call learns the cap from the response header. - if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil { - t.Fatalf("prime call: %v", err) - } - - body := url.Values{} - body.Set("settings", strings.Repeat("x", 4096)) - if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil { - t.Fatalf("push: %v", err) - } - - if capturedEnc != wirecodec.EncodingZstd { - t.Fatalf("Content-Encoding = %q, want %q", capturedEnc, wirecodec.EncodingZstd) - } - if len(capturedHash) != 64 { - t.Fatalf("missing/short X-Config-Sha256: %q", capturedHash) - } - raw, err := wirecodec.Decompress(capturedBody, 1<<20) - if err != nil { - t.Fatalf("server could not decompress the body: %v", err) - } - if string(raw) != body.Encode() { - t.Fatalf("decompressed body mismatch: %q != %q", string(raw), body.Encode()) - } - if wirecodec.Sha256Hex(raw) != capturedHash { - t.Fatal("X-Config-Sha256 does not match the decompressed body") - } -} - -// TestRemoteSendsPlainWhenNoCap: a node that never advertises the cap (old -// build) receives a plain body — but the integrity hash is still attached -// (harmless to old nodes, verified by new ones). -func TestRemoteSendsPlainWhenNoCap(t *testing.T) { - var capturedEnc, capturedHash string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodPost { - capturedEnc = r.Header.Get("Content-Encoding") - capturedHash = r.Header.Get(wirecodec.HashHeader) - } - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - body := url.Values{} - body.Set("settings", strings.Repeat("x", 4096)) - if _, err := r.do(context.Background(), http.MethodPost, "panel/api/inbounds/add", body); err != nil { - t.Fatalf("push: %v", err) - } - - if capturedEnc != "" { - t.Fatalf("a no-cap node must receive a plain body, got Content-Encoding=%q", capturedEnc) - } - if len(capturedHash) != 64 { - t.Fatalf("integrity hash should always be sent, got %q", capturedHash) - } -} diff --git a/internal/web/runtime/remote_mtls_test.go b/internal/web/runtime/remote_mtls_test.go deleted file mode 100644 index 2553f70..0000000 --- a/internal/web/runtime/remote_mtls_test.go +++ /dev/null @@ -1,71 +0,0 @@ -package runtime - -import ( - "context" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// nodeForPlainServer builds an http (non-TLS) node so do()'s token handling can -// be exercised without TLS scaffolding. -func nodeForPlainServer(t *testing.T, srv *httptest.Server, mode, token string) *model.Node { - t.Helper() - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatalf("parse url: %v", err) - } - port, err := strconv.Atoi(u.Port()) - if err != nil { - t.Fatalf("parse port: %v", err) - } - return &model.Node{ - Id: 1, Name: "n1", Scheme: "http", Address: u.Hostname(), Port: port, - BasePath: "/", ApiToken: token, Enable: true, AllowPrivateAddress: true, - TlsVerifyMode: mode, - } -} - -// TestRemoteDo_MTLSNodeNoBearer asserts that an mtls node with no API token -// sends its request with NO Authorization header and does not trip the -// empty-token precondition; while a non-mtls node with no token still errors. -func TestRemoteDo_MTLSNodeNoBearer(t *testing.T) { - var reached bool - var gotAuth string - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - reached = true - gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - t.Run("mtls without token sends no Authorization header", func(t *testing.T) { - reached, gotAuth = false, "sentinel" - r := NewRemote(nodeForPlainServer(t, srv, "mtls", ""), nil) - if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err != nil { - t.Fatalf("mtls node with no token must not error on the token precondition: %v", err) - } - if !reached { - t.Fatal("request did not reach the server") - } - if gotAuth != "" { - t.Fatalf("Authorization header = %q, want empty for a tokenless mtls node", gotAuth) - } - }) - - t.Run("non-mtls without token still errors", func(t *testing.T) { - reached = false - r := NewRemote(nodeForPlainServer(t, srv, "verify", ""), nil) - if _, err := r.do(context.Background(), http.MethodGet, "ping", nil); err == nil { - t.Fatal("non-mtls node with no token must still error") - } - if reached { - t.Fatal("non-mtls tokenless request must not reach the server") - } - }) -} diff --git a/internal/web/runtime/remote_test.go b/internal/web/runtime/remote_test.go deleted file mode 100644 index 4a5c2f6..0000000 --- a/internal/web/runtime/remote_test.go +++ /dev/null @@ -1,469 +0,0 @@ -package runtime - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// TestRemoteDo_RejectsOversizeResponse: a node streaming a body larger than -// maxRemoteResponseBytes must error out instead of the master buffering it -// unbounded. -func TestRemoteDo_RejectsOversizeResponse(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - chunk := bytes.Repeat([]byte("a"), 1<<20) // 1 MiB - for written := 0; written <= maxRemoteResponseBytes; written += len(chunk) { - if _, err := w.Write(chunk); err != nil { - return // client stopped reading at the cap - } - } - })) - defer srv.Close() - - r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) - if _, err := r.do(context.Background(), http.MethodGet, "/probe", nil); !errors.Is(err, errRemoteResponseTooLarge) { - t.Fatalf("do() error = %v, want errRemoteResponseTooLarge", err) - } -} - -// TestRemoteDo_AcceptsNormalResponse confirms the cap does not break a normal -// under-limit envelope. -func TestRemoteDo_AcceptsNormalResponse(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"msg":"ok","obj":{"x":1}}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) - env, err := r.do(context.Background(), http.MethodGet, "/probe", nil) - if err != nil { - t.Fatalf("do() unexpected error: %v", err) - } - if env == nil || !env.Success { - t.Fatalf("env = %+v, want Success=true", env) - } -} - -func TestRemoteSetInboundSubSortIndexSendsOnlyNarrowField(t *testing.T) { - var posted url.Values - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch req.URL.Path { - case "/panel/api/inbounds/list": - _, _ = w.Write([]byte(`{"success":true,"obj":[{"id":42,"tag":"remote-tag"}]}`)) - case "/panel/api/inbounds/42/subSortIndex": - if err := req.ParseForm(); err != nil { - t.Fatalf("ParseForm: %v", err) - } - posted = req.PostForm - _, _ = w.Write([]byte(`{"success":true}`)) - default: - http.NotFound(w, req) - } - })) - defer srv.Close() - r := NewRemote(nodeForPlainServer(t, srv, "verify", "tok"), nil) - ib := &model.Inbound{Tag: "remote-tag", Settings: `{"clients":[{"email":"newer"}]}`} - if err := r.SetInboundSubSortIndex(context.Background(), ib, 7); err != nil { - t.Fatalf("SetInboundSubSortIndex: %v", err) - } - if got := posted.Get("subSortIndex"); got != "7" { - t.Fatalf("subSortIndex = %q, want 7", got) - } - if len(posted) != 1 { - t.Fatalf("posted fields = %v, want only subSortIndex", posted) - } -} - -// TestReadCappedBody_Boundary pins the cap+1 contract cheaply (no large allocs): -// a body of exactly limit is accepted; limit+1 and beyond are rejected. -func TestReadCappedBody_Boundary(t *testing.T) { - const limit = 8 - cases := []struct { - name string - n int - wantErr bool - }{ - {"under", limit - 1, false}, - {"exact", limit, false}, - {"over-by-one", limit + 1, true}, - {"way-over", limit * 4, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - raw, err := readCappedBody(bytes.NewReader(bytes.Repeat([]byte("x"), c.n)), limit) - if c.wantErr { - if !errors.Is(err, errRemoteResponseTooLarge) { - t.Fatalf("n=%d: err=%v, want errRemoteResponseTooLarge", c.n, err) - } - return - } - if err != nil { - t.Fatalf("n=%d: unexpected err %v", c.n, err) - } - if len(raw) != c.n { - t.Fatalf("n=%d: read %d bytes, want %d", c.n, len(raw), c.n) - } - }) - } -} - -// TestRemoteDo_NonOKStatusReturnsHTTPError confirms a non-OK status is reported -// as an HTTP error (with a bounded diagnostic snippet) rather than being read as -// a success payload — i.e. status precedence over the body. -func TestRemoteDo_NonOKStatusReturnsHTTPError(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusInternalServerError) - _, _ = w.Write([]byte("boom")) - })) - defer srv.Close() - - r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) - _, err := r.do(context.Background(), http.MethodGet, "/probe", nil) - if err == nil { - t.Fatal("do() error = nil, want HTTP 500 error") - } - if !strings.Contains(err.Error(), "HTTP 500") || !strings.Contains(err.Error(), "boom") { - t.Fatalf("error = %q, want it to mention HTTP 500 and the body snippet", err) - } -} - -type stubEgress struct{ url string } - -func (s stubEgress) NodeEgressProxyURL(int) string { return s.url } - -// cacheGetTag must resolve a remote inbound id even when the n- prefix -// sits on only one side: the node may store the bare tag while the central -// panel pushes the prefixed form, or vice versa. Without this a mismatch makes -// the push create a duplicate inbound on the node. -func TestCacheGetTag_PrefixAgnostic(t *testing.T) { - cases := []struct { - name string - cacheTag string - lookup string - wantID int - wantFound bool - }{ - {"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true}, - {"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true}, - {"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true}, - {"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := NewRemote(&model.Node{Id: 1, Name: "n1"}, nil) - r.cacheSet(c.cacheTag, 7) - id, ok := r.cacheGetTag(c.lookup) - if ok != c.wantFound || id != c.wantID { - t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound) - } - }) - } -} - -func TestWireInboundIncludesShareAddressFields(t *testing.T) { - values := wireInbound(&model.Inbound{ - ShareAddrStrategy: "custom", - ShareAddr: "edge.example.com", - }, 0) - - if got := values.Get("shareAddrStrategy"); got != "custom" { - t.Fatalf("shareAddrStrategy = %q, want custom", got) - } - if got := values.Get("shareAddr"); got != "edge.example.com" { - t.Fatalf("shareAddr = %q, want edge.example.com", got) - } -} - -// A node that does not mirror DisableFlow re-injects Vision into its own xray -// config and share links, undoing the opt-out on every multi-node deployment. -func TestWireInboundCarriesDisableFlow(t *testing.T) { - if got := wireInbound(&model.Inbound{DisableFlow: true}, 0).Get("disableFlow"); got != "true" { - t.Fatalf("disableFlow = %q, want true", got) - } - if got := wireInbound(&model.Inbound{}, 0).Get("disableFlow"); got != "false" { - t.Fatalf("disableFlow = %q, want false", got) - } -} - -func TestRemoteHTTPClientEgressProxy(t *testing.T) { - // OutboundTag + a resolver → a dedicated proxy client (not the shared default). - withTag := NewRemote(&model.Node{Id: 1, Scheme: "https", TlsVerifyMode: "verify", OutboundTag: "warp"}, stubEgress{url: "socks5://127.0.0.1:1080"}) - c, err := withTag.httpClient() - if err != nil { - t.Fatalf("httpClient: %v", err) - } - if c == defaultNodeHTTPClient { - t.Fatal("OutboundTag + resolver must produce a dedicated egress client, not the shared default") - } - // No OutboundTag → no egress proxy → shared default client (verify mode). - noTag := NewRemote(&model.Node{Id: 2, Scheme: "https", TlsVerifyMode: "verify"}, stubEgress{url: "socks5://127.0.0.1:1080"}) - c2, err := noTag.httpClient() - if err != nil { - t.Fatalf("httpClient: %v", err) - } - if c2 != defaultNodeHTTPClient { - t.Fatal("no OutboundTag must use the shared default client") - } -} - -func TestRemoteDoSetsContentType(t *testing.T) { - var gotCT string - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotCT = r.Header.Get("Content-Type") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true}`)) - })) - defer srv.Close() - - r := NewRemote(nodeForServer(t, srv, "skip", ""), nil) - if _, err := r.do(context.Background(), http.MethodPost, "x", url.Values{"a": {"b"}}); err != nil { - t.Fatalf("do: %v", err) - } - if gotCT != "application/x-www-form-urlencoded" { - t.Fatalf("Content-Type = %q, want application/x-www-form-urlencoded", gotCT) - } -} - -func TestRemoteBaseURL(t *testing.T) { - cases := []struct { - name string - scheme string - port int - bp string - want string - wantErr bool - }{ - {"https default path", "https", 443, "", "https://example.com:443/", false}, - {"http custom path gets trailing slash", "http", 8080, "/panel", "http://example.com:8080/panel/", false}, - {"empty scheme defaults to https", "", 2096, "/", "https://example.com:2096/", false}, - {"invalid scheme defaults to https", "ftp", 2096, "/", "https://example.com:2096/", false}, - {"port zero rejected", "https", 0, "/", "", true}, - {"port above range rejected", "https", 65536, "/", "", true}, - {"negative port rejected", "https", -1, "/", "", true}, - {"max port accepted", "https", 65535, "/", "https://example.com:65535/", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := NewRemote(&model.Node{Address: "example.com", Scheme: c.scheme, Port: c.port, BasePath: c.bp}, nil) - got, err := r.baseURL() - if c.wantErr { - if err == nil { - t.Fatalf("expected error for scheme=%q port=%d", c.scheme, c.port) - } - return - } - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != c.want { - t.Fatalf("baseURL = %q, want %q", got, c.want) - } - }) - } -} - -// Legacy agent-node rows store the node form's placeholder pair -// (address "agent", port 2053). "agent" is a syntactically valid -// hostname, so without a guard baseURL would splice it into a dialable -// URL — https://agent:2053/... — and do() would fire real requests at -// whatever "agent" resolves to. The placeholder must be refused with a -// clear endpoint error instead, in any casing. -func TestRemoteBaseURL_RejectsAgentPlaceholder(t *testing.T) { - for _, addr := range []string{"agent", "Agent", "AGENT"} { - r := NewRemote(&model.Node{Address: addr, Port: 2053, Scheme: "https"}, nil) - got, err := r.baseURL() - if err == nil || !strings.Contains(err.Error(), "no panel endpoint") { - t.Fatalf("baseURL(%q) = %q, %v; want a panel-endpoint error", addr, got, err) - } - } -} - -// The latent trigger: a placeholder row that also carries a panel API -// token passes do()'s credential gate and reaches baseURL. The request -// must die there — never on the network. -func TestRemoteDo_AgentPlaceholderNeverDials(t *testing.T) { - r := NewRemote(&model.Node{Address: "agent", Port: 2053, Scheme: "https", ApiToken: "tok"}, nil) - _, err := r.do(context.Background(), http.MethodGet, "/probe", nil) - if err == nil || !strings.Contains(err.Error(), "no panel endpoint") { - t.Fatalf("do() error = %v, want a panel-endpoint error", err) - } -} - -func TestIsNonEmptySlice(t *testing.T) { - cases := []struct { - name string - in any - want bool - }{ - {"non-empty slice", []any{1}, true}, - {"empty slice", []any{}, false}, - {"nil slice", []any(nil), false}, - {"not a slice", "x", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := isNonEmptySlice(c.in); got != c.want { - t.Fatalf("isNonEmptySlice(%#v) = %v, want %v", c.in, got, c.want) - } - }) - } -} - -func TestWireInboundTrafficReset(t *testing.T) { - with := wireInbound(&model.Inbound{TrafficReset: "monthly", TrafficResetDay: 15}, 0) - if got := with.Get("trafficReset"); got != "monthly" { - t.Fatalf("trafficReset = %q, want monthly", got) - } - if got := with.Get("trafficResetDay"); got != "15" { - t.Fatalf("trafficResetDay = %q, want 15", got) - } - // Empty TrafficReset must be omitted entirely, not sent as an empty field. - without := wireInbound(&model.Inbound{}, 0) - if without.Has("trafficReset") { - t.Fatalf("trafficReset must be omitted when empty, got %q", without.Get("trafficReset")) - } -} - -func TestWireInboundDefaultsShareAddressStrategy(t *testing.T) { - values := wireInbound(&model.Inbound{}, 0) - - if got := values.Get("shareAddrStrategy"); got != "node" { - t.Fatalf("shareAddrStrategy = %q, want node", got) - } - - values = wireInbound(&model.Inbound{ShareAddrStrategy: "auto"}, 0) - if got := values.Get("shareAddrStrategy"); got != "node" { - t.Fatalf("invalid shareAddrStrategy = %q, want node", got) - } -} - -func TestStripNodeInboundTagPrefix(t *testing.T) { - cases := []struct { - nodeID int - tag string - want string - }{ - {2, "n2-in-443-tcp", "in-443-tcp"}, - {2, "in-443-tcp", "in-443-tcp"}, - {2, "my-custom", "my-custom"}, - {2, "n3-in-443-tcp", "n3-in-443-tcp"}, - {0, "n2-in-443-tcp", "n2-in-443-tcp"}, - } - for _, c := range cases { - if got := stripNodeInboundTagPrefix(c.nodeID, c.tag); got != c.want { - t.Fatalf("stripNodeInboundTagPrefix(%d, %q) = %q, want %q", c.nodeID, c.tag, got, c.want) - } - } -} - -func TestWireInboundStripsNodeTagOnPush(t *testing.T) { - values := wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 2) - if got := values.Get("tag"); got != "in-443-tcp" { - t.Fatalf("tag = %q, want in-443-tcp", got) - } - values = wireInbound(&model.Inbound{Tag: "n2-in-443-tcp"}, 0) - if got := values.Get("tag"); got != "n2-in-443-tcp" { - t.Fatalf("nodeID 0 must not strip, got %q", got) - } -} - -func TestSanitizeStreamSettingsForRemote(t *testing.T) { - tests := []struct { - name string - input string - // wantCertFile / wantKeyFile: expected presence after sanitize - wantCertFile bool - wantKeyFile bool - }{ - { - name: "file paths only — kept intact (remote node paths)", - input: `{ - "tlsSettings": { - "certificates": [{ - "certificateFile": "/etc/ssl/cert.crt", - "keyFile": "/etc/ssl/key.key" - }] - } - }`, - wantCertFile: true, - wantKeyFile: true, - }, - { - name: "inline content only — unchanged", - input: `{ - "tlsSettings": { - "certificates": [{ - "certificate": ["-----BEGIN CERTIFICATE-----"], - "key": ["-----BEGIN PRIVATE KEY-----"] - }] - } - }`, - wantCertFile: false, - wantKeyFile: false, - }, - { - name: "both file paths and inline content — file paths stripped (redundant)", - input: `{ - "tlsSettings": { - "certificates": [{ - "certificateFile": "/etc/ssl/cert.crt", - "keyFile": "/etc/ssl/key.key", - "certificate": ["-----BEGIN CERTIFICATE-----"], - "key": ["-----BEGIN PRIVATE KEY-----"] - }] - } - }`, - wantCertFile: false, - wantKeyFile: false, - }, - { - name: "empty stream settings", - input: "", - // empty input returns empty, nothing to check - }, - } - - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - if tc.input == "" { - if got := sanitizeStreamSettingsForRemote(tc.input); got != "" { - t.Errorf("expected empty string, got %q", got) - } - return - } - got := sanitizeStreamSettingsForRemote(tc.input) - var out map[string]any - if err := json.Unmarshal([]byte(got), &out); err != nil { - t.Fatalf("output is not valid JSON: %v\noutput: %s", err, got) - } - - tls, _ := out["tlsSettings"].(map[string]any) - certs, _ := tls["certificates"].([]any) - if len(certs) == 0 { - t.Fatal("certificates array missing in output") - } - cert, _ := certs[0].(map[string]any) - - _, hasCertFile := cert["certificateFile"] - _, hasKeyFile := cert["keyFile"] - - if hasCertFile != tc.wantCertFile { - t.Errorf("certificateFile present=%v, want %v", hasCertFile, tc.wantCertFile) - } - if hasKeyFile != tc.wantKeyFile { - t.Errorf("keyFile present=%v, want %v", hasKeyFile, tc.wantKeyFile) - } - }) - } -} diff --git a/internal/web/runtime/tls_client.go b/internal/web/runtime/tls_client.go deleted file mode 100644 index 0e32769..0000000 --- a/internal/web/runtime/tls_client.go +++ /dev/null @@ -1,282 +0,0 @@ -package runtime - -import ( - "crypto/sha256" - "crypto/subtle" - "crypto/tls" - "encoding/base64" - "encoding/hex" - "net/http" - "strings" - "sync" - "sync/atomic" - "time" - - "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/netproxy" - "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" -) - -// MasterClientCertProvider supplies the master client certificate this panel -// presents to nodes in mtls mode. It is injected by the web layer so the -// runtime package need not import service. -type MasterClientCertProvider func() (tls.Certificate, error) - -var ( - masterClientCertMu sync.RWMutex - masterClientCert MasterClientCertProvider - masterCertEpoch atomic.Uint64 -) - -// SetMasterClientCertProvider installs the provider used to obtain the master -// client certificate for mtls nodes. Passing nil disables it. -func SetMasterClientCertProvider(p MasterClientCertProvider) { - masterClientCertMu.Lock() - defer masterClientCertMu.Unlock() - masterClientCert = p -} - -func getMasterClientCert() (tls.Certificate, error) { - masterClientCertMu.RLock() - p := masterClientCert - masterClientCertMu.RUnlock() - if p == nil { - return tls.Certificate{}, common.NewError("mtls: master client certificate provider not configured") - } - return p() -} - -// InvalidateMasterClientConnections advances the client-credential generation. -// Every cached mTLS transport observes the generation before its next request, -// replaces its TLS transport, and closes the old idle pool. Requests already -// in flight are not interrupted; no request that starts after invalidation can -// reuse a connection authenticated with the previous leaf. -func InvalidateMasterClientConnections() { - masterCertEpoch.Add(1) -} - -// ReloadMasterClientConnections validates that the currently configured -// provider can load the master credential, then invalidates every cached mTLS -// transport. Operators that rotate the credential outside the process (for -// example by restoring settings) can call this without restarting the panel. -func ReloadMasterClientConnections() error { - if _, err := getMasterClientCert(); err != nil { - return err - } - InvalidateMasterClientConnections() - return nil -} - -type idleClosingRoundTripper interface { - http.RoundTripper - CloseIdleConnections() -} - -type credentialRotatingTransport struct { - mu sync.Mutex - generation uint64 - current idleClosingRoundTripper - build func() (idleClosingRoundTripper, error) -} - -func buildStableCredentialTransport(build func() (idleClosingRoundTripper, error)) (idleClosingRoundTripper, uint64, error) { - for { - before := masterCertEpoch.Load() - current, err := build() - if err != nil { - return nil, 0, err - } - after := masterCertEpoch.Load() - if before == after { - return current, after, nil - } - current.CloseIdleConnections() - } -} - -func newCredentialRotatingTransport(build func() (idleClosingRoundTripper, error)) (*credentialRotatingTransport, error) { - current, generation, err := buildStableCredentialTransport(build) - if err != nil { - return nil, err - } - return &credentialRotatingTransport{ - generation: generation, - current: current, - build: build, - }, nil -} - -func (t *credentialRotatingTransport) RoundTrip(req *http.Request) (*http.Response, error) { - t.mu.Lock() - if masterCertEpoch.Load() != t.generation { - next, generation, err := buildStableCredentialTransport(t.build) - if err != nil { - t.mu.Unlock() - return nil, err - } - previous := t.current - t.current = next - t.generation = generation - previous.CloseIdleConnections() - } - current := t.current - t.mu.Unlock() - return current.RoundTrip(req) -} - -func (t *credentialRotatingTransport) CloseIdleConnections() { - t.mu.Lock() - current := t.current - t.mu.Unlock() - current.CloseIdleConnections() -} - -// defaultNodeHTTPClient reaches nodes trusting the system CA store ("verify" -// mode or plain http); shared so connections pool across nodes. -var defaultNodeHTTPClient = &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 64, - MaxIdleConnsPerHost: 4, - IdleConnTimeout: 60 * time.Second, - DialContext: netsafe.SSRFGuardedDialContext, - }, -} - -func HTTPClientForNode(n *model.Node, proxyURL string) (*http.Client, error) { - mode := n.TlsVerifyMode - if mode == "" { - mode = "verify" - } - if proxyURL != "" { - if mode == "mtls" && n.Scheme != "http" { - timeout := remoteHTTPTimeout - build := func() (idleClosingRoundTripper, error) { - client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout) - if err != nil { - return nil, err - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - return nil, common.NewError("mtls proxy client transport does not support credential rotation") - } - tlsCfg, err := tlsConfigForNode(n) - if err != nil { - return nil, err - } - transport.TLSClientConfig = tlsCfg - return transport, nil - } - transport, err := newCredentialRotatingTransport(build) - if err != nil { - return nil, err - } - return &http.Client{Transport: transport, Timeout: timeout}, nil - } - client, err := netproxy.NewHTTPClient(proxyURL, remoteHTTPTimeout) - if err != nil { - return nil, err - } - if mode == "verify" || n.Scheme == "http" { - return client, nil - } - transport, ok := client.Transport.(*http.Transport) - if !ok { - return client, nil - } - tlsCfg, err := tlsConfigForNode(n) - if err != nil { - return nil, err - } - transport.TLSClientConfig = tlsCfg - return client, nil - } - if mode == "verify" || n.Scheme == "http" { - return defaultNodeHTTPClient, nil - } - if mode == "mtls" { - build := func() (idleClosingRoundTripper, error) { - tlsCfg, err := tlsConfigForNode(n) - if err != nil { - return nil, err - } - return &http.Transport{ - MaxIdleConns: 64, - MaxIdleConnsPerHost: 4, - IdleConnTimeout: 60 * time.Second, - DialContext: netsafe.SSRFGuardedDialContext, - TLSClientConfig: tlsCfg, - }, nil - } - transport, err := newCredentialRotatingTransport(build) - if err != nil { - return nil, err - } - return &http.Client{Transport: transport}, nil - } - tlsCfg, err := tlsConfigForNode(n) - if err != nil { - return nil, err - } - return &http.Client{ - Transport: &http.Transport{ - MaxIdleConns: 64, - MaxIdleConnsPerHost: 4, - IdleConnTimeout: 60 * time.Second, - DialContext: netsafe.SSRFGuardedDialContext, - TLSClientConfig: tlsCfg, - }, - }, nil -} - -func tlsConfigForNode(n *model.Node) (*tls.Config, error) { - if n.TlsVerifyMode == "mtls" { - // Present the master client cert; verify the node's server cert against - // the system roots (no InsecureSkipVerify). mtls authenticates the - // caller — it does not change how the node's server identity is checked. - cert, err := getMasterClientCert() - if err != nil { - return nil, err - } - return &tls.Config{ - Certificates: []tls.Certificate{cert}, - MinVersion: tls.VersionTLS12, - }, nil - } - tlsCfg := &tls.Config{InsecureSkipVerify: true} // lgtm[go/disabled-certificate-check] - if n.TlsVerifyMode == "pin" { - want, err := DecodeCertPin(n.PinnedCertSha256) - if err != nil { - return nil, err - } - tlsCfg.VerifyConnection = func(cs tls.ConnectionState) error { - if len(cs.PeerCertificates) == 0 { - return common.NewError("node presented no certificate") - } - sum := sha256.Sum256(cs.PeerCertificates[0].Raw) - if subtle.ConstantTimeCompare(sum[:], want) != 1 { - return common.NewError("node certificate does not match pinned SHA-256") - } - return nil - } - } - return tlsCfg, nil -} - -// DecodeCertPin decodes a SHA-256 cert pin given as base64 (Xray's -// pinnedPeerCertSha256 form) or hex with optional colons into 32 raw bytes. -func DecodeCertPin(s string) ([]byte, error) { - s = strings.TrimSpace(s) - if s == "" { - return nil, common.NewError("certificate pin is empty") - } - if b, err := hex.DecodeString(strings.ReplaceAll(s, ":", "")); err == nil && len(b) == sha256.Size { - return b, nil - } - for _, enc := range []*base64.Encoding{base64.StdEncoding, base64.RawStdEncoding, base64.URLEncoding, base64.RawURLEncoding} { - if b, err := enc.DecodeString(s); err == nil && len(b) == sha256.Size { - return b, nil - } - } - return nil, common.NewError("certificate pin must be a SHA-256 hash (base64 or hex)") -} diff --git a/internal/web/runtime/tls_client_property_test.go b/internal/web/runtime/tls_client_property_test.go deleted file mode 100644 index b859c5b..0000000 --- a/internal/web/runtime/tls_client_property_test.go +++ /dev/null @@ -1,73 +0,0 @@ -package runtime - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "strings" - "testing" - - "pgregory.net/rapid" -) - -func insertColons(h string) string { - var b strings.Builder - for i := 0; i < len(h); i += 2 { - if i > 0 { - b.WriteByte(':') - } - b.WriteString(h[i : i+2]) - } - return b.String() -} - -// TestProp_DecodeCertPin_FormatAgnostic asserts that for ANY 32-byte pin, every -// accepted encoding (hex lower/upper, openssl colon-hex, base64 std/raw/url) decodes -// back to the same bytes. Generalizes the fixed-input TestDecodeCertPin so a mutant -// that breaks one decoding path is caught across the whole input space. -func TestProp_DecodeCertPin_FormatAgnostic(t *testing.T) { - rapid.Check(t, func(t *rapid.T) { - raw := rapid.SliceOfN(rapid.Byte(), sha256.Size, sha256.Size).Draw(t, "raw") - hx := hex.EncodeToString(raw) - forms := []string{ - hx, - strings.ToUpper(hx), - insertColons(hx), - base64.StdEncoding.EncodeToString(raw), - base64.RawStdEncoding.EncodeToString(raw), - base64.URLEncoding.EncodeToString(raw), - base64.RawURLEncoding.EncodeToString(raw), - } - for _, f := range forms { - got, err := DecodeCertPin(f) - if err != nil { - t.Fatalf("DecodeCertPin(%q) errored: %v", f, err) - } - if !bytes.Equal(got, raw) { - t.Fatalf("DecodeCertPin(%q) = %x, want %x", f, got, raw) - } - } - }) -} - -// FuzzDecodeCertPin asserts the security-load-bearing decoder never panics, never -// returns a non-32-byte slice with a nil error, and never returns bytes alongside an -// error. Seeded from the known-good/known-bad cases. -func FuzzDecodeCertPin(f *testing.F) { - seed := sha256.Sum256([]byte("seed")) - f.Add(hex.EncodeToString(seed[:])) - f.Add(base64.StdEncoding.EncodeToString(seed[:])) - f.Add(insertColons(hex.EncodeToString(seed[:]))) - f.Add("") - f.Add("not-a-pin") - f.Fuzz(func(t *testing.T, s string) { - got, err := DecodeCertPin(s) - if err == nil && len(got) != sha256.Size { - t.Fatalf("DecodeCertPin(%q): nil error but %d bytes, want %d", s, len(got), sha256.Size) - } - if err != nil && got != nil { - t.Fatalf("DecodeCertPin(%q): error %v but returned bytes %x", s, err, got) - } - }) -} diff --git a/internal/web/runtime/tls_client_test.go b/internal/web/runtime/tls_client_test.go deleted file mode 100644 index 9059e9f..0000000 --- a/internal/web/runtime/tls_client_test.go +++ /dev/null @@ -1,537 +0,0 @@ -package runtime - -import ( - "context" - "crypto/sha256" - "crypto/tls" - "encoding/base64" - "encoding/hex" - "net/http" - "net/http/httptest" - "net/url" - "strconv" - "strings" - "sync" - "sync/atomic" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" -) - -type generationProbeTransport struct { - id string - closed atomic.Int32 -} - -func (t *generationProbeTransport) RoundTrip(*http.Request) (*http.Response, error) { - return &http.Response{ - StatusCode: http.StatusOK, - Body: http.NoBody, - Header: make(http.Header), - Request: &http.Request{}, - }, nil -} - -func (t *generationProbeTransport) CloseIdleConnections() { - t.closed.Add(1) -} - -func TestCredentialRotatingTransportDropsOldPoolBeforeNextRequest(t *testing.T) { - var selected atomic.Pointer[generationProbeTransport] - oldTransport := &generationProbeTransport{id: "old"} - newTransport := &generationProbeTransport{id: "new"} - selected.Store(oldTransport) - - rotating, err := newCredentialRotatingTransport(func() (idleClosingRoundTripper, error) { - return selected.Load(), nil - }) - if err != nil { - t.Fatalf("newCredentialRotatingTransport: %v", err) - } - rotating.mu.Lock() - initial := rotating.current - rotating.mu.Unlock() - if initial != oldTransport { - t.Fatalf("initial transport = %p, want old %p", initial, oldTransport) - } - - selected.Store(newTransport) - InvalidateMasterClientConnections() - - req := httptest.NewRequest(http.MethodGet, "https://node.example.test/panel/api/server/status", nil) - resp, err := rotating.RoundTrip(req) - if err != nil { - t.Fatalf("RoundTrip after credential rotation: %v", err) - } - _ = resp.Body.Close() - - rotating.mu.Lock() - current := rotating.current - rotating.mu.Unlock() - if current != newTransport { - t.Fatalf("transport after invalidation = %p, want new %p", current, newTransport) - } - if got := oldTransport.closed.Load(); got != 1 { - t.Fatalf("old transport CloseIdleConnections calls = %d, want 1", got) - } -} - -func TestReloadMasterClientConnectionsValidatesProviderBeforeInvalidation(t *testing.T) { - before := masterCertEpoch.Load() - SetMasterClientCertProvider(func() (tls.Certificate, error) { - return tls.Certificate{}, context.Canceled - }) - if err := ReloadMasterClientConnections(); err == nil { - t.Fatal("reload with an invalid provider unexpectedly succeeded") - } - if got := masterCertEpoch.Load(); got != before { - t.Fatalf("failed reload changed generation from %d to %d", before, got) - } - - SetMasterClientCertProvider(func() (tls.Certificate, error) { - return masterCertForTest(t), nil - }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - if err := ReloadMasterClientConnections(); err != nil { - t.Fatalf("ReloadMasterClientConnections: %v", err) - } - if got := masterCertEpoch.Load(); got != before+1 { - t.Fatalf("successful reload generation = %d, want %d", got, before+1) - } -} - -func TestCredentialRotatingTransportRejectsBuildAcrossInvalidation(t *testing.T) { - oldTransport := &generationProbeTransport{id: "old"} - newTransport := &generationProbeTransport{id: "new"} - var selected atomic.Pointer[generationProbeTransport] - selected.Store(oldTransport) - - firstBuildCaptured := make(chan struct{}) - releaseFirstBuild := make(chan struct{}) - var once sync.Once - build := func() (idleClosingRoundTripper, error) { - captured := selected.Load() - once.Do(func() { - close(firstBuildCaptured) - <-releaseFirstBuild - }) - return captured, nil - } - - type result struct { - transport *credentialRotatingTransport - err error - } - resultCh := make(chan result, 1) - go func() { - transport, err := newCredentialRotatingTransport(build) - resultCh <- result{transport: transport, err: err} - }() - - <-firstBuildCaptured - selected.Store(newTransport) - InvalidateMasterClientConnections() - close(releaseFirstBuild) - - got := <-resultCh - if got.err != nil { - t.Fatalf("newCredentialRotatingTransport: %v", got.err) - } - got.transport.mu.Lock() - current := got.transport.current - got.transport.mu.Unlock() - if current != newTransport { - t.Fatalf("transport built across invalidation = %p, want new %p", current, newTransport) - } - if calls := oldTransport.closed.Load(); calls != 1 { - t.Fatalf("stale transport CloseIdleConnections calls = %d, want 1", calls) - } -} - -func TestHTTPClientForNodeMTLSRebuildsTLSConfigAfterCredentialInvalidation(t *testing.T) { - oldCert := masterCertForTest(t) - newCert := masterCertForTest(t) - selected := oldCert - SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - - client, err := HTTPClientForNode(&model.Node{ - Scheme: "https", - Address: "node.example.test", - Port: 443, - TlsVerifyMode: "mtls", - }, "") - if err != nil { - t.Fatalf("HTTPClientForNode: %v", err) - } - rotating, ok := client.Transport.(*credentialRotatingTransport) - if !ok { - t.Fatalf("transport = %T, want *credentialRotatingTransport", client.Transport) - } - leaf := func() []byte { - rotating.mu.Lock() - defer rotating.mu.Unlock() - transport, ok := rotating.current.(*http.Transport) - if !ok { - t.Fatalf("current transport = %T, want *http.Transport", rotating.current) - } - return transport.TLSClientConfig.Certificates[0].Certificate[0] - } - if got := leaf(); string(got) != string(oldCert.Certificate[0]) { - t.Fatal("initial TLS config does not contain the old credential") - } - - selected = newCert - InvalidateMasterClientConnections() - - ctx, cancel := context.WithCancel(context.Background()) - cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil) - if err != nil { - t.Fatalf("NewRequestWithContext: %v", err) - } - if _, err := client.Do(req); err == nil { - t.Fatal("canceled request unexpectedly succeeded") - } - if got := leaf(); string(got) != string(newCert.Certificate[0]) { - t.Fatal("TLS config retained the old credential after invalidation") - } -} - -func TestHTTPClientForNodeProxyMTLSRebuildKeepsProxyAndNewCredential(t *testing.T) { - oldCert := masterCertForTest(t) - newCert := masterCertForTest(t) - selected := oldCert - SetMasterClientCertProvider(func() (tls.Certificate, error) { return selected, nil }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - - const proxyURL = "http://127.0.0.1:18080" - client, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "mtls"}, proxyURL) - if err != nil { - t.Fatalf("HTTPClientForNode: %v", err) - } - rotating, ok := client.Transport.(*credentialRotatingTransport) - if !ok { - t.Fatalf("transport = %T, want rotating transport", client.Transport) - } - current := func() *http.Transport { - rotating.mu.Lock() - defer rotating.mu.Unlock() - transport, ok := rotating.current.(*http.Transport) - if !ok { - t.Fatalf("current transport = %T, want *http.Transport", rotating.current) - } - return transport - } - assertProxy := func(transport *http.Transport) { - t.Helper() - if transport.Proxy == nil { - t.Fatalf("proxy function is nil, want %s", proxyURL) - } - req, _ := http.NewRequest(http.MethodGet, "https://node.example.test/", nil) - got, err := transport.Proxy(req) - if err != nil || got == nil || got.String() != proxyURL { - t.Fatalf("proxy = %v, error = %v, want %s", got, err, proxyURL) - } - } - assertProxy(current()) - - selected = newCert - InvalidateMasterClientConnections() - ctx, cancel := context.WithCancel(context.Background()) - cancel() - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, "https://node.example.test/", nil) - _, _ = client.Do(req) - rebuilt := current() - assertProxy(rebuilt) - if got := rebuilt.TLSClientConfig.Certificates[0].Certificate[0]; string(got) != string(newCert.Certificate[0]) { - t.Fatal("proxy mTLS rebuild retained the old credential") - } -} - -// masterCertForTest builds a real CA-signed client certificate for mtls tests. -func masterCertForTest(t *testing.T) tls.Certificate { - t.Helper() - ca, err := crypto.GenerateNodeCA("test ca") - if err != nil { - t.Fatalf("GenerateNodeCA: %v", err) - } - client, err := crypto.IssueClientCert(ca, "master") - if err != nil { - t.Fatalf("IssueClientCert: %v", err) - } - cert, err := tls.X509KeyPair(client.CertPEM, client.KeyPEM) - if err != nil { - t.Fatalf("X509KeyPair: %v", err) - } - return cert -} - -// TestTLSConfigForNode_MTLS_PresentsClientCert asserts the mtls branch presents -// the master client cert and verifies the node's server cert against system -// roots (no InsecureSkipVerify, no custom RootCAs). -func TestTLSConfigForNode_MTLS_PresentsClientCert(t *testing.T) { - cert := masterCertForTest(t) - SetMasterClientCertProvider(func() (tls.Certificate, error) { return cert, nil }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - - cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}) - if err != nil { - t.Fatalf("tlsConfigForNode(mtls): %v", err) - } - if len(cfg.Certificates) != 1 { - t.Fatalf("mtls config must present exactly one client certificate, got %d", len(cfg.Certificates)) - } - if cfg.InsecureSkipVerify { - t.Fatal("mtls must NOT skip server verification") - } - if cfg.RootCAs != nil { - t.Fatal("mtls verifies the node server against system roots (RootCAs must be nil)") - } -} - -// TestTLSConfigForNode_MTLS_NoProviderFailsClosed asserts mtls fails closed when -// no master client certificate is available, rather than silently dropping auth. -func TestTLSConfigForNode_MTLS_NoProviderFailsClosed(t *testing.T) { - SetMasterClientCertProvider(nil) - if _, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "mtls"}); err == nil { - t.Fatal("mtls without a configured client cert provider must fail closed") - } -} - -// nodeForServer builds a node pointing at a loopback test server (loopback is -// SSRF-blocked, so AllowPrivateAddress is set for the guarded dialer). -func nodeForServer(t *testing.T, srv *httptest.Server, mode, pin string) *model.Node { - t.Helper() - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatalf("parse server url: %v", err) - } - port, err := strconv.Atoi(u.Port()) - if err != nil { - t.Fatalf("parse server port: %v", err) - } - return &model.Node{ - Id: 1, - Name: "n1", - Scheme: "https", - Address: u.Hostname(), - Port: port, - BasePath: "/", - ApiToken: "token", - Enable: true, - AllowPrivateAddress: true, - TlsVerifyMode: mode, - PinnedCertSha256: pin, - } -} - -func leafPinBase64(srv *httptest.Server) string { - sum := sha256.Sum256(srv.Certificate().Raw) - return base64.StdEncoding.EncodeToString(sum[:]) -} - -// A self-signed node must be reachable by Remote ops under skip/pin and -// rejected under verify — the split issue #5264 reported. -func TestRemoteHonorsTLSVerifyMode(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"obj":[]}`)) - })) - defer srv.Close() - - goodPin := leafPinBase64(srv) - wrongPin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size)) - - cases := []struct { - name string - mode string - pin string - wantErr bool - }{ - {"verify rejects self-signed", "verify", "", true}, - {"skip accepts self-signed", "skip", "", false}, - {"pin accepts matching cert", "pin", goodPin, false}, - {"pin rejects mismatched cert", "pin", wrongPin, true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - r := NewRemote(nodeForServer(t, srv, c.mode, c.pin), nil) - _, err := r.ListInboundOptions(context.Background()) - if c.wantErr && err == nil { - t.Fatalf("mode %q: expected error, got nil", c.mode) - } - if !c.wantErr && err != nil { - t.Fatalf("mode %q: unexpected error: %v", c.mode, err) - } - }) - } -} - -// The lazily-built client is cached for the Remote's lifetime so repeated -// operations reuse one pooled transport rather than rebuilding TLS each call. -func TestRemoteClientCached(t *testing.T) { - r := NewRemote(&model.Node{Scheme: "https", TlsVerifyMode: "skip"}, nil) - c1, err1 := r.httpClient() - c2, err2 := r.httpClient() - if err1 != nil || err2 != nil { - t.Fatalf("httpClient errors: %v %v", err1, err2) - } - if c1 != c2 { - t.Fatal("expected the same cached client across calls") - } -} - -func TestHTTPClientForNodeVerifyShared(t *testing.T) { - // verify mode and plain http both reuse the shared default client. - for _, n := range []*model.Node{ - {Scheme: "https", TlsVerifyMode: "verify"}, - {Scheme: "https", TlsVerifyMode: ""}, - {Scheme: "http", TlsVerifyMode: "skip"}, - } { - c, err := HTTPClientForNode(n, "") - if err != nil { - t.Fatalf("HTTPClientForNode(%+v): %v", n, err) - } - if c != defaultNodeHTTPClient { - t.Fatalf("HTTPClientForNode(%+v) = %p, want shared default %p", n, c, defaultNodeHTTPClient) - } - } -} - -func TestHTTPClientForNodePinInvalid(t *testing.T) { - // pin mode must fail closed, and with a specific error per cause — not merely - // "some error" (which a bug anywhere in the build path would also satisfy). - cases := []struct { - name string - pin string - wantErr string - }{ - {"garbage pin", "not-a-pin", "must be a SHA-256 hash"}, - {"empty pin", "", "certificate pin is empty"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, err := HTTPClientForNode(&model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: c.pin}, "") - if err == nil { - t.Fatalf("expected error for pin %q", c.pin) - } - if !strings.Contains(err.Error(), c.wantErr) { - t.Fatalf("error = %q, want it to contain %q", err.Error(), c.wantErr) - } - }) - } -} - -// TestHTTPClientForNode_ProxyPinPreservesPinEnforcement covers the proxy+pin branch -// (tls_client.go:43-52): when a node uses a proxy AND pin mode, the proxy client's -// transport must carry the pinning tls.Config (the `transport.TLSClientConfig = tlsCfg` -// line). Dropping it would silently disable certificate pinning whenever a proxy is set. -func TestHTTPClientForNode_ProxyPinPreservesPinEnforcement(t *testing.T) { - pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size)) - n := &model.Node{Scheme: "https", TlsVerifyMode: "pin", PinnedCertSha256: pin} - - c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080") - if err != nil { - t.Fatalf("HTTPClientForNode: %v", err) - } - if c == defaultNodeHTTPClient { - t.Fatal("proxy client must not be the shared default client") - } - tr, ok := c.Transport.(*http.Transport) - if !ok { - t.Fatalf("transport is %T, want *http.Transport", c.Transport) - } - if tr.TLSClientConfig == nil || tr.TLSClientConfig.VerifyConnection == nil { - t.Fatal("pin mode over a proxy must install a pinning tls.Config (VerifyConnection); pin enforcement was dropped") - } -} - -// TestHTTPClientForNode_ProxyVerifyNoPin covers the proxy+verify branch -// (tls_client.go:40-42): verify mode over a proxy returns the proxy client as-is, -// using system-CA verification and NOT a pin VerifyConnection. -func TestHTTPClientForNode_ProxyVerifyNoPin(t *testing.T) { - n := &model.Node{Scheme: "https", TlsVerifyMode: "verify"} - c, err := HTTPClientForNode(n, "socks5://127.0.0.1:1080") - if err != nil { - t.Fatalf("HTTPClientForNode: %v", err) - } - if c == defaultNodeHTTPClient { - t.Fatal("proxy client must not be the shared default client") - } - if tr, ok := c.Transport.(*http.Transport); ok && tr.TLSClientConfig != nil && tr.TLSClientConfig.VerifyConnection != nil { - t.Fatal("verify mode must not install a pin VerifyConnection") - } -} - -// TestTLSConfigForNode_CurrentContract locks the pre-mTLS behavior of -// tlsConfigForNode so the "mtls" branch added later cannot silently regress the -// existing skip/pin modes (characterization — passes on unchanged code). -func TestTLSConfigForNode_CurrentContract(t *testing.T) { - t.Run("skip disables verification with no VerifyConnection", func(t *testing.T) { - cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "skip"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if !cfg.InsecureSkipVerify { - t.Fatal("skip mode must set InsecureSkipVerify") - } - if cfg.VerifyConnection != nil { - t.Fatal("skip mode must not install a VerifyConnection") - } - }) - t.Run("pin installs a VerifyConnection", func(t *testing.T) { - pin := base64.StdEncoding.EncodeToString(make([]byte, sha256.Size)) - cfg, err := tlsConfigForNode(&model.Node{TlsVerifyMode: "pin", PinnedCertSha256: pin}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cfg.VerifyConnection == nil { - t.Fatal("pin mode must install a VerifyConnection") - } - }) -} - -func TestDecodeCertPin(t *testing.T) { - raw := sha256.Sum256([]byte("cert")) - hexColon := strings.ToUpper(hex.EncodeToString(raw[:])) - // reinsert colons in openssl -fingerprint style - var withColons strings.Builder - for i := 0; i < len(hexColon); i += 2 { - if i > 0 { - withColons.WriteByte(':') - } - withColons.WriteString(hexColon[i : i+2]) - } - - cases := []struct { - name string - in string - wantErr bool - }{ - {"base64 std", base64.StdEncoding.EncodeToString(raw[:]), false}, - {"base64 raw url", base64.RawURLEncoding.EncodeToString(raw[:]), false}, - {"hex bare", hex.EncodeToString(raw[:]), false}, - {"hex colon openssl", withColons.String(), false}, - {"empty", "", true}, - {"garbage", "not-a-pin", true}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - got, err := DecodeCertPin(c.in) - if c.wantErr { - if err == nil { - t.Fatalf("expected error for %q", c.in) - } - return - } - if err != nil { - t.Fatalf("unexpected error for %q: %v", c.in, err) - } - if string(got) != string(raw[:]) { - t.Fatalf("decoded bytes mismatch for %q", c.in) - } - }) - } -} diff --git a/internal/web/runtime/tls_client_wire_test.go b/internal/web/runtime/tls_client_wire_test.go deleted file mode 100644 index 7b2c90b..0000000 --- a/internal/web/runtime/tls_client_wire_test.go +++ /dev/null @@ -1,162 +0,0 @@ -package runtime - -import ( - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "io" - "net/http" - "net/http/httptest" - "sync" - "testing" - "time" -) - -type wireObservation struct { - pin string - remoteAddr string -} - -func startLeafRecordingServer(t *testing.T) (*httptest.Server, *x509.CertPool, func() []wireObservation) { - t.Helper() - var mu sync.Mutex - var seen []wireObservation - srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - observation := wireObservation{remoteAddr: r.RemoteAddr} - if r.TLS != nil && len(r.TLS.PeerCertificates) > 0 { - sum := sha256.Sum256(r.TLS.PeerCertificates[0].Raw) - observation.pin = hex.EncodeToString(sum[:]) - } - mu.Lock() - seen = append(seen, observation) - mu.Unlock() - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("ok")) - })) - srv.TLS = &tls.Config{ClientAuth: tls.RequestClientCert} - srv.StartTLS() - t.Cleanup(srv.Close) - pool := x509.NewCertPool() - pool.AddCert(srv.Certificate()) - return srv, pool, func() []wireObservation { - mu.Lock() - defer mu.Unlock() - result := make([]wireObservation, len(seen)) - copy(result, seen) - return result - } -} - -func pinOf(t *testing.T, cert tls.Certificate) string { - t.Helper() - sum := sha256.Sum256(cert.Certificate[0]) - return hex.EncodeToString(sum[:]) -} - -func rotatingClientForTest(t *testing.T, roots *x509.CertPool) *http.Client { - t.Helper() - build := func() (idleClosingRoundTripper, error) { - cert, err := getMasterClientCert() - if err != nil { - return nil, err - } - return &http.Transport{ - MaxIdleConns: 64, - MaxIdleConnsPerHost: 4, - IdleConnTimeout: 60 * time.Second, - TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: roots, - MinVersion: tls.VersionTLS12, - }, - }, nil - } - transport, err := newCredentialRotatingTransport(build) - if err != nil { - t.Fatalf("newCredentialRotatingTransport: %v", err) - } - return &http.Client{Transport: transport, Timeout: 10 * time.Second} -} - -func doWireRequest(t *testing.T, client *http.Client, url string) { - t.Helper() - response, err := client.Get(url) - if err != nil { - t.Fatalf("request: %v", err) - } - _, _ = io.Copy(io.Discard, response.Body) - _ = response.Body.Close() - if response.StatusCode != http.StatusOK { - t.Fatalf("status=%d want=%d", response.StatusCode, http.StatusOK) - } -} - -func TestCredentialRotationPresentsNewLeafOnNextConnection(t *testing.T) { - server, roots, observations := startLeafRecordingServer(t) - oldCert := masterCertForTest(t) - newCert := masterCertForTest(t) - oldPin := pinOf(t, oldCert) - newPin := pinOf(t, newCert) - if oldPin == newPin { - t.Fatal("test fixture produced identical leaves") - } - var providerMu sync.Mutex - current := oldCert - SetMasterClientCertProvider(func() (tls.Certificate, error) { - providerMu.Lock() - defer providerMu.Unlock() - return current, nil - }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - client := rotatingClientForTest(t, roots) - doWireRequest(t, client, server.URL) - doWireRequest(t, client, server.URL) - baseline := observations() - if len(baseline) != 2 || baseline[0].pin != oldPin || baseline[1].pin != oldPin { - t.Fatalf("baseline=%v", baseline) - } - if baseline[0].remoteAddr != baseline[1].remoteAddr { - t.Fatalf("baseline connections differ: %v", baseline) - } - providerMu.Lock() - current = newCert - providerMu.Unlock() - InvalidateMasterClientConnections() - doWireRequest(t, client, server.URL) - after := observations() - if len(after) != 3 || after[2].pin != newPin { - t.Fatalf("rotation observations=%v want new leaf=%s", after, newPin) - } - if after[2].remoteAddr == baseline[1].remoteAddr { - t.Fatalf("rotated request reused stale connection %s", after[2].remoteAddr) - } -} - -func TestCredentialRotationControlKeepsOldLeafWithoutInvalidation(t *testing.T) { - server, roots, observations := startLeafRecordingServer(t) - oldCert := masterCertForTest(t) - newCert := masterCertForTest(t) - oldPin := pinOf(t, oldCert) - var providerMu sync.Mutex - current := oldCert - SetMasterClientCertProvider(func() (tls.Certificate, error) { - providerMu.Lock() - defer providerMu.Unlock() - return current, nil - }) - t.Cleanup(func() { SetMasterClientCertProvider(nil) }) - client := rotatingClientForTest(t, roots) - doWireRequest(t, client, server.URL) - providerMu.Lock() - current = newCert - providerMu.Unlock() - doWireRequest(t, client, server.URL) - got := observations() - if len(got) != 2 || got[1].pin != oldPin { - t.Fatalf("control observations=%v want stale leaf=%s", got, oldPin) - } - if got[0].remoteAddr != got[1].remoteAddr { - t.Fatalf("control did not reuse connection: %v", got) - } -} diff --git a/internal/web/service/client_bulk.go b/internal/web/service/client_bulk.go index ba59d3c..7cb3c1b 100644 --- a/internal/web/service/client_bulk.go +++ b/internal/web/service/client_bulk.go @@ -1575,7 +1575,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService, } return res } - prevSettings := oldInbound.Settings oldInbound.Settings = string(newSettings) rt, push, _, perr := inboundSvc.nodePushPlan(oldInbound) @@ -1651,7 +1650,6 @@ func (s *ClientService) bulkSetEnableInboundClients(inboundSvc *InboundService, } } if !pushFailed { - advancePushedInbound(rt, prevSettings, oldInbound) } } diff --git a/internal/web/service/client_group_node_sync_test.go b/internal/web/service/client_group_node_sync_test.go index 9a37f3f..947418c 100644 --- a/internal/web/service/client_group_node_sync_test.go +++ b/internal/web/service/client_group_node_sync_test.go @@ -7,78 +7,8 @@ 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/runtime" ) -func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) { - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - - db := database.GetDB() - - const nodeID = 1 - const email = "node-user@example.com" - const uid = "ce8d33df-3a64-4f10-8f9b-91c3a8e0c003" - const wantGroup = "vip" - const wantComment = "renewed manually" - - id := nodeID - central := &model.Inbound{ - UserId: 1, - NodeID: &id, - Tag: "n1-vless", - Enable: true, - Port: 20001, - Protocol: model.VLESS, - Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true,"group":"` + wantGroup + `","comment":"` + wantComment + `"}]}`, - } - if err := db.Create(central).Error; err != nil { - t.Fatalf("create node inbound: %v", err) - } - - if err := db.Create(&model.ClientRecord{ - Email: email, - UUID: uid, - Enable: true, - Group: wantGroup, - Comment: wantComment, - }).Error; err != nil { - t.Fatalf("create client record: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - { - Tag: "n1-vless", - Enable: true, - Port: 20001, - Protocol: model.VLESS, - Settings: `{"clients":[{"email":"` + email + `","id":"` + uid + `","enable":true}]}`, - }, - }, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var row model.ClientRecord - if err := db.Where("email = ?", email).First(&row).Error; err != nil { - t.Fatalf("lookup client row after sync: %v", err) - } - if row.Group != wantGroup { - t.Errorf("group was wiped by node snapshot sync: got %q, want %q", row.Group, wantGroup) - } - if row.Comment != wantComment { - t.Errorf("comment was wiped by node snapshot sync: got %q, want %q", row.Comment, wantComment) - } -} - func TestSyncInbound_KeepsGroupWhenIncomingEmpty(t *testing.T) { dbDir := t.TempDir() t.Setenv("XUI_DB_FOLDER", dbDir) diff --git a/internal/web/service/client_inbound_apply.go b/internal/web/service/client_inbound_apply.go index ffe6c73..f5a24d4 100644 --- a/internal/web/service/client_inbound_apply.go +++ b/internal/web/service/client_inbound_apply.go @@ -29,18 +29,6 @@ func sameClientConfigExceptUpdatedAt(a, b map[string]any) bool { return aerr == nil && berr == nil && string(an) == string(bn) } -// advancePushedInbound advances the node's reconcile-skip fingerprint from the -// pre-edit settings to the saved ones after every per-client push succeeded. -func advancePushedInbound(rt runtime.Runtime, prevSettings string, ib *model.Inbound) { - rem, ok := rt.(*runtime.Remote) - if !ok { - return - } - prev := *ib - prev.Settings = prevSettings - rem.AdvancePushedInbound(&prev, ib) -} - // delInboundClients removes several clients from a single inbound in one pass: // one settings rewrite, one runtime sweep, one Save and one link delta for the // whole batch, instead of repeating the full per-client cycle. It mirrors the @@ -112,7 +100,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId if err != nil { return false, err } - prevSettings := oldInbound.Settings oldInbound.Settings = string(newSettings) var sharedSet map[string]bool @@ -235,7 +222,6 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId } } if nodePush && !nodePushFailed { - advancePushedInbound(nodeRt, prevSettings, oldInbound) } return needRestart, nil @@ -480,7 +466,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model return false, err } - prevSettings := oldInbound.Settings oldInbound.Settings = string(newSettings) // From the stamped wire entries, not from clients: created_at / updated_at / @@ -609,7 +594,6 @@ func (s *ClientService) AddInboundClient(inboundSvc *InboundService, data *model } } if push { - advancePushedInbound(rt, prevSettings, oldInbound) } } @@ -842,7 +826,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo return false, nil } - prevSettings := oldInbound.Settings oldInbound.Settings = string(newSettings) // From the stamped wire entry, not from clients[0]: created_at, the @@ -1021,7 +1004,6 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil { logger.Warning("Error in updating client on", rt.Name(), ":", err1) } else { - advancePushedInbound(rt, prevSettings, oldInbound) } } } else { @@ -1082,7 +1064,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo return false, err } - prevSettings := oldInbound.Settings oldInbound.Settings = string(newSettings) emailShared, err := inboundSvc.emailUsedByOtherInbounds(email, inboundId) @@ -1191,7 +1172,6 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo if err1 != nil { logger.Warning("Error in deleting client on", rt.Name(), ":", err1) } else { - advancePushedInbound(rt, prevSettings, oldInbound) } } } diff --git a/internal/web/service/client_sync_orphan.go b/internal/web/service/client_sync_orphan.go deleted file mode 100644 index 17b780f..0000000 --- a/internal/web/service/client_sync_orphan.go +++ /dev/null @@ -1,85 +0,0 @@ -package service - -import ( - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "gorm.io/gorm" -) - -// How long a client stays recoverable after a node merge concluded it is gone. -// Any merge that sees it attached again inside this window clears the mark. -const syncOrphanReapGrace = 15 * time.Minute - -// A traffic row with no clients row behind it is drift the orphan sweep will -// never mark, so it stays on the old delete-immediately path. -func clientRecordExists(tx *gorm.DB, email string) bool { - var n int64 - if err := tx.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&n).Error; err != nil { - return false - } - return n > 0 -} - -func markSyncOrphan(tx *gorm.DB, email string, nowMs int64) error { - if email == "" { - return nil - } - return tx.Model(&model.ClientRecord{}). - Where("email = ? AND sync_orphaned_at = 0", email). - Update("sync_orphaned_at", nowMs).Error -} - -// A client that is attached again is not orphaned, whatever an earlier merge -// concluded — this is what makes a bad merge recoverable instead of fatal. -func clearSyncOrphanMarks(tx *gorm.DB) error { - return tx.Model(&model.ClientRecord{}). - Where("sync_orphaned_at > 0 AND EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)"). - Update("sync_orphaned_at", 0).Error -} - -// ReapSyncOrphans deletes the clients the node-snapshot sweep marked and no -// later merge reclaimed. It is the only path that hard-deletes for that sweep. -func (s *ClientService) ReapSyncOrphans() (int, error) { - db := database.GetDB() - cutoff := time.Now().Add(-syncOrphanReapGrace).UnixMilli() - - var emails []string - if err := db.Model(&model.ClientRecord{}). - Where("sync_orphaned_at > 0 AND sync_orphaned_at <= ?", cutoff). - Where("NOT EXISTS (SELECT 1 FROM client_inbounds WHERE client_inbounds.client_id = clients.id)"). - Pluck("email", &emails).Error; err != nil { - return 0, err - } - if len(emails) == 0 { - return 0, nil - } - - reaped := 0 - for _, batch := range chunkStrings(emails, sqlInChunk) { - if err := runSerializedTx(func(tx *gorm.DB) error { - if err := adjustGroupBaselinesForRemovedTraffic(tx, batch); err != nil { - return err - } - if err := tx.Where("email IN ?", batch).Delete(&model.ClientRecord{}).Error; err != nil { - return err - } - if err := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; err != nil { - return err - } - if err := tx.Where("email IN ?", batch).Delete(&model.NodeClientTraffic{}).Error; err != nil { - return err - } - return tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error - }); err != nil { - return reaped, err - } - reaped += len(batch) - logger.Infof("reaped %d client(s) confirmed removed on their node", len(batch)) - } - return reaped, nil -} diff --git a/internal/web/service/client_sync_orphan_test.go b/internal/web/service/client_sync_orphan_test.go deleted file mode 100644 index bb057e9..0000000 --- a/internal/web/service/client_sync_orphan_test.go +++ /dev/null @@ -1,148 +0,0 @@ -package service - -import ( - "fmt" - "testing" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/xray" - - "gorm.io/gorm" -) - -func readOrphanMark(t *testing.T, db *gorm.DB, email string) int64 { - t.Helper() - var row model.ClientRecord - if err := db.Where("email = ?", email).First(&row).Error; err != nil { - t.Fatalf("read client %q: %v", email, err) - } - return row.SyncOrphanedAt -} - -func backdateOrphanMark(t *testing.T, db *gorm.DB, email string) { - t.Helper() - past := time.Now().Add(-2 * syncOrphanReapGrace).UnixMilli() - if err := db.Model(&model.ClientRecord{}). - Where("email = ?", email). - Update("sync_orphaned_at", past).Error; err != nil { - t.Fatalf("backdate orphan mark: %v", err) - } -} - -// The merge must soft-orphan, not delete: everything stays recoverable until -// the grace period has elapsed and the reaper confirms nothing reclaimed it. -func TestSyncOrphanSurvivesMergeUntilGraceElapses(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - clientSvc := &ClientService{} - - seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) - - const email = "gone@x" - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email) - settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email) - syncNodeWithSettings(t, svc, 1, "n1-in", settings, - xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true}) - - if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 { - t.Fatalf("setup: clients=%d client_traffics=%d, want 1/1", rec, traf) - } - - if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil { - t.Fatalf("orphaning merge: %v", err) - } - if rec, traf := countClientRows(t, db, email); rec != 1 || traf != 1 { - t.Fatalf("merge hard-deleted the client: clients=%d client_traffics=%d, want 1/1", rec, traf) - } - if readOrphanMark(t, db, email) <= 0 { - t.Fatal("merge did not stamp sync_orphaned_at") - } - - reaped, err := clientSvc.ReapSyncOrphans() - if err != nil { - t.Fatalf("reap inside grace: %v", err) - } - if reaped != 0 { - t.Fatalf("reaped %d client(s) inside the grace period, want 0", reaped) - } - if rec, _ := countClientRows(t, db, email); rec != 1 { - t.Fatal("client removed before the grace period elapsed") - } - - backdateOrphanMark(t, db, email) - reaped, err = clientSvc.ReapSyncOrphans() - if err != nil { - t.Fatalf("reap after grace: %v", err) - } - if reaped != 1 { - t.Fatalf("reaped %d client(s) after the grace period, want 1", reaped) - } - rec, traf := countClientRows(t, db, email) - if rec != 0 || traf != 0 { - t.Fatalf("after reap: clients=%d client_traffics=%d, want 0/0", rec, traf) - } -} - -// A client the node reports again was never gone: clearing the mark is what -// turns a bad merge into a recoverable blip instead of a delayed deletion. -func TestSyncOrphanMarkClearedOnReattach(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - clientSvc := &ClientService{} - - seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) - - const email = "flaky@x" - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email) - settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email) - syncNodeWithSettings(t, svc, 1, "n1-in", settings, - xray.ClientTraffic{Email: email, Up: 5, Down: 5, Enable: true}) - - if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil { - t.Fatalf("orphaning merge: %v", err) - } - if readOrphanMark(t, db, email) <= 0 { - t.Fatal("setup: expected the merge to mark the client") - } - - syncNodeWithSettings(t, svc, 1, "n1-in", settings, - xray.ClientTraffic{Email: email, Up: 6, Down: 6, Enable: true}) - - if orphanedAt := readOrphanMark(t, db, email); orphanedAt != 0 { - t.Fatalf("re-attached client kept its orphan mark: sync_orphaned_at=%d", orphanedAt) - } - - backdateOrphanMark(t, db, email) - if reaped, err := clientSvc.ReapSyncOrphans(); err != nil || reaped != 0 { - t.Fatalf("reaped %d client(s) (err=%v) that the node still reports, want 0", reaped, err) - } -} - -// The reaper is scoped to the node sweep. Orphans from any other cause carry no -// mark and keep their existing manual-cleanup semantics. -func TestReapSyncOrphansIgnoresUnmarkedOrphans(t *testing.T) { - db := initTrafficTestDB(t) - clientSvc := &ClientService{} - - const email = "manual@x" - rec := &model.ClientRecord{Email: email, Enable: true, UUID: "44444444-4444-4444-4444-444444444444"} - if err := db.Create(rec).Error; err != nil { - t.Fatalf("create client: %v", err) - } - - reaped, err := clientSvc.ReapSyncOrphans() - if err != nil { - t.Fatalf("reap: %v", err) - } - if reaped != 0 { - t.Fatalf("reaped %d unmarked orphan(s), want 0", reaped) - } - var surviving int64 - if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&surviving).Error; err != nil { - t.Fatalf("count clients: %v", err) - } - if surviving != 1 { - t.Fatalf("unmarked orphan was reaped: %d rows survive, want 1", surviving) - } -} diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 2590421..9854ae4 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1253,12 +1253,6 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo if _, err := database.CreateHostsFromExternalProxy(tx, inbound.Id, inbound.StreamSettings); err != nil { return err } - if inbound.NodeID != nil { - nodeID := *inbound.NodeID - if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, inbound.Tag); err != nil { - return err - } - } if inbound.Enable { if inbound.NodeID != nil { markDirty = true @@ -1792,13 +1786,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, // the move handling off nodeChanged, not off the stored value. oldInbound.NodeID = targetNodeID if nodeChanged { - // Move: validate the resolved tag against the target node's - // namespace, exactly like a same-node edit does for its node. - if targetNodeID != nil { - if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, *targetNodeID, oldInbound.Tag); err != nil { - return err - } - } // Node sides converge through the dirty marks written at the // end of this transaction (source + target): the source node's // next reconcile sweeps the inbound's old tag, the target's @@ -1912,11 +1899,6 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, } } } - } else { - nodeID := *oldInbound.NodeID - if err := (&NodeService{}).EnsureInboundTagAllowedTx(tx, nodeID, oldInbound.Tag); err != nil { - return err - } } if err := tx.Save(oldInbound).Error; err != nil { diff --git a/internal/web/service/inbound_client_ips.go b/internal/web/service/inbound_client_ips.go index 80c0d75..581e92e 100644 --- a/internal/web/service/inbound_client_ips.go +++ b/internal/web/service/inbound_client_ips.go @@ -9,16 +9,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" "gorm.io/gorm" - "gorm.io/gorm/clause" ) -func (s *InboundService) GetAllInboundClientIps() ([]model.InboundClientIps, error) { - db := database.GetDB() - var ips []model.InboundClientIps - err := db.Model(&model.InboundClientIps{}).Find(&ips).Error - return ips, err -} - // clientIpStaleAfterSeconds mirrors job.ipStaleAfterSeconds: client IPs older than // 30 minutes are evicted. Applying the same cutoff inside the cross-node merge keeps // the synced blob bounded and stops the master's push-back from resurrecting IPs that @@ -59,98 +51,6 @@ func mergeClientIpEntries(old, incoming []clientIpEntry, cutoff int64) []clientI return out } -// MergeInboundClientIps folds client IPs synced from another node into the local -// inbound_client_ips table without double-counting an IP seen on multiple nodes and -// without resurrecting stale entries. Existing rows are updated in place; brand-new -// clients (typically node-only clients with no local row) are created with a fresh -// local id. -func (s *InboundService) MergeInboundClientIps(incomingIps []model.InboundClientIps) error { - db := database.GetDB() - var currentIps []model.InboundClientIps - if err := db.Model(&model.InboundClientIps{}).Find(¤tIps).Error; err != nil { - return err - } - - currentMap := make(map[string]*model.InboundClientIps, len(currentIps)) - for i := range currentIps { - currentMap[currentIps[i].ClientEmail] = ¤tIps[i] - } - - now := time.Now().Unix() - cutoff := now - clientIpStaleAfterSeconds - - // Node syncs run concurrently (one goroutine per node) and shared clients - // appear in several nodes' reports. Locking rows in each node's arbitrary - // report order lets two merges grab the same rows in opposite order, which - // Postgres aborts as a deadlock (40P01) — take them in one global order. - sort.Slice(incomingIps, func(i, j int) bool { - return incomingIps[i].ClientEmail < incomingIps[j].ClientEmail - }) - - tx := db.Begin() - defer func() { - if r := recover(); r != nil { - tx.Rollback() - } - }() - - for _, incoming := range incomingIps { - if incoming.ClientEmail == "" || incoming.Ips == "" { - continue - } - - var incomingEntries []clientIpEntry - _ = json.Unmarshal([]byte(incoming.Ips), &incomingEntries) - - current, exists := currentMap[incoming.ClientEmail] - if !exists { - // New client we've never seen locally. Drop stale entries up front and - // skip the row entirely if nothing is fresh, so we don't persist a row - // that is dead on arrival. - fresh := mergeClientIpEntries(nil, incomingEntries, cutoff) - if len(fresh) == 0 { - continue - } - b, _ := json.Marshal(fresh) - incoming.Ips = string(b) - // Never carry the remote node's primary key into the local table: id - // spaces are independent across nodes and the remote id would collide - // with an unrelated local row. OnConflict guards the race where - // check_client_ip_job creates the same brand-new email between the - // snapshot above and this insert. - incoming.Id = 0 - if err := tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "client_email"}}, - DoNothing: true, - }).Create(&incoming).Error; err != nil { - tx.Rollback() - return err - } - continue - } - - var oldEntries []clientIpEntry - if current.Ips != "" { - _ = json.Unmarshal([]byte(current.Ips), &oldEntries) - } - - merged := mergeClientIpEntries(oldEntries, incomingEntries, cutoff) - b, _ := json.Marshal(merged) - mergedStr := string(b) - - // A concurrent check_client_ip_job db.Save on the same row can interleave - // with this update (benign last-writer-wins; any dropped IP reappears on the - // next scan/sync), so only write when the blob actually changed. - if current.Ips != mergedStr { - if err := tx.Model(&model.InboundClientIps{}).Where("id = ?", current.Id).Update("ips", mergedStr).Error; err != nil { - tx.Rollback() - return err - } - } - } - return tx.Commit().Error -} - func (s *InboundService) UpdateClientIPs(tx *gorm.DB, oldEmail string, newEmail string) error { // The caller only renames onto a free identity, so a row already sitting on // newEmail is stale tracking data — drop it instead of failing the edit. diff --git a/internal/web/service/inbound_client_ips_merge_test.go b/internal/web/service/inbound_client_ips_merge_test.go deleted file mode 100644 index 06a30d1..0000000 --- a/internal/web/service/inbound_client_ips_merge_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package service - -import ( - "encoding/json" - "path/filepath" - "testing" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// setupClientIpTestDB spins up a throwaway SQLite database (migrations + seeders) -// for a single test, mirroring the harness used by the other service tests. -func setupClientIpTestDB(t *testing.T) { - t.Helper() - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) -} - -func marshalIps(t *testing.T, entries ...clientIpEntry) string { - t.Helper() - b, err := json.Marshal(entries) - if err != nil { - t.Fatalf("marshal ips: %v", err) - } - return string(b) -} - -// readClientIps returns the stored IP entries for an email as a map[ip]timestamp, -// plus whether the row exists at all. -func readClientIps(t *testing.T, email string) (map[string]int64, bool) { - t.Helper() - var row model.InboundClientIps - err := database.GetDB().Where("client_email = ?", email).First(&row).Error - if database.IsNotFound(err) { - return nil, false - } - if err != nil { - t.Fatalf("read client ips for %s: %v", email, err) - } - var entries []clientIpEntry - if row.Ips != "" { - if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { - t.Fatalf("unmarshal stored ips for %s: %v", email, err) - } - } - out := make(map[string]int64, len(entries)) - for _, e := range entries { - out[e.IP] = e.Timestamp - } - return out, true -} - -func TestMergeInboundClientIps_CreatesNodeOnlyRowIgnoringRemoteId(t *testing.T) { - setupClientIpTestDB(t) - db := database.GetDB() - now := time.Now().Unix() - - // Local client occupies id 1. - local := &model.InboundClientIps{ClientEmail: "local@x", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})} - if err := db.Create(local).Error; err != nil { - t.Fatalf("seed local row: %v", err) - } - - // Incoming node-only client carries the remote node's id 1, which must not - // collide with the local row. - incoming := []model.InboundClientIps{{ - Id: 1, - ClientEmail: "node@x", - Ips: marshalIps(t, clientIpEntry{IP: "2.2.2.2", Timestamp: now}), - }} - if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil { - t.Fatalf("merge: %v", err) - } - - // Local row is untouched. - if ips, ok := readClientIps(t, "local@x"); !ok || ips["1.1.1.1"] != now { - t.Fatalf("local@x changed unexpectedly: %v (exists=%v)", ips, ok) - } - - // Node row exists with its own ip and a freshly assigned id (not the remote 1). - var nodeRow model.InboundClientIps - if err := db.Where("client_email = ?", "node@x").First(&nodeRow).Error; err != nil { - t.Fatalf("node@x not created: %v", err) - } - if nodeRow.Id == local.Id { - t.Fatalf("node@x reused local id %d instead of a fresh one", nodeRow.Id) - } - if ips, _ := readClientIps(t, "node@x"); ips["2.2.2.2"] != now { - t.Fatalf("node@x missing expected ip: %v", ips) - } -} - -func TestMergeInboundClientIps_DedupKeepsMaxTimestamp(t *testing.T) { - setupClientIpTestDB(t) - db := database.GetDB() - now := time.Now().Unix() - - if err := db.Create(&model.InboundClientIps{ - ClientEmail: "a@x", - Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 100}), - }).Error; err != nil { - t.Fatalf("seed: %v", err) - } - - incoming := []model.InboundClientIps{{ - ClientEmail: "a@x", - Ips: marshalIps(t, - clientIpEntry{IP: "1.1.1.1", Timestamp: now - 50}, // newer than stored -> wins - clientIpEntry{IP: "2.2.2.2", Timestamp: now - 10}, - ), - }} - if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil { - t.Fatalf("merge: %v", err) - } - - ips, _ := readClientIps(t, "a@x") - if len(ips) != 2 { - t.Fatalf("want 2 ips, got %v", ips) - } - if ips["1.1.1.1"] != now-50 { - t.Fatalf("1.1.1.1 should keep max timestamp %d, got %d", now-50, ips["1.1.1.1"]) - } - if ips["2.2.2.2"] != now-10 { - t.Fatalf("2.2.2.2 missing/incorrect: %d", ips["2.2.2.2"]) - } -} - -func TestMergeInboundClientIps_DropsStaleIps(t *testing.T) { - setupClientIpTestDB(t) - db := database.GetDB() - now := time.Now().Unix() - - if err := db.Create(&model.InboundClientIps{ - ClientEmail: "a@x", - Ips: marshalIps(t, - clientIpEntry{IP: "old", Timestamp: now - 3600}, // > 30m -> stale - clientIpEntry{IP: "fresh", Timestamp: now - 60}, - ), - }).Error; err != nil { - t.Fatalf("seed: %v", err) - } - - incoming := []model.InboundClientIps{{ - ClientEmail: "a@x", - Ips: marshalIps(t, - clientIpEntry{IP: "incStale", Timestamp: now - 4000}, // > 30m -> stale - clientIpEntry{IP: "incFresh", Timestamp: now - 10}, - ), - }} - if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil { - t.Fatalf("merge: %v", err) - } - - ips, _ := readClientIps(t, "a@x") - if len(ips) != 2 { - t.Fatalf("want only fresh ips, got %v", ips) - } - if _, ok := ips["old"]; ok { - t.Fatalf("stale local ip not dropped: %v", ips) - } - if _, ok := ips["incStale"]; ok { - t.Fatalf("stale incoming ip not dropped: %v", ips) - } - if ips["fresh"] != now-60 || ips["incFresh"] != now-10 { - t.Fatalf("fresh ips wrong: %v", ips) - } -} - -func TestMergeInboundClientIps_SkipsAllStaleCreate(t *testing.T) { - setupClientIpTestDB(t) - now := time.Now().Unix() - - incoming := []model.InboundClientIps{{ - ClientEmail: "b@x", - Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now - 9999}), - }} - if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil { - t.Fatalf("merge: %v", err) - } - - if _, ok := readClientIps(t, "b@x"); ok { - t.Fatalf("all-stale node-only client should not create a row") - } -} - -func TestMergeInboundClientIps_SkipsBlankRows(t *testing.T) { - setupClientIpTestDB(t) - now := time.Now().Unix() - - incoming := []model.InboundClientIps{ - {ClientEmail: "", Ips: marshalIps(t, clientIpEntry{IP: "1.1.1.1", Timestamp: now})}, - {ClientEmail: "c@x", Ips: ""}, - } - if err := (&InboundService{}).MergeInboundClientIps(incoming); err != nil { - t.Fatalf("merge: %v", err) - } - - var count int64 - if err := database.GetDB().Model(&model.InboundClientIps{}).Count(&count).Error; err != nil { - t.Fatalf("count: %v", err) - } - if count != 0 { - t.Fatalf("blank rows should be skipped, but %d row(s) created", count) - } -} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index b7ad67b..4bda655 100644 --- a/internal/web/service/inbound_node.go +++ b/internal/web/service/inbound_node.go @@ -1,30 +1,23 @@ package service import ( - "context" "errors" "fmt" "sort" - "strings" - "sync" "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" - "gorm.io/gorm/clause" ) -var reportedRemoteTagConflict sync.Map - -// nodeBulkPushThreshold caps how many per-client RPCs a single operation will -// stream to a remote node. Above it, the panel marks the node dirty instead and -// lets one ReconcileNode push converge the whole inbound — far cheaper than M -// sequential round-trips. Small ops stay on the live per-client path. +// nodeBulkPushThreshold caps how many per-client runtime applies a +// single operation will stream to a node. Above it, the panel marks the +// node dirty instead and lets the node's next config pull converge the +// whole inbound. Small ops stay on the live per-client path. const nodeBulkPushThreshold = 32 func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error) { @@ -82,120 +75,6 @@ func (s *InboundService) AnyNodePending(inboundIds []int) bool { return false } -// ReconcileNode pushes every inbound and sweeps undesired remote tags even when -// individual operations fail, returning the failures joined: one inbound the -// node rejects (e.g. a legacy protocol failing validation, #5685) must not -// stall the rest of the node's config — or, via syncOne, its traffic sync. -func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, n *model.Node) error { - if rt == nil || n == nil || n.Id <= 0 { - return nil - } - nodeID := n.Id - db := database.GetDB() - var inbounds []*model.Inbound - if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil { - return err - } - remoteInbounds, err := rt.ListInboundOptions(ctx) - if err != nil { - return err - } - remoteTags := make([]string, 0, len(remoteInbounds)) - remoteTagSet := make(map[string]struct{}, len(remoteTags)) - for _, remoteIb := range remoteInbounds { - remoteTags = append(remoteTags, remoteIb.Tag) - remoteTagSet[remoteIb.Tag] = struct{}{} - } - prefix := nodeTagPrefix(&nodeID) - desiredTags := make(map[string]struct{}, len(inbounds)*2) - var errs []error - for _, ib := range inbounds { - desiredTags[ib.Tag] = struct{}{} - // existsOnNode: does the node already report this inbound under any of the - // tag forms it may be stored as? If so, an unchanged push can be skipped. - _, existsOnNode := remoteTagSet[ib.Tag] - if prefix != "" { - if stripped, found := strings.CutPrefix(ib.Tag, prefix); found { - desiredTags[stripped] = struct{}{} - if _, ok := remoteTagSet[stripped]; ok { - existsOnNode = true - } - } else { - desiredTags[prefix+ib.Tag] = struct{}{} - if _, ok := remoteTagSet[prefix+ib.Tag]; ok { - existsOnNode = true - } - } - } - runtimeIb := ib - if built, bErr := s.buildInboundForNodePush(db, ib); bErr == nil { - runtimeIb = built - } - if !existsOnNode && n.Guid != "" && ib.OriginNodeGuid == n.Guid { - var compatible []runtime.RemoteInboundOption - for _, remoteIb := range remoteInbounds { - if remoteIb.Port == runtimeIb.Port && - remoteIb.Protocol == runtimeIb.Protocol && - strings.TrimSpace(remoteIb.Listen) == strings.TrimSpace(runtimeIb.Listen) { - compatible = append(compatible, remoteIb) - } - } - switch len(compatible) { - case 1: - alias := compatible[0] - desiredTags[alias.Tag] = struct{}{} - rt.AdoptInboundAlias(runtimeIb, alias) - existsOnNode = true - logger.Infof("adopted compatible inbound %q on node %s as %q", alias.Tag, n.Name, ib.Tag) - case 0: - // No compatible occupant: keep the normal create path, which - // leaves a real port/protocol drift loud. - default: - for _, candidate := range compatible { - desiredTags[candidate.Tag] = struct{}{} - } - errs = append(errs, fmt.Errorf("reconcile inbound %q: ambiguous compatible remote inbounds", ib.Tag)) - continue - } - } - if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil { - errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)) - } - } - // Before the first clean sync adopts the node's inbounds, "absent locally" - // means "not imported yet" — sweeping now would wipe the node at onboarding. - if n.InboundsAdoptedAt == 0 { - return errors.Join(errs...) - } - // In "selected" sync mode the panel only manages the selected tags: the - // rest were never imported, so their absence from the local DB must not - // delete them from the node. Only a selected tag missing locally (the - // panel deleted it while the node was unreachable) may be swept. - var selected map[string]struct{} - if n.InboundSyncMode == "selected" { - selected = make(map[string]struct{}, len(n.InboundTags)) - for _, tag := range n.InboundTags { - selected[tag] = struct{}{} - } - } - for _, tag := range remoteTags { - if _, want := desiredTags[tag]; want { - continue - } - if selected != nil { - if _, managed := selected[tag]; !managed { - continue - } - } - if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil { - errs = append(errs, fmt.Errorf("reconcile delete %q: %w", tag, err)) - } - } - return errors.Join(errs...) -} - -const resetGracePeriodMs int64 = 30000 - // onlineGracePeriodMs must comfortably exceed the 5s traffic-poll interval — // Xray's stats counters often report a zero delta for an active session across // a single poll, so a 5s grace would still drop the client on the next tick. @@ -203,20 +82,8 @@ const resetGracePeriodMs int64 = 30000 // long after a real disconnect. const onlineGracePeriodMs int64 = 20000 -type nodeTrafficCounter struct { - Up int64 - Down int64 -} - -func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email string, up, down int64) error { - return tx.Clauses(clause.OnConflict{ - Columns: []clause.Column{{Name: "node_id"}, {Name: "email"}}, - DoUpdates: clause.AssignmentColumns([]string{"up", "down"}), - }).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error -} - // mergeActivationExpiry: master absolute wins; node may only activate when -// master is unset/duration. Node auto-renew goes through nodeClientRenewed. +// master is unset/duration. func mergeActivationExpiry(existing, node int64) int64 { if existing > 0 { return existing @@ -224,35 +91,6 @@ func mergeActivationExpiry(existing, node int64) int64 { return node } -// masterLimitsAllowClient reports whether the master's own deadline and quota -// (including this tick's deltas) still permit the client. -func masterLimitsAllowClient(master *xray.ClientTraffic, now, deltaUp, deltaDown int64) bool { - if master == nil { - return false - } - if master.ExpiryTime > 0 && master.ExpiryTime <= now { - return false - } - if master.Total > 0 && master.Up+deltaUp+master.Down+deltaDown >= master.Total { - return false - } - return true -} - -// nodeDisableIsStale reports an enable=false the node decided against limits the -// master has since changed, so it must not latch back (#6228 / #4917). -func nodeDisableIsStale(master *xray.ClientTraffic, node xray.ClientTraffic, now, deltaUp, deltaDown int64) bool { - if master == nil { - return false - } - // Matching limits mean the node judged the client on the master's own terms: - // that verdict is genuine and still latches, as #4917 requires. - if node.ExpiryTime == master.ExpiryTime && node.Total == master.Total { - return false - } - return masterLimitsAllowClient(master, now, deltaUp, deltaDown) -} - func clampTrafficCounter(v int64) int64 { if v > database.TrafficMax { return database.TrafficMax @@ -263,96 +101,6 @@ func clampTrafficCounter(v int64) int64 { return v } -// applyMasterClientLifecycle overlays the already-merged master row onto a -// node-reported client for SyncInbound (#6228). -func applyMasterClientLifecycle(c *model.Client, master *xray.ClientTraffic, cs *xray.ClientTraffic) { - if master == nil { - // No central row to speak for the client: the node's own latch is all - // there is, and it may only disable. - if cs != nil && !cs.Enable { - c.Enable = false - } - return - } - c.ExpiryTime = mergeActivationExpiry(master.ExpiryTime, c.ExpiryTime) - c.Enable = master.Enable -} - -// nodeClientRenewed reports a node-side auto-renew: an absolute deadline moved -// forward, evidenced by a renewal-count bump or a drop below the stored baseline. -func nodeClientRenewed(existing *xray.ClientTraffic, cs xray.ClientTraffic, canon, base nodeTrafficCounter) bool { - if (cs.Reset <= 0 && cs.ResetDay <= 0) || cs.ExpiryTime <= 0 || existing.ExpiryTime <= 0 { - return false - } - if cs.ExpiryTime <= existing.ExpiryTime { - return false - } - // A client that used no traffic in the period never dips, so the renewal - // counter is the only evidence autoRenewClients leaves behind (#6228). - if cs.ResetCount > existing.ResetCount { - return true - } - return canon.Up < base.Up || canon.Down < base.Down -} - -// liftActivatedClientRecordExpiries copies a node-activated deadline from -// client_traffics onto client records still holding the negative duration (#5714). -func liftActivatedClientRecordExpiries(tx *gorm.DB) error { - return tx.Exec( - `UPDATE clients - SET expiry_time = (SELECT ct.expiry_time FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0 LIMIT 1) - WHERE clients.expiry_time < 0 - AND EXISTS (SELECT 1 FROM client_traffics ct WHERE ct.email = clients.email AND ct.expiry_time > 0)`, - ).Error -} - -// SnapshotHasUnadoptedInbounds reports whether the snapshot carries a tag with -// no central row yet, i.e. the next merge would adopt a new inbound. -func (s *InboundService) SnapshotHasUnadoptedInbounds(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) { - if snap == nil || len(snap.Inbounds) == 0 { - return false, nil - } - var tags []string - if err := database.GetDB().Model(model.Inbound{}). - Where("node_id = ?", nodeID). - Pluck("tag", &tags).Error; err != nil { - return false, err - } - prefix := nodeTagPrefix(&nodeID) - known := make(map[string]struct{}, len(tags)*2) - for _, tag := range tags { - known[tag] = struct{}{} - if prefix != "" { - if stripped, found := strings.CutPrefix(tag, prefix); found { - known[stripped] = struct{}{} - } else { - known[prefix+tag] = struct{}{} - } - } - } - for _, ib := range snap.Inbounds { - if ib == nil { - continue - } - if _, ok := known[ib.Tag]; !ok { - return true, nil - } - } - return false, nil -} - -// SetRemoteTraffic merges a node snapshot. justPushed marks the tick whose -// config push just landed, whose snapshot may still predate it (#6228). -func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) { - var structuralChange bool - err := submitTrafficWrite(func() error { - var inner error - structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty, justPushed) - return inner - }) - return structuralChange, err -} - // GetNodeInboundTrafficTotals returns the current cumulative up/down for every // node-hosted inbound, keyed by tag. The node sync diffs successive snapshots of // this to derive per-inbound speed for the dashboard — node inbounds have no @@ -376,950 +124,6 @@ func (s *InboundService) GetNodeInboundTrafficTotals() (map[string][2]int64, err return out, nil } -func adoptedWireChanged(c, snapIb *model.Inbound, adoptedSettings string) bool { - return c.Settings != adoptedSettings || - c.Enable != snapIb.Enable || - c.Remark != snapIb.Remark || - c.SubSortIndex != normalizeSubSortIndex(snapIb.SubSortIndex) || - c.Listen != snapIb.Listen || - c.Port != snapIb.Port || - c.Protocol != snapIb.Protocol || - c.Total != snapIb.Total || - c.ExpiryTime != snapIb.ExpiryTime || - c.StreamSettings != snapIb.StreamSettings || - c.Sniffing != snapIb.Sniffing || - c.TrafficReset != snapIb.TrafficReset || - c.TrafficResetDay != normalizeTrafficResetDay(snapIb.TrafficResetDay) -} - -// adoptedWireInbound is the central inbound as it reads after adopting the -// node-reported wire fields — the payload the reconcile fingerprint must track. -func adoptedWireInbound(c, snapIb *model.Inbound, adoptedSettings string) *model.Inbound { - a := *c - a.Enable = snapIb.Enable - a.Remark = snapIb.Remark - a.SubSortIndex = normalizeSubSortIndex(snapIb.SubSortIndex) - a.Listen = snapIb.Listen - a.Port = snapIb.Port - a.Protocol = snapIb.Protocol - a.Total = snapIb.Total - a.ExpiryTime = snapIb.ExpiryTime - a.Settings = adoptedSettings - a.StreamSettings = snapIb.StreamSettings - a.Sniffing = snapIb.Sniffing - a.TrafficReset = snapIb.TrafficReset - a.TrafficResetDay = normalizeTrafficResetDay(snapIb.TrafficResetDay) - return &a -} - -func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty, justPushed bool) (bool, error) { - if snap == nil || nodeID <= 0 { - return false, nil - } - db := database.GetDB() - now := time.Now().UnixMilli() - - // originGuidFor attributes a synced inbound to the panel that physically - // hosts it. A node's OWN inbounds report either an empty origin or — on - // builds that set it locally — the node's own panelGuid; both resolve to - // selfKey, which is the node's panelGuid unless that GUID is ambiguous - // (shared with another node or the master, i.e. a cloned server), in which - // case it falls back to the node-unique id so #4983 attribution doesn't - // collapse two physical nodes into one bucket. Only a DIFFERENT, non-empty - // origin (an inbound the node forwards from its own sub-node) is kept as-is, - // so a chained Node1->Node2->Node3 still attributes Node3's inbounds to Node3. - var nodeRow model.Node - db.Select("guid", "config_dirty", "inbound_sync_mode", "inbound_tags").Where("id = ?", nodeID).First(&nodeRow) - // Re-read inside the serialized writer: a client added while this snapshot - // was in flight marks the node dirty after the caller sampled the flag. - dirty = dirty || nodeRow.ConfigDirty - // Adoption, record sync and sweeps still run on a just-pushed tick; only the - // client lifecycle merge waits for a snapshot that reflects the push. - lifecycleFrozen := dirty || justPushed - nodeRow.Id = nodeID - unmanagedTag := unmanagedTagPredicate(&nodeRow) - selfKey := effectiveNodeKey(&model.Node{Id: nodeID, Guid: nodeRow.Guid}) - guidShared := nodeRow.Guid != "" && selfKey != nodeRow.Guid - originGuidFor := func(snapIb *model.Inbound) string { - if snapIb.OriginNodeGuid != "" && snapIb.OriginNodeGuid != nodeRow.Guid { - return snapIb.OriginNodeGuid - } - return selfKey - } - - var central []model.Inbound - if err := db.Model(model.Inbound{}). - Where("node_id = ?", nodeID). - Find(¢ral).Error; err != nil { - return false, err - } - // Index under the stored tag and its prefix-flipped form so a snap matches - // whether the n- prefix lives on the node side, the central side, or - // neither — a mismatch must never spawn a duplicate central inbound. - tagToCentral := make(map[string]*model.Inbound, len(central)*2) - prefix := nodeTagPrefix(&nodeID) - for i := range central { - tagToCentral[central[i].Tag] = ¢ral[i] - if prefix != "" { - if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found { - tagToCentral[stripped] = ¢ral[i] - } else { - tagToCentral[prefix+central[i].Tag] = ¢ral[i] - } - } - } - - var centralClientStats []xray.ClientTraffic - if len(central) > 0 { - ids := make([]int, 0, len(central)) - for i := range central { - ids = append(ids, central[i].Id) - } - if err := db.Model(xray.ClientTraffic{}). - Where("inbound_id IN ?", ids). - Find(¢ralClientStats).Error; err != nil { - return false, err - } - } - type csKey struct { - inboundID int - email string - } - centralCS := make(map[csKey]*xray.ClientTraffic, len(centralClientStats)) - centralCSByEmail := make(map[string]*xray.ClientTraffic, len(centralClientStats)) - for i := range centralClientStats { - centralCS[csKey{centralClientStats[i].InboundId, centralClientStats[i].Email}] = ¢ralClientStats[i] - centralCSByEmail[centralClientStats[i].Email] = ¢ralClientStats[i] - } - - nodeBaselines := make(map[string]nodeTrafficCounter) - var baselineRows []model.NodeClientTraffic - if err := db.Model(&model.NodeClientTraffic{}). - Where("node_id = ?", nodeID). - Find(&baselineRows).Error; err != nil { - return false, err - } - for i := range baselineRows { - nodeBaselines[baselineRows[i].Email] = nodeTrafficCounter{Up: baselineRows[i].Up, Down: baselineRows[i].Down} - } - - var defaultUserId int - if len(central) > 0 { - defaultUserId = central[0].UserId - } else { - var u model.User - if err := db.Model(model.User{}).Order("id asc").First(&u).Error; err == nil { - defaultUserId = u.Id - } else { - defaultUserId = 1 - } - } - - // Union of every email the snapshot still reports, across all inbounds. - // The (node, email) baseline rows are keyed per node, not per inbound, so - // the sweeps below must only drop one when the email left the node - // entirely — an email whose stats moved to (or always lived under) a - // sibling inbound still needs its baseline for the sibling's delta - // computation (#5202). - // - // Xray counts traffic per email, not per inbound, so a multi-attached - // client's shared counter is copied onto every inbound it's on. Fold each - // email to its per-field max (nodeEmailTotals) so divergent copies can't make - // the reset clamp re-add a lower sibling as fresh traffic (#5274). - snapEmailsAll := make(map[string]struct{}) - nodeEmailTotals := make(map[string]nodeTrafficCounter) - for _, snapIb := range snap.Inbounds { - if snapIb == nil { - continue - } - for i := range snapIb.ClientStats { - email := snapIb.ClientStats[i].Email - snapEmailsAll[email] = struct{}{} - cur := nodeEmailTotals[email] - if snapIb.ClientStats[i].Up > cur.Up { - cur.Up = snapIb.ClientStats[i].Up - } - if snapIb.ClientStats[i].Down > cur.Down { - cur.Down = snapIb.ClientStats[i].Down - } - nodeEmailTotals[email] = cur - } - } - - // Membership set for the rowExists checks below. Only the snapshot's emails - // are ever probed, so scope the lookup to those instead of plucking the whole - // client_traffics table (50k+ rows) on every node poll. - existingEmails := make(map[string]struct{}, len(snapEmailsAll)) - if len(snapEmailsAll) > 0 { - snapEmailList := make([]string, 0, len(snapEmailsAll)) - for email := range snapEmailsAll { - snapEmailList = append(snapEmailList, email) - } - for _, batch := range chunkStrings(snapEmailList, sqliteMaxVars) { - var found []string - if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Pluck("email", &found).Error; err != nil { - return false, err - } - for _, e := range found { - existingEmails[e] = struct{}{} - } - } - } - - tx := db.Begin() - committed := false - defer func() { - if !committed { - tx.Rollback() - } - }() - - structuralChange := false - lifecycleLifted := false - - var adoptedInbounds []*model.Inbound - type pendingAdopt struct { - central *model.Inbound - snapIb *model.Inbound - wireSettings string - } - var pendingAdopts []pendingAdopt - - newInboundIDs := make(map[int]struct{}) - - snapTags := make(map[string]struct{}, len(snap.Inbounds)) - for _, snapIb := range snap.Inbounds { - if snapIb == nil { - continue - } - snapTags[snapIb.Tag] = struct{}{} - // Record the prefix-flipped form too so the orphan sweep below keeps a - // central inbound whether its tag carries the n- prefix or not. - if prefix != "" { - if stripped, found := strings.CutPrefix(snapIb.Tag, prefix); found { - snapTags[stripped] = struct{}{} - } else { - snapTags[prefix+snapIb.Tag] = struct{}{} - } - } - - c, ok := tagToCentral[snapIb.Tag] - if !ok { - origin := originGuidFor(snapIb) - var compatible []*model.Inbound - for i := range central { - candidate := ¢ral[i] - if candidate.OriginNodeGuid == origin && - candidate.Port == snapIb.Port && - candidate.Protocol == snapIb.Protocol && - strings.TrimSpace(candidate.Listen) == strings.TrimSpace(snapIb.Listen) { - compatible = append(compatible, candidate) - } - } - switch len(compatible) { - case 1: - c, ok = compatible[0], true - tagToCentral[snapIb.Tag] = c - snapTags[c.Tag] = struct{}{} - case 0: - // A genuinely new inbound follows the normal adoption path. - default: - return false, fmt.Errorf("setRemoteTraffic: inbound %q has ambiguous compatible central aliases", snapIb.Tag) - } - } - if !ok { - if dirty { - continue - } - // Try snap.Tag first; on collision fall back to the n- - // prefixed form so local+node can both own the same port. - pickFreeTag := func() (string, error) { - candidates := []string{snapIb.Tag} - if prefix != "" && !strings.HasPrefix(snapIb.Tag, prefix) { - candidates = append(candidates, prefix+snapIb.Tag) - } - for _, t := range candidates { - var owner model.Inbound - err := tx.Where("tag = ?", t).First(&owner).Error - if errors.Is(err, gorm.ErrRecordNotFound) { - return t, nil - } - if err != nil { - return "", err - } - } - return "", nil - } - chosenTag, err := pickFreeTag() - if err != nil { - logger.Warningf("setRemoteTraffic: check tag %q failed: %v", snapIb.Tag, err) - continue - } - if chosenTag == "" { - key := fmt.Sprintf("%d:%s", nodeID, snapIb.Tag) - if _, seen := reportedRemoteTagConflict.LoadOrStore(key, struct{}{}); !seen { - logger.Warningf( - "setRemoteTraffic: tag %q from node %d collides with an existing inbound even after the n%d- prefix — skipping (rename one side to remove the duplicate)", - snapIb.Tag, nodeID, nodeID, - ) - } - continue - } - reportedRemoteTagConflict.Delete(fmt.Sprintf("%d:%s", nodeID, snapIb.Tag)) - newIb := model.Inbound{ - UserId: defaultUserId, - NodeID: &nodeID, - OriginNodeGuid: originGuidFor(snapIb), - Tag: chosenTag, - Listen: snapIb.Listen, - Port: snapIb.Port, - Protocol: snapIb.Protocol, - Settings: snapIb.Settings, - StreamSettings: snapIb.StreamSettings, - Sniffing: snapIb.Sniffing, - TrafficReset: snapIb.TrafficReset, - TrafficResetDay: normalizeTrafficResetDay(snapIb.TrafficResetDay), - LastTrafficResetTime: snapIb.LastTrafficResetTime, - Enable: snapIb.Enable, - Remark: snapIb.Remark, - SubSortIndex: normalizeSubSortIndex(snapIb.SubSortIndex), - Total: snapIb.Total, - ExpiryTime: snapIb.ExpiryTime, - Up: snapIb.Up, - Down: snapIb.Down, - ShareAddrStrategy: "node", - DisableFlow: snapIb.DisableFlow, - } - if err := tx.Create(&newIb).Error; err != nil { - logger.Warningf("setRemoteTraffic: create central inbound for tag %q failed: %v", snapIb.Tag, err) - continue - } - tagToCentral[snapIb.Tag] = &newIb - if newIb.Tag != snapIb.Tag { - tagToCentral[newIb.Tag] = &newIb - } - if rows := adoptedHostRows(snap.HostGroups, snapIb.Id, newIb.Id); len(rows) > 0 { - if err := tx.Create(&rows).Error; err != nil { - logger.Warningf("setRemoteTraffic: adopt host rows for tag %q failed: %v", newIb.Tag, err) - } - } - newInboundIDs[newIb.Id] = struct{}{} - structuralChange = true - continue - } - - inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs - - // Adopting the node's settings verbatim would re-add a client the master - // deleted moments ago if this snapshot was fetched before the deletion - // push landed — filter just-deleted emails out while their tombstone lives. - adoptedSettings := snapIb.Settings - if stripped, changed := stripTombstonedClients(adoptedSettings); changed { - adoptedSettings = stripped - } - if deduped, changed := dedupeSettingsClients(adoptedSettings); changed { - adoptedSettings = deduped - } - updates := map[string]any{} - if !dirty { - // Defer lifecycle lift until after client_traffics absorbs this tick's - // deltas so quota stale-disable matches SQL (#6228). - pendingAdopts = append(pendingAdopts, pendingAdopt{ - central: c, snapIb: snapIb, wireSettings: adoptedSettings, - }) - updates["enable"] = snapIb.Enable - updates["remark"] = snapIb.Remark - updates["sub_sort_index"] = normalizeSubSortIndex(snapIb.SubSortIndex) - updates["listen"] = snapIb.Listen - updates["port"] = snapIb.Port - updates["protocol"] = snapIb.Protocol - updates["total"] = snapIb.Total - updates["expiry_time"] = snapIb.ExpiryTime - updates["stream_settings"] = snapIb.StreamSettings - updates["sniffing"] = snapIb.Sniffing - updates["traffic_reset"] = snapIb.TrafficReset - updates["traffic_reset_day"] = normalizeTrafficResetDay(snapIb.TrafficResetDay) - updates["last_traffic_reset_time"] = snapIb.LastTrafficResetTime - } - if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) { - updates["up"] = snapIb.Up - updates["down"] = snapIb.Down - } - // Physical-home attribution is independent of config-dirty state, so - // keep it current even while the node has pending offline edits. Writes - // once to backfill an existing row, then stays equal (#4983). - if og := originGuidFor(snapIb); c.OriginNodeGuid != og { - updates["origin_node_guid"] = og - } - - if !dirty && (c.Remark != snapIb.Remark || - c.Listen != snapIb.Listen || - c.Port != snapIb.Port || - c.Total != snapIb.Total || - c.ExpiryTime != snapIb.ExpiryTime || - c.Enable != snapIb.Enable) { - structuralChange = true - } - - if len(updates) > 0 { - if err := tx.Model(model.Inbound{}). - Where("id = ?", c.Id). - Updates(updates).Error; err != nil { - return false, err - } - } - } - - for _, c := range central { - if dirty { - continue - } - // A node inbound created disabled is never delivered, so its absence from - // the snapshot is ambiguous rather than evidence of a node-side delete. - if !c.Enable { - continue - } - if len(snapTags) == 0 { - // A node mid-restart or with a transient DB error can return an empty - // inbound list with success=true. Treat "zero inbounds reported" as - // "nothing to say", not "delete all my inbounds" — otherwise a blip - // wipes the node's central inbounds and every client on them (and - // resets traffic history on re-create). A real per-inbound deletion - // still sweeps, because the node keeps reporting its other inbounds. - continue - } - if _, kept := snapTags[c.Tag]; kept { - continue - } - if unmanagedTag(c.Tag) { - continue - } - // This drops the central inbound and its clients' traffic history, so say - // so: silent removal is indistinguishable from an inbound never arriving. - logger.Warningf("setRemoteTraffic: node %d no longer reports inbound %q (id %d, port %d) — removing it centrally", nodeID, c.Tag, c.Id, c.Port) - var goneEmails []string - if err := tx.Model(xray.ClientTraffic{}). - Where("inbound_id = ?", c.Id). - Pluck("email", &goneEmails).Error; err != nil { - return false, err - } - if len(goneEmails) > 0 { - // Baselines are per (node, email), not per inbound: keep them for - // emails the snapshot still reports under a sibling inbound (#5202). - baselineGone := make([]string, 0, len(goneEmails)) - for _, e := range goneEmails { - if _, still := snapEmailsAll[e]; !still { - baselineGone = append(baselineGone, e) - } - } - // Chunk to avoid SQLite bind var limit when a node has many clients - // removed (e.g. after API bulk delete or structural change on node inbound). - for _, batch := range chunkStrings(baselineGone, sqliteMaxVars) { - if err := tx.Where("node_id = ? AND email IN ?", nodeID, batch). - Delete(&model.NodeClientTraffic{}).Error; err != nil { - return false, err - } - } - // The per-email row is the shared accumulator across every inbound - // (and node) the email is attached to. Only drop it when this was the - // email's last inbound — wiping it while a sibling still feeds it - // loses the summed history, and the next node sync would re-seed the - // row with that node's counter alone. - sharedEmails, sErr := s.emailsUsedByOtherInbounds(goneEmails, c.Id) - if sErr != nil { - return false, sErr - } - delEmails := make([]string, 0, len(goneEmails)) - for _, e := range goneEmails { - if !sharedEmails[strings.ToLower(strings.TrimSpace(e))] { - delEmails = append(delEmails, e) - } - } - for _, batch := range chunkStrings(delEmails, sqliteMaxVars) { - if err := tx.Where("inbound_id = ? AND email IN ?", c.Id, batch). - Delete(&xray.ClientTraffic{}).Error; err != nil { - return false, err - } - } - } - if err := s.clientService.DetachInbound(tx, c.Id); err != nil { - return false, err - } - if err := tx.Where("id = ?", c.Id). - Delete(&model.Inbound{}).Error; err != nil { - return false, err - } - delete(tagToCentral, c.Tag) - structuralChange = true - } - - for _, snapIb := range snap.Inbounds { - if snapIb == nil { - continue - } - c, ok := tagToCentral[snapIb.Tag] - if !ok { - continue - } - snapEmails := make(map[string]struct{}, len(snapIb.ClientStats)) - // Parsed once per inbound on the first renewal candidate, not per client. - var snapExpiries map[string]int64 - for _, cs := range snapIb.ClientStats { - snapEmails[cs.Email] = struct{}{} - - // Node-wide total, not this inbound's possibly-stale copy (#5274). - canon := nodeEmailTotals[cs.Email] - - base, seen := nodeBaselines[cs.Email] - var deltaUp, deltaDown int64 - if seen { - if deltaUp = canon.Up - base.Up; deltaUp < 0 { - deltaUp = 0 - } - if deltaDown = canon.Down - base.Down; deltaDown < 0 { - deltaDown = 0 - } - } - - if _, rowExists := existingEmails[cs.Email]; !rowExists { - if dirty { - continue - } - _, isNewInbound := newInboundIDs[c.Id] - // On a known inbound a missing row plus a live tombstone means the - // master just deleted this client and the snapshot predates the - // deletion push — recreating the row (at zero) would resurrect the - // client. A freshly adopted inbound still gets its row (seeded at - // zero) so adoption semantics stay intact. - if !isNewInbound && isClientEmailTombstoned(cs.Email) { - continue - } - var seedUp, seedDown int64 - if isNewInbound && !isClientEmailTombstoned(cs.Email) { - seedUp, seedDown = canon.Up, canon.Down - } - row := &xray.ClientTraffic{ - InboundId: c.Id, - Email: cs.Email, - Enable: cs.Enable, - Total: cs.Total, - ExpiryTime: cs.ExpiryTime, - Reset: cs.Reset, - ResetDay: cs.ResetDay, - Up: seedUp, - Down: seedDown, - LastOnline: cs.LastOnline, - } - if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "email"}}, DoNothing: true}). - Create(row).Error; err != nil { - return false, err - } - centralCS[csKey{c.Id, cs.Email}] = row - centralCSByEmail[cs.Email] = row - existingEmails[cs.Email] = struct{}{} - structuralChange = true - if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil { - return false, err - } - nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down} - continue - } - - existing := centralCSByEmail[cs.Email] - if existing != nil { - expiryChanged := !lifecycleFrozen && existing.ExpiryTime != mergeActivationExpiry(existing.ExpiryTime, cs.ExpiryTime) - // Only a real latch to disabled is structural; one-way merge never - // re-enables from the node. - enableChanged := !lifecycleFrozen && existing.Enable && !cs.Enable && - !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) - metaChanged := !lifecycleFrozen && (existing.Total != cs.Total || existing.Reset != cs.Reset) - if enableChanged || metaChanged || expiryChanged { - structuralChange = true - } - } - - renewed := !lifecycleFrozen && seen && existing != nil && nodeClientRenewed(existing, cs, canon, base) - if renewed { - // Reject when the node's own settings still carry the old absolute: - // lagging ClientStats after a master shorten mimic a renew (#6228). - if snapExpiries == nil { - snapExpiries = settingsClientAbsoluteExpiries(snapIb.Settings) - } - if se, ok := snapExpiries[cs.Email]; ok && se <= existing.ExpiryTime { - renewed = false - } - } - if renewed { - // A renewal starts a fresh quota window: adopt the node's counters - // and enable state, drop stale pushes (mirrors autoRenewClients). - if err := tx.Exec( - fmt.Sprintf( - `UPDATE client_traffics - SET up = ?, down = ?, enable = ?, total = ?, - expiry_time = ?, reset = ?, reset_day = ?, reset_count = ?, last_online = %s - WHERE email = ?`, - database.GreatestExpr("last_online", "?"), - ), - canon.Up, canon.Down, cs.Enable, cs.Total, - cs.ExpiryTime, cs.Reset, cs.ResetDay, cs.ResetCount, - cs.LastOnline, cs.Email, - ).Error; err != nil { - return false, err - } - if err := clearGlobalTraffic(tx, cs.Email); err != nil { - return false, err - } - existing.Up = canon.Up - existing.Down = canon.Down - existing.Enable = cs.Enable - existing.Total = cs.Total - existing.ExpiryTime = cs.ExpiryTime - existing.Reset = cs.Reset - existing.ResetCount = cs.ResetCount - structuralChange = true - } else if lifecycleFrozen { - // Push pending or just landed: only counters may move, the master - // keeps expiry/enable/total/reset. - if err := tx.Exec( - fmt.Sprintf( - `UPDATE client_traffics - SET up = %s, down = %s, last_online = %s - WHERE email = ?`, - database.ClampedAddExpr("up"), - database.ClampedAddExpr("down"), - database.GreatestExpr("last_online", "?"), - ), - deltaUp, deltaDown, cs.LastOnline, cs.Email, - ).Error; err != nil { - return false, err - } - if existing != nil { - existing.Up = clampTrafficCounter(existing.Up + deltaUp) - existing.Down = clampTrafficCounter(existing.Down + deltaDown) - } - } else { - enableExpr := database.ClientTrafficEnableMergeExpr() - expiryExpr := database.ClientTrafficExpiryMergeExpr() - if err := tx.Exec( - fmt.Sprintf( - `UPDATE client_traffics - SET up = %s, down = %s, enable = %s, total = ?, - expiry_time = %s, - reset = ?, reset_day = ?, last_online = %s - WHERE email = ?`, - database.ClampedAddExpr("up"), - database.ClampedAddExpr("down"), - enableExpr, - expiryExpr, - database.GreatestExpr("last_online", "?"), - ), - deltaUp, deltaDown, - cs.Enable, cs.ExpiryTime, cs.Total, now, deltaUp, deltaDown, - cs.Total, - cs.ExpiryTime, cs.Reset, cs.ResetDay, - cs.LastOnline, cs.Email, - ).Error; err != nil { - return false, err - } - if existing != nil { - priorExpiry := existing.ExpiryTime - if !cs.Enable && !nodeDisableIsStale(existing, cs, now, deltaUp, deltaDown) { - existing.Enable = false - } - existing.ExpiryTime = mergeActivationExpiry(priorExpiry, cs.ExpiryTime) - existing.Up = clampTrafficCounter(existing.Up + deltaUp) - existing.Down = clampTrafficCounter(existing.Down + deltaDown) - existing.Total = cs.Total - existing.Reset = cs.Reset - } - } - // A dip plus a lagging longer expiry mimics nodeClientRenewed and would - // undo a master shorten once the freeze lifts (#6228). - if lifecycleFrozen && seen && (canon.Up < base.Up || canon.Down < base.Down) { - continue - } - if err := s.upsertNodeBaseline(tx, nodeID, cs.Email, canon.Up, canon.Down); err != nil { - return false, err - } - nodeBaselines[cs.Email] = nodeTrafficCounter{Up: canon.Up, Down: canon.Down} - } - - for k, existing := range centralCS { - if dirty { - continue - } - if k.inboundID != c.Id { - continue - } - if _, kept := snapEmails[k.email]; kept { - continue - } - // Gone from this inbound's stats but still reported by the node under - // a sibling inbound: both the shared accumulator row and the (node, - // email) baseline must survive, or the sibling's next delta would - // compute against nothing and freeze the counter (#5202). - if _, still := snapEmailsAll[k.email]; still { - continue - } - if err := tx.Where("node_id = ? AND email = ?", nodeID, existing.Email). - Delete(&model.NodeClientTraffic{}).Error; err != nil { - return false, err - } - // Same shared-accumulator rule as the inbound-removal sweep above: - // keep the row while another inbound still references the email. - stillUsed, uErr := s.emailUsedByOtherInbounds(existing.Email, c.Id) - if uErr != nil { - return false, uErr - } - // Usage, quota and expiry live on this row, so a client the orphan - // sweep will mark keeps it until the reaper confirms the removal. - if !stillUsed && !clientRecordExists(tx, existing.Email) { - if err := tx.Where("inbound_id = ? AND email = ?", c.Id, existing.Email). - Delete(&xray.ClientTraffic{}).Error; err != nil { - return false, err - } - } - structuralChange = true - } - } - - type oldSet struct { - inboundID int - emails map[string]struct{} - } - var perInboundOld []oldSet - syncFailedInbounds := map[int]struct{}{} - for _, p := range pendingAdopts { - lifted, liftChanged := liftClientLifecycleInSettings(p.wireSettings, centralCSByEmail) - adoptedSettings := p.wireSettings - if liftChanged { - adoptedSettings = lifted - lifecycleLifted = true - } - if p.central.Settings != adoptedSettings { - if err := tx.Model(model.Inbound{}). - Where("id = ?", p.central.Id). - Update("settings", adoptedSettings).Error; err != nil { - return false, err - } - structuralChange = true - } - // The fingerprint stamps the un-lifted wire blob on purpose: a lift must - // leave reconcile a mismatch to re-push against. - if liftChanged || adoptedWireChanged(p.central, p.snapIb, p.wireSettings) { - adoptedInbounds = append(adoptedInbounds, adoptedWireInbound(p.central, p.snapIb, p.wireSettings)) - } - } - for _, snapIb := range snap.Inbounds { - if snapIb == nil { - continue - } - c, ok := tagToCentral[snapIb.Tag] - if !ok { - continue - } - if dirty { - continue - } - var oldEmailsRows []string - if err := tx.Table("clients"). - Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). - Where("client_inbounds.inbound_id = ?", c.Id). - Pluck("email", &oldEmailsRows).Error; err == nil { - oldEmails := make(map[string]struct{}, len(oldEmailsRows)) - for _, e := range oldEmailsRows { - if e != "" { - oldEmails[e] = struct{}{} - } - } - perInboundOld = append(perInboundOld, oldSet{inboundID: c.Id, emails: oldEmails}) - } - - clients, gcErr := s.GetClients(snapIb) - if gcErr != nil { - logger.Warningf("setRemoteTraffic: parse clients for tag %q failed: %v", snapIb.Tag, gcErr) - continue - } - csByEmail := make(map[string]xray.ClientTraffic, len(snapIb.ClientStats)) - for _, cs := range snapIb.ClientStats { - csByEmail[cs.Email] = cs - } - filtered := clients[:0] - for i := range clients { - if isClientEmailTombstoned(clients[i].Email) { - continue - } - existing := centralCSByEmail[clients[i].Email] - var csPtr *xray.ClientTraffic - if cs, hit := csByEmail[clients[i].Email]; hit { - csCopy := cs - csPtr = &csCopy - } - applyMasterClientLifecycle(&clients[i], existing, csPtr) - filtered = append(filtered, clients[i]) - } - localEmails := make([]string, 0, len(filtered)) - for i := range filtered { - if filtered[i].Email != "" { - localEmails = append(localEmails, filtered[i].Email) - } - } - if len(localEmails) > 0 { - var localMeta []struct { - Email string - Comment string `gorm:"column:comment"` - } - if err := tx.Table("clients"). - Select("email, comment"). - Where("email IN ?", localEmails). - Find(&localMeta).Error; err == nil { - commentByEmail := make(map[string]string, len(localMeta)) - for _, m := range localMeta { - commentByEmail[m.Email] = m.Comment - } - for i := range filtered { - if cmt, ok := commentByEmail[filtered[i].Email]; ok { - filtered[i].Comment = cmt - } - } - } - } - if err := s.clientService.SyncInbound(tx, c.Id, filtered); err != nil { - logger.Warningf("setRemoteTraffic: sync clients for tag %q failed: %v", snapIb.Tag, err) - syncFailedInbounds[c.Id] = struct{}{} - } - } - - for _, old := range perInboundOld { - // The sweep's premise is that links were just rebuilt from the snapshot, - // which is exactly what a failed SyncInbound violates. - if _, failed := syncFailedInbounds[old.inboundID]; failed { - continue - } - var stillAttached []string - if err := tx.Table("clients"). - Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id"). - Where("client_inbounds.inbound_id = ?", old.inboundID). - Pluck("email", &stillAttached).Error; err != nil { - continue - } - stillSet := make(map[string]struct{}, len(stillAttached)) - for _, e := range stillAttached { - stillSet[e] = struct{}{} - } - for email := range old.emails { - if _, kept := stillSet[email]; kept { - continue - } - var attachmentCount int64 - if err := tx.Table("client_inbounds"). - Joins("JOIN clients ON clients.id = client_inbounds.client_id"). - Where("clients.email = ?", email). - Count(&attachmentCount).Error; err != nil { - continue - } - if attachmentCount > 0 { - continue - } - // "Ended the merge unattached" is true for a real remote deletion and - // equally true for a bad merge, so record a strike instead of deleting. - if err := markSyncOrphan(tx, email, now); err != nil { - logger.Warningf("setRemoteTraffic: mark orphan %q failed: %v", email, err) - continue - } - structuralChange = true - } - } - - if err := clearSyncOrphanMarks(tx); err != nil { - logger.Warning("setRemoteTraffic: clear orphan marks failed:", err) - } - - if err := liftActivatedClientRecordExpiries(tx); err != nil { - logger.Warning("setRemoteTraffic: lift activated expiries failed:", err) - } - - if err := tx.Commit().Error; err != nil { - return false, err - } - committed = true - - if lifecycleLifted && !dirty { - var already model.Node - if err := database.GetDB().Select("config_dirty").Where("id = ?", nodeID).First(&already).Error; err == nil && already.ConfigDirty { - logger.Debugf("setRemoteTraffic: node %d lifecycle lift; already dirty", nodeID) - } else { - logger.Infof("setRemoteTraffic: node %d lifecycle lift; marking dirty for re-push", nodeID) - if err := (&NodeService{}).MarkNodeDirty(nodeID); err != nil { - logger.Warningf("setRemoteTraffic: mark node %d dirty after lifecycle lift failed: %v", nodeID, err) - } - } - } - - if len(adoptedInbounds) > 0 { - if mgr := runtime.GetManager(); mgr != nil { - if rt, rtErr := mgr.RuntimeFor(&nodeID); rtErr == nil { - if rem, ok := rt.(*runtime.Remote); ok { - for _, ib := range adoptedInbounds { - rem.RecordAdoptedInbound(ib) - } - } - } - } - } - - tree := snap.OnlineTree - switch { - case len(tree) == 0 && len(snap.OnlineEmails) > 0: - // Old-build node (no GUID tree): key its flat online list under its - // own effective identity so attribution still works for that branch. - tree = map[string][]string{selfKey: snap.OnlineEmails} - case guidShared && len(tree) > 0: - // Newer cloned node: its own clients arrive keyed under the shared - // panelGuid. Remap just that entry to the node-unique key so the - // clones don't merge; descendant subtrees keep their distinct GUIDs. - if _, ok := tree[nodeRow.Guid]; ok { - tree = remapGuidTreeKey(tree, nodeRow.Guid, selfKey) - } - } - s.SetNodeOnlineTree(nodeID, tree) - - activeTree := normalizeActiveInboundTreeTags(snap.ActiveInboundTree, tagToCentral) - if guidShared && len(activeTree) > 0 { - if _, ok := activeTree[nodeRow.Guid]; ok { - activeTree = remapGuidTreeKey(activeTree, nodeRow.Guid, selfKey) - } - } - if len(activeTree) > 0 { - activeTree = filterGuidTreeKeys(activeTree, activeInboundGuidKeys(snap.Inbounds, tagToCentral, originGuidFor)) - } - s.SetNodeActiveInboundTree(nodeID, activeTree) - - return structuralChange, nil -} - -func (s *InboundService) restartRemoteNodesOnDisable(nodeIDs []int) { - restartOnDisable, err := (&SettingService{}).GetRestartXrayOnClientDisable() - if err != nil { - logger.Warning("disableInvalidClients: get RestartXrayOnClientDisable failed:", err) - return - } - if !restartOnDisable { - return - } - for _, nodeID := range nodeIDs { - nodeIDCopy := nodeID - rt, rtErr := runtime.GetManager().RuntimeFor(&nodeIDCopy) - if rtErr != nil { - logger.Warning("disableInvalidClients: get runtime for node", nodeID, "failed:", rtErr) - continue - } - if rtErr = rt.RestartXray(context.Background()); rtErr != nil { - logger.Warning("disableInvalidClients: restart xray on node", nodeID, "failed:", rtErr) - } - } -} - func (s *InboundService) GetOnlineClients() []string { // Lite master: no local core aggregates online clients; the online // store (see online_store.go) is fed by agent reports and node polling. @@ -1436,85 +240,6 @@ func mergeEmails(a, b []string) []string { return out } -func remapGuidTreeKey(tree map[string][]string, from, to string) map[string][]string { - if from == "" || to == "" || from == to { - return tree - } - remapped := make(map[string][]string, len(tree)) - for guid, values := range tree { - if guid == from { - guid = to - } - remapped[guid] = mergeEmails(remapped[guid], values) - } - return remapped -} - -func normalizeActiveInboundTreeTags(tree map[string][]string, tagToCentral map[string]*model.Inbound) map[string][]string { - if len(tree) == 0 { - return nil - } - out := make(map[string][]string, len(tree)) - for guid, tags := range tree { - if guid == "" || len(tags) == 0 { - continue - } - seen := make(map[string]struct{}, len(tags)) - for _, tag := range tags { - if tag == "" { - continue - } - if central, ok := tagToCentral[tag]; ok && central != nil && central.Tag != "" { - tag = central.Tag - } - if _, dup := seen[tag]; dup { - continue - } - seen[tag] = struct{}{} - out[guid] = append(out[guid], tag) - } - } - if len(out) == 0 { - return nil - } - return out -} - -func activeInboundGuidKeys(inbounds []*model.Inbound, tagToCentral map[string]*model.Inbound, originGuidFor func(*model.Inbound) string) map[string]struct{} { - allowed := make(map[string]struct{}) - for _, ib := range inbounds { - if ib == nil { - continue - } - if _, ok := tagToCentral[ib.Tag]; !ok { - continue - } - if guid := originGuidFor(ib); guid != "" { - allowed[guid] = struct{}{} - } - } - return allowed -} - -func filterGuidTreeKeys(tree map[string][]string, allowed map[string]struct{}) map[string][]string { - if len(tree) == 0 || len(allowed) == 0 { - return nil - } - out := make(map[string][]string, len(tree)) - for guid, values := range tree { - if _, ok := allowed[guid]; !ok { - continue - } - if len(values) > 0 { - out[guid] = values - } - } - if len(out) == 0 { - return nil - } - return out -} - func (s *InboundService) GetClientsLastOnline() (map[string]int64, error) { db := database.GetDB() var rows []xray.ClientTraffic diff --git a/internal/web/service/inbound_node_ips.go b/internal/web/service/inbound_node_ips.go index 2384046..bcedc16 100644 --- a/internal/web/service/inbound_node_ips.go +++ b/internal/web/service/inbound_node_ips.go @@ -119,67 +119,6 @@ func (s *InboundService) RecordLocalClientIps(panelGuid string, observed map[str return upsertNodeClientIps(panelGuid, observed) } -// MergeClientIpsByGuid folds a node's guid-keyed attribution report (its own -// panelGuid subtree plus any descendants) into the local table, preserving which -// physical node each IP is on across a chain. When node is non-nil and its own -// panelGuid is ambiguous (shared with another node or the master — a cloned -// server), the node's own subtree is remapped to its node-unique key so two -// clones don't collapse into one attribution row; descendant subtrees keep their -// distinct GUIDs. A nil node merges the report verbatim. -func (s *InboundService) MergeClientIpsByGuid(node *model.Node, trees map[string]map[string][]model.ClientIpEntry) error { - if node != nil && node.Guid != "" { - if eff := effectiveNodeKey(node); eff != node.Guid { - if sub, ok := trees[node.Guid]; ok { - delete(trees, node.Guid) - if existing, ok := trees[eff]; ok { - for email, ips := range sub { - existing[email] = append(existing[email], ips...) - } - } else { - trees[eff] = sub - } - } - } - } - for guid, perEmail := range trees { - if err := upsertNodeClientIps(guid, perEmail); err != nil { - return err - } - } - return nil -} - -// GetClientIpsByGuid returns this panel's full attribution subtree (guid -> email -// -> fresh IPs), dropping stale entries. It is what the clientIpsByGuid endpoint -// serves to a parent panel. -func (s *InboundService) GetClientIpsByGuid() (map[string]map[string][]model.ClientIpEntry, error) { - db := database.GetDB() - var rows []model.NodeClientIp - if err := db.Find(&rows).Error; err != nil { - return nil, err - } - cutoff := time.Now().Unix() - clientIpStaleAfterSeconds - out := make(map[string]map[string][]model.ClientIpEntry) - for _, row := range rows { - if row.NodeGuid == "" || row.Email == "" || row.Ips == "" { - continue - } - var entries []model.ClientIpEntry - if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { - continue - } - fresh := mergeModelClientIpEntries(nil, entries, cutoff) - if len(fresh) == 0 { - continue - } - if out[row.NodeGuid] == nil { - out[row.NodeGuid] = make(map[string][]model.ClientIpEntry) - } - out[row.NodeGuid][row.Email] = fresh - } - return out, nil -} - // GetClientIpNodeAttribution returns, for one client email, a map of IP -> the // guid that most recently observed it (within the stale window). Used to label // each IP in the panel with the node it is connecting to. diff --git a/internal/web/service/inbound_node_ips_test.go b/internal/web/service/inbound_node_ips_test.go index c534b14..6875ace 100644 --- a/internal/web/service/inbound_node_ips_test.go +++ b/internal/web/service/inbound_node_ips_test.go @@ -9,6 +9,37 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) +// clientIpTrees reads the node client-IP store back as guid -> email -> +// entries (fresh entries only). It replaces the removed GetClientIpsByGuid +// service read, whose only remaining consumers were these tests. +func clientIpTrees(t *testing.T) map[string]map[string][]model.ClientIpEntry { + t.Helper() + var rows []model.NodeClientIp + if err := database.GetDB().Find(&rows).Error; err != nil { + t.Fatalf("read ip store: %v", err) + } + cutoff := time.Now().Unix() - clientIpStaleAfterSeconds + out := make(map[string]map[string][]model.ClientIpEntry) + for _, row := range rows { + if row.NodeGuid == "" || row.Email == "" || row.Ips == "" { + continue + } + var entries []model.ClientIpEntry + if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { + continue + } + fresh := mergeModelClientIpEntries(nil, entries, cutoff) + if len(fresh) == 0 { + continue + } + if out[row.NodeGuid] == nil { + out[row.NodeGuid] = make(map[string][]model.ClientIpEntry) + } + out[row.NodeGuid][row.Email] = fresh + } + return out +} + func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) { setupClientIpTestDB(t) now := time.Now().Unix() @@ -20,10 +51,7 @@ func TestRecordLocalClientIps_RoundTripByGuid(t *testing.T) { t.Fatalf("record: %v", err) } - trees, err := svc.GetClientIpsByGuid() - if err != nil { - t.Fatalf("byGuid: %v", err) - } + trees := clientIpTrees(t) got := trees["guid-A"]["u@x"] if len(got) != 2 { t.Fatalf("want 2 entries, got %v", got) @@ -50,7 +78,7 @@ func TestRecordLocalClientIps_MergesAndDropsStale(t *testing.T) { t.Fatalf("record 2: %v", err) } - trees, _ := svc.GetClientIpsByGuid() + trees := clientIpTrees(t) got := map[string]int64{} for _, e := range trees["g"]["u@x"] { got[e.IP] = e.Timestamp @@ -103,8 +131,8 @@ func TestGetClientIpNodeAttribution_NewestGuidWins(t *testing.T) { }); err != nil { t.Fatalf("record gA: %v", err) } - if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{ - "gB": {"u@x": {{IP: "9.9.9.9", Timestamp: now}}}, + if err := upsertNodeClientIps("gB", map[string][]model.ClientIpEntry{ + "u@x": {{IP: "9.9.9.9", Timestamp: now}}, }); err != nil { t.Fatalf("merge gB: %v", err) } @@ -145,8 +173,8 @@ func TestGetClientIpsWithNodes_LabelsNodes(t *testing.T) { }); err != nil { t.Fatalf("record local: %v", err) } - if err := svc.MergeClientIpsByGuid(nil, map[string]map[string][]model.ClientIpEntry{ - "node-guid": {"u@x": {{IP: "2.2.2.2", Timestamp: now}}}, + if err := upsertNodeClientIps("node-guid", map[string][]model.ClientIpEntry{ + "u@x": {{IP: "2.2.2.2", Timestamp: now}}, }); err != nil { t.Fatalf("merge node: %v", err) } diff --git a/internal/web/service/inbound_node_move_test.go b/internal/web/service/inbound_node_move_test.go index 8393a51..89b0858 100644 --- a/internal/web/service/inbound_node_move_test.go +++ b/internal/web/service/inbound_node_move_test.go @@ -1,131 +1,34 @@ package service import ( - "context" "encoding/json" "fmt" - "net/http" - "net/http/httptest" - "sort" - "strconv" "strings" - "sync" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" ) -// statefulFakePanel is a node panel double with real state: added inbounds -// appear in its list, deleted ones disappear. It records every mutation so -// move tests can assert what reconcile actually pushed where. -type statefulFakePanel struct { - mu sync.Mutex - byTag map[string]int - byID map[int]string - nextID int - added []string - updated []int - deleted []int -} - -func newStatefulFakePanel(t *testing.T, seed map[string]int) (*httptest.Server, *statefulFakePanel) { +// moveTestNode creates a plain online node row for move tests. Nodes +// are never dialed anymore, so no fake panel stands behind the row. +func moveTestNode(t *testing.T, name string) *model.Node { t.Helper() - p := &statefulFakePanel{byTag: map[string]int{}, byID: map[int]string{}, nextID: 1000} - for tag, id := range seed { - p.byTag[tag] = id - p.byID[id] = tag + n := &model.Node{ + Name: name, + Scheme: "http", + Address: "127.0.0.1", + Port: 2053, + BasePath: "/", + Enable: true, + Status: "online", + InboundsAdoptedAt: 1, } - writeOK := func(w http.ResponseWriter, obj any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj}) + if err := database.GetDB().Create(n).Error; err != nil { + t.Fatalf("create node: %v", err) } - mux := http.NewServeMux() - mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) { - type row struct { - Id int `json:"id"` - Tag string `json:"tag"` - Port int `json:"port"` - Protocol string `json:"protocol"` - Listen string `json:"listen"` - } - p.mu.Lock() - rows := make([]row, 0, len(p.byTag)) - for tag, id := range p.byTag { - rows = append(rows, row{Id: id, Tag: tag}) - } - p.mu.Unlock() - writeOK(w, rows) - }) - mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "bad form", http.StatusBadRequest) - return - } - tag := r.Form.Get("tag") - p.mu.Lock() - p.nextID++ - id := p.nextID - p.byTag[tag] = id - p.byID[id] = tag - p.added = append(p.added, tag) - p.mu.Unlock() - writeOK(w, map[string]any{"id": id, "tag": tag}) - }) - mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/")) - if err != nil { - http.Error(w, "bad id", http.StatusBadRequest) - return - } - p.mu.Lock() - p.updated = append(p.updated, id) - p.mu.Unlock() - writeOK(w, nil) - }) - mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/")) - if err != nil { - http.Error(w, "bad id", http.StatusBadRequest) - return - } - p.mu.Lock() - if tag, ok := p.byID[id]; ok { - delete(p.byID, id) - delete(p.byTag, tag) - } - p.deleted = append(p.deleted, id) - p.mu.Unlock() - writeOK(w, nil) - }) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - return ts, p -} - -func (p *statefulFakePanel) tags() []string { - p.mu.Lock() - defer p.mu.Unlock() - out := make([]string, 0, len(p.byTag)) - for tag := range p.byTag { - out = append(out, tag) - } - sort.Strings(out) - return out -} - -func (p *statefulFakePanel) addedTags() []string { - p.mu.Lock() - defer p.mu.Unlock() - return append([]string(nil), p.added...) -} - -func (p *statefulFakePanel) deletedIDs() []int { - p.mu.Lock() - defer p.mu.Unlock() - return append([]int(nil), p.deleted...) + return n } const moveTestSettings = `{"clients":[{"id":"11111111-2222-3333-4444-555555555555","email":"mv@example.com","enable":true}]}` @@ -177,10 +80,8 @@ func clientTrafficTotals(t *testing.T, email string) (int64, int64) { func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) { setupConflictDB(t) - tsA, panelA := newStatefulFakePanel(t, map[string]int{"movable": 11}) - tsB, panelB := newStatefulFakePanel(t, nil) - nodeA := reconcileTestNode(t, tsA, "move-a", "all", nil) - nodeB := reconcileTestNode(t, tsB, "move-b", "all", nil) + nodeA := moveTestNode(t, "move-a") + nodeB := moveTestNode(t, "move-b") seeded := seedMoveInbound(t, "movable", 2443, &nodeA.Id) @@ -205,28 +106,8 @@ func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) { if up, down := clientTrafficTotals(t, "mv@example.com"); up != 111 || down != 222 { t.Fatalf("client traffic changed across move: up=%d down=%d, want 111/222", up, down) } - - // Reconcile the source: the moved inbound must be swept from panel A. - if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeA, nil), nodeA); err != nil { - t.Fatalf("ReconcileNode source: %v", err) - } - if got := panelA.deletedIDs(); len(got) != 1 || got[0] != 11 { - t.Fatalf("source panel deletions = %v, want [11]", got) - } - if tags := panelA.tags(); len(tags) != 0 { - t.Fatalf("source panel still holds tags %v after reconcile", tags) - } - - // Reconcile the target: the inbound must be pushed to panel B. - if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(nodeB, nil), nodeB); err != nil { - t.Fatalf("ReconcileNode target: %v", err) - } - if got := panelB.addedTags(); len(got) != 1 || got[0] != "movable" { - t.Fatalf("target panel additions = %v, want [movable]", got) - } - if tags := panelB.tags(); len(tags) != 1 || tags[0] != "movable" { - t.Fatalf("target panel tags = %v, want [movable]", tags) - } + // Convergence itself is the nodes' next config pull (both sides are + // dirty-marked above); the master no longer pushes to node panels. } // An auto-generated tag carries the node prefix: a move re-derives it under @@ -234,10 +115,8 @@ func TestUpdateInbound_MoveBetweenPanelNodes(t *testing.T) { func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) { setupConflictDB(t) - tsA, _ := newStatefulFakePanel(t, nil) - tsB, _ := newStatefulFakePanel(t, nil) - nodeA := reconcileTestNode(t, tsA, "tag-a", "all", nil) - nodeB := reconcileTestNode(t, tsB, "tag-b", "all", nil) + nodeA := moveTestNode(t, "tag-a") + nodeB := moveTestNode(t, "tag-b") oldTag := fmt.Sprintf("n%d-in-2444-tcp", nodeA.Id) seeded := seedMoveInbound(t, oldTag, 2444, &nodeA.Id) @@ -262,8 +141,7 @@ func TestUpdateInbound_MoveRegeneratesAutoTagPrefix(t *testing.T) { func TestUpdateInbound_MoveFlipsAgentConfig(t *testing.T) { setupConflictDB(t) - tsA, _ := newStatefulFakePanel(t, nil) - nodePanel := reconcileTestNode(t, tsA, "cfg-panel", "all", nil) + nodePanel := moveTestNode(t, "cfg-panel") agentNode := &model.Node{Name: "cfg-agent", Enable: true, Status: "online", InboundsAdoptedAt: 1} if err := database.GetDB().Create(agentNode).Error; err != nil { t.Fatalf("create agent node row: %v", err) diff --git a/internal/web/service/inbound_node_online_integration_test.go b/internal/web/service/inbound_node_online_integration_test.go deleted file mode 100644 index cf0cf8c..0000000 --- a/internal/web/service/inbound_node_online_integration_test.go +++ /dev/null @@ -1,122 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -// The traditional-node path: NodeTrafficSyncJob fetches a TrafficSnapshot -// from each node panel (runtime.Remote.FetchTrafficSnapshot) and hands it -// to InboundService.SetRemoteTraffic. Before the online-store restoration, -// the snapshot's OnlineTree / ActiveInboundTree were dropped on the floor -// inside that call (the store setters were no-op stubs), so polled nodes -// never showed anyone online. This drives the exact public entry the job -// calls and asserts the snapshot's online data lands in the store. -func TestSetRemoteTraffic_FeedsOnlineStore(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - svc := InboundService{} - - const nodeID = 4242 - const nodeGuid = "trad-node-guid-4242" - svc.ClearNodeOnlineClients(nodeID) - t.Cleanup(func() { svc.ClearNodeOnlineClients(nodeID) }) - - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "trad-node", - Address: "10.0.4.2", - Port: 2053, - ApiToken: "t", - Guid: nodeGuid, - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - { - Tag: "in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[{"email":"trad@example.com"}]}`, - }, - }, - OnlineTree: map[string][]string{nodeGuid: {"trad@example.com"}}, - ActiveInboundTree: map[string][]string{nodeGuid: {"in-443-tcp"}}, - } - - if _, err := svc.SetRemoteTraffic(nodeID, snap, false, false); err != nil { - t.Fatalf("SetRemoteTraffic: %v", err) - } - - assertStringSet(t, "online union", svc.GetOnlineClients(), []string{"trad@example.com"}) - byGuid := svc.GetOnlineClientsByGuid() - assertStringSet(t, "online byGuid", byGuid[nodeGuid], []string{"trad@example.com"}) - - // The active tree is normalized from node-side tags to central tags; - // whatever tag the adopted central inbound carries is what must show. - var central model.Inbound - if err := db.Where("node_id = ?", nodeID).First(¢ral).Error; err != nil { - t.Fatalf("load central inbound: %v", err) - } - active := svc.GetActiveInboundsByGuid() - assertStringSet(t, "active byGuid", active[nodeGuid], []string{central.Tag}) - - // A failed probe makes the job clear the node; its clients must stop - // showing as online immediately. - svc.ClearNodeOnlineClients(nodeID) - if got := svc.GetOnlineClients(); len(got) != 0 { - t.Fatalf("online union after clear = %v, want empty", got) - } -} - -// Old-build nodes have no GUID tree endpoint: the job passes their flat -// OnlineEmails in the snapshot and SetRemoteTraffic must key them under -// the node's own effective identity. -func TestSetRemoteTraffic_FlatOnlineEmailsFallback(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - svc := InboundService{} - - const nodeID = 4243 - const nodeGuid = "flat-node-guid-4243" - svc.ClearNodeOnlineClients(nodeID) - t.Cleanup(func() { svc.ClearNodeOnlineClients(nodeID) }) - - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "flat-node", - Address: "10.0.4.3", - Port: 2053, - ApiToken: "t", - Guid: nodeGuid, - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - { - Tag: "in-8443-tcp", - Enable: true, - Port: 8443, - Protocol: model.VLESS, - Settings: `{"clients":[{"email":"flat@example.com"}]}`, - }, - }, - OnlineEmails: []string{"flat@example.com"}, - } - - if _, err := svc.SetRemoteTraffic(nodeID, snap, false, false); err != nil { - t.Fatalf("SetRemoteTraffic: %v", err) - } - - assertStringSet(t, "online union (flat)", svc.GetOnlineClients(), []string{"flat@example.com"}) - byGuid := svc.GetOnlineClientsByGuid() - assertStringSet(t, "online byGuid (flat)", byGuid[nodeGuid], []string{"flat@example.com"}) -} diff --git a/internal/web/service/inbound_node_reconcile_test.go b/internal/web/service/inbound_node_reconcile_test.go deleted file mode 100644 index 9817652..0000000 --- a/internal/web/service/inbound_node_reconcile_test.go +++ /dev/null @@ -1,406 +0,0 @@ -package service - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "sort" - "strconv" - "strings" - "sync" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -// fakeNodePanel serves just enough of the node API for ReconcileNode: the -// inbound list plus update/del endpoints, recording which remote ids get -// deleted. -func fakeNodePanel(t *testing.T, tagToID map[string]int) (*httptest.Server, func() []int) { - t.Helper() - var mu sync.Mutex - var deleted []int - writeOK := func(w http.ResponseWriter, obj any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj}) - } - mux := http.NewServeMux() - mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) { - type row struct { - Id int `json:"id"` - Tag string `json:"tag"` - } - rows := make([]row, 0, len(tagToID)) - for tag, id := range tagToID { - rows = append(rows, row{Id: id, Tag: tag}) - } - writeOK(w, rows) - }) - mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, _ *http.Request) { - writeOK(w, nil) - }) - mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/")) - if err != nil { - http.Error(w, "bad id", http.StatusBadRequest) - return - } - mu.Lock() - deleted = append(deleted, id) - mu.Unlock() - writeOK(w, nil) - }) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - return ts, func() []int { - mu.Lock() - defer mu.Unlock() - out := append([]int(nil), deleted...) - sort.Ints(out) - return out - } -} - -func reconcileTestNode(t *testing.T, ts *httptest.Server, name, mode string, tags []string) *model.Node { - t.Helper() - u, err := url.Parse(ts.URL) - if err != nil { - t.Fatalf("parse test server URL: %v", err) - } - port, err := strconv.Atoi(u.Port()) - if err != nil { - t.Fatalf("parse test server port: %v", err) - } - n := &model.Node{ - Name: name, - Scheme: "http", - Address: u.Hostname(), - Port: port, - BasePath: "/", - ApiToken: "tok", - Enable: true, - AllowPrivateAddress: true, - Status: "online", - InboundSyncMode: mode, - InboundTags: tags, - InboundsAdoptedAt: 1, - } - if err := database.GetDB().Create(n).Error; err != nil { - t.Fatalf("create node: %v", err) - } - return n -} - -// In "selected" sync mode the panel never imports the unselected inbounds, so -// reconcile must not treat their absence from the local DB as a deletion: only -// a *selected* tag missing locally may be swept from the node. -func TestReconcileNode_SelectedModeLeavesUnselectedRemoteInbounds(t *testing.T) { - setupConflictDB(t) - - ts, deletedIDs := fakeNodePanel(t, map[string]int{ - "keep": 1, - "selected-gone": 2, - "unmanaged": 3, - }) - node := reconcileTestNode(t, ts, "sel-node", "selected", []string{"keep", "selected-gone"}) - seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) - - svc := InboundService{} - if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil { - t.Fatalf("ReconcileNode: %v", err) - } - - got := deletedIDs() - if len(got) != 1 || got[0] != 2 { - t.Fatalf("deleted remote ids = %v, want [2] (unmanaged inbound 3 must survive)", got) - } -} - -// "all" mode keeps the original anti-entropy contract: every remote inbound -// missing from the local DB is deleted on the node. -func TestReconcileNode_AllModeDeletesUndesiredRemoteInbounds(t *testing.T) { - setupConflictDB(t) - - ts, deletedIDs := fakeNodePanel(t, map[string]int{ - "keep": 1, - "gone-a": 2, - "gone-b": 3, - }) - node := reconcileTestNode(t, ts, "all-node", "all", nil) - seedInboundConflictNode(t, "keep", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) - - svc := InboundService{} - if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil { - t.Fatalf("ReconcileNode: %v", err) - } - - got := deletedIDs() - if len(got) != 2 || got[0] != 2 || got[1] != 3 { - t.Fatalf("deleted remote ids = %v, want [2 3]", got) - } -} - -// A node whose pre-existing inbounds were never adopted into the central DB -// has zero local rows for legitimate reasons: reconcile before that first -// adoption must not sweep — it would delete every real inbound on the node -// right after onboarding (add node, save it again, watch it get wiped). -func TestReconcileNode_SkipsSweepBeforeFirstAdoption(t *testing.T) { - setupConflictDB(t) - - ts, deletedIDs := fakeNodePanel(t, map[string]int{ - "real-a": 1, - "real-b": 2, - "real-c": 3, - }) - node := reconcileTestNode(t, ts, "fresh-node", "all", nil) - node.InboundsAdoptedAt = 0 - - svc := InboundService{} - if err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node); err != nil { - t.Fatalf("ReconcileNode: %v", err) - } - - if got := deletedIDs(); len(got) != 0 { - t.Fatalf("deleted remote ids = %v, want none before first adoption", got) - } -} - -// One inbound the node rejects (e.g. a legacy protocol failing the node's -// request validation, #5685) must not abort the reconcile: the healthy inbound -// is still pushed, the delete sweep still runs, and the returned error names -// the failed tag so the caller keeps the dirty flag set for retry. -func TestReconcileNode_ContinuesPastFailedInbound(t *testing.T) { - setupConflictDB(t) - - var mu sync.Mutex - updated := map[int]int{} - var deleted []int - tagToID := map[string]int{"legacy": 1, "healthy": 2, "gone": 3} - writeOK := func(w http.ResponseWriter, obj any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj}) - } - mux := http.NewServeMux() - mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) { - type row struct { - Id int `json:"id"` - Tag string `json:"tag"` - } - rows := make([]row, 0, len(tagToID)) - for tag, id := range tagToID { - rows = append(rows, row{Id: id, Tag: tag}) - } - writeOK(w, rows) - }) - mux.HandleFunc("/panel/api/inbounds/update/", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/update/")) - if err != nil { - http.Error(w, "bad id", http.StatusBadRequest) - return - } - if id == tagToID["legacy"] { - http.Error(w, "request body failed validation", http.StatusBadRequest) - return - } - mu.Lock() - updated[id]++ - mu.Unlock() - writeOK(w, nil) - }) - mux.HandleFunc("/panel/api/inbounds/del/", func(w http.ResponseWriter, r *http.Request) { - id, err := strconv.Atoi(strings.TrimPrefix(r.URL.Path, "/panel/api/inbounds/del/")) - if err != nil { - http.Error(w, "bad id", http.StatusBadRequest) - return - } - mu.Lock() - deleted = append(deleted, id) - mu.Unlock() - writeOK(w, nil) - }) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - - node := reconcileTestNode(t, ts, "half-broken-node", "all", nil) - seedInboundConflictNode(t, "legacy", "", 1080, model.Protocol("socks"), ``, `{"auth":"noauth"}`, &node.Id) - seedInboundConflictNode(t, "healthy", "", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) - - svc := InboundService{} - err := svc.ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node) - if err == nil { - t.Fatal("ReconcileNode: want an error naming the rejected inbound, got nil") - } - if !strings.Contains(err.Error(), `reconcile inbound "legacy"`) { - t.Fatalf("ReconcileNode error = %q, want it to name inbound \"legacy\"", err) - } - - mu.Lock() - healthyPushes := updated[tagToID["healthy"]] - gotDeleted := append([]int(nil), deleted...) - mu.Unlock() - if healthyPushes != 1 { - t.Fatalf("healthy inbound pushed %d times, want 1", healthyPushes) - } - sort.Ints(gotDeleted) - if len(gotDeleted) != 1 || gotDeleted[0] != tagToID["gone"] { - t.Fatalf("deleted remote ids = %v, want [%d] (sweep must still run past the failure)", gotDeleted, tagToID["gone"]) - } -} - -func TestReconcileNode_AdoptsCompatibleOriginInboundWithoutRemoteMutation(t *testing.T) { - setupConflictDB(t) - - var mu sync.Mutex - mutations := 0 - writeOK := func(w http.ResponseWriter, obj any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj}) - } - mux := http.NewServeMux() - mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) { - writeOK(w, []map[string]any{{"id": 41, "tag": "already-deployed", "listen": "", "port": 8443, "protocol": "vless"}}) - }) - mux.HandleFunc("/panel/api/inbounds/", func(w http.ResponseWriter, _ *http.Request) { - mu.Lock() - mutations++ - mu.Unlock() - writeOK(w, nil) - }) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - - node := reconcileTestNode(t, ts, "adopt-node", "all", nil) - node.Guid = "origin-guid" - if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil { - t.Fatalf("update node guid: %v", err) - } - seedInboundConflictNode(t, "desired-name", "", 8443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) - if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil { - t.Fatalf("set origin guid: %v", err) - } - - svc := InboundService{} - rt := runtime.NewRemote(node, nil) - if err := svc.ReconcileNode(context.Background(), rt, node); err != nil { - t.Fatalf("first ReconcileNode: %v", err) - } - if err := svc.ReconcileNode(context.Background(), rt, node); err != nil { - t.Fatalf("second ReconcileNode: %v", err) - } - mu.Lock() - got := mutations - mu.Unlock() - if got != 0 { - t.Fatalf("remote mutations = %d, want 0 while adopting compatible deployed inbound", got) - } -} - -func TestReconcileNode_AmbiguousCompatibleInboundsAreNotSwept(t *testing.T) { - setupConflictDB(t) - - ts, deletedIDs := fakeNodePanel(t, map[string]int{"alias-a": 51, "alias-b": 52}) - node := reconcileTestNode(t, ts, "ambiguous-node", "all", nil) - node.Guid = "origin-guid" - if err := database.GetDB().Model(node).Update("guid", node.Guid).Error; err != nil { - t.Fatalf("update node guid: %v", err) - } - seedInboundConflictNode(t, "desired-name", "", 0, model.Protocol(""), `{}`, `{"clients":[]}`, &node.Id) - if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil { - t.Fatalf("set origin guid: %v", err) - } - - err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node) - if err == nil || !strings.Contains(err.Error(), "ambiguous compatible remote inbounds") { - t.Fatalf("ReconcileNode error = %v, want ambiguity error", err) - } - if got := deletedIDs(); len(got) != 0 { - t.Fatalf("deleted ambiguous candidates = %v, want none", got) - } -} - -func TestReconcileNode_IncompatiblePortOccupantRemainsLoud(t *testing.T) { - setupConflictDB(t) - - writeOK := func(w http.ResponseWriter, obj any) { - w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "msg": "", "obj": obj}) - } - mux := http.NewServeMux() - mux.HandleFunc("/panel/api/inbounds/list", func(w http.ResponseWriter, _ *http.Request) { - writeOK(w, []map[string]any{{"id": 42, "tag": "port-owner", "listen": "", "port": 9443, "protocol": "trojan"}}) - }) - mux.HandleFunc("/panel/api/inbounds/add", func(w http.ResponseWriter, _ *http.Request) { - _ = json.NewEncoder(w).Encode(map[string]any{"success": false, "msg": "port already occupied", "obj": nil}) - }) - ts := httptest.NewServer(mux) - t.Cleanup(ts.Close) - - node := reconcileTestNode(t, ts, "drift-node", "all", nil) - node.Guid = "origin-guid" - seedInboundConflictNode(t, "desired-name", "", 9443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, &node.Id) - if err := database.GetDB().Model(&model.Inbound{}).Where("tag = ?", "desired-name").Update("origin_node_guid", node.Guid).Error; err != nil { - t.Fatalf("set origin guid: %v", err) - } - - err := (&InboundService{}).ReconcileNode(context.Background(), runtime.NewRemote(node, nil), node) - if err == nil || !strings.Contains(err.Error(), "port already occupied") { - t.Fatalf("ReconcileNode error = %v, want loud incompatible-port error", err) - } -} - -func TestEnsureInboundTagAllowed(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - svc := NodeService{} - - selected := &model.Node{ - Name: "ensure-sel", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", - InboundSyncMode: "selected", InboundTags: []string{"a"}, - } - if err := db.Create(selected).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - if err := svc.EnsureInboundTagAllowed(selected.Id, "b"); err != nil { - t.Fatalf("EnsureInboundTagAllowed add: %v", err) - } - var got model.Node - if err := db.First(&got, selected.Id).Error; err != nil { - t.Fatalf("reload node: %v", err) - } - if len(got.InboundTags) != 2 || got.InboundTags[0] != "a" || got.InboundTags[1] != "b" { - t.Fatalf("InboundTags = %#v, want [a b]", got.InboundTags) - } - - if err := svc.EnsureInboundTagAllowed(selected.Id, "a"); err != nil { - t.Fatalf("EnsureInboundTagAllowed existing: %v", err) - } - if err := db.First(&got, selected.Id).Error; err != nil { - t.Fatalf("reload node: %v", err) - } - if len(got.InboundTags) != 2 { - t.Fatalf("existing tag must not duplicate, got %#v", got.InboundTags) - } - - all := &model.Node{ - Name: "ensure-all", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", - InboundSyncMode: "all", - } - if err := db.Create(all).Error; err != nil { - t.Fatalf("create node: %v", err) - } - if err := svc.EnsureInboundTagAllowed(all.Id, "x"); err != nil { - t.Fatalf("EnsureInboundTagAllowed all-mode: %v", err) - } - var gotAll model.Node - if err := db.First(&gotAll, all.Id).Error; err != nil { - t.Fatalf("reload node: %v", err) - } - if len(gotAll.InboundTags) != 0 { - t.Fatalf("all-mode node must stay without tags, got %#v", gotAll.InboundTags) - } -} diff --git a/internal/web/service/inbound_node_sweep_log_test.go b/internal/web/service/inbound_node_sweep_log_test.go deleted file mode 100644 index 2ea3f08..0000000 --- a/internal/web/service/inbound_node_sweep_log_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package service - -import ( - "strconv" - "strings" - "sync" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/logger" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" - - "github.com/op/go-logging" -) - -// Removing a central inbound also drops its clients' traffic history, so the -// sweep must name what it removed — a silent drop is undiagnosable in the field. -// captureLogLines installs a logger hook collecting "LEVEL message" lines -// for the rest of the test. It replaces reads of the in-memory log buffer, -// which was removed together with the web log viewer. -func captureLogLines(t *testing.T) *[]string { - t.Helper() - var mu sync.Mutex - lines := &[]string{} - prev := logger.SetLogHook(func(level, msg string) { - mu.Lock() - defer mu.Unlock() - *lines = append(*lines, level+" "+msg) - }) - t.Cleanup(func() { logger.SetLogHook(prev) }) - return lines -} - -func TestNodeSnapshotSweepLogsRemovedInbound(t *testing.T) { - logger.InitLogger(logging.WARNING) - logLines := captureLogLines(t) - setupBulkDB(t) - nodeID, _ := setupNodeRuntime(t) - - gone := nodeInbound(t, nodeID, 31777, nil) - survivor := nodeInbound(t, nodeID, 31778, nil) - - // The snapshot reports only the survivor, so the other row is swept. - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ - Tag: survivor.Tag, Port: survivor.Port, Protocol: model.VLESS, Enable: true, - Settings: survivor.Settings, - }}} - if _, err := (&InboundService{}).setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var remaining int64 - if err := database.GetDB().Model(model.Inbound{}).Where("id = ?", gone.Id).Count(&remaining).Error; err != nil { - t.Fatalf("count swept inbound: %v", err) - } - if remaining != 0 { - t.Fatalf("fixture did not sweep inbound %d; the log assertion below would be meaningless", gone.Id) - } - - want := strconv.Itoa(gone.Id) - for _, line := range *logLines { - if strings.Contains(line, gone.Tag) && strings.Contains(line, want) { - return - } - } - t.Fatalf("sweep removed inbound %q (id %d) without naming it in the log", gone.Tag, gone.Id) -} diff --git a/internal/web/service/inbound_traffic.go b/internal/web/service/inbound_traffic.go index a22d266..19a86b6 100644 --- a/internal/web/service/inbound_traffic.go +++ b/internal/web/service/inbound_traffic.go @@ -26,15 +26,11 @@ import ( const depletedClientsClause = "reset = 0 and reset_day = 0 and ((total > 0 and up + down >= total) or (expiry_time > 0 and expiry_time <= ?))" func (s *InboundService) AddTraffic(inboundTraffics []*xray.Traffic, clientTraffics []*xray.ClientTraffic) (needRestart bool, clientsDisabled bool, err error) { - var disabledNodeIDs []int err = submitTrafficWrite(func() error { var inner error - needRestart, clientsDisabled, disabledNodeIDs, inner = s.addTrafficLocked(inboundTraffics, clientTraffics) + needRestart, clientsDisabled, _, inner = s.addTrafficLocked(inboundTraffics, clientTraffics) return inner }) - if err == nil && len(disabledNodeIDs) > 0 { - s.restartRemoteNodesOnDisable(disabledNodeIDs) - } return } diff --git a/internal/web/service/inbound_traffic_global.go b/internal/web/service/inbound_traffic_global.go index 6e88f73..9c1bd8f 100644 --- a/internal/web/service/inbound_traffic_global.go +++ b/internal/web/service/inbound_traffic_global.go @@ -161,34 +161,6 @@ func overlayGlobalTrafficValues(db *gorm.DB, rows []xray.ClientTraffic) { overlayGlobalTraffic(db, ptrs) } -// GetNodeClientTraffics returns this panel's aggregated traffic rows for the -// clients known to live on the given node (those with a delta baseline) — -// the payload for Remote.PushGlobalClientTraffics. The rows carry the global -// overlay so a mid-chain panel forwards the widest view it has seen, not just -// its own aggregate. -func (s *InboundService) GetNodeClientTraffics(nodeID int) ([]*xray.ClientTraffic, error) { - db := database.GetDB() - var emails []string - if err := db.Model(&model.NodeClientTraffic{}). - Where("node_id = ?", nodeID). - Pluck("email", &emails).Error; err != nil { - return nil, err - } - if len(emails) == 0 { - return nil, nil - } - out := make([]*xray.ClientTraffic, 0, len(emails)) - for _, batch := range chunkStrings(emails, sqlInChunk) { - var page []*xray.ClientTraffic - if err := db.Model(xray.ClientTraffic{}).Where("email IN ?", batch).Find(&page).Error; err != nil { - return nil, err - } - out = append(out, page...) - } - overlayGlobalTraffic(db, out) - return out, nil -} - // overlayInboundsClientStats applies the global overlay to every preloaded // ClientStats row across the given inbounds. UI read paths only — never the // full /panel/api/inbounds/list payload, which doubles as the traffic diff --git a/internal/web/service/node.go b/internal/web/service/node.go index 2da797e..4ceca00 100644 --- a/internal/web/service/node.go +++ b/internal/web/service/node.go @@ -2,15 +2,9 @@ package service import ( "context" - "crypto/sha256" - "crypto/tls" - "encoding/base64" - "encoding/json" "errors" "fmt" - "net" "net/http" - "net/url" "slices" "strconv" "strings" @@ -18,14 +12,12 @@ import ( "time" "github.com/mhsanaei/3x-ui/v3/internal/config" - "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/selfupdate" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/netsafe" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "gorm.io/gorm" ) @@ -54,70 +46,6 @@ type HeartbeatPatch struct { type NodeService struct{} -// FetchCertFingerprint connects to the node over HTTPS without verifying the -// certificate and returns the leaf certificate's SHA-256 as base64, so the UI -// can offer a "fetch and pin current certificate" action. -func (s *NodeService) FetchCertFingerprint(ctx context.Context, n *model.Node) (string, error) { - addr, err := netsafe.NormalizeHost(n.Address) - if err != nil { - return "", err - } - scheme := n.Scheme - if scheme != "http" && scheme != "https" { - scheme = "https" - } - if scheme != "https" { - return "", common.NewError("certificate pinning is only available for https nodes") - } - if n.Port <= 0 || n.Port > 65535 { - return "", common.NewError("node port must be 1-65535") - } - probeURL := &url.URL{ - Scheme: scheme, - Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)), - Path: normalizeBasePath(n.BasePath) + "panel/api/server/status", - } - req, err := http.NewRequestWithContext( - netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress), - http.MethodGet, probeURL.String(), nil) - if err != nil { - return "", err - } - client := &http.Client{ - Transport: &http.Transport{ - DialContext: netsafe.SSRFGuardedDialContext, - TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // lgtm[go/disabled-certificate-check] - }, - } - resp, err := client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - if resp.TLS == nil || len(resp.TLS.PeerCertificates) == 0 { - return "", common.NewError("node did not present a TLS certificate") - } - sum := sha256.Sum256(resp.TLS.PeerCertificates[0].Raw) - return base64.StdEncoding.EncodeToString(sum[:]), nil -} - -// decryptToken exposes plaintext to callers. Failures blank only this token -// and surface through LastError instead of dropping the node row. -func decryptToken(n *model.Node) { - if n == nil || n.ApiToken == "" { - return - } - pt, err := nodetoken.Decrypt(n.Id, n.ApiToken) - if err != nil { - n.ApiToken = "" - if n.LastError == "" { - n.LastError = "token decrypt failed: " + err.Error() - } - return - } - n.ApiToken = pt -} - func (s *NodeService) GetAll() ([]*model.Node, error) { db := database.GetDB() var nodes []*model.Node @@ -125,9 +53,6 @@ func (s *NodeService) GetAll() ([]*model.Node, error) { if err != nil || len(nodes) == 0 { return nodes, err } - for _, n := range nodes { - decryptToken(n) - } type inboundRow struct { Id int @@ -390,7 +315,6 @@ func (s *NodeService) GetById(id int) (*model.Node, error) { if err := db.Model(model.Node{}).Where("id = ?", id).First(n).Error; err != nil { return nil, err } - decryptToken(n) return n, nil } @@ -446,7 +370,6 @@ func (s *NodeService) normalize(n *model.Node) error { // node's. func (s *NodeService) normalizeMode(n *model.Node, agentEndpoint bool) error { n.Name = strings.TrimSpace(n.Name) - n.ApiToken = strings.TrimSpace(n.ApiToken) if n.Name == "" { return common.NewError("node name is required") } @@ -465,37 +388,6 @@ func (s *NodeService) normalizeMode(n *model.Node, agentEndpoint bool) error { if n.Scheme != "http" && n.Scheme != "https" { n.Scheme = "https" } - if n.TlsVerifyMode != "skip" && n.TlsVerifyMode != "pin" && n.TlsVerifyMode != "mtls" { - n.TlsVerifyMode = "verify" - } - if n.TlsVerifyMode == "mtls" && n.Scheme != "https" { - return common.NewError("mtls requires the node scheme to be https") - } - n.PinnedCertSha256 = strings.TrimSpace(n.PinnedCertSha256) - if n.InboundSyncMode != "selected" { - n.InboundSyncMode = "all" - n.InboundTags = nil - } else { - seen := make(map[string]struct{}, len(n.InboundTags)) - tags := make([]string, 0, len(n.InboundTags)) - for _, tag := range n.InboundTags { - tag = strings.TrimSpace(tag) - if tag == "" { - continue - } - if _, ok := seen[tag]; ok { - continue - } - seen[tag] = struct{}{} - tags = append(tags, tag) - } - n.InboundTags = tags - } - if n.TlsVerifyMode == "pin" { - if _, err := runtime.DecodeCertPin(n.PinnedCertSha256); err != nil { - return common.NewError(err.Error()) - } - } n.BasePath = normalizeBasePath(n.BasePath) return nil } @@ -508,45 +400,19 @@ func (s *NodeService) create(n *model.Node, agentEndpoint bool) error { if err := s.normalizeMode(n, agentEndpoint); err != nil { return err } - db := database.GetDB() - if !nodetoken.Enabled() { - return db.Create(n).Error - } - plaintext := n.ApiToken - return db.Transaction(func(tx *gorm.DB) error { - // The id-bound ciphertext can only be produced after insertion. Never put - // plaintext in the initial tuple: PostgreSQL WAL would retain it. - n.ApiToken = "" - defer func() { n.ApiToken = plaintext }() - if err := tx.Create(n).Error; err != nil { - return err - } - enc, err := nodetoken.Encrypt(n.Id, plaintext) - if err != nil { - return err - } - if enc == plaintext { - return nil // off-mode / empty token: nothing to rewrite - } - // DB column gets ciphertext; the in-memory struct keeps plaintext so the - // create response echoes the same usable value GetById would return. - return tx.Model(model.Node{}).Where("id = ?", n.Id).Update("api_token", enc).Error - }) + return database.GetDB().Create(n).Error } func (s *NodeService) CreateFromRequest(req *NodeMutationRequest) (*NodeView, error) { - if err := req.validateCredentials(true); err != nil { + if err := req.validate(); err != nil { return nil, err } - isAgent := req.NodeType == "agent" - if isAgent { - // A brand-new agent node has no binding yet, so no resolved - // address can be known: the placeholder sanitizes to empty and - // is filled in by later writes once the agent reports. - sanitizeAgentEndpoint(req, "") - } + // Every node is agent-managed: a brand-new node has no binding yet, + // so no resolved address can be known — the placeholder sanitizes + // to empty and is filled in by later writes once the agent reports. + sanitizeAgentEndpoint(req, "") n := req.toNode() - if err := s.create(n, isAgent); err != nil { + if err := s.create(n, true); err != nil { return nil, err } return toNodeView(n), nil @@ -576,24 +442,10 @@ func (s *NodeService) Update(id int, in *model.Node) error { if err := s.normalize(in); err != nil { return err } - inboundTagsJSON, err := json.Marshal(in.InboundTags) - if err != nil { - return err - } db := database.GetDB() - existing := &model.Node{} - if err := db.Where("id = ?", id).First(existing).Error; err != nil { + if err := db.Where("id = ?", id).First(&model.Node{}).Error; err != nil { return err } - // Blank means keep the hidden stored token; non-blank values are encrypted. - apiToken := existing.ApiToken - if in.ApiToken != "" { - enc, eerr := nodetoken.Encrypt(id, in.ApiToken) - if eerr != nil { - return eerr - } - apiToken = enc - } updates := map[string]any{ "name": in.Name, "remark": in.Remark, @@ -601,65 +453,30 @@ func (s *NodeService) Update(id int, in *model.Node) error { "address": in.Address, "port": in.Port, "base_path": in.BasePath, - "api_token": apiToken, "enable": in.Enable, "allow_private_address": in.AllowPrivateAddress, - "tls_verify_mode": in.TlsVerifyMode, - "pinned_cert_sha256": in.PinnedCertSha256, - "inbound_sync_mode": in.InboundSyncMode, - "inbound_tags": string(inboundTagsJSON), - "outbound_tag": in.OutboundTag, } - if err := db.Transaction(func(tx *gorm.DB) error { + return db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { return err } return s.MarkNodeDirtyTx(tx, id) - }); err != nil { - return err - } - if mgr := runtime.GetManager(); mgr != nil { - mgr.InvalidateNode(id) - } - return nil + }) } func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error { - if err := req.validateCredentials(false); err != nil { + if err := req.validate(); err != nil { return err } - isAgent := req.NodeType == "agent" || s.IsAgentManaged(id) - if isAgent { - sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id)) - } + sanitizeAgentEndpoint(req, s.resolvedAgentAddress(id)) in := req.toNode() - if err := s.normalizeMode(in, isAgent); err != nil { - return err - } - inboundTagsJSON, err := json.Marshal(in.InboundTags) - if err != nil { + if err := s.normalizeMode(in, true); err != nil { return err } db := database.GetDB() - existing := &model.Node{} - if err := db.Where("id = ?", id).First(existing).Error; err != nil { + if err := db.Where("id = ?", id).First(&model.Node{}).Error; err != nil { return err } - apiToken := existing.ApiToken - switch { - case req.ClearApiToken: - apiToken = "" - case req.ApiToken != nil: - apiToken, err = nodetoken.Encrypt(id, *req.ApiToken) - if err != nil { - return err - } - } - // Agent nodes authenticate with their agent token, never a panel API - // token — the requirement below must not make them uneditable. - if !isAgent && apiToken == "" && in.Enable && in.TlsVerifyMode != "mtls" { - return common.NewError("apiToken is required unless mtls is enabled") - } updates := map[string]any{ "name": in.Name, "remark": in.Remark, @@ -667,14 +484,8 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error "address": in.Address, "port": in.Port, "base_path": in.BasePath, - "api_token": apiToken, "enable": in.Enable, "allow_private_address": in.AllowPrivateAddress, - "tls_verify_mode": in.TlsVerifyMode, - "pinned_cert_sha256": in.PinnedCertSha256, - "inbound_sync_mode": in.InboundSyncMode, - "inbound_tags": string(inboundTagsJSON), - "outbound_tag": in.OutboundTag, } if err := db.Transaction(func(tx *gorm.DB) error { if err := tx.Model(model.Node{}).Where("id = ?", id).Updates(updates).Error; err != nil { @@ -684,7 +495,7 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error // node row: flip every binding of this node in the same // transaction, so the flag and the node edit land together. // Nil means the form didn't touch the switch. - if isAgent && req.ProbeOnly != nil { + if req.ProbeOnly != nil { if err := tx.Model(&model.AgentNode{}). Where("node_id = ?", id). Update("probe_only", *req.ProbeOnly).Error; err != nil { @@ -695,222 +506,9 @@ func (s *NodeService) UpdateFromRequest(id int, req *NodeMutationRequest) error }); err != nil { return err } - if mgr := runtime.GetManager(); mgr != nil { - mgr.InvalidateNode(id) - } return nil } -func (s *NodeService) RuntimeNodeFromRequest(id int, req *NodeMutationRequest) (*model.Node, error) { - if err := req.validateCredentials(id == 0); err != nil { - return nil, err - } - var n *model.Node - if id > 0 { - existing, err := s.GetById(id) - if err != nil { - return nil, err - } - n = existing - } else { - n = &model.Node{} - } - overlay := req.toNode() - overlay.Id = id - if req.ApiToken == nil { - overlay.ApiToken = n.ApiToken - } - if req.ClearApiToken { - overlay.ApiToken = "" - } - *n = *overlay - if err := s.normalize(n); err != nil { - return nil, err - } - if n.ApiToken == "" && n.Enable && n.TlsVerifyMode != "mtls" { - return nil, common.NewError("apiToken is required unless mtls is enabled") - } - return n, nil -} - -func (s *NodeService) NodeFromRequestForCertificate(req *NodeMutationRequest) (*model.Node, error) { - if req == nil { - return nil, common.NewError("node request is required") - } - n := req.toNode() - if n.Scheme == "" { - n.Scheme = "https" - } - if n.BasePath == "" { - n.BasePath = "/" - } - if err := s.normalize(n); err != nil { - return nil, err - } - return n, nil -} - -// MigrateNodeTokensToActiveKey uses compare-and-swap to avoid clobbering live -// changes. Current-key rows are skipped; changed and skipped counts are returned. -func (s *NodeService) MigrateNodeTokensToActiveKey() (int, int, error) { - codec := nodetoken.Active() - if !codec.Enabled() { - return 0, 0, errors.New("node-token encryption is off; set NODE_TOKEN_ENCRYPTION=migration|required and a key first") - } - db := database.GetDB() - var nodes []*model.Node - if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil { - return 0, 0, err - } - changed, skipped := 0, 0 - for _, n := range nodes { - old := n.ApiToken - if old == "" { - skipped++ - continue - } - if codec.EncryptedWithActive(old) { - if _, err := codec.Decrypt(n.Id, old); err != nil { - return changed, skipped, fmt.Errorf("node %d validate active ciphertext: %w", n.Id, err) - } - skipped++ - continue - } - plain, err := codec.Decrypt(n.Id, old) // plaintext passes through; old-key ciphertext is decrypted - if err != nil { - return changed, skipped, fmt.Errorf("node %d decrypt: %w", n.Id, err) - } - enc, err := codec.Encrypt(n.Id, plain) - if err != nil { - return changed, skipped, fmt.Errorf("node %d encrypt: %w", n.Id, err) - } - res := db.Model(model.Node{}).Where("id = ? AND api_token = ?", n.Id, old).Update("api_token", enc) - if res.Error != nil { - return changed, skipped, res.Error - } - if res.RowsAffected == 1 { - changed++ - } else { - skipped++ // raced with a live update; a later run handles it - } - } - return changed, skipped, nil -} - -func (s *NodeService) GetRemoteInboundOptions(ctx context.Context, n *model.Node) ([]runtime.RemoteInboundOption, error) { - if err := s.normalize(n); err != nil { - return nil, err - } - if n.OutboundTag == "" { - return runtime.NewRemote(n, nil).ListInboundOptions(ctx) - } - // Mirror ProbeWithOutbound: a node being added/edited has no persistent - // egress bridge yet, so route the list call through a temporary one or the - // remote panel stays unreachable and the request times out. - var options []runtime.RemoteInboundOption - var err error - s.withOutboundBridge(n.Id, n.OutboundTag, func(proxyURL string) { - options, err = runtime.NewRemote(n, staticEgressResolver(proxyURL)).ListInboundOptions(ctx) - }) - return options, err -} - -// staticEgressResolver hands a fixed proxy URL to runtime.NewRemote. An empty -// string yields a direct connection, so it doubles as the graceful fallback -// when a temporary bridge can't be built. -type staticEgressResolver string - -func (r staticEgressResolver) NodeEgressProxyURL(int) string { return string(r) } - -// EnsureInboundTagAllowed adds a panel-managed inbound's tag to the node's -// selection when the node syncs in "selected" mode. Without it, the next -// traffic sync would filter the tag out of the snapshot and the orphan sweep -// would silently delete the central row the panel just created or renamed. -// Tags are only ever added (never removed): on a rename the node may keep -// reporting the old tag until the remote update lands, and a leftover entry -// that matches nothing is harmless. -func (s *NodeService) EnsureInboundTagAllowed(nodeID int, tag string) error { - return s.EnsureInboundTagAllowedTx(database.GetDB(), nodeID, tag) -} - -func (s *NodeService) EnsureInboundTagAllowedTx(tx *gorm.DB, nodeID int, tag string) error { - tag = strings.TrimSpace(tag) - if nodeID <= 0 || tag == "" { - return nil - } - if tx == nil { - tx = database.GetDB() - } - node := &model.Node{} - if err := tx.Where("id = ?", nodeID).First(node).Error; err != nil { - return err - } - if node.InboundSyncMode != "selected" { - return nil - } - if slices.Contains(node.InboundTags, tag) { - return nil - } - buf, err := json.Marshal(append(node.InboundTags, tag)) - if err != nil { - return err - } - return tx.Model(model.Node{}).Where("id = ?", nodeID). - Updates(map[string]any{"inbound_tags": string(buf)}).Error -} - -func nodeSelectedTagSet(n *model.Node) map[string]struct{} { - if n == nil || n.InboundSyncMode != "selected" { - return nil - } - prefix := nodeTagPrefix(&n.Id) - allowed := make(map[string]struct{}, len(n.InboundTags)*2) - for _, tag := range n.InboundTags { - allowed[tag] = struct{}{} - if prefix != "" { - if stripped, found := strings.CutPrefix(tag, prefix); found { - allowed[stripped] = struct{}{} - } else { - allowed[prefix+tag] = struct{}{} - } - } - } - return allowed -} - -// A deselected tag is still served by the node — FilterNodeSnapshot just stops -// reporting it — so its absence must never be read as "the node deleted it". -func unmanagedTagPredicate(n *model.Node) func(string) bool { - managed := nodeSelectedTagSet(n) - if managed == nil { - return func(string) bool { return false } - } - return func(tag string) bool { - _, ok := managed[tag] - return !ok - } -} - -func FilterNodeSnapshot(n *model.Node, snap *runtime.TrafficSnapshot) { - if n == nil || snap == nil || n.InboundSyncMode != "selected" { - return - } - allowed := nodeSelectedTagSet(n) - for _, tag := range snap.ManagedAliases { - allowed[tag] = struct{}{} - } - filtered := make([]*model.Inbound, 0, len(snap.Inbounds)) - for _, inbound := range snap.Inbounds { - if inbound == nil { - continue - } - if _, ok := allowed[inbound.Tag]; ok { - filtered = append(filtered, inbound) - } - } - snap.Inbounds = filtered -} - func (s *NodeService) Delete(id int) error { db := database.GetDB() // Refuse to delete a node that still owns inbounds: dropping the node row @@ -952,9 +550,6 @@ func (s *NodeService) Delete(id int) error { }); err != nil { return err } - if mgr := runtime.GetManager(); mgr != nil { - mgr.InvalidateNode(id) - } nodeMetrics.drop(nodeMetricKey(id, "cpu")) nodeMetrics.drop(nodeMetricKey(id, "mem")) return nil @@ -965,36 +560,9 @@ func (s *NodeService) SetEnable(id int, enable bool) error { if err := db.Model(model.Node{}).Where("id = ?", id).Update("enable", enable).Error; err != nil { return err } - if mgr := runtime.GetManager(); mgr != nil { - mgr.InvalidateNode(id) - } return nil } -// GetWebCertFiles asks a node for its own web TLS certificate/key file paths, -// used by "Set Cert from Panel" so a node-assigned inbound gets paths that -// exist on the node rather than the central panel. See issue #4854. -func (s *NodeService) GetWebCertFiles(id int) (*runtime.WebCertFiles, error) { - n, err := s.GetById(id) - if err != nil || n == nil { - return nil, fmt.Errorf("node not found") - } - if !n.Enable { - return nil, fmt.Errorf("node is disabled") - } - mgr := runtime.GetManager() - if mgr == nil { - return nil, fmt.Errorf("runtime manager unavailable") - } - remote, err := mgr.RemoteFor(n) - if err != nil { - return nil, err - } - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) - defer cancel() - return remote.GetWebCertFiles(ctx) -} - // NodeUpdateResult reports the outcome of triggering a panel self-update on one // node so the UI can show per-node success/failure for a bulk request. type NodeUpdateResult struct { @@ -1075,24 +643,7 @@ func (s *NodeService) UpdatePanels(ids []int, dev bool) ([]NodeUpdateResult, err res.OK = true } default: - mgr := runtime.GetManager() - if mgr == nil { - res.Error = "runtime manager unavailable" - break - } - remote, remoteErr := mgr.RemoteFor(n) - if remoteErr != nil { - res.Error = remoteErr.Error() - break - } - ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) - updErr := remote.UpdatePanel(ctx, dev) - cancel() - if updErr != nil { - res.Error = updErr.Error() - } else { - res.OK = true - } + res.Error = "node is not agent-managed" } results = append(results, res) } @@ -1187,15 +738,6 @@ func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error { Update("config_dirty", false).Error } -func (s *NodeService) MarkNodeInboundsAdopted(id int) error { - if id <= 0 { - return nil - } - return database.GetDB().Model(model.Node{}). - Where("id = ? AND inbounds_adopted_at = 0", id). - Update("inbounds_adopted_at", time.Now().Unix()).Error -} - func (s *NodeService) NodeSyncState(id int) (enabled bool, status string, dirty bool, dirtyAt int64, err error) { if id <= 0 { return false, "", false, 0, errors.New("invalid node id") @@ -1295,150 +837,15 @@ func (s *NodeService) agentNodeProbe(n *model.Node) (patch HeartbeatPatch, err e } func (s *NodeService) Probe(ctx context.Context, n *model.Node) (HeartbeatPatch, error) { - // Agent-managed nodes have no panel to dial — their address row - // is a placeholder ("agent") — so a probe answers from the agent - // heartbeat instead of dialing a dead URL and persisting a - // LastError the agent heartbeat never clears (V2.2.1 ②). + // Every node is agent-managed: it has no panel to dial — its + // address row is a placeholder ("agent") — so a probe answers + // from the agent heartbeat instead of dialing a dead URL and + // persisting a LastError the agent heartbeat never clears + // (V2.2.1 ②). A node with no bound agent reads offline. if patch, err, ok := s.agentNodeProbe(n); ok { return patch, err } - proxyURL := "" - if n.OutboundTag != "" { - if mgr := runtime.GetManager(); mgr != nil { - proxyURL = mgr.NodeEgressProxyURL(n.Id) - } - } - return s.probe(ctx, n, proxyURL) -} - -func (s *NodeService) ProbeWithOutbound(ctx context.Context, n *model.Node, outboundTag string) (HeartbeatPatch, error) { - if outboundTag == "" { - return s.Probe(ctx, n) - } - var patch HeartbeatPatch - var err error - s.withOutboundBridge(n.Id, outboundTag, func(proxyURL string) { - if proxyURL == "" { - patch, err = s.Probe(ctx, n) - return - } - patch, err = s.probe(ctx, n, proxyURL) - }) - return patch, err -} - -// withOutboundBridge used to stand up a temporary loopback SOCKS5 inbound -// in the panel's own running xray to reach a node through its connection -// outbound. The lite master runs no local core, so probes always take the -// direct-connection fallback this function used when xray was down. -func (s *NodeService) withOutboundBridge(nodeID int, outboundTag string, fn func(proxyURL string)) { - fn("") -} - -func (s *NodeService) probe(ctx context.Context, n *model.Node, proxyURL string) (HeartbeatPatch, error) { - patch := HeartbeatPatch{LastHeartbeat: time.Now().Unix()} - - addr, err := netsafe.NormalizeHost(n.Address) - if err != nil { - patch.LastError = err.Error() - return patch, err - } - scheme := n.Scheme - if scheme != "http" && scheme != "https" { - scheme = "https" - } - if n.Port <= 0 || n.Port > 65535 { - patch.LastError = "node port must be 1-65535" - return patch, errors.New(patch.LastError) - } - probeURL := &url.URL{ - Scheme: scheme, - Host: net.JoinHostPort(addr, strconv.Itoa(n.Port)), - Path: normalizeBasePath(n.BasePath) + "panel/api/server/status", - } - - req, err := http.NewRequestWithContext( - netsafe.ContextWithAllowPrivate(ctx, n.AllowPrivateAddress), - http.MethodGet, probeURL.String(), nil) - if err != nil { - patch.LastError = err.Error() - return patch, err - } - if n.ApiToken != "" { - token, derr := nodetoken.Decrypt(n.Id, n.ApiToken) - if derr != nil { - patch.LastError = derr.Error() - return patch, derr - } - req.Header.Set("Authorization", "Bearer "+token) - } - req.Header.Set("Accept", "application/json") - - client, err := runtime.HTTPClientForNode(n, proxyURL) - if err != nil { - patch.LastError = err.Error() - return patch, err - } - - start := time.Now() - resp, err := client.Do(req) - if err != nil { - patch.LastError = err.Error() - return patch, err - } - defer resp.Body.Close() - patch.LatencyMs = int(time.Since(start) / time.Millisecond) - - if resp.StatusCode != http.StatusOK { - patch.LastError = fmt.Sprintf("HTTP %d from remote panel", resp.StatusCode) - return patch, errors.New(patch.LastError) - } - - var envelope struct { - Success bool `json:"success"` - Msg string `json:"msg"` - Obj *struct { - CpuPct float64 `json:"cpu"` - Mem struct { - Current uint64 `json:"current"` - Total uint64 `json:"total"` - } `json:"mem"` - Xray struct { - Version string `json:"version"` - State string `json:"state"` - ErrorMsg string `json:"errorMsg"` - } `json:"xray"` - PanelVersion string `json:"panelVersion"` - PanelGuid string `json:"panelGuid"` - Uptime uint64 `json:"uptime"` - NetIO struct { - Up uint64 `json:"up"` - Down uint64 `json:"down"` - } `json:"netIO"` - } `json:"obj"` - } - if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { - patch.LastError = "decode response: " + err.Error() - return patch, err - } - if !envelope.Success || envelope.Obj == nil { - patch.LastError = "remote returned success=false: " + envelope.Msg - return patch, errors.New(patch.LastError) - } - o := envelope.Obj - patch.CpuPct = o.CpuPct - if o.Mem.Total > 0 { - patch.MemPct = float64(o.Mem.Current) * 100.0 / float64(o.Mem.Total) - } - patch.XrayVersion = o.Xray.Version - patch.XrayState = o.Xray.State - patch.XrayError = o.Xray.ErrorMsg - patch.PanelVersion = o.PanelVersion - patch.Guid = o.PanelGuid - patch.UptimeSecs = o.Uptime - patch.NetUp = o.NetIO.Up - patch.NetDown = o.NetIO.Down - return patch, nil + return HeartbeatPatch{LatencyMs: -1}, errAgentOffline } type ProbeResultUI struct { diff --git a/internal/web/service/node_agent_endpoint_test.go b/internal/web/service/node_agent_endpoint_test.go index 4fdc34a..3c3b5df 100644 --- a/internal/web/service/node_agent_endpoint_test.go +++ b/internal/web/service/node_agent_endpoint_test.go @@ -10,10 +10,7 @@ import ( // Agent nodes have no panel endpoint: the node form submits the // placeholder pair address="agent", port=2053 for them. The write paths // must never store that pair — an agent node's row carries its resolved -// address when one is known, or empty when it is not — while traditional -// nodes keep the strict endpoint validation they always had. - -func strPtr(s string) *string { return &s } +// address when one is known, or empty when it is not. func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) { setupConflictDB(t) @@ -21,7 +18,7 @@ func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) { view, err := svc.CreateFromRequest(&NodeMutationRequest{ Name: "agent-new", Enable: true, Scheme: "https", - Address: "agent", Port: 2053, NodeType: "agent", + Address: "agent", Port: 2053, }) if err != nil { t.Fatalf("CreateFromRequest(agent): %v", err) @@ -40,34 +37,23 @@ func TestCreateFromRequest_AgentPlaceholderNotStored(t *testing.T) { } } -func TestCreateFromRequest_TraditionalKeepsEndpoint(t *testing.T) { +func TestCreateFromRequest_RealAddressKeptVerbatim(t *testing.T) { setupConflictDB(t) svc := &NodeService{} view, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "trad-new", Enable: true, Scheme: "https", - Address: "198.51.100.10", Port: 8443, ApiToken: strPtr("tok-1"), + Name: "real-new", Enable: true, Scheme: "https", + Address: "198.51.100.10", Port: 8443, }) if err != nil { - t.Fatalf("CreateFromRequest(traditional): %v", err) + t.Fatalf("CreateFromRequest(real address): %v", err) } stored, err := svc.GetById(view.Id) if err != nil { t.Fatalf("GetById: %v", err) } if stored.Address != "198.51.100.10" || stored.Port != 8443 { - t.Fatalf("stored traditional endpoint = %q:%d, want 198.51.100.10:8443", stored.Address, stored.Port) - } -} - -func TestCreateFromRequest_TraditionalEmptyAddressStillRejected(t *testing.T) { - setupConflictDB(t) - svc := &NodeService{} - - if _, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "trad-bad", Enable: true, Scheme: "https", ApiToken: strPtr("tok-2"), - }); err == nil { - t.Fatal("traditional create with an empty address must still fail validation") + t.Fatalf("stored endpoint = %q:%d, want 198.51.100.10:8443", stored.Address, stored.Port) } } @@ -95,7 +81,7 @@ func TestUpdateFromRequest_AgentPlaceholderReplacedByResolved(t *testing.T) { // Exactly what the edit form submits for an agent node today. err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ Name: "agent-upd", Enable: true, Scheme: "https", - Address: "agent", Port: 2053, NodeType: "agent", + Address: "agent", Port: 2053, }) if err != nil { t.Fatalf("UpdateFromRequest(agent): %v", err) @@ -116,7 +102,7 @@ func TestUpdateFromRequest_AgentNoReport_SanitizesToEmpty(t *testing.T) { err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ Name: "agent-silent", Enable: true, Scheme: "https", - Address: "agent", Port: 2053, NodeType: "agent", + Address: "agent", Port: 2053, }) if err != nil { t.Fatalf("UpdateFromRequest(agent): %v", err) @@ -137,7 +123,7 @@ func TestUpdateFromRequest_AgentRealAddressKeptVerbatim(t *testing.T) { err := svc.UpdateFromRequest(node.Id, &NodeMutationRequest{ Name: "agent-real", Enable: true, Scheme: "https", - Address: "edge.example.com", Port: 443, NodeType: "agent", + Address: "edge.example.com", Port: 443, }) if err != nil { t.Fatalf("UpdateFromRequest(agent, real address): %v", err) diff --git a/internal/web/service/node_client_expiry_sync_test.go b/internal/web/service/node_client_expiry_sync_test.go index bdd498c..e9c8aa1 100644 --- a/internal/web/service/node_client_expiry_sync_test.go +++ b/internal/web/service/node_client_expiry_sync_test.go @@ -1,14 +1,7 @@ package service import ( - "fmt" "testing" - "time" - - "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/runtime" - "github.com/mhsanaei/3x-ui/v3/internal/xray" ) // TestMergeActivationExpiry covers the pure reconciliation rule in isolation. @@ -41,977 +34,3 @@ func TestMergeActivationExpiry(t *testing.T) { }) } } - -func TestNodeDisableIsStale(t *testing.T) { - now := time.Now().UnixMilli() - const quota = int64(100) - cases := []struct { - name string - master *xray.ClientTraffic - node xray.ClientTraffic - deltaUp int64 - deltaDown int64 - wantStale bool - }{ - {name: "nil master", node: xray.ClientTraffic{ExpiryTime: earlyAbs}}, - { - name: "matching limits are a genuine verdict", - master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota}, - node: xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota}, - wantStale: false, - }, - { - name: "node still holds the pre-extension deadline", - master: &xray.ClientTraffic{ExpiryTime: lateAbs}, - node: xray.ClientTraffic{ExpiryTime: earlyAbs}, - wantStale: true, - }, - { - name: "node still holds the pre-top-up quota", - master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: 2 * quota, Up: 60, Down: 50}, - node: xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota}, - wantStale: true, - }, - { - name: "master itself expired", - master: &xray.ClientTraffic{ExpiryTime: earlyAbs}, - node: xray.ClientTraffic{ExpiryTime: earlyAbs - 1000}, - }, - { - name: "master itself over quota", - master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 60, Down: 50}, - node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota}, - }, - { - name: "this tick's deltas cross the master quota", - master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 40, Down: 50}, - node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota}, - deltaUp: 10, - deltaDown: 10, - }, - { - // Same row without the deltas: the crossing above is the deltas' doing. - name: "under the master quota before this tick's deltas", - master: &xray.ClientTraffic{ExpiryTime: lateAbs, Total: quota, Up: 40, Down: 50}, - node: xray.ClientTraffic{ExpiryTime: earlyAbs, Total: quota}, - wantStale: true, - }, - { - name: "un-activated node duration against an absolute master", - master: &xray.ClientTraffic{ExpiryTime: lateAbs}, - node: xray.ClientTraffic{ExpiryTime: -2592000000}, - wantStale: true, - }, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - if got := nodeDisableIsStale(c.master, c.node, now, c.deltaUp, c.deltaDown); got != c.wantStale { - t.Fatalf("nodeDisableIsStale(...) = %v, want %v", got, c.wantStale) - } - }) - } -} - -// TestNodeFirstConnectExpiry_NotClobbered reproduces the multi-node bug: a -// client is attached to inbounds on two nodes with a "start after first connect" -// expiry. The client connects only on node 1, which activates an absolute -// deadline; node 2 never sees a connection and keeps reporting the negative -// duration. The shared per-email client_traffics row must hold the activated -// deadline — a later node-2 sync must not reset it back to "not started". -func TestNodeFirstConnectExpiry_NotClobbered(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - createNodeInbound(t, db, 2, "n2-in", 41002) - svc := &InboundService{} - - const email = "delayed" - const duration = int64(-2592000000) // 30 days, not yet started - - // Both nodes start out reporting the un-activated negative duration. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != duration { - t.Fatalf("before any connection: expiry = %d, want %d", got, duration) - } - - // Client connects on node 1: it activates an absolute deadline. - const activated = int64(1893456000000) // some absolute ms timestamp - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated) - } - - // Node 2 (no connection there) keeps reporting the negative duration. This - // must NOT reset the activated deadline. - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: duration, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("node 2 clobbered the activated deadline: expiry = %d, want %d", got, activated) - } - - // Subsequent node 1 syncs keep the same absolute deadline. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, ExpiryTime: activated, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("after further node 1 sync: expiry = %d, want %d", got, activated) - } -} - -// TestNodeFirstConnectExpiry_NotClobbered_WithSettings exercises the full -// production sync path — snapshots carrying real settings JSON, which drives the -// GetClients/SyncInbound branch inside setRemoteTrafficLocked — to prove that -// branch does not re-derive the per-email client_traffics.expiry_time from the -// node's (still negative) settings and undo the merge guard. -func TestNodeFirstConnectExpiry_NotClobbered_WithSettings(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed") - createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "delayed") - svc := &InboundService{} - - const email = "delayed" - const duration = int64(-2592000000) - const activated = int64(1893456000000) - - negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}` - actSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":1893456000000}]}` - - // Both nodes start un-activated. - syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true}) - syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true}) - - // Node 1 activates (both its ClientStats and its settings now carry the - // absolute deadline, like a real node after adjustTraffics). - syncNodeWithSettings(t, svc, 1, "n1-in", actSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("after node 1 activation: expiry = %d, want %d", got, activated) - } - - // Node 2 still reports the negative duration in BOTH ClientStats and - // settings. Neither the merge nor SyncInbound may reset the deadline. - syncNodeWithSettings(t, svc, 2, "n2-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("node 2 settings-sync clobbered the deadline: expiry = %d, want %d", got, activated) - } -} - -// TestNodeRenewExtendsExpiry: node auto-renew (reset + later expiry + counter -// drop) must still move master expiry forward via nodeClientRenewed. -func TestNodeRenewExtendsExpiry(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renewing" - const first = int64(1893456000000) - const renewed = first + int64(2592000000) // +30 days after auto-renew - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 0, Down: 0, ExpiryTime: first, Reset: 30, Enable: true, - }) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 100, Down: 100, ExpiryTime: first, Reset: 30, Enable: true, - }) - if got := readTraffic(t, db, email).ExpiryTime; got != first { - t.Fatalf("after activation: expiry = %d, want %d", got, first) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 5, Down: 5, ExpiryTime: renewed, Reset: 30, Enable: true, - }) - if got := readTraffic(t, db, email).ExpiryTime; got != renewed { - t.Fatalf("node renewal did not propagate: expiry = %d, want %d", got, renewed) - } -} - -// TestNodeRenew_WithMatchingSettings: renew still applies when settings JSON -// also carries the later absolute (guard must not block real renewals). -func TestNodeRenew_WithMatchingSettings(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renew-settings" - firstSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, renewFirstExpiry) - renewSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, renewSecondExpiry) - - syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{ - Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true, - }) - syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{ - Email: email, Up: 100, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true, - }) - syncNodeWithSettings(t, svc, 1, "n1-in", renewSettings, xray.ClientTraffic{ - Email: email, Up: 5, Down: 5, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true, - }) - if got := readTraffic(t, db, email).ExpiryTime; got != renewSecondExpiry { - t.Fatalf("renewal with matching settings: got %d want %d", got, renewSecondExpiry) - } -} - -// A node still holding the pre-extension deadline must not undo the extension on -// traffics, client records or the adopted settings JSON (#6228). -func TestNodeStaleExpiryAfterExtend_NotClobbered(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "extended") - svc := &InboundService{} - - const email = "extended" - expired, extended := earlyAbs, lateAbs - - staleSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired) - - syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - if got := readTraffic(t, db, email); got.ExpiryTime != expired || got.Enable { - t.Fatalf("after expiry: expiry=%d enable=%v, want expiry=%d enable=false", - got.ExpiryTime, got.Enable, expired) - } - - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil { - t.Fatalf("master extend traffic: %v", err) - } - if err := db.Model(&model.ClientRecord{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil { - t.Fatalf("master extend record: %v", err) - } - - syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - - got := readTraffic(t, db, email) - if got.ExpiryTime != extended { - t.Fatalf("stale node expiry clobbered traffics: expiry=%d, want %d", got.ExpiryTime, extended) - } - if !got.Enable { - t.Fatal("stale node disable latched traffics.enable off after extension") - } - - var rec model.ClientRecord - if err := db.Where("email = ?", email).First(&rec).Error; err != nil { - t.Fatalf("read client record: %v", err) - } - if rec.ExpiryTime != extended { - t.Fatalf("stale SyncInbound clobbered record expiry: %d, want %d", rec.ExpiryTime, extended) - } - if !rec.Enable { - t.Fatal("stale SyncInbound latched clients.enable off after extension") - } - - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("read inbound: %v", err) - } - clients, err := svc.GetClients(&ib) - if err != nil { - t.Fatalf("GetClients: %v", err) - } - var found bool - for _, c := range clients { - if c.Email != email { - continue - } - found = true - if c.ExpiryTime != extended { - t.Fatalf("adopted settings kept stale expiry: %d, want %d", c.ExpiryTime, extended) - } - if !c.Enable { - t.Fatal("adopted settings kept enable=false after extension") - } - } - if !found { - t.Fatal("client missing from adopted inbound settings after stale sync") - } -} - -// TestNodeStaleLift_MarksNodeDirty: a lifecycle lift must mark the node dirty -// and store lifted settings centrally so reconcile can re-push (#6228). -func TestNodeStaleLift_MarksNodeDirty(t *testing.T) { - db := initTrafficTestDB(t) - node := &model.Node{Name: "lift-n", Address: "127.0.0.1", Port: 2097, ApiToken: "tok", Enable: true, Status: "online"} - if err := db.Create(node).Error; err != nil { - t.Fatalf("create node: %v", err) - } - createNodeInboundWithClient(t, db, node.Id, "n1-in", 41001, "extended") - svc := &InboundService{} - - const email = "extended" - expired, extended := earlyAbs, lateAbs - staleSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired) - - syncNodeWithSettings(t, svc, node.Id, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil { - t.Fatalf("master extend: %v", err) - } - if err := db.Model(model.Node{}).Where("id = ?", node.Id). - Updates(map[string]any{"config_dirty": false, "config_dirty_at": int64(0)}).Error; err != nil { - t.Fatalf("clear dirty: %v", err) - } - - syncNodeWithSettings(t, svc, node.Id, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - - var n model.Node - if err := db.Select("config_dirty").Where("id = ?", node.Id).First(&n).Error; err != nil { - t.Fatalf("read node: %v", err) - } - if !n.ConfigDirty { - t.Fatal("lifecycle lift must mark the node dirty so reconcile re-pushes") - } - - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("read inbound: %v", err) - } - clients, err := svc.GetClients(&ib) - if err != nil { - t.Fatalf("GetClients: %v", err) - } - var found bool - for _, c := range clients { - if c.Email != email { - continue - } - found = true - if c.ExpiryTime != extended || !c.Enable { - t.Fatalf("lifted settings not stored: expiry=%d enable=%v", c.ExpiryTime, c.Enable) - } - } - if !found { - t.Fatal("client missing from lifted inbound settings") - } - if ib.Settings == staleSettings { - t.Fatal("central settings must differ from pre-lift wire blob (FP basis)") - } -} - -// A node disable decided on the master's own limits is genuine and must still -// one-way-merge enable=false onto the master (#4917). -func TestNodeQuotaDisable_SameExpiryStillLatches(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "quota" - const expiry = int64(1893456000000) - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: expiry, Enable: true, - }) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: expiry, Enable: false, - }) - if got := readTraffic(t, db, email); got.Enable { - t.Fatal("same-expiry node disable must still latch master enable off (#4917)") - } -} - -// Once the master row is itself depleted, a node disable latches even though the -// node's limits lag — otherwise genuine quota cuts would be skipped. -func TestNodeQuotaDisable_OlderExpiryStillLatchesWhenOverQuota(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "quota-lag" - early, late := earlyAbs, lateAbs - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: late, Enable: true, - }) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{ - "expiry_time": late, "enable": true, "up": int64(60), "down": int64(50), "total": int64(100), - }).Error; err != nil { - t.Fatalf("seed over-quota master: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: early, Enable: false, - }) - if got := readTraffic(t, db, email); got.Enable { - t.Fatal("over-quota master must still adopt node disable despite older node expiry") - } - if got := readTraffic(t, db, email); got.ExpiryTime != late { - t.Fatalf("expiry should stay at master extension: got %d want %d", got.ExpiryTime, late) - } -} - -// TestNodeMasterShorten_NotClobberedWhileDirty: master shortened expiry while -// config_dirty; a lagging longer node snapshot must not raise client_traffics. -func TestNodeMasterShorten_NotClobberedWhileDirty(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shortened") - svc := &InboundService{} - - const email = "shortened" - longExp, shortExp := lateAbs, earlyAbs - longSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp) - - syncNodeWithSettings(t, svc, 1, "n1-in", longSettings, - xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil { - t.Fatalf("master shorten: %v", err) - } - if err := db.Model(model.Node{}).Where("id = ?", 1). - Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil { - t.Fatalf("mark dirty: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", Settings: longSettings, - ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: longExp, Enable: true, Up: 5, Down: 5}}, - }}, - } - before := readTraffic(t, db, email) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Update("total", int64(999)).Error; err != nil { - t.Fatalf("master total: %v", err) - } - if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil { - t.Fatalf("dirty sync: %v", err) - } - got := readTraffic(t, db, email) - if got.ExpiryTime != shortExp { - t.Fatalf("dirty sync raised expiry: got %d want %d", got.ExpiryTime, shortExp) - } - if got.Total != 999 { - t.Fatalf("dirty sync adopted node total: got %d want 999", got.Total) - } - if got.Up < before.Up+5 || got.Down < before.Down+5 { - t.Fatalf("dirty sync must still accumulate traffic: before=(%d,%d) after=(%d,%d)", - before.Up, before.Down, got.Up, got.Down) - } -} - -// The master's shortened absolute survives a snapshot whose ClientStats still -// report the longer deadline (#6228). -func TestNodeMasterShorten_LaggingClientStatsIgnored(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "short-settings") - svc := &InboundService{} - - const email = "short-settings" - longExp, shortExp := lateAbs, earlyAbs - longSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp) - shortSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, shortExp) - - syncNodeWithSettings(t, svc, 1, "n1-in", longSettings, - xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil { - t.Fatalf("master shorten: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", Settings: shortSettings, - ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: longExp, Enable: true}}, - }}, - } - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("clean sync: %v", err) - } - if got := readTraffic(t, db, email); got.ExpiryTime != shortExp { - t.Fatalf("lagging ClientStats raised expiry: got %d want %d", got.ExpiryTime, shortExp) - } -} - -// TestNodeMasterShorten_CleanSiblingCannotRaise: a clean sibling still holding -// the longer deadline in settings+ClientStats must not undo a master shorten. -func TestNodeMasterShorten_CleanSiblingCannotRaise(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "sib-short") - createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "sib-short") - svc := &InboundService{} - - const email = "sib-short" - longExp, shortExp := lateAbs, earlyAbs - longSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, longExp) - - syncNodeWithSettings(t, svc, 1, "n1-in", longSettings, - xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true}) - syncNodeWithSettings(t, svc, 2, "n2-in", longSettings, - xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil { - t.Fatalf("master shorten: %v", err) - } - - // Sibling 2 is clean and still reports the old longer deadline. - syncNodeWithSettings(t, svc, 2, "n2-in", longSettings, - xray.ClientTraffic{Email: email, ExpiryTime: longExp, Enable: true}) - if got := readTraffic(t, db, email); got.ExpiryTime != shortExp { - t.Fatalf("clean sibling raised shortened expiry: got %d want %d", got.ExpiryTime, shortExp) - } -} - -// TestNodeMasterShorten_LaggingStatsNotTreatedAsRenew: after shorten, ClientStats -// may still show the longer deadline with Reset+dip — must not call renewal. -func TestNodeMasterShorten_LaggingStatsNotTreatedAsRenew(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "short-renew-trap" - longExp, shortExp := lateAbs, earlyAbs - shortSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, shortExp) - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 0, Down: 0, ExpiryTime: longExp, Reset: 30, Enable: true, - }) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 100, Down: 100, ExpiryTime: longExp, Reset: 30, Enable: true, - }) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": shortExp, "enable": true}).Error; err != nil { - t.Fatalf("master shorten: %v", err) - } - - syncNodeWithSettings(t, svc, 1, "n1-in", shortSettings, - xray.ClientTraffic{Email: email, Up: 5, Down: 5, ExpiryTime: longExp, Reset: 30, Enable: true}) - if got := readTraffic(t, db, email); got.ExpiryTime != shortExp { - t.Fatalf("lagging stats treated as renew: got %d want %d", got.ExpiryTime, shortExp) - } -} - -// TestNodeStaleExpiryAfterExtend_WhileDirty keeps master extend while dirty. -func TestNodeStaleExpiryAfterExtend_WhileDirty(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "extended-dirty") - svc := &InboundService{} - - const email = "extended-dirty" - expired, extended := earlyAbs, lateAbs - staleSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired) - - syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil { - t.Fatalf("master extend: %v", err) - } - if err := db.Model(model.Node{}).Where("id = ?", 1). - Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil { - t.Fatalf("mark dirty: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", Settings: staleSettings, - ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: expired, Enable: false}}, - }}, - } - if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil { - t.Fatalf("dirty sync: %v", err) - } - got := readTraffic(t, db, email) - if got.ExpiryTime != extended || !got.Enable { - t.Fatalf("dirty sync clobbered extend: expiry=%d enable=%v", got.ExpiryTime, got.Enable) - } -} - -// TestNodeRenewal_SkippedWhileDirty: renewal-shaped stats must not adopt -// expiry/enable over a pending master push. -func TestNodeRenewal_SkippedWhileDirty(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renew-dirty" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 100, Down: 100, ExpiryTime: earlyAbs, Enable: true, Reset: 30, - }) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": earlyAbs, "enable": true, "up": int64(100), "down": int64(100)}).Error; err != nil { - t.Fatalf("seed master: %v", err) - } - if err := db.Model(model.Node{}).Where("id = ?", 1). - Updates(map[string]any{"config_dirty": true, "config_dirty_at": int64(1)}).Error; err != nil { - t.Fatalf("mark dirty: %v", err) - } - // Renewal shape: later expiry + counters below baseline. - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", - ClientStats: []xray.ClientTraffic{{ - Email: email, Up: 10, Down: 10, ExpiryTime: lateAbs, Enable: true, Reset: 30, - }}, - }}, - } - if _, err := svc.setRemoteTrafficLocked(1, snap, true, false); err != nil { - t.Fatalf("dirty sync: %v", err) - } - got := readTraffic(t, db, email) - if got.ExpiryTime != earlyAbs { - t.Fatalf("renewal while dirty raised expiry: got %d want %d", got.ExpiryTime, earlyAbs) - } - - // Same renewal-shaped stats on a clean tick must still advance expiry — - // dirty must not have burned the dipped baseline. - if err := db.Model(model.Node{}).Where("id = ?", 1). - Updates(map[string]any{"config_dirty": false, "config_dirty_at": int64(0)}).Error; err != nil { - t.Fatalf("clear dirty: %v", err) - } - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("clean sync: %v", err) - } - if got := readTraffic(t, db, email); got.ExpiryTime != lateAbs { - t.Fatalf("renewal after dirty clear did not apply: got %d want %d", got.ExpiryTime, lateAbs) - } -} - -// TestNodeExtend_FreshSettingsLaggingDisable: after extend, settings may already -// show the new absolute while ClientStats still report enable=false + old expiry. -func TestNodeExtend_FreshSettingsLaggingDisable(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "ext-lag") - svc := &InboundService{} - - const email = "ext-lag" - expired, extended := earlyAbs, lateAbs - staleSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":false,"expiryTime":%d}]}`, email, expired) - freshSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, extended) - - syncNodeWithSettings(t, svc, 1, "n1-in", staleSettings, - xray.ClientTraffic{Email: email, ExpiryTime: expired, Enable: false}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"expiry_time": extended, "enable": true}).Error; err != nil { - t.Fatalf("master extend: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", Settings: freshSettings, - ClientStats: []xray.ClientTraffic{{Email: email, ExpiryTime: expired, Enable: false}}, - }}, - } - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("clean sync: %v", err) - } - got := readTraffic(t, db, email) - if got.ExpiryTime != extended { - t.Fatalf("expiry clobbered: got %d want %d", got.ExpiryTime, extended) - } - if !got.Enable { - t.Fatal("lagging ClientStats enable=false latched master off after extend") - } -} - -// A client that used no traffic never dips below its baseline, so the renewal -// counter is the only evidence the node auto-renewed (#6228). -func TestNodeRenew_ZeroTrafficUsesResetCount(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "idle-renew" - firstSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, earlyAbs) - renewSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, lateAbs) - - syncNodeWithSettings(t, svc, 1, "n1-in", firstSettings, xray.ClientTraffic{ - Email: email, ExpiryTime: earlyAbs, Reset: 30, Enable: true, - }) - syncNodeWithSettings(t, svc, 1, "n1-in", renewSettings, xray.ClientTraffic{ - Email: email, ExpiryTime: lateAbs, Reset: 30, ResetCount: 1, Enable: true, - }) - - got := readTraffic(t, db, email) - if got.ExpiryTime != lateAbs { - t.Fatalf("zero-traffic renewal dropped: expiry=%d want %d", got.ExpiryTime, lateAbs) - } - if got.ResetCount != 1 { - t.Fatalf("renewal count not persisted, so the next renewal cannot be seen: got %d want 1", got.ResetCount) - } -} - -// A quota top-up leaves the expiry alone, so the node's lagging disable must be -// recognised by the stale quota it was decided against (#6228). -func TestNodeQuotaTopUp_LaggingDisableIgnored(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "topped-up" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 10, Down: 10, Total: 100, ExpiryTime: lateAbs, Enable: true, - }) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{ - "total": int64(500), "enable": true, "up": int64(60), "down": int64(50), - }).Error; err != nil { - t.Fatalf("master top-up: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: email, Up: 60, Down: 50, Total: 100, ExpiryTime: lateAbs, Enable: false, - }) - if got := readTraffic(t, db, email); !got.Enable { - t.Fatal("node disable decided on the pre-top-up quota latched over the raised one") - } -} - -// The settings lift is authoritative in both directions: a blob predating a -// master disable must not carry enable=true back into central settings (#4917). -func TestNodeStaleEnable_LiftedOffInSettings(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "cut-off") - svc := &InboundService{} - - const email = "cut-off" - liveSettings := fmt.Sprintf( - `{"clients":[{"email":%q,"enable":true,"expiryTime":%d}]}`, email, lateAbs) - - syncNodeWithSettings(t, svc, 1, "n1-in", liveSettings, - xray.ClientTraffic{Email: email, ExpiryTime: lateAbs, Enable: true}) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Update("enable", false).Error; err != nil { - t.Fatalf("master disable: %v", err) - } - - syncNodeWithSettings(t, svc, 1, "n1-in", liveSettings, - xray.ClientTraffic{Email: email, ExpiryTime: lateAbs, Enable: true}) - - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("read inbound: %v", err) - } - clients, err := svc.GetClients(&ib) - if err != nil { - t.Fatalf("GetClients: %v", err) - } - var found bool - for _, c := range clients { - if c.Email != email { - continue - } - found = true - if c.Enable { - t.Fatal("adopted settings re-enabled a client the master disabled") - } - } - if !found { - t.Fatal("client missing from adopted inbound settings") - } -} - -// The tick whose push just landed freezes only the lifecycle merge: adoption, -// new client rows and traffic accumulation must keep working (#6228). -func TestNodeJustPushed_FreezesLifecycleOnly(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const kept = "kept" - const fresh = "fresh" - extended := lateAbs + 86400000 - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{ - Email: kept, Up: 10, Down: 10, Total: 100, ExpiryTime: lateAbs, Enable: true, - }) - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", kept). - Updates(map[string]any{"expiry_time": extended, "total": int64(500)}).Error; err != nil { - t.Fatalf("master edit: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "n1-in", - ClientStats: []xray.ClientTraffic{ - {Email: kept, Up: 20, Down: 20, Total: 100, ExpiryTime: lateAbs, Enable: false}, - {Email: fresh, Up: 5, Down: 5, ExpiryTime: lateAbs, Enable: true}, - }, - }}, - } - if _, err := svc.setRemoteTrafficLocked(1, snap, false, true); err != nil { - t.Fatalf("just-pushed sync: %v", err) - } - - got := readTraffic(t, db, kept) - if got.ExpiryTime != extended || got.Total != 500 || !got.Enable { - t.Fatalf("just-pushed tick adopted lagging lifecycle: expiry=%d total=%d enable=%v", - got.ExpiryTime, got.Total, got.Enable) - } - if got.Up != 10 || got.Down != 10 { - t.Fatalf("just-pushed tick dropped this tick's traffic: up=%d down=%d, want 10/10", got.Up, got.Down) - } - if row := readTraffic(t, db, fresh); row.Email != fresh { - t.Fatalf("just-pushed tick skipped adoption of a new client: %+v", row) - } -} - -// TestClientTrafficMergeSQLMatchesHelpers pins the dialect SQL expressions -// against the Go helpers so the in-memory replay after UPDATE cannot drift. -func TestClientTrafficMergeSQLMatchesHelpers(t *testing.T) { - db := initTrafficTestDB(t) - - const email = "sql-merge" - now := time.Now().UnixMilli() - cases := []struct { - name string - masterExpiry int64 - masterEnable bool - masterUp, masterDown, tot int64 - deltaUp, deltaDown int64 - nodeExpiry, nodeTotal int64 - nodeEnable bool - wantExpiry int64 - wantEnable bool - }{ - { - name: "stale expiry+disable after extend", - masterExpiry: lateAbs, masterEnable: true, - nodeExpiry: earlyAbs, nodeEnable: false, - wantExpiry: lateAbs, wantEnable: true, - }, - { - name: "same expiry quota disable", - masterExpiry: lateAbs, masterEnable: true, - masterUp: 60, masterDown: 50, tot: 100, - nodeExpiry: lateAbs, nodeTotal: 100, nodeEnable: false, - wantExpiry: lateAbs, wantEnable: false, - }, - { - name: "older expiry but master over quota", - masterExpiry: lateAbs, masterEnable: true, - masterUp: 60, masterDown: 50, tot: 100, - nodeExpiry: earlyAbs, nodeTotal: 100, nodeEnable: false, - wantExpiry: lateAbs, wantEnable: false, - }, - { - name: "master absolute ignores later node", - masterExpiry: earlyAbs, masterEnable: true, - nodeExpiry: lateAbs, nodeEnable: true, - wantExpiry: earlyAbs, wantEnable: true, - }, - { - name: "negative node keeps absolute", - masterExpiry: lateAbs, masterEnable: true, - nodeExpiry: -2592000000, nodeEnable: true, - wantExpiry: lateAbs, wantEnable: true, - }, - { - name: "expired master latches node disable", - masterExpiry: earlyAbs, masterEnable: true, - nodeExpiry: earlyAbs - 1000, nodeEnable: false, - wantExpiry: earlyAbs, wantEnable: false, - }, - { - name: "older expiry crosses quota via deltas", - masterExpiry: lateAbs, masterEnable: true, - masterUp: 40, masterDown: 50, tot: 100, - deltaUp: 10, deltaDown: 10, - nodeExpiry: earlyAbs, nodeTotal: 100, nodeEnable: false, - wantExpiry: lateAbs, wantEnable: false, - }, - } - - enableExpr := database.ClientTrafficEnableMergeExpr() - expiryExpr := database.ClientTrafficExpiryMergeExpr() - for i, c := range cases { - t.Run(c.name, func(t *testing.T) { - rowEmail := fmt.Sprintf("%s-%d", email, i) - if err := db.Create(&xray.ClientTraffic{ - InboundId: 1, Email: rowEmail, Enable: c.masterEnable, - ExpiryTime: c.masterExpiry, Up: c.masterUp, Down: c.masterDown, Total: c.tot, - }).Error; err != nil { - t.Fatalf("seed: %v", err) - } - master := &xray.ClientTraffic{ - ExpiryTime: c.masterExpiry, Enable: c.masterEnable, - Up: c.masterUp, Down: c.masterDown, Total: c.tot, - } - wantExpiry := mergeActivationExpiry(c.masterExpiry, c.nodeExpiry) - wantEnable := c.masterEnable - node := xray.ClientTraffic{ExpiryTime: c.nodeExpiry, Total: c.nodeTotal} - if !c.nodeEnable && !nodeDisableIsStale(master, node, now, c.deltaUp, c.deltaDown) { - wantEnable = false - } - if wantExpiry != c.wantExpiry || wantEnable != c.wantEnable { - t.Fatalf("helper expectation drift: helpers=(%d,%v) fixture=(%d,%v)", - wantExpiry, wantEnable, c.wantExpiry, c.wantEnable) - } - - if err := db.Exec( - fmt.Sprintf( - `UPDATE client_traffics SET enable = %s, expiry_time = %s WHERE email = ?`, - enableExpr, expiryExpr, - ), - c.nodeEnable, c.nodeExpiry, c.nodeTotal, now, c.deltaUp, c.deltaDown, - c.nodeExpiry, - rowEmail, - ).Error; err != nil { - t.Fatalf("SQL merge: %v", err) - } - got := readTraffic(t, db, rowEmail) - if got.ExpiryTime != c.wantExpiry || got.Enable != c.wantEnable { - t.Fatalf("SQL merge got expiry=%d enable=%v, want expiry=%d enable=%v", - got.ExpiryTime, got.Enable, c.wantExpiry, c.wantEnable) - } - }) - } -} - -// Relative to the run: the merge rules now compare the master deadline against -// wall-clock now, so fixed timestamps would rot into the wrong side of it. -var ( - earlyAbs = time.Now().UnixMilli() - 30*86400000 - lateAbs = time.Now().UnixMilli() + 30*86400000 -) - -// TestNodeActivationLiftsClientRecordExpiry reproduces #5714: the node activates -// the deadline (positive ClientStats) while its settings JSON still carries the -// negative duration, so SyncInbound keeps writing the stale value into the -// client record and the Clients page shows "not started" forever. -func TestNodeActivationLiftsClientRecordExpiry(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "delayed") - svc := &InboundService{} - - const email = "delayed" - const duration = int64(-2592000000) - const activated = int64(1798448344010) - negSettings := `{"clients":[{"email":"delayed","enable":true,"expiryTime":-2592000000}]}` - - if err := db.Create(&model.ClientRecord{Email: email, Enable: true, ExpiryTime: duration}).Error; err != nil { - t.Fatalf("seed client record: %v", err) - } - - readRecordExpiry := func() int64 { - t.Helper() - var rec model.ClientRecord - if err := db.Where("email = ?", email).First(&rec).Error; err != nil { - t.Fatalf("read client record: %v", err) - } - return rec.ExpiryTime - } - - syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, ExpiryTime: duration, Enable: true}) - if got := readRecordExpiry(); got != duration { - t.Fatalf("before activation: record expiry = %d, want %d", got, duration) - } - - syncNodeWithSettings(t, svc, 1, "n1-in", negSettings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, ExpiryTime: activated, Enable: true}) - if got := readTraffic(t, db, email).ExpiryTime; got != activated { - t.Fatalf("client_traffics not activated: expiry = %d, want %d", got, activated) - } - if got := readRecordExpiry(); got != activated { - t.Fatalf("client record kept stale duration (#5714): expiry = %d, want %d", got, activated) - } -} diff --git a/internal/web/service/node_client_renew_sync_test.go b/internal/web/service/node_client_renew_sync_test.go deleted file mode 100644 index 42486d2..0000000 --- a/internal/web/service/node_client_renew_sync_test.go +++ /dev/null @@ -1,109 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/xray" -) - -const ( - renewFirstExpiry = int64(1893456000000) - renewSecondExpiry = renewFirstExpiry + int64(2592000000) - renewPeriodDays = 30 -) - -// TestNodeRenew_ResetsMasterTraffic reproduces #5843: a node-side auto-renew -// extends the deadline and resets the node's counters, and the master must -// start a fresh quota window instead of keeping the old period's usage. -func TestNodeRenew_ResetsMasterTraffic(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renew-reset" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 950, 150, "before renewal") - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 5, Down: 2, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) - ct := readTraffic(t, db, email) - assertUpDown(t, ct, 5, 2, "after renewal") - if ct.ExpiryTime != renewSecondExpiry { - t.Errorf("renewal expiry = %d, want %d", ct.ExpiryTime, renewSecondExpiry) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 15, Down: 12, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 15, 12, "accumulating in the new period") -} - -// TestNodeRenew_ReenablesDepletedClient: the node re-enables a client it -// renews; the master's depleted-disable must lift with the fresh window. -func TestNodeRenew_ReenablesDepletedClient(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renew-enable" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - if err := db.Model(&xray.ClientTraffic{}).Where("email = ?", email).Update("enable", false).Error; err != nil { - t.Fatalf("force-disable master row: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) - if ct := readTraffic(t, db, email); !ct.Enable { - t.Error("renewal must re-enable the master row when the node reports the client enabled") - } -} - -// TestNodeRenew_ClearsGlobalTraffic mirrors autoRenewClients: stale cross-panel -// pushes for the renewed email must not re-deplete the fresh window. -func TestNodeRenew_ClearsGlobalTraffic(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "renew-global" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 100, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - if err := db.Create(&model.ClientGlobalTraffic{MasterGuid: "m1", Email: email, Up: 800, Down: 90}).Error; err != nil { - t.Fatalf("seed global traffic: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 3, Down: 1, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) - - var cnt int64 - if err := db.Model(&model.ClientGlobalTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { - t.Fatalf("count global traffic: %v", err) - } - if cnt != 0 { - t.Errorf("renewal must clear stale global-traffic rows, found %d", cnt) - } -} - -// TestNodeCounterDip_SameExpiry_KeepsTraffic: a plain counter dip (#5456) with -// no deadline movement stays on the clamp path even for a renewable client. -func TestNodeCounterDip_SameExpiry_KeepsTraffic(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "dip-only" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 150, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 10, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 950, 150, "dip without renewal") -} - -// TestNodeExpiryAdvance_RisingCounters_Accumulates: a manual deadline extension -// while traffic keeps flowing is not a renewal and must keep accumulating. -func TestNodeExpiryAdvance_RisingCounters_Accumulates(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "extend-only" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 0, Down: 0, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 50, ExpiryTime: renewFirstExpiry, Reset: renewPeriodDays, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 160, Down: 90, ExpiryTime: renewSecondExpiry, Reset: renewPeriodDays, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 160, 90, "extension while accumulating") -} diff --git a/internal/web/service/node_client_traffic_sum_test.go b/internal/web/service/node_client_traffic_sum_test.go deleted file mode 100644 index f13c030..0000000 --- a/internal/web/service/node_client_traffic_sum_test.go +++ /dev/null @@ -1,531 +0,0 @@ -package service - -import ( - "fmt" - "path/filepath" - "strings" - "testing" - - "gorm.io/gorm" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" - "github.com/mhsanaei/3x-ui/v3/internal/xray" -) - -func initTrafficTestDB(t *testing.T) *gorm.DB { - t.Helper() - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - return database.GetDB() -} - -func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) { - t.Helper() - nid := nodeID - ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid} - if err := db.Create(ib).Error; err != nil { - t.Fatalf("create node inbound %q: %v", tag, err) - } -} - -// createNodeInboundWithClient mirrors createNodeInbound but stores the client -// in the settings JSON, which the node sync turns into a client_inbounds link. -func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) { - t.Helper() - nid := nodeID - settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid, Settings: settings} - if err := db.Create(ib).Error; err != nil { - t.Fatalf("create node inbound %q: %v", tag, err) - } -} - -func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats ...xray.ClientTraffic) { - t.Helper() - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}}, - } - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err) - } -} - -// syncNodeWithSettings mirrors syncNode but carries a real settings JSON on -// the snapshot inbound, like production nodes do — the sync mirrors snapshot -// settings onto the central row, and the shared-accumulator guard reads the -// clients out of those settings. -func syncNodeWithSettings(t *testing.T, svc *InboundService, nodeID int, tag, settings string, stats ...xray.ClientTraffic) { - t.Helper() - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}}, - } - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err) - } -} - -func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic { - t.Helper() - var ct xray.ClientTraffic - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil { - t.Fatalf("read client_traffics %q: %v", email, err) - } - return ct -} - -func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) { - t.Helper() - if ct.Up != wantUp || ct.Down != wantDown { - t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown) - } -} - -func TestTwoNodesShareEmail_SumsCorrectly(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - createNodeInbound(t, db, 2, "n2-in", 41002) - svc := &InboundService{} - - const email = "shared" - - // First-ever sync from each node: the row is created at 0 and the current - // node counters become the baseline. Only deltas beyond those baselines count. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) - - assertUpDown(t, readTraffic(t, db, email), 0, 0, "after baselines — historical node traffic not imported") - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true}) - - assertUpDown(t, readTraffic(t, db, email), 110, 110, "after both nodes grow — deltas (50+60) accrue") -} - -func TestSingleNode_MirrorsCorrectly(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "solo" - // First sync: row seeded at 0, current counter becomes the baseline. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 500, Down: 600, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync — historical traffic not imported") - - // Second sync: delta (700-500=200 / 800-600=200) accrues normally. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 700, Down: 800, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 200, 200, "second sync — delta accrues") -} - -func TestNodeAdd_ImportsClientHistoryWithNewInbound(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - const email = "newnode-client" - const histUp, histDown int64 = 6_000_000_000, 200_000_000_000 - - syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp, Down: histDown, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), histUp, histDown, "node-add: client history imported with its brand-new inbound") - - syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: histUp + 1024, Down: histDown + 2048, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), histUp+1024, histDown+2048, "post-import delta accrues, no double count") -} - -// TestStaleSnapshot_DeletedClientNotResurrected reproduces #5739: a snapshot -// fetched just before a client's deletion still names the email. Applying it -// must neither recreate the client_traffics row (at zero) nor re-add the -// client to the central inbound's settings while the delete tombstone lives. -func TestStaleSnapshot_DeletedClientNotResurrected(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "victim-5739") - svc := &InboundService{} - - const email = "victim-5739" - withClient := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - - if err := db.Model(&model.Inbound{}).Where("tag = ?", "n1-in"). - Update("settings", `{"clients": []}`).Error; err != nil { - t.Fatalf("clear central settings: %v", err) - } - if err := db.Where("email = ?", email).Delete(&xray.ClientTraffic{}).Error; err != nil { - t.Fatalf("delete stats row: %v", err) - } - tombstoneClientEmail(email) - - syncNodeWithSettings(t, svc, 1, "n1-in", withClient, xray.ClientTraffic{Email: email, Up: 120, Down: 120, Enable: true}) - - var rows int64 - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).Count(&rows).Error; err != nil { - t.Fatalf("count stats rows: %v", err) - } - if rows != 0 { - t.Errorf("deleted client's stats row resurrected by stale snapshot (%d rows)", rows) - } - - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("load inbound: %v", err) - } - if strings.Contains(ib.Settings, email) { - t.Errorf("deleted client re-added to central settings: %s", ib.Settings) - } -} - -func TestNodeAdd_TombstonedClientNotResurrected(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - const email = "deleted-ghost" - const stale int64 = 50_000_000_000 - - tombstoneClientEmail(email) - syncNode(t, svc, 1, "fresh-in", xray.ClientTraffic{Email: email, Up: stale, Down: stale, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 0, 0, "tombstoned client must not resurrect via node-add seed") -} - -func TestUpgrade_PreExistingRow_NoDoubleCount(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "legacy" - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("load inbound: %v", err) - } - if err := db.Create(&xray.ClientTraffic{InboundId: ib.Id, Email: email, Up: 1000, Down: 2000, Enable: true}).Error; err != nil { - t.Fatalf("seed pre-existing row: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1000, Down: 2000, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 1000, 2000, "first snapshot must not double-count") - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 1100, Down: 2100, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 1100, 2100, "growth after upgrade accrues") -} - -// TestGhostData_NoPhantomTraffic reproduces the 50 GB phantom traffic bug: -// a node retains stale data for a deleted client (e.g. de-1 was offline during -// deletion). When the master first syncs this email again it must NOT import the -// stale counter — it sets Up=0 and records the current node value as the -// baseline so only future deltas count. -func TestGhostData_NoPhantomTraffic(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "de1-in", 41001) - svc := &InboundService{} - - const email = "pouya" - const staleBytes int64 = 50 * 1024 * 1024 * 1024 // 50 GB stale on de-1 - - // Node reports a client with a large pre-existing counter (ghost data from a - // deleted account that survived on the node). Master has no row for this email. - syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes, Down: staleBytes, Enable: true}) - - ct := readTraffic(t, db, email) - if ct.Up >= staleBytes || ct.Down >= staleBytes { - t.Errorf("ghost data imported: up=%d down=%d; stale node counter must not count as new traffic", ct.Up, ct.Down) - } - assertUpDown(t, ct, 0, 0, "first sync of ghost email — imported as 0, not 50 GB") - - // Subsequent syncs only add incremental usage beyond the baseline. - syncNode(t, svc, 1, "de1-in", xray.ClientTraffic{Email: email, Up: staleBytes + 1024, Down: staleBytes + 2048, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 1024, 2048, "only incremental traffic beyond baseline counts") -} - -func TestNodeCounterReset_NoReAdd(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - svc := &InboundService{} - - const email = "restart" - // First sync seeds at 0 (baseline=900). Second sync adds delta 950-900=50. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 900, Down: 900, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 950, Down: 950, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 50, 50, "before node reset") - - // Node reboot drops the counter to 50. delta=50-950=-900 is a counter reset, - // not new traffic: add 0 and rebaseline to 50, never re-add the node's full - // cumulative counter onto the master total (#5456). - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 50, Down: 50, Enable: true}) - ct := readTraffic(t, db, email) - if ct.Up < 0 || ct.Down < 0 { - t.Fatalf("row went negative after node reset: up=%d down=%d", ct.Up, ct.Down) - } - assertUpDown(t, ct, 50, 50, "after node counter reset: rebaselined, not re-added") - - // Post-reset accrual resumes from the new baseline: 80-50=30. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 80, Down: 80, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 80, 80, "post-reset delta accrues from rebaselined counter") -} - -func TestCentralReset_NoReAdd(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - createNodeInbound(t, db, 2, "n2-in", 41002) - svc := &InboundService{} - - const email = "reset" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) - - if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email). - Updates(map[string]any{"up": 0, "down": 0}).Error; err != nil { - t.Fatalf("simulate central reset: %v", err) - } - - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 210, Down: 210, Enable: true}) - syncNode(t, svc, 2, "n2-in", xray.ClientTraffic{Email: email, Up: 205, Down: 205, Enable: true}) - - assertUpDown(t, readTraffic(t, db, email), 15, 15, "after central reset only increments accrue") -} - -// A real reset (ResetClientTrafficByEmail) must clear the per-node baseline so -// the node's pre-reset cumulative — including traffic it counted but had not yet -// synced — cannot leak back onto the master after the reset (#5476, #5390). -func TestCentralResetClearsNodeBaseline_NoLeak(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInbound(t, db, 1, "n1-in", 41001) - StartTrafficWriter() - svc := &InboundService{} - - const email = "reset-revert" - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 200, 200, "before reset") - - if err := svc.ResetClientTrafficByEmail(email); err != nil { - t.Fatalf("ResetClientTrafficByEmail: %v", err) - } - assertUpDown(t, readTraffic(t, db, email), 0, 0, "right after reset") - - var baselines int64 - if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&baselines).Error; err != nil { - t.Fatalf("count baselines: %v", err) - } - if baselines != 0 { - t.Fatalf("reset must clear node baseline rows, found %d", baselines) - } - - // Node still reports its pre-reset cumulative (340 > last synced 300: usage it - // had not synced before the reset). It must not revert the reset. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 340, Down: 340, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 0, 0, "stale node counter must not revert reset") - - // Genuine post-reset usage accrues from the rebaselined counter: 370-340=30. - syncNode(t, svc, 1, "n1-in", xray.ClientTraffic{Email: email, Up: 370, Down: 370, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 30, 30, "post-reset usage accrues") -} - -func TestInboundRemoval_KeepsSharedEmailRow(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared") - createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared") - svc := &InboundService{} - - const email = "shared" - settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - // Baselines set: node1=100, node2=200. Row seeded at 0. - syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) - // node1 delta=50, node2 delta=60 → total=110. - syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 150, Down: 150, Enable: true}) - syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 260, Down: 260, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 110, 110, "baseline sum") - - // Node 1 rebuilt (reinstall / another master's reconcile): its inbound - // vanishes from the snapshot. The shared accumulator must survive — losing - // it would let the next node sync re-seed the row with that node's counter - // alone, showing only the last panel's number instead of the sum. - if _, err := svc.setRemoteTrafficLocked(1, &runtime.TrafficSnapshot{}, false, false); err != nil { - t.Fatalf("sync node 1 with empty snapshot: %v", err) - } - assertUpDown(t, readTraffic(t, db, email), 110, 110, "after node 1 inbound removal") - - // Node 2 keeps accruing onto the surviving row. node2 delta=300-260=40 → 150. - syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 300, Down: 300, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 150, 150, "after node 2 grows") -} - -func TestClientGoneFromOneNode_KeepsSharedEmailRow(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, "shared") - createNodeInboundWithClient(t, db, 2, "n2-in", 41002, "shared") - svc := &InboundService{} - - const email = "shared" - settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - // First syncs set baselines (node1=100, node2=200). Row seeded at 0. - syncNodeWithSettings(t, svc, 1, "n1-in", settings, xray.ClientTraffic{Email: email, Up: 100, Down: 100, Enable: true}) - syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 200, Down: 200, Enable: true}) - - // Client detached from node 1's inbound only: its stats vanish from that - // inbound's snapshot while node 2 still hosts the email. - syncNodeWithSettings(t, svc, 1, "n1-in", `{"clients": []}`) - assertUpDown(t, readTraffic(t, db, email), 0, 0, "after client left node 1 — row unchanged at 0") - - // Node 2 delta: 240-200=40 → accrues to 40. - syncNodeWithSettings(t, svc, 2, "n2-in", settings, xray.ClientTraffic{Email: email, Up: 240, Down: 240, Enable: true}) - assertUpDown(t, readTraffic(t, db, email), 40, 40, "node 2 keeps accruing") -} - -// TestStatsUnderSiblingInbound_KeepsNodeBaseline reproduces the recurring -// sweep bug behind #5202: the client is attached to two inbounds of the SAME -// node, the node reports its stats under n1-a only, but the master-side row -// is owned by n1-b's mirror. The per-email sweep for n1-b must not drop the -// (node, email) baseline that n1-a's delta computation needs — doing so every -// cycle froze the client's counter permanently. -func TestStatsUnderSiblingInbound_KeepsNodeBaseline(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "fresh") - createNodeInbound(t, db, 1, "n1-b", 41002) - svc := &InboundService{} - - const email = "fresh" - var ibB model.Inbound - if err := db.Where("tag = ?", "n1-b").First(&ibB).Error; err != nil { - t.Fatalf("load n1-b: %v", err) - } - // Master-side row created when the client was added on the panel, owned by - // n1-b's mirror (e.g. the client form targeted that inbound). - if err := db.Create(&xray.ClientTraffic{InboundId: ibB.Id, Email: email, Enable: true}).Error; err != nil { - t.Fatalf("seed master row: %v", err) - } - - settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - sync := func(up, down int64) { - t.Helper() - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ - {Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: up, Down: down, Enable: true}}}, - {Tag: "n1-b", Settings: `{"clients": []}`}, - }} - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("sync: %v", err) - } - } - - sync(630, 630) - var baselines int64 - if err := db.Model(&model.NodeClientTraffic{}).Where("node_id = ? AND email = ?", 1, email).Count(&baselines).Error; err != nil { - t.Fatalf("count baselines: %v", err) - } - if baselines != 1 { - t.Fatalf("baseline must survive the sibling-inbound sweep, found %d rows", baselines) - } - - sync(700, 700) - assertUpDown(t, readTraffic(t, db, email), 70, 70, "delta accrues once baseline survives") -} - -// TestMultiAttach_SameNode_DivergentSiblings reproduces #5274: a client is -// attached to several inbounds of the SAME node. Xray reports client traffic -// globally per email, so the node's enriched inbound list copies one shared -// counter onto every inbound the client is on. When those copies diverge — a -// legacy per-inbound row surviving the v3.2.x→v3.3.x upgrade, or any drift — -// the per-inbound delta loop used to treat the lower sibling as a node-counter -// reset and re-add its full value, inflating the client far past real usage. -// The merge must collapse the email to its node-wide total and count it once. -func TestMultiAttach_SameNode_DivergentSiblings(t *testing.T) { - db := initTrafficTestDB(t) - createNodeInboundWithClient(t, db, 1, "n1-a", 41001, "multi") - createNodeInboundWithClient(t, db, 1, "n1-b", 41002, "multi") - createNodeInboundWithClient(t, db, 1, "n1-c", 41003, "multi") - svc := &InboundService{} - - const email = "multi" - settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) - - // The three inbounds report the same email with diverging values; the - // node's true per-email total is the largest (the shared global counter). - sync := func(a, b, c int64) { - t.Helper() - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ - {Tag: "n1-a", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: a, Down: a, Enable: true}}}, - {Tag: "n1-b", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: b, Down: b, Enable: true}}}, - {Tag: "n1-c", Settings: settings, ClientStats: []xray.ClientTraffic{{Email: email, Up: c, Down: c, Enable: true}}}, - }} - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("sync: %v", err) - } - } - - // First-ever sync: row seeded at 0, canon (max of siblings = 100) becomes the - // baseline. The node total is NOT imported as historical traffic — this prevents - // ghost data from a previously-deleted account being counted as real usage. - sync(100, 50, 80) - assertUpDown(t, readTraffic(t, db, email), 0, 0, "first sync seeds at 0 — not the sum (230) nor the max (100)") - - // Second sync: canon grew from 100→150, delta=50 accrues. Siblings 60 and 90 - // do not re-add their full values — the canon clamp prevents inflation. - sync(150, 60, 90) - assertUpDown(t, readTraffic(t, db, email), 50, 50, "second sync: only the 50-unit increment counts, not every sibling") - - // Equal siblings (the healthy current-schema case): canon grew from 150→200, - // delta=50 accrues once. - sync(200, 200, 200) - assertUpDown(t, readTraffic(t, db, email), 100, 100, "equal siblings accrue the single increment") -} - -func TestDelClientStat_CleansNodeBaselines(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - const email = "gone" - if err := db.Create(&xray.ClientTraffic{InboundId: 1, Email: email, Enable: true}).Error; err != nil { - t.Fatalf("seed client_traffics: %v", err) - } - if err := db.Create(&model.NodeClientTraffic{NodeId: 1, Email: email, Up: 10, Down: 10}).Error; err != nil { - t.Fatalf("seed node baseline 1: %v", err) - } - if err := db.Create(&model.NodeClientTraffic{NodeId: 2, Email: email, Up: 20, Down: 20}).Error; err != nil { - t.Fatalf("seed node baseline 2: %v", err) - } - - if err := svc.DelClientStat(db, email); err != nil { - t.Fatalf("DelClientStat: %v", err) - } - - var cnt int64 - if err := db.Model(&model.NodeClientTraffic{}).Where("email = ?", email).Count(&cnt).Error; err != nil { - t.Fatalf("count baselines: %v", err) - } - if cnt != 0 { - t.Errorf("expected node baselines cleaned, found %d", cnt) - } -} - -func TestNodeDelete_CleansNodeBaselines(t *testing.T) { - db := initTrafficTestDB(t) - nodeSvc := NodeService{} - - if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "a", Up: 1, Down: 1}).Error; err != nil { - t.Fatalf("seed node 7 a: %v", err) - } - if err := db.Create(&model.NodeClientTraffic{NodeId: 7, Email: "b", Up: 2, Down: 2}).Error; err != nil { - t.Fatalf("seed node 7 b: %v", err) - } - if err := db.Create(&model.NodeClientTraffic{NodeId: 8, Email: "c", Up: 3, Down: 3}).Error; err != nil { - t.Fatalf("seed node 8 c: %v", err) - } - - if err := nodeSvc.Delete(7); err != nil { - t.Fatalf("NodeService.Delete(7): %v", err) - } - - var sevenCnt, eightCnt int64 - db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 7).Count(&sevenCnt) - db.Model(&model.NodeClientTraffic{}).Where("node_id = ?", 8).Count(&eightCnt) - if sevenCnt != 0 { - t.Errorf("node 7 baselines not cleaned: %d remain", sevenCnt) - } - if eightCnt != 1 { - t.Errorf("node 8 baseline should survive, found %d", eightCnt) - } -} diff --git a/internal/web/service/node_contract.go b/internal/web/service/node_contract.go index d86b0fa..ba8f552 100644 --- a/internal/web/service/node_contract.go +++ b/internal/web/service/node_contract.go @@ -9,30 +9,23 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/util/common" ) -// NodeView is the browser/API read contract for nodes. Credentials are -// write-only: responses expose only whether a node has a token configured. +// NodeView is the browser/API read contract for nodes. type NodeView struct { - Id int `json:"id" example:"1"` - Name string `json:"name" example:"edge-1"` - Remark string `json:"remark" example:"Primary edge"` - Scheme string `json:"scheme" example:"https"` - Address string `json:"address" example:"node.example.com"` - Port int `json:"port" example:"2053"` - BasePath string `json:"basePath" example:"/"` - HasApiToken bool `json:"hasApiToken" example:"true"` - Enable bool `json:"enable" example:"true"` - AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"` - TlsVerifyMode string `json:"tlsVerifyMode" example:"verify"` - PinnedCertSha256 string `json:"pinnedCertSha256" example:""` - InboundSyncMode string `json:"inboundSyncMode" example:"all"` - InboundTags []string `json:"inboundTags" example:"[\"in-443-tcp\"]"` - OutboundTag string `json:"outboundTag" example:"direct"` - // NodeType is "agent" for lightweight agent-managed nodes (an agent is - // bound to the node) and "traditional" otherwise. It is derived from the - // agent binding, not stored on the node row; read paths that serve the - // panel UI annotate it via annotateNodeTypes. Empty on views built - // outside those paths. - NodeType string `json:"nodeType,omitempty" example:"traditional"` + Id int `json:"id" example:"1"` + Name string `json:"name" example:"edge-1"` + Remark string `json:"remark" example:"Primary edge"` + Scheme string `json:"scheme" example:"https"` + Address string `json:"address" example:"node.example.com"` + Port int `json:"port" example:"2053"` + BasePath string `json:"basePath" example:"/"` + Enable bool `json:"enable" example:"true"` + AllowPrivateAddress bool `json:"allowPrivateAddress" example:"false"` + // NodeType is "agent" for agent-managed nodes (an agent is bound to + // the node) and "traditional" for a legacy row with no binding. It + // is derived from the agent binding, not stored on the node row; + // read paths that serve the panel UI annotate it via + // annotateNodeTypes. Empty on views built outside those paths. + NodeType string `json:"nodeType,omitempty" example:"agent"` // Ipv4/Ipv6 carry the resolved externally usable host addresses for // agent nodes (P13 telemetry, public-address resolution on top). // The nodes page renders them as plain text on agent rows instead @@ -84,8 +77,6 @@ type NodeView struct { ActiveEmails []string `json:"activeEmails,omitempty" example:"[\"a@example.com\"]"` DisabledEmails []string `json:"disabledEmails,omitempty" example:"[\"b@example.com\"]"` DepletedEmails []string `json:"depletedEmails,omitempty" example:"[\"c@example.com\"]"` - ParentGuid string `json:"parentGuid,omitempty" example:""` - Transitive bool `json:"transitive,omitempty" example:"false"` CreatedAt int64 `json:"createdAt" example:"1700000000"` UpdatedAt int64 `json:"updatedAt" example:"1700003600"` } @@ -102,14 +93,8 @@ func toNodeView(n *model.Node) *NodeView { Address: n.Address, Port: n.Port, BasePath: n.BasePath, - HasApiToken: n.ApiToken != "", Enable: n.Enable, AllowPrivateAddress: n.AllowPrivateAddress, - TlsVerifyMode: n.TlsVerifyMode, - PinnedCertSha256: n.PinnedCertSha256, - InboundSyncMode: n.InboundSyncMode, - InboundTags: n.InboundTags, - OutboundTag: n.OutboundTag, Guid: n.Guid, Status: n.Status, LastHeartbeat: n.LastHeartbeat, @@ -136,8 +121,6 @@ func toNodeView(n *model.Node) *NodeView { ActiveEmails: n.ActiveEmails, DisabledEmails: n.DisabledEmails, DepletedEmails: n.DepletedEmails, - ParentGuid: n.ParentGuid, - Transitive: n.Transitive, CreatedAt: n.CreatedAt, UpdatedAt: n.UpdatedAt, } @@ -280,83 +263,37 @@ func annotateAgentTelemetry(views []*NodeView) { } } -// NodeMutationRequest is the node write/probe contract. ApiToken is accepted -// only as input. On update, nil means keep the stored token; replacement and -// clearing are explicit and mutually exclusive. +// NodeMutationRequest is the node write contract. Every node is +// agent-managed: it dials out to the master and authenticates with the +// agent token issued on creation, so the request carries no panel +// endpoint credentials. type NodeMutationRequest struct { - Id int `json:"id" form:"id"` - Name string `json:"name" form:"name" validate:"required"` - Remark string `json:"remark" form:"remark"` - Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` - Address string `json:"address" form:"address"` - Port int `json:"port" form:"port" validate:"gte=0,lte=65535"` - BasePath string `json:"basePath" form:"basePath"` - ApiToken *string `json:"apiToken,omitempty" form:"apiToken"` - ClearApiToken bool `json:"clearApiToken,omitempty" form:"clearApiToken"` - Enable bool `json:"enable" form:"enable"` - AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"` - TlsVerifyMode string `json:"tlsVerifyMode" form:"tlsVerifyMode" validate:"omitempty,oneof=verify skip pin mtls"` - PinnedCertSha256 string `json:"pinnedCertSha256" form:"pinnedCertSha256"` - InboundSyncMode string `json:"inboundSyncMode" form:"inboundSyncMode" validate:"omitempty,oneof=all selected"` - InboundTags []string `json:"inboundTags" form:"inboundTags"` - OutboundTag string `json:"outboundTag" form:"outboundTag"` - // NodeType distinguishes traditional panel-to-panel nodes from lightweight - // agent nodes. Agent nodes skip the panel reachability probe and the API - // token requirement; an agent token is issued on creation instead. - NodeType string `json:"nodeType,omitempty" form:"nodeType" validate:"omitempty,oneof=traditional agent"` + Id int `json:"id" form:"id"` + Name string `json:"name" form:"name" validate:"required"` + Remark string `json:"remark" form:"remark"` + Scheme string `json:"scheme" form:"scheme" validate:"omitempty,oneof=http https"` + Address string `json:"address" form:"address"` + Port int `json:"port" form:"port" validate:"gte=0,lte=65535"` + BasePath string `json:"basePath" form:"basePath"` + Enable bool `json:"enable" form:"enable"` + AllowPrivateAddress bool `json:"allowPrivateAddress" form:"allowPrivateAddress"` // ProbeOnly (V2.1.0) flips an agent node into probe-only mode. A // pointer so updates can tell "leave the flag alone" (nil) from an - // explicit on/off; applied to the bound agent rows, agent nodes - // only. On create it presets the flag on the freshly issued agent. + // explicit on/off; applied to the bound agent rows. On create it + // presets the flag on the freshly issued agent. ProbeOnly *bool `json:"probeOnly,omitempty" form:"probeOnly"` } -func (r *NodeMutationRequest) validateCredentials(create bool) error { +func (r *NodeMutationRequest) validate() error { if r == nil { return common.NewError("node request is required") } - // Agent nodes dial out to the master; address/port are not needed. - // Traditional nodes still require them. - if r.NodeType != "agent" { - if strings.TrimSpace(r.Address) == "" { - return common.NewError("address is required") - } - if r.Port < 1 || r.Port > 65535 { - return common.NewError("port must be between 1 and 65535") - } - } - if r.ApiToken != nil && r.ClearApiToken { - return common.NewError("apiToken and clearApiToken are mutually exclusive") - } - if r.ApiToken != nil { - *r.ApiToken = strings.TrimSpace(*r.ApiToken) - if *r.ApiToken == "" { - if create { - return common.NewError("apiToken is required unless mtls is enabled") - } - r.ApiToken = nil - } - } - if create { - if r.ClearApiToken { - return common.NewError("credentials cannot be cleared while creating a node") - } - // Agent nodes authenticate via agent token, not panel API token. - if r.NodeType != "agent" && r.ApiToken == nil && r.TlsVerifyMode != "mtls" { - return common.NewError("apiToken is required unless mtls is enabled") - } - } - if r.ClearApiToken && r.Enable && r.TlsVerifyMode != "mtls" { - return common.NewError("disable the node or enable mtls before clearing its apiToken") - } return nil } // isAgentPlaceholderAddress reports whether addr is the placeholder the // node form submits for agent nodes ("agent": they dial out to the master -// and have no panel endpoint to store). Keep in sync with the guard in -// internal/web/runtime's baseURL, which refuses to dial the placeholder -// for rows written before this sanitization existed. +// and have no panel endpoint to store). func isAgentPlaceholderAddress(addr string) bool { return strings.EqualFold(strings.TrimSpace(addr), "agent") } @@ -378,7 +315,7 @@ func sanitizeAgentEndpoint(req *NodeMutationRequest, resolved string) { } func (r *NodeMutationRequest) toNode() *model.Node { - n := &model.Node{ + return &model.Node{ Id: r.Id, Name: r.Name, Remark: r.Remark, @@ -388,14 +325,5 @@ func (r *NodeMutationRequest) toNode() *model.Node { BasePath: r.BasePath, Enable: r.Enable, AllowPrivateAddress: r.AllowPrivateAddress, - TlsVerifyMode: r.TlsVerifyMode, - PinnedCertSha256: r.PinnedCertSha256, - InboundSyncMode: r.InboundSyncMode, - InboundTags: r.InboundTags, - OutboundTag: r.OutboundTag, } - if r.ApiToken != nil { - n.ApiToken = *r.ApiToken - } - return n } diff --git a/internal/web/service/node_credentials_writeonly_test.go b/internal/web/service/node_credentials_writeonly_test.go deleted file mode 100644 index faccab5..0000000 --- a/internal/web/service/node_credentials_writeonly_test.go +++ /dev/null @@ -1,201 +0,0 @@ -package service - -import ( - "encoding/json" - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -func TestNodeCredentialsNeverMarshal(t *testing.T) { - raw, err := json.Marshal(&model.Node{ - Id: 7, - Name: "node", - ApiToken: "plain-secret-token", - }) - if err != nil { - t.Fatalf("marshal node: %v", err) - } - out := string(raw) - if strings.Contains(out, "plain-secret-token") || strings.Contains(out, "apiToken") { - t.Fatalf("model.Node JSON leaked api token field: %s", out) - } -} - -func TestNodeViewExposesOnlyCredentialPresence(t *testing.T) { - setupConflictDB(t) - - svc := &NodeService{} - reqToken := "write-only-secret" - view, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "node-view", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ApiToken: &reqToken, - Enable: true, - }) - if err != nil { - t.Fatalf("create from request: %v", err) - } - if !view.HasApiToken { - t.Fatal("create view should report credential presence") - } - - got, err := svc.GetViewById(view.Id) - if err != nil { - t.Fatalf("get view: %v", err) - } - raw, err := json.Marshal(got) - if err != nil { - t.Fatalf("marshal view: %v", err) - } - out := string(raw) - if !strings.Contains(out, `"hasApiToken":true`) { - t.Fatalf("view does not report credential presence: %s", out) - } - if strings.Contains(out, reqToken) || strings.Contains(out, "apiToken") { - t.Fatalf("NodeView leaked plaintext or apiToken key: %s", out) - } -} - -func TestNodeCredentialMutationSemantics(t *testing.T) { - setupConflictDB(t) - svc := &NodeService{} - - initial := "initial-token" - view, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "mut", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ApiToken: &initial, - Enable: true, - }) - if err != nil { - t.Fatalf("create: %v", err) - } - before := rawStoredNodeToken(t, view.Id) - if before != initial { - t.Fatalf("stored token = %q, want %q", before, initial) - } - - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mut-renamed", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - Enable: true, - }); err != nil { - t.Fatalf("keep-token update: %v", err) - } - if after := rawStoredNodeToken(t, view.Id); after != before { - t.Fatalf("omitted token should keep existing token: %q -> %q", before, after) - } - - blank := " " - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mut-blank", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ApiToken: &blank, - Enable: true, - }); err != nil { - t.Fatalf("blank apiToken should keep existing token on update: %v", err) - } - if afterBlank := rawStoredNodeToken(t, view.Id); afterBlank != before { - t.Fatalf("blank token should keep existing token: %q -> %q", before, afterBlank) - } - - next := "next-token" - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mut", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ApiToken: &next, - Enable: true, - }); err != nil { - t.Fatalf("replace token: %v", err) - } - if replaced := rawStoredNodeToken(t, view.Id); replaced != next { - t.Fatalf("replace token stored %q, want %q", replaced, next) - } - - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mut", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ClearApiToken: true, - Enable: true, - }); err == nil { - t.Fatal("enabled non-mtls node must not clear apiToken") - } - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mut", - Scheme: "https", - Address: "127.0.0.1", - Port: 2096, - ClearApiToken: true, - Enable: false, - }); err != nil { - t.Fatalf("clear disabled token: %v", err) - } - if cleared := rawStoredNodeToken(t, view.Id); cleared != "" { - t.Fatalf("clear token left stored value %q", cleared) - } - - if _, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "mtls-only", - Scheme: "https", - Address: "127.0.0.1", - Port: 2097, - Enable: true, - TlsVerifyMode: "mtls", - }); err != nil { - t.Fatalf("mtls create without token: %v", err) - } -} - -func TestNodeUpdateRequiresTokenWhenNoStoredTokenAndMtlsDisabled(t *testing.T) { - setupConflictDB(t) - svc := &NodeService{} - - view, err := svc.CreateFromRequest(&NodeMutationRequest{ - Name: "mtls-empty", - Scheme: "https", - Address: "127.0.0.1", - Port: 2098, - Enable: true, - TlsVerifyMode: "mtls", - }) - if err != nil { - t.Fatalf("create mtls node: %v", err) - } - blank := "" - if err := svc.UpdateFromRequest(view.Id, &NodeMutationRequest{ - Name: "mtls-empty", - Scheme: "https", - Address: "127.0.0.1", - Port: 2098, - ApiToken: &blank, - Enable: true, - BasePath: "/", - OutboundTag: "", - }); err == nil { - t.Fatal("enabled non-mtls node without stored token must be rejected") - } -} - -func rawStoredNodeToken(t *testing.T, id int) string { - t.Helper() - var n model.Node - if err := database.GetDB().Select("api_token").Where("id = ?", id).First(&n).Error; err != nil { - t.Fatalf("load raw node token: %v", err) - } - return n.ApiToken -} diff --git a/internal/web/service/node_dirty_test.go b/internal/web/service/node_dirty_test.go index 111cba5..b6163a6 100644 --- a/internal/web/service/node_dirty_test.go +++ b/internal/web/service/node_dirty_test.go @@ -8,103 +8,8 @@ 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/runtime" ) -// While a node is config-dirty (a local edit committed before it could be -// mirrored to the node), the traffic pull must not overwrite the central -// inbound's config columns from the node's stale snapshot — only traffic -// counters may advance. Otherwise a reconnecting node reverts the edit. -func TestSetRemoteTraffic_DirtyPreservesConfig(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - node := &model.Node{Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"} - if err := db.Create(node).Error; err != nil { - t.Fatalf("create node: %v", err) - } - id := node.Id - - const desiredSettings = `{"clients":[{"email":"a@x"}]}` - central := &model.Inbound{ - UserId: 1, - NodeID: &id, - Tag: "in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: desiredSettings, - } - if err := db.Create(central).Error; err != nil { - t.Fatalf("create inbound: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[{"email":"b@x"}]}`, - Up: 500, - Down: 700, - }}, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(id, snap, true, false); err != nil { - t.Fatalf("setRemoteTrafficLocked dirty: %v", err) - } - - var got model.Inbound - if err := db.First(&got, central.Id).Error; err != nil { - t.Fatalf("reload inbound: %v", err) - } - if got.Settings != desiredSettings { - t.Fatalf("dirty pull overwrote settings: want %q got %q", desiredSettings, got.Settings) - } - if got.Up != 500 || got.Down != 700 { - t.Fatalf("traffic counters not applied while dirty: up=%d down=%d", got.Up, got.Down) - } -} - -func TestSetRemoteTraffic_MissingDisabledInboundIsNotSwept(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - node := &model.Node{Name: "disabled-snapshot", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, Status: "online"} - if err := db.Create(node).Error; err != nil { - t.Fatal(err) - } - disabled := &model.Inbound{ - UserId: 1, NodeID: &node.Id, Tag: "disabled", Enable: false, - Port: 24443, Protocol: model.VLESS, Settings: `{"clients":[]}`, - } - reported := &model.Inbound{ - UserId: 1, NodeID: &node.Id, Tag: "reported", Enable: true, - Port: 24444, Protocol: model.VLESS, Settings: `{"clients":[]}`, - } - if err := db.Create(disabled).Error; err != nil { - t.Fatal(err) - } - if err := db.Create(reported).Error; err != nil { - t.Fatal(err) - } - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ - Tag: reported.Tag, Enable: true, - Port: reported.Port, Protocol: reported.Protocol, Settings: reported.Settings, - }}} - if _, err := (&InboundService{}).setRemoteTrafficLocked(node.Id, snap, false, false); err != nil { - t.Fatal(err) - } - var count int64 - if err := db.Model(&model.Inbound{}).Where("id=?", disabled.Id).Count(&count).Error; err != nil { - t.Fatal(err) - } - if count != 1 { - t.Fatalf("disabled inbound rows=%d, want 1", count) - } -} - // Deleting a *disabled* client attached to a node inbound must still propagate // to the node. The node's own DB carries the (disabled) client, so the central // panel has to mark the node dirty (→ reconcile) instead of dropping the delete diff --git a/internal/web/service/node_host_adopt_test.go b/internal/web/service/node_host_adopt_test.go deleted file mode 100644 index 7ab57fc..0000000 --- a/internal/web/service/node_host_adopt_test.go +++ /dev/null @@ -1,77 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/entity" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -func TestSetRemoteTraffic_AdoptsNodeHostRows(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 6 - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "host-node", - Address: "10.0.0.6", - Port: 2053, - ApiToken: "t", - Guid: "host-node-guid", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Id: 77, - Tag: "host-adopt-443", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - }}, - HostGroups: []*entity.HostGroup{{ - GroupId: "g-node", - InboundIds: []int{77, 99}, - Hosts: []string{"cdn.example.com:8443"}, - Remark: "cdn", - Security: "tls", - Sni: "sni.example.com", - Fingerprint: "firefox", - }}, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var central model.Inbound - if err := db.Where("tag = ?", "host-adopt-443").First(¢ral).Error; err != nil { - t.Fatalf("load adopted inbound: %v", err) - } - var hosts []model.Host - if err := db.Where("inbound_id = ?", central.Id).Find(&hosts).Error; err != nil { - t.Fatalf("load adopted hosts: %v", err) - } - if len(hosts) != 1 { - t.Fatalf("adopted host rows = %d, want 1", len(hosts)) - } - h := hosts[0] - if h.GroupId != "g-node" || h.Address != "cdn.example.com" || h.Port != 8443 || - h.Security != "tls" || h.Sni != "sni.example.com" || h.Fingerprint != "firefox" || h.Remark != "cdn" { - t.Fatalf("adopted host mismatch: %+v", h) - } - - var total int64 - if err := db.Model(&model.Host{}).Count(&total).Error; err != nil { - t.Fatalf("count hosts: %v", err) - } - if total != 1 { - t.Fatalf("total host rows = %d, want 1 (group member for un-adopted node inbound 99 must not materialize)", total) - } -} diff --git a/internal/web/service/node_mtls.go b/internal/web/service/node_mtls.go deleted file mode 100644 index 242eedc..0000000 --- a/internal/web/service/node_mtls.go +++ /dev/null @@ -1,53 +0,0 @@ -package service - -import ( - "crypto/tls" - "strings" - - "github.com/mhsanaei/3x-ui/v3/internal/util/common" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -// NodeMtlsCaCert returns the PEM of this panel's node-auth CA certificate (the -// public half) to copy into a node's mTLS trust setting, minting the CA and the -// master client cert on first call so the panel is ready to present a client -// certificate to mtls nodes. -func (s *NodeService) NodeMtlsCaCert() (string, error) { - settings := SettingService{} - ca, err := settings.EnsureNodeMtlsCA() - if err != nil { - return "", err - } - if _, err := settings.EnsureMasterClientCert(); err != nil { - return "", err - } - return string(ca.CertPEM), nil -} - -// ReloadMasterMtlsClient validates the master credential currently stored by -// the panel and drops cached mTLS connection pools. This makes an intentional -// out-of-process credential rotation take effect without restarting x-ui (and -// therefore without stopping the xray child process in the same service). -func (s *NodeService) ReloadMasterMtlsClient() error { - stored, err := (&SettingService{}).LoadMasterClientCert() - if err != nil { - return err - } - if _, err := tls.X509KeyPair(stored.CertPEM, stored.KeyPEM); err != nil { - return err - } - runtime.InvalidateMasterClientConnections() - return nil -} - -// SetNodeMtlsTrustCA stores the CA certificate bundle trusted for incoming -// node-API clients. An empty value clears it; changes apply after restart. -func (s *NodeService) SetNodeMtlsTrustCA(caPem string) error { - caPem = strings.TrimSpace(caPem) - if caPem != "" { - if _, err := parseCertificateBundlePEM([]byte(caPem)); err != nil { - return common.NewError("invalid trust CA certificate bundle: ", err) - } - } - return (&SettingService{}).setString(settingNodeMtlsClientCA, caPem) -} diff --git a/internal/web/service/node_mtls_test.go b/internal/web/service/node_mtls_test.go deleted file mode 100644 index 4571fb6..0000000 --- a/internal/web/service/node_mtls_test.go +++ /dev/null @@ -1,138 +0,0 @@ -package service - -import ( - "crypto/tls" - "crypto/x509" - "encoding/pem" - "testing" - - "github.com/go-playground/validator/v10" - - "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/runtime" -) - -func TestReloadMasterMtlsClientDoesNotMintMissingCredential(t *testing.T) { - _ = setupSettingMtlsDB(t) - runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) { - pair, err := (&SettingService{}).EnsureMasterClientCert() - if err != nil { - return tls.Certificate{}, err - } - return tls.X509KeyPair(pair.CertPEM, pair.KeyPEM) - }) - t.Cleanup(func() { runtime.SetMasterClientCertProvider(nil) }) - if err := (&NodeService{}).ReloadMasterMtlsClient(); err == nil { - t.Fatal("reload on a fresh database unexpectedly succeeded") - } - var count int64 - keys := []string{settingNodeMtlsCaCert, settingNodeMtlsCaKey, settingNodeMtlsClientCert, settingNodeMtlsClientKey} - if err := database.GetDB().Model(&model.Setting{}).Where("key IN ?", keys).Count(&count).Error; err != nil { - t.Fatalf("count mTLS settings: %v", err) - } - if count != 0 { - t.Fatalf("reload created %d mTLS setting rows, want 0", count) - } -} - -func TestNormalizeKeepsMtls(t *testing.T) { - s := &NodeService{} - cases := []struct { - name string - in model.Node - wantMode string - wantErr bool - }{ - {"mtls over https preserved", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "mtls"}, "mtls", false}, - {"mtls over http rejected", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "http", TlsVerifyMode: "mtls"}, "", true}, - {"unknown mode clamped to verify", model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", TlsVerifyMode: "bogus"}, "verify", false}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - n := c.in - err := s.normalize(&n) - if c.wantErr { - if err == nil { - t.Fatal("expected an error") - } - return - } - if err != nil { - t.Fatalf("normalize: %v", err) - } - if n.TlsVerifyMode != c.wantMode { - t.Fatalf("TlsVerifyMode = %q, want %q", n.TlsVerifyMode, c.wantMode) - } - }) - } -} - -func TestNodeTlsVerifyModeValidatorAcceptsMtls(t *testing.T) { - v := validator.New(validator.WithRequiredStructEnabled()) - base := model.Node{Name: "n", Address: "node.example.com", Port: 2053, Scheme: "https", ApiToken: "t"} - - for _, m := range []string{"verify", "skip", "pin", "mtls"} { - n := base - n.TlsVerifyMode = m - if err := v.Struct(n); err != nil { - t.Fatalf("validator rejected valid TlsVerifyMode %q: %v", m, err) - } - } - bad := base - bad.TlsVerifyMode = "bogus" - if err := v.Struct(bad); err == nil { - t.Fatal("validator must reject an unknown TlsVerifyMode") - } -} - -func TestNodeMtlsCaCert(t *testing.T) { - _ = setupSettingMtlsDB(t) - - got, err := (&NodeService{}).NodeMtlsCaCert() - if err != nil { - t.Fatalf("NodeMtlsCaCert: %v", err) - } - block, _ := pem.Decode([]byte(got)) - if block == nil || block.Type != "CERTIFICATE" { - t.Fatalf("NodeMtlsCaCert must return a CERTIFICATE PEM, got %q", got) - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatalf("parse returned cert: %v", err) - } - if !cert.IsCA { - t.Fatal("NodeMtlsCaCert must return the CA certificate (IsCA)") - } -} - -func TestSetNodeMtlsTrustCA(t *testing.T) { - _ = setupSettingMtlsDB(t) - ns := &NodeService{} - settings := SettingService{} - - ca, err := settings.EnsureNodeMtlsCA() - if err != nil { - t.Fatalf("EnsureNodeMtlsCA: %v", err) - } - - if err := ns.SetNodeMtlsTrustCA(string(ca.CertPEM)); err != nil { - t.Fatalf("SetNodeMtlsTrustCA(valid): %v", err) - } - pool, err := settings.NodeMtlsClientCAPool() - if err != nil || pool == nil { - t.Fatalf("valid trust CA must persist + build a pool: pool=%v err=%v", pool, err) - } - - if err := ns.SetNodeMtlsTrustCA("not a certificate"); err == nil { - t.Fatal("invalid PEM must be rejected (fail closed)") - } - - if err := ns.SetNodeMtlsTrustCA(""); err != nil { - t.Fatalf("clearing the trust CA must be allowed: %v", err) - } - pool, _ = settings.NodeMtlsClientCAPool() - if pool != nil { - t.Fatal("cleared trust CA must yield a nil pool (mTLS off)") - } -} diff --git a/internal/web/service/node_netmetrics_test.go b/internal/web/service/node_netmetrics_test.go index 60b4ef6..01100c4 100644 --- a/internal/web/service/node_netmetrics_test.go +++ b/internal/web/service/node_netmetrics_test.go @@ -1,12 +1,7 @@ package service import ( - "context" - "net/http" - "net/http/httptest" - "net/url" "slices" - "strconv" "testing" "time" @@ -14,31 +9,8 @@ import ( "github.com/mhsanaei/3x-ui/v3/internal/database/model" ) -func TestProbeParsesNetIO(t *testing.T) { - srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"success":true,"obj":{"cpu":5,"mem":{"current":1,"total":2},"netIO":{"up":1000,"down":2000},"panelGuid":"g","uptime":42}}`)) - })) - defer srv.Close() - - u, err := url.Parse(srv.URL) - if err != nil { - t.Fatalf("parse url: %v", err) - } - port, _ := strconv.Atoi(u.Port()) - n := &model.Node{Scheme: "http", Address: u.Hostname(), Port: port, BasePath: "/", ApiToken: "t", AllowPrivateAddress: true} - - patch, err := (&NodeService{}).probe(context.Background(), n, "") - if err != nil { - t.Fatalf("probe: %v", err) - } - if patch.NetUp != 1000 || patch.NetDown != 2000 { - t.Fatalf("net throughput not parsed from status: up=%d down=%d", patch.NetUp, patch.NetDown) - } -} - func TestUpdateHeartbeatStoresNetMetrics(t *testing.T) { - _ = setupSettingMtlsDB(t) + _ = initTrafficTestDB(t) s := &NodeService{} n := &model.Node{Name: "netn", Address: "1.2.3.4", Port: 2053, Scheme: "https", ApiToken: "t"} diff --git a/internal/web/service/node_origin_guid_test.go b/internal/web/service/node_origin_guid_test.go deleted file mode 100644 index acc631a..0000000 --- a/internal/web/service/node_origin_guid_test.go +++ /dev/null @@ -1,284 +0,0 @@ -package service - -import ( - "slices" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -func assertStringSet(t *testing.T, label string, got, want []string) { - t.Helper() - g := append([]string(nil), got...) - w := append([]string(nil), want...) - slices.Sort(g) - slices.Sort(w) - if !slices.Equal(g, w) { - t.Fatalf("%s = %v, want %v", label, got, want) - } -} - -// #4983: a synced inbound's OriginNodeGuid must point at the panel that -// physically hosts it. A node's own local inbound (empty origin in its -// snapshot) is attributed to the node's own GUID; an inbound the node forwards -// from its own sub-node (non-empty origin) keeps that deeper GUID across the -// hop — so a chained Node1->Node2->Node3 attributes Node3's inbounds to Node3. -func TestSetRemoteTraffic_AttributesOriginNodeGuid(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "node2", - Address: "10.0.0.2", - Port: 2053, - ApiToken: "t", - Guid: "node2-guid", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - { // node2's own local inbound — reports no origin - Tag: "in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - }, - { // forwarded from node2's sub-node (node3) — carries node3's guid - Tag: "in-8443-tcp", - Enable: true, - Port: 8443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - OriginNodeGuid: "node3-guid", - }, - }, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - origin := func(tag string) string { - var ib model.Inbound - if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil { - t.Fatalf("load inbound %q: %v", tag, err) - } - return ib.OriginNodeGuid - } - - if og := origin("in-443-tcp"); og != "node2-guid" { - t.Fatalf("local inbound origin = %q, want node2-guid (the node's own GUID)", og) - } - if og := origin("in-8443-tcp"); og != "node3-guid" { - t.Fatalf("forwarded inbound origin = %q, want node3-guid (kept across the hop)", og) - } -} - -// A cloned node reports its OWN inbound with its own (duplicated) panelGuid as -// the origin. That must be remapped to the node-unique key, not stored verbatim -// — otherwise origin_node_guid keeps the shared GUID while online is keyed by -// the node-unique key, and the inbound page reads an empty bucket (shows -// offline). A genuinely forwarded sub-node GUID is still kept across the hop. -func TestSetRemoteTraffic_RemapsClonedNodeOwnGuidOrigin(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - // Two nodes share one panelGuid (cloned servers). - for _, n := range []*model.Node{ - {Id: 1, Name: "a", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup"}, - {Id: 2, Name: "b", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup"}, - } { - if err := db.Create(n).Error; err != nil { - t.Fatalf("create node %s: %v", n.Name, err) - } - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - { // node 1's OWN inbound, reporting its own (shared) panelGuid as origin - Tag: "own-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - OriginNodeGuid: "dup", - }, - { // forwarded from a sub-node with a distinct guid — kept across the hop - Tag: "fwd-8443-tcp", - Enable: true, - Port: 8443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - OriginNodeGuid: "child-guid", - }, - }, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - origin := func(tag string) string { - var ib model.Inbound - if err := db.Where("tag = ?", tag).First(&ib).Error; err != nil { - t.Fatalf("load inbound %q: %v", tag, err) - } - return ib.OriginNodeGuid - } - - if og := origin("own-443-tcp"); og != "node:1" { - t.Fatalf("cloned node's own inbound origin = %q, want node:1 (remapped from shared GUID)", og) - } - if og := origin("fwd-8443-tcp"); og != "child-guid" { - t.Fatalf("forwarded inbound origin = %q, want child-guid (kept across the hop)", og) - } -} - - - - -// A node mid-restart can return an empty inbound list with success=true. The -// sync must NOT treat that as "delete all my inbounds" — otherwise a blip wipes -// the node's central inbounds and every client on them (what happened to the -// Germany node: 0 clients but still online). -func TestSetRemoteTraffic_EmptySnapshotKeepsCentralInbounds(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - if err := db.Create(&model.Node{ - Id: nodeID, Name: "n", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "g", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - nidPtr := nodeID - if err := db.Create(&model.Inbound{ - UserId: 1, NodeID: &nidPtr, Tag: "remote-in", Enable: true, - Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, - }).Error; err != nil { - t.Fatalf("create central inbound: %v", err) - } - - // Empty snapshot — the node reported no inbounds this cycle. - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, &runtime.TrafficSnapshot{}, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var count int64 - if err := db.Model(&model.Inbound{}).Where("tag = ?", "remote-in").Count(&count).Error; err != nil { - t.Fatalf("count inbounds: %v", err) - } - if count != 1 { - t.Fatalf("empty snapshot must not delete the central inbound; got count = %d", count) - } -} - -func TestSetRemoteTraffic_PreservesLocalShareAddressStrategy(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "node2", - Address: "10.0.0.2", - Port: 2053, - ApiToken: "t", - Guid: "node2-guid", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - nodeIDPtr := nodeID - if err := db.Create(&model.Inbound{ - UserId: 1, - NodeID: &nodeIDPtr, - Tag: "remote-in", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - ShareAddrStrategy: "custom", - ShareAddr: "edge.example.com", - }).Error; err != nil { - t.Fatalf("create central inbound: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "remote-in", - Enable: true, - Port: 8443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - }}, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var ib model.Inbound - if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil { - t.Fatalf("load inbound: %v", err) - } - if ib.ShareAddrStrategy != "custom" || ib.ShareAddr != "edge.example.com" { - t.Fatalf("share address fields were overwritten: strategy=%q addr=%q", ib.ShareAddrStrategy, ib.ShareAddr) - } - if ib.Port != 8443 { - t.Fatalf("sync should still update regular remote fields; port = %d, want 8443", ib.Port) - } -} - -func TestSetRemoteTraffic_DefaultsShareAddressFieldsForNewCentralInbound(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - if err := db.Create(&model.Node{ - Id: nodeID, - Name: "node2", - Address: "10.0.0.2", - Port: 2053, - ApiToken: "t", - Guid: "node2-guid", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "remote-in", - Enable: true, - Port: 8443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - ShareAddrStrategy: "custom", - ShareAddr: "remote.example.com", - }}, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var ib model.Inbound - if err := db.Where("tag = ?", "remote-in").First(&ib).Error; err != nil { - t.Fatalf("load inbound: %v", err) - } - if ib.ShareAddrStrategy != "node" || ib.ShareAddr != "" { - t.Fatalf("new central inbound share fields = (%q, %q), want (node, empty)", ib.ShareAddrStrategy, ib.ShareAddr) - } -} diff --git a/internal/web/service/node_probe_only_test.go b/internal/web/service/node_probe_only_test.go index 107652a..c3ceb57 100644 --- a/internal/web/service/node_probe_only_test.go +++ b/internal/web/service/node_probe_only_test.go @@ -28,7 +28,7 @@ func TestNodeProbeOnlyUpdateAndView(t *testing.T) { lies := false base := func() *NodeMutationRequest { return &NodeMutationRequest{ - Name: "probe-svc", NodeType: "agent", Enable: true, + Name: "probe-svc", Enable: true, Scheme: "https", BasePath: "/", } } diff --git a/internal/web/service/node_shared_guid_test.go b/internal/web/service/node_shared_guid_test.go index bb77f1c..24b99a4 100644 --- a/internal/web/service/node_shared_guid_test.go +++ b/internal/web/service/node_shared_guid_test.go @@ -3,7 +3,6 @@ package service import ( "fmt" "testing" - "time" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" @@ -86,77 +85,3 @@ func TestEffectiveNodeKey_FallsBackOnCollision(t *testing.T) { t.Errorf("master collision: got %q, want node:4", got) } } - -// recountByGuid must split per-node counts even when two direct nodes share a -// GUID and their inbounds still carry that shared GUID as origin (pre-backfill). -func TestRecountByGuid_SplitsClonedNodesWithSharedGuid(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - svc := NodeService{} - selfGuid, _ := (&SettingService{}).GetPanelGuid() - - n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"} - n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"} - n3 := &model.Node{Id: 3, Name: "C", Address: "10.0.0.3", Port: 2053, ApiToken: "t", Guid: "uniq", Status: "online"} - for _, n := range []*model.Node{n1, n2, n3} { - if err := db.Create(n).Error; err != nil { - t.Fatalf("create node %s: %v", n.Name, err) - } - } - - id1, id2, id3 := 1, 2, 3 - inbounds := []*model.Inbound{ - {Tag: "a", Port: 1001, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id1, OriginNodeGuid: "dup"}, - {Tag: "b", Port: 1002, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id2, OriginNodeGuid: "dup"}, - {Tag: "c", Port: 1003, Protocol: model.VLESS, Settings: `{"clients":[]}`, Enable: true, NodeID: &id3, OriginNodeGuid: "uniq"}, - } - for _, ib := range inbounds { - if err := db.Create(ib).Error; err != nil { - t.Fatalf("create inbound %s: %v", ib.Tag, err) - } - } - - nodes := []*model.Node{n1, n2, n3} - svc.recountByGuid(nodes, selfGuid) - - if n1.InboundCount != 1 || n2.InboundCount != 1 { - t.Errorf("cloned nodes must not share inbound counts: n1=%d n2=%d, want 1,1", n1.InboundCount, n2.InboundCount) - } - if n3.InboundCount != 1 { - t.Errorf("unique node InboundCount = %d, want 1", n3.InboundCount) - } -} - -// A cloned node's IP-attribution subtree must be stored under its node-unique -// key, so a second clone sharing the GUID can't overwrite it in node_client_ips. -func TestMergeClientIpsByGuid_RemapsClonedNodeSubtree(t *testing.T) { - setupClientIpTestDB(t) - db := database.GetDB() - svc := &InboundService{} - now := time.Now().Unix() - - n1 := &model.Node{Id: 1, Name: "A", Address: "10.0.0.1", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"} - n2 := &model.Node{Id: 2, Name: "B", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "dup", Status: "online"} - for _, n := range []*model.Node{n1, n2} { - if err := db.Create(n).Error; err != nil { - t.Fatalf("create node: %v", err) - } - } - - if err := svc.MergeClientIpsByGuid(n1, map[string]map[string][]model.ClientIpEntry{ - "dup": {"u@x": {{IP: "1.1.1.1", Timestamp: now}}}, - }); err != nil { - t.Fatalf("merge n1: %v", err) - } - - var rows []model.NodeClientIp - if err := db.Find(&rows).Error; err != nil { - t.Fatalf("load rows: %v", err) - } - if len(rows) != 1 { - t.Fatalf("want 1 attribution row, got %d", len(rows)) - } - if rows[0].NodeGuid != "node:1" { - t.Errorf("cloned node IPs must be stored under node-unique key, got %q", rows[0].NodeGuid) - } -} diff --git a/internal/web/service/node_sweep_guard_test.go b/internal/web/service/node_sweep_guard_test.go index 76d1cac..aa378df 100644 --- a/internal/web/service/node_sweep_guard_test.go +++ b/internal/web/service/node_sweep_guard_test.go @@ -1,12 +1,10 @@ package service import ( - "fmt" "testing" "github.com/mhsanaei/3x-ui/v3/internal/database" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" "github.com/mhsanaei/3x-ui/v3/internal/xray" "gorm.io/gorm" @@ -30,102 +28,6 @@ func seedNodeRow(t *testing.T, db *gorm.DB, n *model.Node) { } } -func snapshotWithClients(t *testing.T, tag, settings string, stats ...xray.ClientTraffic) *runtime.TrafficSnapshot { - t.Helper() - return &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{Tag: tag, Settings: settings, ClientStats: stats}}, - } -} - -func snapshotWithoutClients(t *testing.T, tag string) *runtime.TrafficSnapshot { - t.Helper() - return snapshotWithClients(t, tag, `{"clients":[]}`) -} - -func snapshotWithTwoInbounds(t *testing.T, tagA, settingsA, emailA, tagB, settingsB, emailB string) *runtime.TrafficSnapshot { - t.Helper() - return &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{ - {Tag: tagA, Settings: settingsA, ClientStats: []xray.ClientTraffic{{Email: emailA, Enable: true}}}, - {Tag: tagB, Settings: settingsB, ClientStats: []xray.ClientTraffic{{Email: emailB, Enable: true}}}, - }, - } -} - -// The job samples config_dirty before the snapshot round-trip; a client added -// in that window is deleted again unless the merge re-reads the flag itself. -func TestSetRemoteTrafficRereadsConfigDirty(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - seedNodeRow(t, db, &model.Node{Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true}) - - const email = "carol" - createNodeInboundWithClient(t, db, 1, "n1-in", 41001, email) - settings := fmt.Sprintf(`{"clients":[{"email":%q,"enable":true}]}`, email) - syncNodeWithSettings(t, svc, 1, "n1-in", settings, - xray.ClientTraffic{Email: email, Up: 1, Down: 1, Enable: true}) - - if rec, _ := countClientRows(t, db, email); rec != 1 { - t.Fatalf("setup: client not attached, got %d rows", rec) - } - - if err := db.Model(model.Node{}).Where("id = ?", 1).Update("config_dirty", true).Error; err != nil { - t.Fatalf("mark node dirty: %v", err) - } - - if _, err := svc.setRemoteTrafficLocked(1, snapshotWithoutClients(t, "n1-in"), false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - rec, traf := countClientRows(t, db, email) - if rec != 1 || traf != 1 { - t.Fatalf("stale dirty=false wiped a client added mid-flight: clients=%d client_traffics=%d, want 1/1", rec, traf) - } -} - -// FilterNodeSnapshot stops reporting a deselected tag; reading that absence as -// "the node deleted it" wiped the master's copy of an inbound still running. -func TestDeselectedTagIsNotSwept(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - seedNodeRow(t, db, &model.Node{ - Id: 1, Name: "n1", Address: "127.0.0.1", Port: 2096, ApiToken: "tok", Enable: true, - InboundSyncMode: "selected", InboundTags: []string{"keep"}, - }) - - createNodeInboundWithClient(t, db, 1, "keep", 41001, "kept@x") - createNodeInboundWithClient(t, db, 1, "drop", 41002, "dropped@x") - - keepSettings := `{"clients":[{"email":"kept@x","enable":true}]}` - dropSettings := `{"clients":[{"email":"dropped@x","enable":true}]}` - snap := snapshotWithTwoInbounds(t, "keep", keepSettings, "kept@x", "drop", dropSettings, "dropped@x") - if _, err := svc.setRemoteTrafficLocked(1, snap, false, false); err != nil { - t.Fatalf("seed sync: %v", err) - } - if rec, traf := countClientRows(t, db, "dropped@x"); rec != 1 || traf != 1 { - t.Fatalf("setup: dropped@x not seeded, clients=%d client_traffics=%d", rec, traf) - } - - keepOnly := snapshotWithClients(t, "keep", keepSettings, xray.ClientTraffic{Email: "kept@x", Enable: true}) - if _, err := svc.setRemoteTrafficLocked(1, keepOnly, false, false); err != nil { - t.Fatalf("post-deselect sync: %v", err) - } - - rec, traf := countClientRows(t, db, "dropped@x") - if rec != 1 || traf != 1 { - t.Fatalf("deselecting a tag deleted its clients: clients=%d client_traffics=%d, want 1/1", rec, traf) - } - var inbounds int64 - if err := db.Model(model.Inbound{}).Where("tag = ?", "drop").Count(&inbounds).Error; err != nil { - t.Fatalf("count inbounds: %v", err) - } - if inbounds != 1 { - t.Fatalf("deselecting a tag deleted the inbound the node still serves: got %d rows, want 1", inbounds) - } -} - func TestSyncInboundStoresTrimmedEmail(t *testing.T) { db := initTrafficTestDB(t) svc := &ClientService{} diff --git a/internal/web/service/node_sync_link_churn_test.go b/internal/web/service/node_sync_link_churn_test.go deleted file mode 100644 index 9ee6c6e..0000000 --- a/internal/web/service/node_sync_link_churn_test.go +++ /dev/null @@ -1,70 +0,0 @@ -package service - -import ( - "fmt" - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/xray" -) - -// The node traffic poll runs every 5s and re-syncs every node inbound from its -// snapshot. A steady-state poll must not rewrite the inbound's whole membership -// set — that churn was the bulk of the write load in #6252. -func TestNodeSyncDoesNotChurnInboundLinks(t *testing.T) { - db := initTrafficTestDB(t) - svc := &InboundService{} - - emails := []string{"n1@x", "n2@x", "n3@x"} - entries := make([]string, 0, len(emails)) - stats := make([]xray.ClientTraffic, 0, len(emails)) - for i, e := range emails { - entries = append(entries, fmt.Sprintf(`{"email": %q, "enable": true}`, e)) - stats = append(stats, xray.ClientTraffic{Email: e, Up: int64(100 * (i + 1)), Down: 100, Enable: true}) - } - settings := `{"clients": [` + strings.Join(entries, ",") + `]}` - - createNodeInbound(t, db, 1, "n1-in", 41101) - syncNodeWithSettings(t, svc, 1, "n1-in", settings, stats...) - - var ib model.Inbound - if err := db.Where("tag = ?", "n1-in").First(&ib).Error; err != nil { - t.Fatalf("load inbound: %v", err) - } - before := linksOf(t, ib.Id) - if len(before) != len(emails) { - t.Fatalf("link count after first sync = %d, want %d", len(before), len(emails)) - } - stampLinkCreatedAt(t, ib.Id) - - // Second poll: identical client set, counters have grown. - for i := range stats { - stats[i].Up += 500 - stats[i].Down += 500 - } - syncNodeWithSettings(t, svc, 1, "n1-in", settings, stats...) - - after := linksOf(t, ib.Id) - if len(after) != len(before) { - t.Fatalf("link count after second sync = %d, want %d", len(after), len(before)) - } - for id, link := range after { - if link.CreatedAt != 1 { - t.Errorf("client %d: link created_at = %d, want the 1 sentinel: a steady-state node poll rebuilt the membership set", - id, link.CreatedAt) - } - } - - // A client removed on the node must still lose its link, or the soft-orphan - // sweep that reads this table would stop seeing remote deletions. - shrunk := `{"clients": [` + strings.Join(entries[:2], ",") + `]}` - syncNodeWithSettings(t, svc, 1, "n1-in", shrunk, stats[:2]...) - pruned := linksOf(t, ib.Id) - if len(pruned) != 2 { - t.Fatalf("link count after shrink = %d, want 2", len(pruned)) - } - if _, still := pruned[recordID(t, "n3@x")]; still { - t.Error("n3@x link survived a snapshot that dropped it") - } -} diff --git a/internal/web/service/node_tag_sync_test.go b/internal/web/service/node_tag_sync_test.go deleted file mode 100644 index 8dde58a..0000000 --- a/internal/web/service/node_tag_sync_test.go +++ /dev/null @@ -1,100 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -// A node-backed inbound whose central tag carries the n- prefix must -// survive a snapshot in which the node reports the bare tag (prefix lives on -// the central side only). Before the fix the orphan sweep matched snapTags -// exactly, so it deleted and recreated the inbound on every sync — churning -// its id and dropping traffic for that cycle. -func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - id := nodeID - central := &model.Inbound{ - UserId: 1, - NodeID: &id, - Tag: "n1-in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - } - if err := db.Create(central).Error; err != nil { - t.Fatalf("create node inbound: %v", err) - } - centralID := central.Id - - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{ - Tag: "in-443-tcp", - Enable: true, - Port: 443, - Protocol: model.VLESS, - Settings: `{"clients":[]}`, - Up: 1000, - Down: 2000, - }}, - } - - svc := InboundService{} - if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var rows []model.Inbound - if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil { - t.Fatalf("list node inbounds: %v", err) - } - if len(rows) != 1 { - t.Fatalf("expected exactly 1 node inbound (no churn), got %d", len(rows)) - } - if rows[0].Id != centralID { - t.Fatalf("inbound was deleted+recreated: id %d -> %d", centralID, rows[0].Id) - } - if rows[0].Up != 1000 || rows[0].Down != 2000 { - t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down) - } -} - -func TestSetRemoteTraffic_AdoptsCompatibleOriginAliasWithoutDuplicate(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - const nodeID = 1 - if err := db.Create(&model.Node{Id: nodeID, Name: "node", Address: "10.0.0.2", Port: 2053, ApiToken: "t", Guid: "node-guid"}).Error; err != nil { - t.Fatalf("create node: %v", err) - } - id := nodeID - central := &model.Inbound{UserId: 1, NodeID: &id, OriginNodeGuid: "node-guid", Tag: "desired-name", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`} - if err := db.Create(central).Error; err != nil { - t.Fatalf("create central inbound: %v", err) - } - - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{{ - Tag: "already-deployed", Enable: true, Port: 8443, Protocol: model.VLESS, - Settings: `{"clients":[]}`, Up: 11, Down: 22, - }}} - if _, err := (&InboundService{}).setRemoteTrafficLocked(nodeID, snap, false, false); err != nil { - t.Fatalf("setRemoteTrafficLocked: %v", err) - } - - var rows []model.Inbound - if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil { - t.Fatalf("list node inbounds: %v", err) - } - if len(rows) != 1 || rows[0].Id != central.Id || rows[0].Tag != "desired-name" { - t.Fatalf("alias adoption rows = %#v, want original central inbound only", rows) - } - if rows[0].Up != 11 || rows[0].Down != 22 { - t.Fatalf("alias traffic = %d/%d, want 11/22", rows[0].Up, rows[0].Down) - } -} diff --git a/internal/web/service/node_test.go b/internal/web/service/node_test.go index ddddc0b..08c50b6 100644 --- a/internal/web/service/node_test.go +++ b/internal/web/service/node_test.go @@ -4,7 +4,6 @@ import ( "testing" "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" ) func TestNormalizeBasePath(t *testing.T) { @@ -161,92 +160,3 @@ func TestNodeService_Normalize_OverridesUnknownScheme(t *testing.T) { t.Fatalf("Scheme = %q, want https", n.Scheme) } } - -func TestNodeService_NormalizeInboundSelection(t *testing.T) { - s := &NodeService{} - n := &model.Node{ - Name: "n", - Address: "example.com", - Port: 443, - InboundSyncMode: "selected", - InboundTags: []string{" alpha ", "", "beta", "alpha"}, - } - if err := s.normalize(n); err != nil { - t.Fatalf("unexpected error: %v", err) - } - if n.InboundSyncMode != "selected" { - t.Fatalf("InboundSyncMode = %q, want selected", n.InboundSyncMode) - } - if len(n.InboundTags) != 2 || n.InboundTags[0] != "alpha" || n.InboundTags[1] != "beta" { - t.Fatalf("InboundTags = %#v, want [alpha beta]", n.InboundTags) - } -} - -func TestFilterNodeSnapshot(t *testing.T) { - snapshot := func() *runtime.TrafficSnapshot { - return &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ - {Tag: "alpha"}, - {Tag: "beta"}, - {Tag: "gamma"}, - }} - } - - all := snapshot() - FilterNodeSnapshot(&model.Node{InboundSyncMode: "all"}, all) - if len(all.Inbounds) != 3 { - t.Fatalf("all mode kept %d inbounds, want 3", len(all.Inbounds)) - } - - selected := snapshot() - FilterNodeSnapshot(&model.Node{ - InboundSyncMode: "selected", - InboundTags: []string{"beta"}, - }, selected) - if len(selected.Inbounds) != 1 || selected.Inbounds[0].Tag != "beta" { - t.Fatalf("selected mode produced %#v, want only beta", selected.Inbounds) - } - - none := snapshot() - FilterNodeSnapshot(&model.Node{InboundSyncMode: "selected"}, none) - if len(none.Inbounds) != 0 { - t.Fatalf("empty selection kept %d inbounds, want 0", len(none.Inbounds)) - } -} - -func TestFilterNodeSnapshotMatchesPrefixedSelectedTag(t *testing.T) { - snap := &runtime.TrafficSnapshot{Inbounds: []*model.Inbound{ - {Tag: "in-100-tcp"}, - {Tag: "in-443-tcp"}, - }} - FilterNodeSnapshot(&model.Node{ - Id: 5, - InboundSyncMode: "selected", - InboundTags: []string{"in-100-tcp", "n5-in-443-tcp"}, - }, snap) - - kept := make(map[string]bool, len(snap.Inbounds)) - for _, ib := range snap.Inbounds { - kept[ib.Tag] = true - } - if !kept["in-443-tcp"] { - t.Fatalf("node-side tag in-443-tcp filtered out despite the prefixed central tag being selected; kept=%v", kept) - } - if !kept["in-100-tcp"] { - t.Fatalf("bare selected tag in-100-tcp was dropped; kept=%v", kept) - } -} - -func TestFilterNodeSnapshotKeepsAdoptedAlias(t *testing.T) { - snap := &runtime.TrafficSnapshot{ - Inbounds: []*model.Inbound{{Tag: "deployed-alias"}, {Tag: "unmanaged"}}, - ManagedAliases: []string{"deployed-alias"}, - } - FilterNodeSnapshot(&model.Node{ - InboundSyncMode: "selected", - InboundTags: []string{"desired-name"}, - }, snap) - - if len(snap.Inbounds) != 1 || snap.Inbounds[0].Tag != "deployed-alias" { - t.Fatalf("filtered snapshot = %#v, want adopted alias only", snap.Inbounds) - } -} diff --git a/internal/web/service/node_token_encryption_test.go b/internal/web/service/node_token_encryption_test.go deleted file mode 100644 index f9fb0d4..0000000 --- a/internal/web/service/node_token_encryption_test.go +++ /dev/null @@ -1,180 +0,0 @@ -package service - -import ( - "errors" - "strings" - "testing" - - "gorm.io/gorm" - - "github.com/mhsanaei/3x-ui/v3/internal/crypto/nodetoken" - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// enableNodeTokenEncryption installs a test keyring and restores off mode so -// the package-global codec cannot leak between tests. -func enableNodeTokenEncryption(t *testing.T) { - t.Helper() - var k [32]byte - for i := range k { - k[i] = byte(i + 1) - } - ring := &nodetoken.Keyring{ActiveID: "t1", Keys: map[string][32]byte{"t1": k}} - codec, err := nodetoken.NewCodec(nodetoken.ModeRequired, ring) - if err != nil { - t.Fatalf("new codec: %v", err) - } - nodetoken.Init(codec) - t.Cleanup(func() { - off, _ := nodetoken.NewCodec(nodetoken.ModeOff, nil) - nodetoken.Init(off) - }) -} - -func TestNodeToken_CreateNeverInsertsPlaintextTuple(t *testing.T) { - setupConflictDB(t) - enableNodeTokenEncryption(t) - db := database.GetDB() - const callback = "test:no-plaintext-node-insert" - if err := db.Callback().Create().Before("gorm:create").Register(callback, func(tx *gorm.DB) { - if node, ok := tx.Statement.Dest.(*model.Node); ok && node.ApiToken != "" { - tx.AddError(errors.New("plaintext token reached node INSERT")) - } - }); err != nil { - t.Fatalf("register callback: %v", err) - } - t.Cleanup(func() { _ = db.Callback().Create().Remove(callback) }) - - n := &model.Node{Name: "no-plain", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true} - if err := (&NodeService{}).Create(n); err != nil { - t.Fatalf("Create: %v", err) - } - if n.ApiToken != "secret" { - t.Fatalf("in-memory token = %q, want plaintext response value", n.ApiToken) - } -} - -func rawStoredToken(t *testing.T, id int) string { - t.Helper() - var n model.Node - if err := database.GetDB().Model(model.Node{}).Where("id = ?", id).First(&n).Error; err != nil { - t.Fatalf("raw load: %v", err) - } - return n.ApiToken -} - -// Create stores the token encrypted at rest; GetById returns it decrypted. -func TestNodeToken_EncryptedAtRest_PlaintextInMemory(t *testing.T) { - setupConflictDB(t) - enableNodeTokenEncryption(t) - svc := &NodeService{} - - n := &model.Node{Name: "enc1", Address: "127.0.0.1", Port: 2096, ApiToken: "super-secret", Enable: true} - if err := svc.Create(n); err != nil { - t.Fatalf("create: %v", err) - } - - stored := rawStoredToken(t, n.Id) - if !nodetoken.IsEncrypted(stored) { - t.Fatalf("token at rest is not encrypted: %q", stored) - } - if strings.Contains(stored, "super-secret") { - t.Fatalf("plaintext leaked into stored column: %q", stored) - } - - got, err := svc.GetById(n.Id) - if err != nil { - t.Fatalf("get: %v", err) - } - if got.ApiToken != "super-secret" { - t.Fatalf("GetById should return plaintext, got %q", got.ApiToken) - } -} - -// A blank token on Update keeps the stored one (the UI doesn't echo secrets). -func TestNodeToken_UpdateBlankKeepsExisting(t *testing.T) { - setupConflictDB(t) - enableNodeTokenEncryption(t) - svc := &NodeService{} - - n := &model.Node{Name: "enc2", Address: "127.0.0.1", Port: 2096, ApiToken: "keep-me", Enable: true} - if err := svc.Create(n); err != nil { - t.Fatalf("create: %v", err) - } - before := rawStoredToken(t, n.Id) - - // Update with empty token must not wipe or change the stored ciphertext. - upd := &model.Node{Name: "enc2-renamed", Address: "127.0.0.1", Port: 2096, ApiToken: "", Enable: true} - if err := svc.Update(n.Id, upd); err != nil { - t.Fatalf("update: %v", err) - } - if after := rawStoredToken(t, n.Id); after != before { - t.Fatalf("blank-token update changed stored token: %q -> %q", before, after) - } - got, _ := svc.GetById(n.Id) - if got.ApiToken != "keep-me" { - t.Fatalf("token lost after blank update, got %q", got.ApiToken) - } - if got.Name != "enc2-renamed" { - t.Fatalf("other fields should still update, got name %q", got.Name) - } -} - -// The migration re-encrypts a legacy plaintext row under the active key (CAS). -func TestNodeToken_MigratePlaintextRows(t *testing.T) { - setupConflictDB(t) - // Insert a legacy plaintext row directly (encryption off at insert time). - db := database.GetDB() - legacy := &model.Node{Name: "legacy", Address: "127.0.0.1", Port: 2096, ApiToken: "legacy-plain", Enable: true} - if err := db.Create(legacy).Error; err != nil { - t.Fatalf("create legacy: %v", err) - } - if rawStoredToken(t, legacy.Id) != "legacy-plain" { - t.Fatal("precondition: legacy row should be plaintext") - } - - enableNodeTokenEncryption(t) - changed, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey() - if err != nil { - t.Fatalf("migrate: %v", err) - } - if changed != 1 { - t.Fatalf("expected 1 row re-encrypted, got %d", changed) - } - if stored := rawStoredToken(t, legacy.Id); !nodetoken.IsEncrypted(stored) { - t.Fatalf("legacy row not encrypted after migration: %q", stored) - } - got, _ := (&NodeService{}).GetById(legacy.Id) - if got.ApiToken != "legacy-plain" { - t.Fatalf("migrated token no longer decrypts to original: %q", got.ApiToken) - } - - // Idempotent: a second run changes nothing. - changed2, _, _ := (&NodeService{}).MigrateNodeTokensToActiveKey() - if changed2 != 0 { - t.Fatalf("second migration should be a no-op, changed %d", changed2) - } -} - -func TestNodeToken_MigrationRejectsCorruptActiveCiphertext(t *testing.T) { - setupConflictDB(t) - enableNodeTokenEncryption(t) - n := &model.Node{Name: "corrupt", Address: "127.0.0.1", Port: 2096, ApiToken: "secret", Enable: true} - if err := (&NodeService{}).Create(n); err != nil { - t.Fatalf("Create: %v", err) - } - stored := rawStoredToken(t, n.Id) - body := strings.LastIndexByte(stored, ':') + 1 - replacement := byte('A') - if stored[body] == replacement { - replacement = 'B' - } - corrupt := stored[:body] + string(replacement) + stored[body+1:] - if err := database.GetDB().Model(&model.Node{}).Where("id = ?", n.Id).Update("api_token", corrupt).Error; err != nil { - t.Fatalf("corrupt row: %v", err) - } - if _, _, err := (&NodeService{}).MigrateNodeTokensToActiveKey(); err == nil { - t.Fatal("migration trusted a corrupt active-key ciphertext") - } -} diff --git a/internal/web/service/node_tree.go b/internal/web/service/node_tree.go index ad3383f..6974053 100644 --- a/internal/web/service/node_tree.go +++ b/internal/web/service/node_tree.go @@ -1,164 +1,12 @@ package service -import ( - "context" - "sync" - - "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/runtime" -) - -// LocalDescendants returns this panel's read-only summaries of the nodes it -// directly manages, so a parent panel can surface them as transitive sub-nodes -// (#4983). Only nodes with a known GUID are included — a stable identity is -// required to attribute them one hop up. Not recursive: each panel reports its -// own direct nodes, and a master walks one level via each direct node's -// endpoint, which covers the Node1 -> Node2 -> Node3 case. -func (s *NodeService) LocalDescendants() ([]model.NodeSummary, error) { - selfGuid, _ := (&SettingService{}).GetPanelGuid() - db := database.GetDB() - var nodes []*model.Node - if err := db.Model(model.Node{}).Order("id asc").Find(&nodes).Error; err != nil { - return nil, err - } - out := make([]model.NodeSummary, 0, len(nodes)) - for _, n := range nodes { - if n.Guid == "" { - continue - } - out = append(out, model.NodeSummary{ - Guid: n.Guid, - ParentGuid: selfGuid, - Name: n.Name, - Address: n.Address, - Scheme: n.Scheme, - Port: n.Port, - Status: n.Status, - LastHeartbeat: n.LastHeartbeat, - LatencyMs: n.LatencyMs, - PanelVersion: n.PanelVersion, - XrayVersion: n.XrayVersion, - XrayState: n.XrayState, - XrayError: n.XrayError, - }) - } - return out, nil -} - -var ( - nodeDescendantsMu sync.RWMutex - nodeDescendantsCache = map[int][]model.NodeSummary{} -) - -// RefreshDescendants pulls a direct node's published sub-node summaries and -// caches them keyed by node id. Best-effort: a fetch error keeps the last good -// set (the node may be briefly unreachable). Called from the heartbeat job. -func (s *NodeService) RefreshDescendants(ctx context.Context, n *model.Node) { - if n == nil { - return - } - mgr := runtime.GetManager() - if mgr == nil { - return - } - rt, err := mgr.RemoteFor(n) - if err != nil { - return - } - summaries, err := rt.GetDescendants(ctx) - if err != nil { - return - } - nodeDescendantsMu.Lock() - if len(summaries) == 0 { - delete(nodeDescendantsCache, n.Id) - } else { - nodeDescendantsCache[n.Id] = summaries - } - nodeDescendantsMu.Unlock() -} - -// ClearDescendants drops a node's cached sub-node summaries (its probe failed). -func (s *NodeService) ClearDescendants(nodeID int) { - nodeDescendantsMu.Lock() - delete(nodeDescendantsCache, nodeID) - nodeDescendantsMu.Unlock() -} - -func cachedDescendants() []model.NodeSummary { - nodeDescendantsMu.RLock() - defer nodeDescendantsMu.RUnlock() - out := make([]model.NodeSummary, 0) - for _, list := range nodeDescendantsCache { - out = append(out, list...) - } - return out -} - -// GetNodeTree returns the direct nodes plus any transitive sub-nodes learned -// from them, with per-GUID counts so each node shows only the inbounds/online -// it physically hosts (#4983). Direct nodes carry the master's own GUID as -// ParentGuid; a transitive node carries its parent node's GUID. Transitive -// nodes are read-only projections (Id == 0). Used by the Nodes page and the -// heartbeat broadcast — never for probing/syncing, which stay on GetAll. -func (s *NodeService) GetNodeTree() ([]*model.Node, error) { - nodes, err := s.GetAll() - if err != nil { - return nodes, err - } - selfGuid, _ := (&SettingService{}).GetPanelGuid() - directGuids := make(map[string]struct{}, len(nodes)) - for _, n := range nodes { - n.ParentGuid = selfGuid - if n.Guid != "" { - directGuids[n.Guid] = struct{}{} - } - } - - seen := make(map[string]struct{}) - var transitive []*model.Node - for _, sum := range cachedDescendants() { - if sum.Guid == "" { - continue - } - if _, ok := directGuids[sum.Guid]; ok { - continue // already shown as a direct node - } - if _, ok := seen[sum.Guid]; ok { - continue - } - seen[sum.Guid] = struct{}{} - transitive = append(transitive, &model.Node{ - Guid: sum.Guid, - ParentGuid: sum.ParentGuid, - Name: sum.Name, - Address: sum.Address, - Scheme: sum.Scheme, - Port: sum.Port, - Status: sum.Status, - LastHeartbeat: sum.LastHeartbeat, - LatencyMs: sum.LatencyMs, - PanelVersion: sum.PanelVersion, - XrayVersion: sum.XrayVersion, - XrayState: sum.XrayState, - XrayError: sum.XrayError, - Transitive: true, - }) - } - if len(transitive) == 0 { - return nodes, nil - } - - all := make([]*model.Node, 0, len(nodes)+len(transitive)) - all = append(all, nodes...) - all = append(all, transitive...) - s.recountByGuid(all, selfGuid) - return all, nil -} - +// GetNodeTreeView is the node-list read path: every node row as a view, +// annotated with its agent binding and the bound agent's telemetry. All +// nodes are direct agent nodes — the transitive sub-node projections of +// the panel-to-panel era are gone, so the per-node counts computed by +// GetAll are the final ones. func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) { - nodes, err := s.GetNodeTree() + nodes, err := s.GetAll() if err != nil { return nil, err } @@ -167,104 +15,3 @@ func (s *NodeService) GetNodeTreeView() ([]*NodeView, error) { annotateAgentTelemetry(views) return views, nil } - -// recountByGuid recomputes InboundCount/OnlineCount/DepletedCount for every node -// in the tree, keyed by the GUID that physically hosts each inbound, so a direct -// node shows only its own inbounds and each transitive node shows its own -// (#4983). In a flat topology the per-GUID and per-node-id counts coincide, so -// this only changes behaviour once a transitive node exists. -func (s *NodeService) recountByGuid(nodes []*model.Node, selfGuid string) { - db := database.GetDB() - type ibRow struct { - Id int - NodeID *int `gorm:"column:node_id"` - OriginNodeGuid string `gorm:"column:origin_node_guid"` - } - var ibRows []ibRow - if err := db.Table("inbounds").Select("id, node_id, origin_node_guid").Scan(&ibRows).Error; err != nil { - return - } - ambiguous := ambiguousNodeGuids(nodes, selfGuid) - effByInbound := make(map[int]string, len(ibRows)) - inboundCountByGuid := make(map[string]int) - for _, r := range ibRows { - guid := r.OriginNodeGuid - if guid == "" { - if r.NodeID != nil { - guid = synthNodeGuid(*r.NodeID) - } else { - guid = selfGuid - } - } else if r.NodeID != nil { - // Origin still holds an ambiguous GUID (cloned server / master-shared, - // not yet re-attributed): bucket under the hosting node's unique id so - // the clones don't merge. - if _, bad := ambiguous[guid]; bad { - guid = synthNodeGuid(*r.NodeID) - } - } - effByInbound[r.Id] = guid - inboundCountByGuid[guid]++ - } - - // Classify by EMAIL (not the stale client_traffics.inbound_id) and bucket - // each client under its inbound's effective attribution GUID, deduping a - // client attached to several inbounds under the same GUID. The email - // lists are appended in the same pass so they stay consistent with the - // recounted tallies they annotate. - depletedByGuid := make(map[string]int) - disabledByGuid := make(map[string]int) - activeByGuid := make(map[string]int) - depletedEmailsByGuid := make(map[string][]string) - disabledEmailsByGuid := make(map[string][]string) - activeEmailsByGuid := make(map[string][]string) - if statuses, err := s.nodeClientStatuses(); err == nil { - seen := make(map[string]map[int]struct{}) - for _, st := range statuses { - guid, ok := effByInbound[st.InboundID] - if !ok { - continue - } - clientsSeen := seen[guid] - if clientsSeen == nil { - clientsSeen = make(map[int]struct{}) - seen[guid] = clientsSeen - } - if _, dup := clientsSeen[st.ClientID]; dup { - continue - } - clientsSeen[st.ClientID] = struct{}{} - switch { - case st.Depleted: - depletedByGuid[guid]++ - if st.Email != "" { - depletedEmailsByGuid[guid] = append(depletedEmailsByGuid[guid], st.Email) - } - case st.Disabled: - disabledByGuid[guid]++ - if st.Email != "" { - disabledEmailsByGuid[guid] = append(disabledEmailsByGuid[guid], st.Email) - } - default: - activeByGuid[guid]++ - if st.Email != "" { - activeEmailsByGuid[guid] = append(activeEmailsByGuid[guid], st.Email) - } - } - } - } - - onlineByGuid := s.onlineEmailsByGuid() - for _, n := range nodes { - guid := effectiveNodeGuid(n, ambiguous) - n.InboundCount = inboundCountByGuid[guid] - n.OnlineCount = len(onlineByGuid[guid]) - n.OnlineEmails = sortedEmailSet(onlineByGuid[guid]) - n.DepletedCount = depletedByGuid[guid] - n.DisabledCount = disabledByGuid[guid] - n.ActiveCount = activeByGuid[guid] - n.DepletedEmails = depletedEmailsByGuid[guid] - n.DisabledEmails = disabledEmailsByGuid[guid] - n.ActiveEmails = activeEmailsByGuid[guid] - } -} diff --git a/internal/web/service/node_tree_test.go b/internal/web/service/node_tree_test.go deleted file mode 100644 index 4e7548c..0000000 --- a/internal/web/service/node_tree_test.go +++ /dev/null @@ -1,82 +0,0 @@ -package service - -import ( - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" -) - -// #4983: a transitive sub-node learned from a direct node must surface as its -// own read-only entry nested under its parent, and per-GUID counts must split a -// direct node's own inbounds from its sub-nodes'. -func TestGetNodeTree_SurfacesTransitiveNodeNestedUnderParent(t *testing.T) { - setupConflictDB(t) - db := database.GetDB() - - svc := NodeService{} - selfGuid, _ := (&SettingService{}).GetPanelGuid() - - if err := db.Create(&model.Node{ - Id: 1, Name: "Node2", Address: "10.0.0.2", Port: 2053, - ApiToken: "t", Guid: "node2-guid", Status: "online", - }).Error; err != nil { - t.Fatalf("create node: %v", err) - } - - // Node2's own inbound and a transitive inbound physically on Node3 - // (managed through Node2, so node_id = Node2 but origin = Node3). - nid := 1 - if err := db.Create(&model.Inbound{Tag: "n1-own", Enable: true, Port: 443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node2-guid"}).Error; err != nil { - t.Fatalf("create own inbound: %v", err) - } - if err := db.Create(&model.Inbound{Tag: "n1-via", Enable: true, Port: 8443, Protocol: model.VLESS, Settings: `{"clients":[]}`, NodeID: &nid, OriginNodeGuid: "node3-guid"}).Error; err != nil { - t.Fatalf("create transitive inbound: %v", err) - } - - // The heartbeat learned that Node2 manages Node3. - nodeDescendantsMu.Lock() - nodeDescendantsCache[1] = []model.NodeSummary{{ - Guid: "node3-guid", ParentGuid: "node2-guid", Name: "Node3", Address: "10.0.0.3", Status: "online", - }} - nodeDescendantsMu.Unlock() - t.Cleanup(func() { - nodeDescendantsMu.Lock() - nodeDescendantsCache = map[int][]model.NodeSummary{} - nodeDescendantsMu.Unlock() - }) - - tree, err := svc.GetNodeTree() - if err != nil { - t.Fatalf("GetNodeTree: %v", err) - } - - var node2, node3 *model.Node - for _, n := range tree { - switch n.Guid { - case "node2-guid": - node2 = n - case "node3-guid": - node3 = n - } - } - if node2 == nil || node3 == nil { - t.Fatalf("expected Node2 + transitive Node3, got %d nodes", len(tree)) - return - } - if node2.ParentGuid != selfGuid { - t.Errorf("Node2 parent = %q, want this panel's GUID %q", node2.ParentGuid, selfGuid) - } - if !node3.Transitive || node3.ParentGuid != "node2-guid" { - t.Errorf("Node3 should be transitive under node2-guid, got transitive=%v parent=%q", node3.Transitive, node3.ParentGuid) - } - if node3.Id != 0 { - t.Errorf("transitive node must be a read-only projection (Id 0), got Id=%d", node3.Id) - } - if node2.InboundCount != 1 { - t.Errorf("Node2 should host only its own inbound, got InboundCount=%d", node2.InboundCount) - } - if node3.InboundCount != 1 { - t.Errorf("transitive Node3 should host its 1 inbound, got %d", node3.InboundCount) - } -} diff --git a/internal/web/service/panel/api_token.go b/internal/web/service/panel/api_token.go index fdbb2de..b84ab69 100644 --- a/internal/web/service/panel/api_token.go +++ b/internal/web/service/panel/api_token.go @@ -58,10 +58,8 @@ func NormalizeScope(scope string) (string, error) { return model.ApiScopeAdmin, nil case model.ApiScopeMonitor: return model.ApiScopeMonitor, nil - case model.ApiScopeNodeSync: - return model.ApiScopeNodeSync, nil default: - return "", common.NewError("scope must be 'admin', 'monitor', or 'node-sync'") + return "", common.NewError("scope must be 'admin' or 'monitor'") } } diff --git a/internal/web/service/panel/api_token_scope_test.go b/internal/web/service/panel/api_token_scope_test.go index 5630203..ca70b9f 100644 --- a/internal/web/service/panel/api_token_scope_test.go +++ b/internal/web/service/panel/api_token_scope_test.go @@ -29,16 +29,16 @@ func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) { svc := &ApiTokenService{} future := time.Now().Add(time.Hour).UnixMilli() - created, err := svc.Create("node-1", model.ApiScopeNodeSync, future) + created, err := svc.Create("monitor-1", model.ApiScopeMonitor, future) if err != nil { - t.Fatalf("create node-sync token: %v", err) + t.Fatalf("create monitor token: %v", err) } row, ok := svc.MatchToken(created.Token) if !ok { - t.Fatal("fresh node-sync token did not match") + t.Fatal("fresh monitor token did not match") } - if row.Scope != model.ApiScopeNodeSync || row.ExpiresAt != future { - t.Fatalf("matched row scope/expiry = %s/%d, want node-sync/%d", row.Scope, row.ExpiresAt, future) + if row.Scope != model.ApiScopeMonitor || row.ExpiresAt != future { + t.Fatalf("matched row scope/expiry = %s/%d, want monitor/%d", row.Scope, row.ExpiresAt, future) } if _, err := svc.Create("bad-scope", "superuser", 0); err == nil { @@ -76,13 +76,13 @@ func TestAPITokenScopeExpiryAndExpectedRevoke(t *testing.T) { } if err := svc.DisableExpectedScope(created.Id, model.ApiScopeAdmin); err == nil { - t.Fatal("expected-scope revoke must refuse a node-sync token when admin was expected") + t.Fatal("expected-scope revoke must refuse a monitor token when admin was expected") } if _, ok := svc.MatchToken(created.Token); !ok { t.Fatal("wrong expected-scope revoke disabled the token") } - if err := svc.DisableExpectedScope(created.Id, model.ApiScopeNodeSync); err != nil { - t.Fatalf("disable expected node-sync token: %v", err) + if err := svc.DisableExpectedScope(created.Id, model.ApiScopeMonitor); err != nil { + t.Fatalf("disable expected monitor token: %v", err) } if _, ok := svc.MatchToken(created.Token); ok { t.Fatal("disabled token still matched") diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 482466f..6e44331 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -699,23 +699,6 @@ func isVirtualInterface(name string) bool { return false } -// GetConfigJson returns the stored xray template as JSON. The lite master -// builds no local config (there is no local core to run one), so this is -// the template itself — the same document the settings page edits. -func (s *ServerService) GetConfigJson() (any, error) { - template, err := s.settingService.GetXrayConfigTemplate() - if err != nil { - return nil, err - } - - var jsonData any - if err := json.Unmarshal([]byte(template), &jsonData); err != nil { - return nil, err - } - - return jsonData, nil -} - func (s *ServerService) GetDb() ([]byte, error) { if database.IsPostgres() { return s.exportPostgresDB() diff --git a/internal/web/service/setting.go b/internal/web/service/setting.go index 64dacb9..bb43b9a 100644 --- a/internal/web/service/setting.go +++ b/internal/web/service/setting.go @@ -51,56 +51,45 @@ var defaultValueMap = map[string]string{ // policy.system by the B1 migration; the B1 migrator writes the // migrated values, this entry is the factory default (inbound on, // outbound off) for databases that never stored a template. - "trafficStatsSwitches": `{"inboundUplink":true,"inboundDownlink":true,"outboundUplink":false,"outboundDownlink":false}`, - "webListen": "", - "webDomain": "", - "webPort": "2053", - "webCertFile": "", - "webKeyFile": "", - "secret": random.Seq(32), - "panelGuid": uuid.NewString(), - "apiToken": "", - // Node mTLS material (opt-in). All default empty: the CA + master client - // cert are minted lazily on first use, and the node-side trust CA is pasted - // in by the operator. Kept out of entity.AllSetting so private keys never - // reach the settings UI/export. - "nodeMtlsCaCertPem": "", - "nodeMtlsCaKeyPem": "", - "nodeMtlsClientCertPem": "", - "nodeMtlsClientKeyPem": "", - "nodeMtlsClientCertSha256": "", - "nodeMtlsClientCAPem": "", - "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), - "sessionMaxAge": "360", - "trustedProxyCIDRs": DefaultTrustedProxyCIDRs, - "pageSize": "25", - "expireDiff": "0", - "trafficDiff": "0", - "remarkTemplate": DefaultRemarkTemplate, - "subShowIdentityOnAllLinks": "false", - "timeLocation": "Local", - "subEnable": "true", - "subTitle": "", - "subSupportUrl": "", - "subProfileUrl": "", - "subAnnounce": "", - "subHideSettings": "false", - "subListen": "", - "subPort": "2096", - "subPath": "/sub/", - "subDomain": "", - "subCertFile": "", - "subKeyFile": "", - "subUpdates": "12", - "subEncrypt": "true", - "subURI": "", - "datepicker": "gregorian", - "warp": "", - "warpUpdateInterval": "0", - "nord": "", - "pia": "", - "restartXrayOnClientDisable": "true", - "devChannelEnable": "false", + "trafficStatsSwitches": `{"inboundUplink":true,"inboundDownlink":true,"outboundUplink":false,"outboundDownlink":false}`, + "webListen": "", + "webDomain": "", + "webPort": "2053", + "webCertFile": "", + "webKeyFile": "", + "secret": random.Seq(32), + "panelGuid": uuid.NewString(), + "apiToken": "", + "webBasePath": normalizeBasePath(getEnv("XUI_INIT_WEB_BASE_PATH", "/")), + "sessionMaxAge": "360", + "trustedProxyCIDRs": DefaultTrustedProxyCIDRs, + "pageSize": "25", + "expireDiff": "0", + "trafficDiff": "0", + "remarkTemplate": DefaultRemarkTemplate, + "subShowIdentityOnAllLinks": "false", + "timeLocation": "Local", + "subEnable": "true", + "subTitle": "", + "subSupportUrl": "", + "subProfileUrl": "", + "subAnnounce": "", + "subHideSettings": "false", + "subListen": "", + "subPort": "2096", + "subPath": "/sub/", + "subDomain": "", + "subCertFile": "", + "subKeyFile": "", + "subUpdates": "12", + "subEncrypt": "true", + "subURI": "", + "datepicker": "gregorian", + "warp": "", + "warpUpdateInterval": "0", + "nord": "", + "pia": "", + "devChannelEnable": "false", "tgBotEnable": "false", "tgBotToken": "", @@ -383,12 +372,6 @@ func (s *SettingService) PanelEgressProxyURL() string { return "" } -func (s *SettingService) NodeEgressProxyURL(nodeID int) string { - // The per-node egress bridge also lived in the panel's own xray core; - // with no local core there is never a bridge: direct connection. - return "" -} - // NewProxiedHTTPClient returns an HTTP client that routes the panel's own // outbound requests through the configured panel outbound (via the loopback // SOCKS bridge in the running Xray). When the feature is off or the bridge @@ -622,14 +605,6 @@ func (s *SettingService) SetPia(data string) error { return s.setString("pia", data) } -func (s *SettingService) GetRestartXrayOnClientDisable() (bool, error) { - return s.getBool("restartXrayOnClientDisable") -} - -func (s *SettingService) SetRestartXrayOnClientDisable(value bool) error { - return s.setBool("restartXrayOnClientDisable", value) -} - // GetDevChannelEnable reports whether the panel self-update tracks the rolling // per-commit dev release instead of the latest stable tag. func (s *SettingService) GetDevChannelEnable() (bool, error) { diff --git a/internal/web/service/setting_mtls.go b/internal/web/service/setting_mtls.go deleted file mode 100644 index d62812c..0000000 --- a/internal/web/service/setting_mtls.go +++ /dev/null @@ -1,233 +0,0 @@ -package service - -import ( - "bytes" - "crypto/sha256" - "crypto/tls" - "crypto/x509" - "encoding/hex" - "encoding/pem" - "errors" - "fmt" - "strings" - "sync" - - "gorm.io/gorm" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/database/model" - "github.com/mhsanaei/3x-ui/v3/internal/util/common" - "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" - "github.com/mhsanaei/3x-ui/v3/internal/web/runtime" -) - -var masterClientCredentialMu sync.Mutex - -const ( - settingNodeMtlsCaCert = "nodeMtlsCaCertPem" - settingNodeMtlsCaKey = "nodeMtlsCaKeyPem" - settingNodeMtlsClientCert = "nodeMtlsClientCertPem" - settingNodeMtlsClientKey = "nodeMtlsClientKeyPem" - settingNodeMtlsClientPin = "nodeMtlsClientCertSha256" - settingNodeMtlsClientCA = "nodeMtlsClientCAPem" -) - -// EnsureNodeMtlsCA returns this panel's node-auth CA, minting and persisting it -// on first use and reusing the stored pair thereafter. The CA private key never -// leaves the panel. -func (s *SettingService) EnsureNodeMtlsCA() (crypto.CertKeyPEM, error) { - certPem, err := s.getString(settingNodeMtlsCaCert) - if err != nil { - return crypto.CertKeyPEM{}, err - } - keyPem, err := s.getString(settingNodeMtlsCaKey) - if err != nil { - return crypto.CertKeyPEM{}, err - } - if certPem != "" && keyPem != "" { - return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil - } - // Fail closed on a half-present pair: regenerating here would silently rotate - // the CA and break trust on nodes that already hold the old cert. Only mint - // when neither half exists (first use). - if certPem != "" || keyPem != "" { - return crypto.CertKeyPEM{}, common.NewError("node mTLS CA is incomplete: one of cert/key is missing; refusing to regenerate") - } - ca, err := crypto.GenerateNodeCA("3x-ui node mTLS CA") - if err != nil { - return crypto.CertKeyPEM{}, err - } - if err := s.saveSetting(settingNodeMtlsCaCert, string(ca.CertPEM)); err != nil { - return crypto.CertKeyPEM{}, err - } - if err := s.saveSetting(settingNodeMtlsCaKey, string(ca.KeyPEM)); err != nil { - return crypto.CertKeyPEM{}, err - } - return ca, nil -} - -func clientCertSHA256FromPEM(certPEM []byte) (string, error) { - block, rest := pem.Decode(certPEM) - if block == nil || block.Type != "CERTIFICATE" || len(strings.TrimSpace(string(rest))) != 0 { - return "", common.NewError("client certificate is not valid PEM") - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return "", err - } - sum := sha256.Sum256(cert.Raw) - return hex.EncodeToString(sum[:]), nil -} - -// EnsureMasterClientCert returns the client certificate this panel presents when -// calling its nodes over mTLS, issuing it from the node CA on first use and -// reusing the stored pair thereafter. -func (s *SettingService) EnsureMasterClientCert() (crypto.CertKeyPEM, error) { - masterClientCredentialMu.Lock() - defer masterClientCredentialMu.Unlock() - - certPem, err := s.getString(settingNodeMtlsClientCert) - if err != nil { - return crypto.CertKeyPEM{}, err - } - keyPem, err := s.getString(settingNodeMtlsClientKey) - if err != nil { - return crypto.CertKeyPEM{}, err - } - storedPin, err := s.getString(settingNodeMtlsClientPin) - if err != nil { - return crypto.CertKeyPEM{}, err - } - storedPin = strings.ToLower(strings.TrimSpace(storedPin)) - if certPem != "" && keyPem != "" { - if _, err := tls.X509KeyPair([]byte(certPem), []byte(keyPem)); err != nil { - return crypto.CertKeyPEM{}, common.NewError("stored master client certificate/key pair is invalid: ", err) - } - actualPin, err := clientCertSHA256FromPEM([]byte(certPem)) - if err != nil { - return crypto.CertKeyPEM{}, err - } - if storedPin != "" && storedPin != actualPin { - return crypto.CertKeyPEM{}, common.NewError("stored master client certificate does not match nodeMtlsClientCertSha256; refusing to rotate") - } - if storedPin == "" { - if err := s.saveSetting(settingNodeMtlsClientPin, actualPin); err != nil { - return crypto.CertKeyPEM{}, err - } - } - return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil - } - // Half a stored pair signals corrupted settings; reissuing would rotate the - // master client credential (and indirectly the CA). Only mint on first use. - if certPem != "" || keyPem != "" { - return crypto.CertKeyPEM{}, common.NewError("master client cert is incomplete: one of cert/key is missing; refusing to reissue") - } - ca, err := s.EnsureNodeMtlsCA() - if err != nil { - return crypto.CertKeyPEM{}, err - } - client, err := crypto.IssueClientCert(ca, "3x-ui master") - if err != nil { - return crypto.CertKeyPEM{}, err - } - pin, err := clientCertSHA256FromPEM(client.CertPEM) - if err != nil { - return crypto.CertKeyPEM{}, err - } - if err := saveMasterClientCredential(client, pin); err != nil { - return crypto.CertKeyPEM{}, err - } - runtime.InvalidateMasterClientConnections() - return client, nil -} - -func saveMasterClientCredential(client crypto.CertKeyPEM, pin string) error { - values := map[string]string{ - settingNodeMtlsClientCert: string(client.CertPEM), - settingNodeMtlsClientKey: string(client.KeyPEM), - settingNodeMtlsClientPin: pin, - } - return database.GetDB().Transaction(func(tx *gorm.DB) error { - for key, value := range values { - result := tx.Model(&model.Setting{}).Where("key = ?", key).Update("value", value) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - if err := tx.Create(&model.Setting{Key: key, Value: value}).Error; err != nil { - return err - } - } - } - return nil - }) -} - -// LoadMasterClientCert returns only the already-persisted credential. It never -// mints or changes CA/client settings. -func (s *SettingService) LoadMasterClientCert() (crypto.CertKeyPEM, error) { - certPem, err := s.getString(settingNodeMtlsClientCert) - if err != nil { - return crypto.CertKeyPEM{}, err - } - keyPem, err := s.getString(settingNodeMtlsClientKey) - if err != nil { - return crypto.CertKeyPEM{}, err - } - if certPem == "" || keyPem == "" { - return crypto.CertKeyPEM{}, common.NewError("master client certificate is not fully configured") - } - return crypto.CertKeyPEM{CertPEM: []byte(certPem), KeyPEM: []byte(keyPem)}, nil -} - -// NodeMtlsClientCAPool builds the trust pool used as the panel listener's -// ClientCAs for incoming node-API client certificates. It returns (nil, nil) -// when no trust CA is configured, so mTLS stays off and the listener behaves -// exactly as before. -func (s *SettingService) NodeMtlsClientCAPool() (*x509.CertPool, error) { - caPem, err := s.getString(settingNodeMtlsClientCA) - if err != nil { - return nil, err - } - if caPem == "" { - return nil, nil - } - certs, err := parseCertificateBundlePEM([]byte(caPem)) - if err != nil { - return nil, fmt.Errorf("nodeMtlsClientCAPem is not a valid certificate bundle: %w", err) - } - pool := x509.NewCertPool() - for _, cert := range certs { - pool.AddCert(cert) - } - return pool, nil -} - -// parseCertificateBundlePEM avoids AppendCertsFromPEM because that helper can -// silently accept a bundle after parsing only its first certificate. -func parseCertificateBundlePEM(bundle []byte) ([]*x509.Certificate, error) { - rest := bytes.TrimSpace(bundle) - if len(rest) == 0 { - return nil, errors.New("certificate bundle is empty") - } - certs := make([]*x509.Certificate, 0, 1) - for len(rest) > 0 { - if !bytes.HasPrefix(rest, []byte("-----BEGIN CERTIFICATE-----")) { - return nil, errors.New("certificate bundle contains malformed or non-PEM data") - } - block, next := pem.Decode(rest) - if block == nil { - return nil, errors.New("certificate bundle contains malformed or non-PEM data") - } - if block.Type != "CERTIFICATE" { - return nil, errors.New("certificate bundle contains a non-certificate PEM block") - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return nil, errors.New("certificate bundle contains an invalid certificate") - } - certs = append(certs, cert) - rest = bytes.TrimSpace(next) - } - return certs, nil -} diff --git a/internal/web/service/setting_mtls_bundle_test.go b/internal/web/service/setting_mtls_bundle_test.go deleted file mode 100644 index c1dface..0000000 --- a/internal/web/service/setting_mtls_bundle_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package service - -import ( - "strings" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" -) - -func mustNodeCAPEM(t *testing.T, name string) string { - t.Helper() - ca, err := crypto.GenerateNodeCA(name) - if err != nil { - t.Fatalf("GenerateNodeCA(%q): %v", name, err) - } - return string(ca.CertPEM) -} - -func TestParseCertificateBundlePEM(t *testing.T) { - first := mustNodeCAPEM(t, "bundle test CA one") - second := mustNodeCAPEM(t, "bundle test CA two") - - corrupt := strings.Replace(second, "-----BEGIN CERTIFICATE-----\n", "-----BEGIN CERTIFICATE-----\nAA", 1) - - tests := []struct { - name string - bundle string - wantCerts int - wantErr string - }{ - {name: "single certificate", bundle: first, wantCerts: 1}, - {name: "two certificates", bundle: first + second, wantCerts: 2}, - {name: "empty", bundle: "", wantErr: "certificate bundle is empty"}, - {name: "whitespace only", bundle: "\n\t \n", wantErr: "certificate bundle is empty"}, - {name: "leading non-PEM data", bundle: "junk\n" + first, wantErr: "certificate bundle contains malformed or non-PEM data"}, - {name: "interstitial non-PEM data", bundle: first + "junk\n" + second, wantErr: "certificate bundle contains malformed or non-PEM data"}, - {name: "second certificate corrupt", bundle: first + corrupt, wantErr: "certificate bundle contains malformed or non-PEM data"}, - {name: "trailing non-PEM data", bundle: first + "not a certificate\n", wantErr: "certificate bundle contains malformed or non-PEM data"}, - {name: "non-certificate block", bundle: first + "-----BEGIN PRIVATE KEY-----\nAAAA\n-----END PRIVATE KEY-----\n", wantErr: "certificate bundle contains malformed or non-PEM data"}, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - certs, err := parseCertificateBundlePEM([]byte(tt.bundle)) - if tt.wantErr != "" { - if err == nil || err.Error() != tt.wantErr { - t.Fatalf("parseCertificateBundlePEM() error = %v, want %q", err, tt.wantErr) - } - return - } - if err != nil { - t.Fatalf("parseCertificateBundlePEM(): %v", err) - } - if len(certs) != tt.wantCerts { - t.Fatalf("parseCertificateBundlePEM() = %d certs, want %d", len(certs), tt.wantCerts) - } - }) - } -} - -func TestNodeMtlsClientCAPoolRejectsPartiallyValidBundle(t *testing.T) { - s := setupSettingMtlsDB(t) - - valid := mustNodeCAPEM(t, "pool test CA") - if err := s.setString("nodeMtlsClientCAPem", valid+"-----BEGIN CERTIFICATE-----\nnot base64\n-----END CERTIFICATE-----\n"); err != nil { - t.Fatalf("setString: %v", err) - } - - pool, err := s.NodeMtlsClientCAPool() - want := "nodeMtlsClientCAPem is not a valid certificate bundle: certificate bundle contains malformed or non-PEM data" - if err == nil || err.Error() != want { - t.Fatalf("NodeMtlsClientCAPool() = %v, error = %v, want %q", pool, err, want) - } -} diff --git a/internal/web/service/setting_mtls_test.go b/internal/web/service/setting_mtls_test.go deleted file mode 100644 index 55d28c5..0000000 --- a/internal/web/service/setting_mtls_test.go +++ /dev/null @@ -1,274 +0,0 @@ -package service - -import ( - "bytes" - "crypto/x509" - "encoding/pem" - "path/filepath" - "testing" - "time" - - "github.com/mhsanaei/3x-ui/v3/internal/database" - "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" -) - -func setupSettingMtlsDB(t *testing.T) *SettingService { - t.Helper() - dbDir := t.TempDir() - t.Setenv("XUI_DB_FOLDER", dbDir) - if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { - t.Fatalf("InitDB: %v", err) - } - t.Cleanup(func() { _ = database.CloseDB() }) - return &SettingService{} -} - -func TestEnsureNodeMtlsCA_Idempotent(t *testing.T) { - s := setupSettingMtlsDB(t) - - first, err := s.EnsureNodeMtlsCA() - if err != nil { - t.Fatalf("EnsureNodeMtlsCA (first): %v", err) - } - block, _ := pem.Decode(first.CertPEM) - if block == nil { - t.Fatal("CA cert is not valid PEM") - } - caCert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - t.Fatalf("parse CA cert: %v", err) - } - if !caCert.IsCA { - t.Fatal("stored CA must have IsCA=true") - } - - second, err := s.EnsureNodeMtlsCA() - if err != nil { - t.Fatalf("EnsureNodeMtlsCA (second): %v", err) - } - if !bytes.Equal(first.CertPEM, second.CertPEM) || !bytes.Equal(first.KeyPEM, second.KeyPEM) { - t.Fatal("EnsureNodeMtlsCA must be idempotent: second call returned different PEMs") - } -} - -func TestEnsureMasterClientCert_VerifiesAndIdempotent(t *testing.T) { - s := setupSettingMtlsDB(t) - - ca, err := s.EnsureNodeMtlsCA() - if err != nil { - t.Fatalf("EnsureNodeMtlsCA: %v", err) - } - client, err := s.EnsureMasterClientCert() - if err != nil { - t.Fatalf("EnsureMasterClientCert: %v", err) - } - - cblock, _ := pem.Decode(client.CertPEM) - if cblock == nil { - t.Fatal("client cert is not valid PEM") - } - leaf, err := x509.ParseCertificate(cblock.Bytes) - if err != nil { - t.Fatalf("parse client cert: %v", err) - } - caBlock, _ := pem.Decode(ca.CertPEM) - roots := x509.NewCertPool() - roots.AddCert(mustParse(t, caBlock.Bytes)) - if _, err := leaf.Verify(x509.VerifyOptions{ - Roots: roots, - KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageClientAuth}, - }); err != nil { - t.Fatalf("master client cert must verify against the node CA for client auth: %v", err) - } - - again, err := s.EnsureMasterClientCert() - if err != nil { - t.Fatalf("EnsureMasterClientCert (second): %v", err) - } - if !bytes.Equal(client.CertPEM, again.CertPEM) || !bytes.Equal(client.KeyPEM, again.KeyPEM) { - t.Fatal("EnsureMasterClientCert must be idempotent") - } -} - -func TestEnsureMasterClientCertRejectsMismatchedStoredKey(t *testing.T) { - s := setupSettingMtlsDB(t) - first, err := s.EnsureMasterClientCert() - if err != nil { - t.Fatal(err) - } - ca, err := s.EnsureNodeMtlsCA() - if err != nil { - t.Fatal(err) - } - other, err := crypto.IssueClientCert(ca, "other master") - if err != nil { - t.Fatal(err) - } - if err := s.setString(settingNodeMtlsClientCert, string(first.CertPEM)); err != nil { - t.Fatal(err) - } - if err := s.setString(settingNodeMtlsClientKey, string(other.KeyPEM)); err != nil { - t.Fatal(err) - } - if _, err := s.EnsureMasterClientCert(); err == nil { - t.Fatal("mismatched stored certificate and key were accepted") - } -} - -func TestEnsureMasterClientCert_ReissuesLeafWhenCAStillExists(t *testing.T) { - s := setupSettingMtlsDB(t) - - client, err := s.EnsureMasterClientCert() - if err != nil { - t.Fatalf("EnsureMasterClientCert: %v", err) - } - pin, err := clientCertSHA256FromPEM(client.CertPEM) - if err != nil { - t.Fatalf("clientCertSHA256FromPEM: %v", err) - } - if err := s.setString(settingNodeMtlsClientPin, pin); err != nil { - t.Fatalf("persist client pin: %v", err) - } - if err := s.setString(settingNodeMtlsClientCert, ""); err != nil { - t.Fatalf("clear client cert: %v", err) - } - if err := s.setString(settingNodeMtlsClientKey, ""); err != nil { - t.Fatalf("clear client key: %v", err) - } - - reissued, err := s.EnsureMasterClientCert() - if err != nil { - t.Fatalf("reissue with surviving CA: %v", err) - } - newPin, err := clientCertSHA256FromPEM(reissued.CertPEM) - if err != nil { - t.Fatalf("new pin: %v", err) - } - if newPin == pin { - t.Fatal("reissued credential kept the lost leaf identity") - } - stored, err := s.getString(settingNodeMtlsClientPin) - if err != nil || stored != newPin { - t.Fatalf("stored pin = %q, error = %v, want %q", stored, err, newPin) - } -} - -func TestEnsureMasterClientCertConcurrentFirstUseMintsOneCredential(t *testing.T) { - s := setupSettingMtlsDB(t) - blocked := make(chan struct{}) - masterClientCredentialMu.Lock() - go func() { - _, _ = s.EnsureMasterClientCert() - close(blocked) - }() - select { - case <-blocked: - masterClientCredentialMu.Unlock() - t.Fatal("EnsureMasterClientCert returned while its serialization lock was held") - case <-time.After(50 * time.Millisecond): - } - masterClientCredentialMu.Unlock() - select { - case <-blocked: - case <-time.After(time.Second): - t.Fatal("EnsureMasterClientCert remained blocked after serialization lock release") - } - const callers = 8 - start := make(chan struct{}) - results := make(chan crypto.CertKeyPEM, callers) - errs := make(chan error, callers) - for range callers { - go func() { - <-start - credential, err := s.EnsureMasterClientCert() - results <- credential - errs <- err - }() - } - close(start) - var first crypto.CertKeyPEM - for i := range callers { - credential := <-results - if err := <-errs; err != nil { - t.Fatalf("caller %d: %v", i, err) - } - if i == 0 { - first = credential - } else if !bytes.Equal(first.CertPEM, credential.CertPEM) || !bytes.Equal(first.KeyPEM, credential.KeyPEM) { - t.Fatalf("caller %d received a different credential", i) - } - } - storedCert, _ := s.getString(settingNodeMtlsClientCert) - storedKey, _ := s.getString(settingNodeMtlsClientKey) - if storedCert != string(first.CertPEM) || storedKey != string(first.KeyPEM) { - t.Fatal("persisted credential differs from concurrent callers") - } -} - -func TestEnsureMasterClientCert_PersistsCredentialAtomically(t *testing.T) { - s := setupSettingMtlsDB(t) - db := database.GetDB() - trigger := `CREATE TRIGGER fail_master_pin_insert - BEFORE INSERT ON settings - WHEN NEW.key = 'nodeMtlsClientCertSha256' - BEGIN SELECT RAISE(ABORT, 'injected pin failure'); END` - if err := db.Exec(trigger).Error; err != nil { - t.Fatalf("create failure trigger: %v", err) - } - - if _, err := s.EnsureMasterClientCert(); err == nil { - t.Fatal("injected persistence failure unexpectedly succeeded") - } - for _, key := range []string{settingNodeMtlsClientCert, settingNodeMtlsClientKey, settingNodeMtlsClientPin} { - got, err := s.getString(key) - if err != nil { - t.Fatalf("get %s after rollback: %v", key, err) - } - if got != "" { - t.Fatalf("%s persisted despite transaction rollback", key) - } - } - - if err := db.Exec("DROP TRIGGER fail_master_pin_insert").Error; err != nil { - t.Fatalf("drop failure trigger: %v", err) - } - if _, err := s.EnsureMasterClientCert(); err != nil { - t.Fatalf("retry after rollback: %v", err) - } -} - -func TestNodeMtlsClientCAPool(t *testing.T) { - s := setupSettingMtlsDB(t) - - pool, err := s.NodeMtlsClientCAPool() - if err != nil { - t.Fatalf("NodeMtlsClientCAPool (unset): %v", err) - } - if pool != nil { - t.Fatal("with no trust CA configured, the pool must be nil (mTLS off; listener unchanged)") - } - - ca, err := s.EnsureNodeMtlsCA() - if err != nil { - t.Fatalf("EnsureNodeMtlsCA: %v", err) - } - if err := s.setString("nodeMtlsClientCAPem", string(ca.CertPEM)); err != nil { - t.Fatalf("set trust CA: %v", err) - } - pool, err = s.NodeMtlsClientCAPool() - if err != nil { - t.Fatalf("NodeMtlsClientCAPool (set): %v", err) - } - if pool == nil { - t.Fatal("with a trust CA configured, the pool must be non-nil") - } -} - -func mustParse(t *testing.T, der []byte) *x509.Certificate { - t.Helper() - c, err := x509.ParseCertificate(der) - if err != nil { - t.Fatalf("ParseCertificate: %v", err) - } - return c -} diff --git a/internal/web/service/traffic_test_helpers_test.go b/internal/web/service/traffic_test_helpers_test.go new file mode 100644 index 0000000..8db0837 --- /dev/null +++ b/internal/web/service/traffic_test_helpers_test.go @@ -0,0 +1,121 @@ +package service + +import ( + "encoding/json" + "fmt" + "path/filepath" + "slices" + "testing" + + "gorm.io/gorm" + + "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" + "github.com/mhsanaei/3x-ui/v3/internal/xray" +) + +func assertStringSet(t *testing.T, label string, got, want []string) { + t.Helper() + g := append([]string(nil), got...) + w := append([]string(nil), want...) + slices.Sort(g) + slices.Sort(w) + if !slices.Equal(g, w) { + t.Fatalf("%s = %v, want %v", label, got, want) + } +} + +func marshalIps(t *testing.T, entries ...clientIpEntry) string { + t.Helper() + b, err := json.Marshal(entries) + if err != nil { + t.Fatalf("marshal ips: %v", err) + } + return string(b) +} + +// readClientIps returns the stored IP entries for an email as a map[ip]timestamp, +// plus whether the row exists at all. +func readClientIps(t *testing.T, email string) (map[string]int64, bool) { + t.Helper() + var row model.InboundClientIps + err := database.GetDB().Where("client_email = ?", email).First(&row).Error + if database.IsNotFound(err) { + return nil, false + } + if err != nil { + t.Fatalf("read ips %q: %v", email, err) + } + var entries []clientIpEntry + if err := json.Unmarshal([]byte(row.Ips), &entries); err != nil { + t.Fatalf("unmarshal ips %q: %v", email, err) + } + out := make(map[string]int64, len(entries)) + for _, e := range entries { + out[e.IP] = e.Timestamp + } + return out, true +} + +// Shared fixtures for the traffic/node test files. These helpers lived +// in node_client_traffic_sum_test.go until that file's snapshot-merge +// tests were retired with the traditional node sync (B6); the helpers +// themselves serve surviving tests, so they moved here. + +func setupClientIpTestDB(t *testing.T) { + t.Helper() + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) +} + +func initTrafficTestDB(t *testing.T) *gorm.DB { + t.Helper() + dbDir := t.TempDir() + t.Setenv("XUI_DB_FOLDER", dbDir) + if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil { + t.Fatalf("InitDB: %v", err) + } + t.Cleanup(func() { _ = database.CloseDB() }) + return database.GetDB() +} + +func createNodeInbound(t *testing.T, db *gorm.DB, nodeID int, tag string, port int) { + t.Helper() + nid := nodeID + ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid} + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create node inbound %q: %v", tag, err) + } +} + +// createNodeInboundWithClient mirrors createNodeInbound but stores the client +// in the settings JSON. +func createNodeInboundWithClient(t *testing.T, db *gorm.DB, nodeID int, tag string, port int, email string) { + t.Helper() + nid := nodeID + settings := fmt.Sprintf(`{"clients": [{"email": %q, "enable": true}]}`, email) + ib := &model.Inbound{UserId: 1, Tag: tag, Enable: true, Port: port, Protocol: model.VLESS, NodeID: &nid, Settings: settings} + if err := db.Create(ib).Error; err != nil { + t.Fatalf("create node inbound %q: %v", tag, err) + } +} + +func readTraffic(t *testing.T, db *gorm.DB, email string) xray.ClientTraffic { + t.Helper() + var ct xray.ClientTraffic + if err := db.Model(xray.ClientTraffic{}).Where("email = ?", email).First(&ct).Error; err != nil { + t.Fatalf("read client_traffics %q: %v", email, err) + } + return ct +} + +func assertUpDown(t *testing.T, ct xray.ClientTraffic, wantUp, wantDown int64, when string) { + t.Helper() + if ct.Up != wantUp || ct.Down != wantDown { + t.Errorf("%s: up=%d down=%d, want %d/%d", when, ct.Up, ct.Down, wantUp, wantDown) + } +} diff --git a/internal/web/web.go b/internal/web/web.go index 5f6e560..2b9300c 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -316,7 +316,6 @@ const ( cadenceNodeHeartbeat = "@every 5s" cadenceNodeTraffic = "@every 5s" cadenceOutboundSub = "@every 5m" - cadenceReapOrphans = "@every 5m" cadenceXrayLogPrune = "@every 10m" // cpu.Percent samples over a full minute (blocking), so a finer cadence just // stacks overlapping samplers; subscribers rate-limit alerts to 1/min anyway. @@ -353,8 +352,6 @@ func (s *Server) startTask(restartXray bool, loc *time.Location) { // Outbound subscription auto-refresh (respects per-sub updateInterval) _, _ = s.cron.AddJob(cadenceOutboundSub, job.NewOutboundSubscriptionJob()) - _, _ = s.cron.AddJob(cadenceReapOrphans, job.NewReapSyncOrphansJob()) - // Probe history housekeeping (item 7 B2): roll the closed hours of // probe.db into the hourly tier and prune past the retention // window. Hourly on the hour, plus one pass shortly after startup @@ -531,20 +528,10 @@ func (s *Server) start(restartXray bool) (err error) { s.cron.Start() // Wire the inbound-runtime manager once so InboundService can route - // add/update/delete to either the local xray or a remote node panel. - // The closures bridge into XrayService (which owns the running xray - // process state) without forcing the runtime package to import service. + // add/update/delete through the (no-op) local runtime: the lite + // master runs no core, so writes land in the database and agent + // nodes converge by pulling their config. runtime.SetManager(runtime.NewManager()) - runtime.GetManager().SetNodeEgressResolver(&s.settingService) - // Supply the master client certificate for nodes in mtls mode. Issued lazily - // from the node CA on first use; runtime stays free of a service import. - runtime.SetMasterClientCertProvider(func() (tls.Certificate, error) { - ck, err := s.settingService.EnsureMasterClientCert() - if err != nil { - return tls.Certificate{}, err - } - return tls.X509KeyPair(ck.CertPEM, ck.KeyPEM) - }) engine, err := s.initRouter() if err != nil { @@ -586,15 +573,6 @@ func (s *Server) start(restartXray bool) (err error) { c := &tls.Config{ Certificates: []tls.Certificate{cert}, } - // Opt-in node mTLS: when a trust CA is configured, request and verify - // client certs (VerifyClientCertIfGiven keeps browsers working). With - // no CA the listener is unchanged. - if pool, perr := s.settingService.NodeMtlsClientCAPool(); perr != nil { - logger.Warning("node mTLS: failed to build client CA trust pool:", perr) - } else if pool != nil { - applyNodeMtls(c, pool) - logger.Info("Node mTLS enabled: verifying client certificates for the node API") - } listener = network.NewAutoHttpsListener(listener) listener = tls.NewListener(listener, c) logger.Info("Web server running HTTPS on", listener.Addr()) diff --git a/internal/web/web_mtls.go b/internal/web/web_mtls.go deleted file mode 100644 index c23d6a3..0000000 --- a/internal/web/web_mtls.go +++ /dev/null @@ -1,19 +0,0 @@ -package web - -import ( - "crypto/tls" - "crypto/x509" -) - -// applyNodeMtls configures the panel listener to request and verify client -// certificates against pool. It uses VerifyClientCertIfGiven so browsers (which -// present no client cert) keep working; a presented cert that fails to verify -// aborts the handshake. With a nil pool the config is left untouched, so the -// no-mTLS listener is byte-identical to before. -func applyNodeMtls(cfg *tls.Config, pool *x509.CertPool) { - if pool == nil { - return - } - cfg.ClientAuth = tls.VerifyClientCertIfGiven - cfg.ClientCAs = pool -} diff --git a/internal/web/web_mtls_test.go b/internal/web/web_mtls_test.go deleted file mode 100644 index faaee69..0000000 --- a/internal/web/web_mtls_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package web - -import ( - "crypto/tls" - "crypto/x509" - "net/http" - "net/http/httptest" - "strconv" - "testing" - - "github.com/mhsanaei/3x-ui/v3/internal/util/crypto" -) - -// TestPanelTLSAcceptsClientWithoutClientCert characterizes the invariant the -// mTLS work must preserve: the panel's HTTPS listener — configured today with a -// server certificate and NO ClientAuth — completes the TLS handshake for a -// client that presents no client certificate (i.e. every browser). When mTLS is -// wired into web.go, the no-CA path must keep this behavior byte-for-byte. P1.6 -// extends this file with the VerifyClientCertIfGiven + ClientCAs cases. -func TestPanelTLSAcceptsClientWithoutClientCert(t *testing.T) { - srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - })) - defer srv.Close() - - // Precondition: like web.go today, the listener requests no client cert. - if srv.TLS.ClientAuth != tls.NoClientCert { - t.Fatalf("precondition: ClientAuth = %v, want NoClientCert", srv.TLS.ClientAuth) - } - - // srv.Client() trusts the server's self-signed cert and presents NO client cert. - resp, err := srv.Client().Get(srv.URL) - if err != nil { - t.Fatalf("request without a client certificate failed: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want 200", resp.StatusCode) - } -} - -// TestApplyNodeMtls exercises the listener policy applied by web.go: a nil pool -// leaves the listener unchanged (no client auth, browsers work); a set pool is -// request-but-don't-require, so no-cert clients still handshake while a -// CA-signed client cert is verified and a foreign cert is rejected. -func TestApplyNodeMtls(t *testing.T) { - ca, err := crypto.GenerateNodeCA("test ca") - if err != nil { - t.Fatalf("GenerateNodeCA: %v", err) - } - clientPEM, err := crypto.IssueClientCert(ca, "master") - if err != nil { - t.Fatalf("IssueClientCert: %v", err) - } - clientCert, err := tls.X509KeyPair(clientPEM.CertPEM, clientPEM.KeyPEM) - if err != nil { - t.Fatalf("client X509KeyPair: %v", err) - } - caPool := x509.NewCertPool() - if !caPool.AppendCertsFromPEM(ca.CertPEM) { - t.Fatal("append CA to pool") - } - - otherCA, err := crypto.GenerateNodeCA("other ca") - if err != nil { - t.Fatalf("GenerateNodeCA(other): %v", err) - } - foreignPEM, err := crypto.IssueClientCert(otherCA, "intruder") - if err != nil { - t.Fatalf("IssueClientCert(foreign): %v", err) - } - foreignCert, err := tls.X509KeyPair(foreignPEM.CertPEM, foreignPEM.KeyPEM) - if err != nil { - t.Fatalf("foreign X509KeyPair: %v", err) - } - - newServer := func(pool *x509.CertPool) *httptest.Server { - srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - n := 0 - if r.TLS != nil { - n = len(r.TLS.VerifiedChains) - } - w.Header().Set("X-Verified-Chains", strconv.Itoa(n)) - w.WriteHeader(http.StatusOK) - })) - srv.TLS = &tls.Config{} - applyNodeMtls(srv.TLS, pool) - srv.StartTLS() - return srv - } - // clientFor forces the client to present cert via GetClientCertificate so the - // server's verification is what's under test (the default Certificates path - // would let the Go client silently withhold a cert whose CA the server didn't - // advertise, masking the reject behavior). - clientFor := func(srv *httptest.Server, cert *tls.Certificate) *http.Client { - roots := x509.NewCertPool() - roots.AddCert(srv.Certificate()) - cfg := &tls.Config{RootCAs: roots} - if cert != nil { - c := *cert - cfg.GetClientCertificate = func(*tls.CertificateRequestInfo) (*tls.Certificate, error) { - return &c, nil - } - } - return &http.Client{Transport: &http.Transport{TLSClientConfig: cfg}} - } - - t.Run("nil pool leaves the listener without client auth", func(t *testing.T) { - srv := newServer(nil) - defer srv.Close() - if srv.TLS.ClientAuth != tls.NoClientCert { - t.Fatalf("nil pool must not set ClientAuth, got %v", srv.TLS.ClientAuth) - } - resp, err := clientFor(srv, nil).Get(srv.URL) - if err != nil { - t.Fatalf("no-cert client failed: %v", err) - } - resp.Body.Close() - }) - - t.Run("pool set still accepts a no-cert client", func(t *testing.T) { - srv := newServer(caPool) - defer srv.Close() - resp, err := clientFor(srv, nil).Get(srv.URL) - if err != nil { - t.Fatalf("no-cert client must still handshake under VerifyClientCertIfGiven: %v", err) - } - defer resp.Body.Close() - if got := resp.Header.Get("X-Verified-Chains"); got != "0" { - t.Fatalf("no-cert client verified chains = %s, want 0", got) - } - }) - - t.Run("pool set verifies the master client cert", func(t *testing.T) { - srv := newServer(caPool) - defer srv.Close() - resp, err := clientFor(srv, &clientCert).Get(srv.URL) - if err != nil { - t.Fatalf("master client cert must be accepted: %v", err) - } - defer resp.Body.Close() - if got := resp.Header.Get("X-Verified-Chains"); got != "1" { - t.Fatalf("master cert verified chains = %s, want 1 (cert was not verified)", got) - } - }) - - t.Run("pool set rejects a foreign-CA client cert", func(t *testing.T) { - srv := newServer(caPool) - defer srv.Close() - if _, err := clientFor(srv, &foreignCert).Get(srv.URL); err == nil { - t.Fatal("a client cert from an untrusted CA must fail the handshake") - } - }) -} diff --git a/main.go b/main.go index 4c460be..d0d8fc6 100644 --- a/main.go +++ b/main.go @@ -320,24 +320,6 @@ func showSetting(show bool) { } } -func encryptNodeTokens() { - _ = godotenv.Load() - if err := initNodeTokenCrypto(); err != nil { - fmt.Println("node-token encryption init failed:", err) - os.Exit(1) - } - if err := database.InitDB(config.GetDBPath()); err != nil { - fmt.Println("database initialization failed:", err) - os.Exit(1) - } - changed, skipped, err := (&service.NodeService{}).MigrateNodeTokensToActiveKey() - if err != nil { - fmt.Println("token migration failed:", err) - os.Exit(1) - } - fmt.Printf("node-token migration complete: %d re-encrypted, %d already current/skipped\n", changed, skipped) -} - // updateSetting updates various panel settings including port, credentials, base path, and listen IP. func updateSetting(port int, username string, password string, webBasePath string, listenIP string) error { err := database.InitDB(config.GetDBPath()) @@ -608,8 +590,6 @@ func main() { runAgent(os.Args[2:]) case "migrate": migrateDb() - case "encrypt-tokens": - encryptNodeTokens() case "migrate-db": if err := migrateDbCmd.Parse(os.Args[2:]); err != nil { fmt.Println(err)