B6 backend: remove traditional node path, node mTLS and remote merge engine; drop restartXrayOnClientDisable (rider 1) and ApiScopeNodeSync (rider 3)
This commit is contained in:
@@ -34,7 +34,6 @@ export class AllSetting {
|
||||
subPort = 2096;
|
||||
subPath = '/sub/';
|
||||
subDomain = '';
|
||||
restartXrayOnClientDisable = true;
|
||||
subCertFile = '';
|
||||
subKeyFile = '';
|
||||
subUpdates = 12;
|
||||
|
||||
@@ -178,17 +178,6 @@ export default function GeneralTab({ allSetting, updateSetting }: GeneralTabProp
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.restartXrayOnClientDisable')}
|
||||
description={t('pages.settings.restartXrayOnClientDisableDesc')}
|
||||
>
|
||||
<Switch
|
||||
checked={allSetting.restartXrayOnClientDisable}
|
||||
onChange={(v) => updateSetting({ restartXrayOnClientDisable: v })}
|
||||
/>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem
|
||||
paddings="small"
|
||||
title={t('pages.settings.trafficStatsInboundUplink')}
|
||||
|
||||
@@ -19,7 +19,7 @@ interface ApiTokenRow {
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
createdAt: number;
|
||||
scope: 'admin' | 'monitor' | 'node-sync';
|
||||
scope: 'admin' | 'monitor';
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ export const AllSettingSchema = z
|
||||
subPort: z.number().int().min(0).max(65535).optional(),
|
||||
subPath: absolutePath.optional(),
|
||||
subDomain: z.string().optional(),
|
||||
restartXrayOnClientDisable: z.boolean().optional(),
|
||||
subCertFile: z.string().optional(),
|
||||
subKeyFile: z.string().optional(),
|
||||
subUpdates: z.number().int().min(0).max(525600).optional(),
|
||||
|
||||
@@ -36,33 +36,3 @@ func GreatestExpr(a, b string) string {
|
||||
}
|
||||
return fmt.Sprintf("MAX(%s, %s)", a, b)
|
||||
}
|
||||
|
||||
// ClientTrafficEnableMergeExpr: placeholders nodeEnable, nodeExpiry, nodeTotal,
|
||||
// now, deltaUp, deltaDown. Mirrors nodeDisableIsStale (#6228 / #4917).
|
||||
func ClientTrafficEnableMergeExpr() string {
|
||||
if IsPostgres() {
|
||||
return `CASE
|
||||
WHEN ?::boolean THEN enable::boolean
|
||||
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::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`
|
||||
}
|
||||
|
||||
@@ -185,11 +185,10 @@ const ApiTokenUnixMillisecondsThreshold int64 = 100_000_000_000
|
||||
const (
|
||||
ApiScopeAdmin = "admin"
|
||||
ApiScopeMonitor = "monitor"
|
||||
ApiScopeNodeSync = "node-sync"
|
||||
)
|
||||
|
||||
func IsKnownApiScope(s string) bool {
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor || s == ApiScopeNodeSync
|
||||
return s == ApiScopeAdmin || s == ApiScopeMonitor
|
||||
}
|
||||
|
||||
type ApiToken struct {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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,62 +64,18 @@ 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 {
|
||||
// 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)
|
||||
@@ -194,9 +102,6 @@ func (a *NodeController) add(c *gin.Context) {
|
||||
"agentToken": token,
|
||||
"nodeType": "agent",
|
||||
}, nil)
|
||||
return
|
||||
}
|
||||
jsonMsgObj(c, I18nWeb(c, "pages.nodes.toasts.add"), view, 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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 {
|
||||
// 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// agentLastSeenMs returns the newest last_seen (Unix milliseconds, the unit
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+15
-132
@@ -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
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
local: NewLocal(),
|
||||
remotes: make(map[int]*Remote),
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<id>- 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<id>- 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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<id>- 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+20
-613
@@ -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.
|
||||
// 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))
|
||||
}
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -9,8 +9,7 @@ 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"`
|
||||
@@ -19,20 +18,14 @@ type NodeView struct {
|
||||
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"`
|
||||
// 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,9 +263,10 @@ 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"`
|
||||
@@ -291,72 +275,25 @@ type NodeMutationRequest struct {
|
||||
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"`
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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: "/",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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<id>- 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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'")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -60,16 +60,6 @@ var defaultValueMap = map[string]string{
|
||||
"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,
|
||||
@@ -99,7 +89,6 @@ var defaultValueMap = map[string]string{
|
||||
"warpUpdateInterval": "0",
|
||||
"nord": "",
|
||||
"pia": "",
|
||||
"restartXrayOnClientDisable": "true",
|
||||
"devChannelEnable": "false",
|
||||
|
||||
"tgBotEnable": "false",
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
+3
-25
@@ -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())
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user