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)