mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 12:44:22 +00:00
fix(node): keep client/inbound edits working when a node is offline (#4923, #4931)
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Some checks are pending
CI / go-test (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / frontend (push) Waiting to run
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (push) Waiting to run
Node-backed client and inbound edits no longer hard-fail when the backing node is offline or disabled. Edits commit to the panel DB immediately and reconcile to the node when it reconnects (eventual consistency); the panel is the single source of truth for desired config. - Add Node.ConfigDirty/ConfigDirtyAt; mark a node dirty when an edit commits without reaching it (cleared via CAS on ConfigDirtyAt after a full reconcile). - nodePushPlan() reads node state fresh from the DB, skips the push for offline/disabled nodes (no 10s hang), and treats push failures as non-fatal across every mutation path (client add/update/del + bulk + attach/detach; inbound add/update/del/toggle/resetTraffic). - ReconcileNode() pushes the panel's desired config to a node on reconnect (refreshing the remote tag cache first) and prunes node-side orphans; runs before the traffic pull in the node sync job. - While a node is dirty the traffic pull applies only up/down deltas and node-initiated disables, never overwriting desired config from a stale node snapshot. - Surface a non-blocking 'saved; will sync on reconnect' warning to the UI. Validated with a two-panel Docker E2E: client delete/update, attach/detach, and inbound add/delete all reconcile correctly offline -> reconnect.
This commit is contained in:
parent
e08456269b
commit
b40f869f2a
16 changed files with 674 additions and 220 deletions
|
|
@ -396,6 +396,9 @@ type Node struct {
|
||||||
UptimeSecs uint64 `json:"uptimeSecs"`
|
UptimeSecs uint64 `json:"uptimeSecs"`
|
||||||
LastError string `json:"lastError"`
|
LastError string `json:"lastError"`
|
||||||
|
|
||||||
|
ConfigDirty bool `json:"configDirty" gorm:"default:false"`
|
||||||
|
ConfigDirtyAt int64 `json:"configDirtyAt"`
|
||||||
|
|
||||||
InboundCount int `json:"inboundCount" gorm:"-"`
|
InboundCount int `json:"inboundCount" gorm:"-"`
|
||||||
ClientCount int `json:"clientCount" gorm:"-"`
|
ClientCount int `json:"clientCount" gorm:"-"`
|
||||||
OnlineCount int `json:"onlineCount" gorm:"-"`
|
OnlineCount int `json:"onlineCount" gorm:"-"`
|
||||||
|
|
|
||||||
|
|
@ -322,6 +322,8 @@ export interface Node {
|
||||||
apiToken: string;
|
apiToken: string;
|
||||||
basePath: string;
|
basePath: string;
|
||||||
clientCount: number;
|
clientCount: number;
|
||||||
|
configDirty: boolean;
|
||||||
|
configDirtyAt: number;
|
||||||
cpuPct: number;
|
cpuPct: number;
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
depletedCount: number;
|
depletedCount: number;
|
||||||
|
|
|
||||||
|
|
@ -339,6 +339,8 @@ export const NodeSchema = z.object({
|
||||||
apiToken: z.string(),
|
apiToken: z.string(),
|
||||||
basePath: z.string(),
|
basePath: z.string(),
|
||||||
clientCount: z.number().int(),
|
clientCount: z.number().int(),
|
||||||
|
configDirty: z.boolean(),
|
||||||
|
configDirtyAt: z.number().int(),
|
||||||
cpuPct: z.number(),
|
cpuPct: z.number(),
|
||||||
createdAt: z.number().int(),
|
createdAt: z.number().int(),
|
||||||
depletedCount: z.number().int(),
|
depletedCount: z.number().int(),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
import type { AxiosError, AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
import i18next from 'i18next';
|
||||||
import { getMessage } from './messageBus';
|
import { getMessage } from './messageBus';
|
||||||
|
|
||||||
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
type RespEnvelope = { success?: unknown; msg?: unknown; obj?: unknown };
|
||||||
|
|
@ -32,6 +33,14 @@ export class HttpUtil {
|
||||||
}
|
}
|
||||||
const messageType = msg.success ? 'success' : 'error';
|
const messageType = msg.success ? 'success' : 'error';
|
||||||
getMessage()[messageType](msg.msg);
|
getMessage()[messageType](msg.msg);
|
||||||
|
if (
|
||||||
|
msg.success &&
|
||||||
|
msg.obj &&
|
||||||
|
typeof msg.obj === 'object' &&
|
||||||
|
(msg.obj as { nodePending?: unknown }).nodePending === true
|
||||||
|
) {
|
||||||
|
getMessage().warning(i18next.t('pages.inbounds.toasts.savedNodeOfflineWillSync'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
static _respToMsg(resp: AxiosResponse | undefined): Msg {
|
||||||
|
|
|
||||||
|
|
@ -132,7 +132,7 @@ func (a *ClientController) create(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(payload.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
@ -152,7 +152,7 @@ func (a *ClientController) update(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientUpdateSuccess"), pendingNodeObj(a.clientService.HasPendingNode(&a.inboundService, email)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
@ -190,7 +190,7 @@ func (a *ClientController) attach(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientAddSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
@ -470,7 +470,7 @@ func (a *ClientController) detach(c *gin.Context) {
|
||||||
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), nil)
|
jsonMsgObj(c, I18nWeb(c, "pages.inbounds.toasts.inboundClientDeleteSuccess"), pendingNodeObj(a.inboundService.AnyNodePending(body.InboundIds)), nil)
|
||||||
if needRestart {
|
if needRestart {
|
||||||
a.xrayService.SetToNeedRestart()
|
a.xrayService.SetToNeedRestart()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -182,6 +182,16 @@ func jsonMsgObj(c *gin.Context, msg string, obj any, err error) {
|
||||||
c.JSON(http.StatusOK, m)
|
c.JSON(http.StatusOK, m)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pendingNodeObj returns a response object flagging that the save committed
|
||||||
|
// locally but a backing node was offline/disabled, so the change will be
|
||||||
|
// mirrored to the node once it reconnects. Returns nil when nothing is pending.
|
||||||
|
func pendingNodeObj(pending bool) any {
|
||||||
|
if pending {
|
||||||
|
return gin.H{"nodePending": true}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// pureJsonMsg sends a pure JSON message response with custom status code.
|
// pureJsonMsg sends a pure JSON message response with custom status code.
|
||||||
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
func pureJsonMsg(c *gin.Context, statusCode int, success bool, msg string) {
|
||||||
c.JSON(statusCode, entity.Msg{
|
c.JSON(statusCode, entity.Msg{
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
const (
|
const (
|
||||||
nodeTrafficSyncConcurrency = 8
|
nodeTrafficSyncConcurrency = 8
|
||||||
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
nodeTrafficSyncRequestTimeout = 4 * time.Second
|
||||||
|
nodeReconcileTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
type NodeTrafficSyncJob struct {
|
type NodeTrafficSyncJob struct {
|
||||||
|
|
@ -151,21 +152,37 @@ func (j *NodeTrafficSyncJob) Run() {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
func (j *NodeTrafficSyncJob) syncOne(mgr *runtime.Manager, n *model.Node) {
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
rt, err := mgr.RemoteFor(n)
|
rt, err := mgr.RemoteFor(n)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
logger.Warning("node traffic sync: remote lookup failed for", n.Name, ":", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if n.ConfigDirty {
|
||||||
|
reconcileCtx, reconcileCancel := context.WithTimeout(context.Background(), nodeReconcileTimeout)
|
||||||
|
reconcileErr := j.inboundService.ReconcileNode(reconcileCtx, rt, n.Id)
|
||||||
|
reconcileCancel()
|
||||||
|
if reconcileErr != nil {
|
||||||
|
logger.Warning("node traffic sync: reconcile for", n.Name, "failed:", reconcileErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if clearErr := j.nodeService.ClearNodeDirty(n.Id, n.ConfigDirtyAt); clearErr != nil {
|
||||||
|
logger.Warning("node traffic sync: clear dirty for", n.Name, "failed:", clearErr)
|
||||||
|
}
|
||||||
|
j.structural.set()
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), nodeTrafficSyncRequestTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
snap, err := rt.FetchTrafficSnapshot(ctx)
|
snap, err := rt.FetchTrafficSnapshot(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: fetch from", n.Name, "failed:", err)
|
||||||
j.inboundService.ClearNodeOnlineClients(n.Id)
|
j.inboundService.ClearNodeOnlineClients(n.Id)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap)
|
_, _, dirty, _, _ := j.nodeService.NodeSyncState(n.Id)
|
||||||
|
changed, err := j.inboundService.SetRemoteTraffic(n.Id, snap, dirty)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
logger.Warning("node traffic sync: merge for", n.Name, "failed:", err)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -191,6 +191,19 @@ func (r *Remote) cacheDel(tag string) {
|
||||||
delete(r.remoteIDByTag, tag)
|
delete(r.remoteIDByTag, 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) refreshRemoteIDs(ctx context.Context) error {
|
func (r *Remote) refreshRemoteIDs(ctx context.Context) error {
|
||||||
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
env, err := r.do(ctx, http.MethodGet, "panel/api/inbounds/list", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -547,6 +547,17 @@ func validateClientSubID(subID string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ClientService) HasPendingNode(inboundSvc *InboundService, email string) bool {
|
||||||
|
if strings.TrimSpace(email) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
ids, err := s.GetInboundIdsForEmail(nil, email)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return inboundSvc.AnyNodePending(ids)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
func (s *ClientService) Create(inboundSvc *InboundService, payload *ClientCreatePayload) (bool, error) {
|
||||||
if payload == nil {
|
if payload == nil {
|
||||||
return false, common.NewError("empty payload")
|
return false, common.NewError("empty payload")
|
||||||
|
|
@ -1290,6 +1301,7 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
for _, r := range removed {
|
for _, r := range removed {
|
||||||
email := r.email
|
email := r.email
|
||||||
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
emailShared := sharedSet[strings.ToLower(strings.TrimSpace(email))]
|
||||||
|
|
@ -1324,12 +1336,18 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if oldInbound.NodeID != nil && len(email) > 0 {
|
if oldInbound.NodeID != nil && len(email) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
return needRestart, rterr
|
return needRestart, perr
|
||||||
}
|
}
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if dirty {
|
||||||
return needRestart, err1
|
markDirty = true
|
||||||
|
}
|
||||||
|
if push {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1344,6 +1362,11 @@ func (s *ClientService) delInboundClients(inboundSvc *InboundService, inboundId
|
||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2722,27 +2745,33 @@ func (s *ClientService) bulkAdjustInboundClients(
|
||||||
}
|
}
|
||||||
oldInbound.Settings = string(newSettings)
|
oldInbound.Settings = string(newSettings)
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
if oldInbound.NodeID != nil {
|
if oldInbound.NodeID != nil {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
for email := range foundEmails {
|
for email := range foundEmails {
|
||||||
res.perEmailSkipped[email] = rterr.Error()
|
res.perEmailSkipped[email] = perr.Error()
|
||||||
delete(foundEmails, email)
|
delete(foundEmails, email)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for email := range foundEmails {
|
if dirty {
|
||||||
entry := plan[email]
|
markDirty = true
|
||||||
updated := *entry.record.ToClient()
|
}
|
||||||
if entry.applyExpiry {
|
if push {
|
||||||
updated.ExpiryTime = entry.newExpiry
|
for email := range foundEmails {
|
||||||
}
|
entry := plan[email]
|
||||||
if entry.applyTotal {
|
updated := *entry.record.ToClient()
|
||||||
updated.TotalGB = entry.newTotal
|
if entry.applyExpiry {
|
||||||
}
|
updated.ExpiryTime = entry.newExpiry
|
||||||
updated.UpdatedAt = nowMs
|
}
|
||||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
|
if entry.applyTotal {
|
||||||
res.perEmailSkipped[email] = err1.Error()
|
updated.TotalGB = entry.newTotal
|
||||||
delete(foundEmails, email)
|
}
|
||||||
|
updated.UpdatedAt = nowMs
|
||||||
|
if err1 := rt.UpdateUser(context.Background(), oldInbound, email, updated); err1 != nil {
|
||||||
|
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2765,6 +2794,10 @@ func (s *ClientService) bulkAdjustInboundClients(
|
||||||
res.perEmailSkipped[email] = txErr.Error()
|
res.perEmailSkipped[email] = txErr.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
@ -3083,6 +3116,7 @@ func (s *ClientService) bulkDelInboundClients(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
if oldInbound.NodeID == nil {
|
if oldInbound.NodeID == nil {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
||||||
if rterr != nil {
|
if rterr != nil {
|
||||||
|
|
@ -3104,17 +3138,22 @@ func (s *ClientService) bulkDelInboundClients(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
for email := range foundEmails {
|
for email := range foundEmails {
|
||||||
res.perEmailSkipped[email] = rterr.Error()
|
res.perEmailSkipped[email] = perr.Error()
|
||||||
delete(foundEmails, email)
|
delete(foundEmails, email)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
for email := range foundEmails {
|
if dirty {
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
markDirty = true
|
||||||
res.perEmailSkipped[email] = err1.Error()
|
}
|
||||||
delete(foundEmails, email)
|
if push {
|
||||||
|
for email := range foundEmails {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3136,6 +3175,10 @@ func (s *ClientService) bulkDelInboundClients(
|
||||||
res.perEmailSkipped[email] = txErr.Error()
|
res.perEmailSkipped[email] = txErr.Error()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return res
|
return res
|
||||||
|
|
@ -3608,50 +3651,61 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return false, err
|
||||||
return false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
for _, client := range clients {
|
if oldInbound.NodeID == nil {
|
||||||
if len(client.Email) == 0 {
|
if !push {
|
||||||
needRestart = true
|
needRestart = true
|
||||||
continue
|
} else {
|
||||||
}
|
for _, client := range clients {
|
||||||
inboundSvc.AddClientStat(tx, data.Id, &client)
|
if len(client.Email) == 0 {
|
||||||
if !client.Enable {
|
needRestart = true
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
cipher := ""
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
||||||
if oldInbound.Protocol == "shadowsocks" {
|
if !client.Enable {
|
||||||
cipher = oldSettings["method"].(string)
|
continue
|
||||||
}
|
}
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
cipher := ""
|
||||||
"email": client.Email,
|
if oldInbound.Protocol == "shadowsocks" {
|
||||||
"id": client.ID,
|
cipher = oldSettings["method"].(string)
|
||||||
"auth": client.Auth,
|
}
|
||||||
"security": client.Security,
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
"flow": client.Flow,
|
"email": client.Email,
|
||||||
"password": client.Password,
|
"id": client.ID,
|
||||||
"cipher": cipher,
|
"auth": client.Auth,
|
||||||
})
|
"security": client.Security,
|
||||||
if err1 == nil {
|
"flow": client.Flow,
|
||||||
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
"password": client.Password,
|
||||||
} else {
|
"cipher": cipher,
|
||||||
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
})
|
||||||
needRestart = true
|
if err1 == nil {
|
||||||
|
logger.Debug("Client added on", rt.Name(), ":", client.Email)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -3659,9 +3713,12 @@ func (s *ClientService) addInboundClient(inboundSvc *InboundService, data *model
|
||||||
if len(client.Email) > 0 {
|
if len(client.Email) > 0 {
|
||||||
inboundSvc.AddClientStat(tx, data.Id, &client)
|
inboundSvc.AddClientStat(tx, data.Id, &client)
|
||||||
}
|
}
|
||||||
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
|
if push {
|
||||||
err = err1
|
if err1 := rt.AddClient(context.Background(), oldInbound, client); err1 != nil {
|
||||||
return false, err
|
logger.Warning("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
push = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3839,11 +3896,17 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -3903,50 +3966,55 @@ func (s *ClientService) UpdateInboundClient(inboundSvc *InboundService, data *mo
|
||||||
}
|
}
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if len(oldEmail) > 0 {
|
if len(oldEmail) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return false, err
|
||||||
return false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
if oldClients[clientIndex].Enable {
|
if oldInbound.NodeID == nil {
|
||||||
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
if !push {
|
||||||
if err1 == nil {
|
needRestart = true
|
||||||
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
} else {
|
||||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
if oldClients[clientIndex].Enable {
|
||||||
logger.Debug("User is already deleted. Nothing to do more...")
|
err1 := rt.RemoveUser(context.Background(), oldInbound, oldEmail)
|
||||||
} else {
|
if err1 == nil {
|
||||||
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
logger.Debug("Old client deleted on", rt.Name(), ":", oldEmail)
|
||||||
needRestart = true
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", oldEmail)) {
|
||||||
|
logger.Debug("User is already deleted. Nothing to do more...")
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clients[0].Enable {
|
||||||
|
cipher := ""
|
||||||
|
if oldInbound.Protocol == "shadowsocks" {
|
||||||
|
cipher = oldSettings["method"].(string)
|
||||||
|
}
|
||||||
|
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
||||||
|
"email": clients[0].Email,
|
||||||
|
"id": clients[0].ID,
|
||||||
|
"security": clients[0].Security,
|
||||||
|
"flow": clients[0].Flow,
|
||||||
|
"auth": clients[0].Auth,
|
||||||
|
"password": clients[0].Password,
|
||||||
|
"cipher": cipher,
|
||||||
|
})
|
||||||
|
if err1 == nil {
|
||||||
|
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
||||||
|
} else {
|
||||||
|
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if clients[0].Enable {
|
} else if push {
|
||||||
cipher := ""
|
|
||||||
if oldInbound.Protocol == "shadowsocks" {
|
|
||||||
cipher = oldSettings["method"].(string)
|
|
||||||
}
|
|
||||||
err1 := rt.AddUser(context.Background(), oldInbound, map[string]any{
|
|
||||||
"email": clients[0].Email,
|
|
||||||
"id": clients[0].ID,
|
|
||||||
"security": clients[0].Security,
|
|
||||||
"flow": clients[0].Flow,
|
|
||||||
"auth": clients[0].Auth,
|
|
||||||
"password": clients[0].Password,
|
|
||||||
"cipher": cipher,
|
|
||||||
})
|
|
||||||
if err1 == nil {
|
|
||||||
logger.Debug("Client edited on", rt.Name(), ":", clients[0].Email)
|
|
||||||
} else {
|
|
||||||
logger.Debug("Error in adding client on", rt.Name(), ":", err1)
|
|
||||||
needRestart = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
if err1 := rt.UpdateUser(context.Background(), oldInbound, oldEmail, clients[0]); err1 != nil {
|
||||||
err = err1
|
logger.Warning("Error in updating client on", rt.Name(), ":", err1)
|
||||||
return false, err
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -4038,6 +4106,7 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
|
|
||||||
if len(email) > 0 {
|
if len(email) > 0 {
|
||||||
var enables []bool
|
var enables []bool
|
||||||
|
|
@ -4073,12 +4142,18 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if oldInbound.NodeID != nil && len(email) > 0 {
|
if oldInbound.NodeID != nil && len(email) > 0 {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
return false, rterr
|
return false, perr
|
||||||
}
|
}
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if dirty {
|
||||||
return false, err1
|
markDirty = true
|
||||||
|
}
|
||||||
|
if push {
|
||||||
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := db.Save(oldInbound).Error; err != nil {
|
if err := db.Save(oldInbound).Error; err != nil {
|
||||||
|
|
@ -4091,6 +4166,11 @@ func (s *ClientService) DelInboundClient(inboundSvc *InboundService, inboundId i
|
||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4159,6 +4239,7 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
}
|
}
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
|
|
||||||
if len(email) > 0 && !emailShared {
|
if len(email) > 0 && !emailShared {
|
||||||
if !keepTraffic {
|
if !keepTraffic {
|
||||||
|
|
@ -4175,25 +4256,29 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
}
|
}
|
||||||
|
|
||||||
if needApiDel {
|
if needApiDel {
|
||||||
rt, rterr := inboundSvc.runtimeFor(oldInbound)
|
rt, push, dirty, perr := inboundSvc.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else if oldInbound.NodeID == nil {
|
}
|
||||||
if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
if oldInbound.NodeID == nil {
|
||||||
|
if !push {
|
||||||
|
needRestart = true
|
||||||
|
} else if err1 := rt.RemoveUser(context.Background(), oldInbound, email); err1 == nil {
|
||||||
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
logger.Debug("Client deleted on", rt.Name(), ":", email)
|
||||||
needRestart = false
|
needRestart = false
|
||||||
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
} else if strings.Contains(err1.Error(), fmt.Sprintf("User %s not found.", email)) {
|
||||||
logger.Debug("User is already deleted. Nothing to do more...")
|
logger.Debug("User is already deleted. Nothing to do more...")
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Error in deleting client on", rt.Name(), ":", err1)
|
logger.Debug("Error in deleting client on", rt.Name(), ":", email)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
} else {
|
} else if push {
|
||||||
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
if err1 := rt.DeleteUser(context.Background(), oldInbound, email); err1 != nil {
|
||||||
return false, err1
|
logger.Warning("Error in deleting client on", rt.Name(), ":", err1)
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -4209,6 +4294,11 @@ func (s *ClientService) DelInboundClientByEmail(inboundSvc *InboundService, inbo
|
||||||
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
if err := s.SyncInbound(db, inboundId, finalClients); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
return needRestart, nil
|
return needRestart, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -62,7 +62,7 @@ func TestSetRemoteTraffic_PreservesPanelLocalGroupAndComment(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := InboundService{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,92 @@ func (s *InboundService) runtimeFor(ib *model.Inbound) (runtime.Runtime, error)
|
||||||
return mgr.RuntimeFor(ib.NodeID)
|
return mgr.RuntimeFor(ib.NodeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) nodePushPlan(ib *model.Inbound) (runtime.Runtime, bool, bool, error) {
|
||||||
|
if ib.NodeID == nil {
|
||||||
|
rt, err := s.runtimeFor(ib)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, nil
|
||||||
|
}
|
||||||
|
return rt, true, false, nil
|
||||||
|
}
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
enabled, status, _, _, err := nodeSvc.NodeSyncState(*ib.NodeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, false, err
|
||||||
|
}
|
||||||
|
if !enabled || status == "offline" {
|
||||||
|
return nil, false, true, nil
|
||||||
|
}
|
||||||
|
rt, err := s.runtimeFor(ib)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, true, nil
|
||||||
|
}
|
||||||
|
return rt, true, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) NodeIsPending(nodeID *int) bool {
|
||||||
|
if nodeID == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (&NodeService{}).IsNodePending(*nodeID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) AnyNodePending(inboundIds []int) bool {
|
||||||
|
if len(inboundIds) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
for _, id := range inboundIds {
|
||||||
|
ib, err := s.GetInbound(id)
|
||||||
|
if err != nil || ib.NodeID == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if nodeSvc.IsNodePending(*ib.NodeID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote, nodeID int) error {
|
||||||
|
if rt == nil || nodeID <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
db := database.GetDB()
|
||||||
|
var inbounds []*model.Inbound
|
||||||
|
if err := db.Model(model.Inbound{}).Where("node_id = ?", nodeID).Find(&inbounds).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
remoteTags, err := rt.ListRemoteTags(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
prefix := nodeTagPrefix(&nodeID)
|
||||||
|
desiredTags := make(map[string]struct{}, len(inbounds)*2)
|
||||||
|
for _, ib := range inbounds {
|
||||||
|
desiredTags[ib.Tag] = struct{}{}
|
||||||
|
if prefix != "" {
|
||||||
|
if stripped, found := strings.CutPrefix(ib.Tag, prefix); found {
|
||||||
|
desiredTags[stripped] = struct{}{}
|
||||||
|
} else {
|
||||||
|
desiredTags[prefix+ib.Tag] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rt.UpdateInbound(ctx, ib, ib); err != nil {
|
||||||
|
return fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, tag := range remoteTags {
|
||||||
|
if _, want := desiredTags[tag]; want {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := rt.DelInbound(ctx, &model.Inbound{Tag: tag}); err != nil {
|
||||||
|
return fmt.Errorf("reconcile delete %q: %w", tag, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type CopyClientsResult struct {
|
type CopyClientsResult struct {
|
||||||
Added []string `json:"added"`
|
Added []string `json:"added"`
|
||||||
Skipped []string `json:"skipped"`
|
Skipped []string `json:"skipped"`
|
||||||
|
|
@ -575,11 +661,17 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||||
|
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err == nil {
|
if err != nil {
|
||||||
tx.Commit()
|
|
||||||
} else {
|
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && inbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -600,20 +692,25 @@ func (s *InboundService) AddInbound(inbound *model.Inbound) (*model.Inbound, boo
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
if inbound.Enable {
|
if inbound.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
err = rterr
|
err = perr
|
||||||
return inbound, false, err
|
return inbound, false, err
|
||||||
}
|
}
|
||||||
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
if dirty {
|
||||||
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
markDirty = true
|
||||||
} else {
|
}
|
||||||
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
if push {
|
||||||
if inbound.NodeID != nil {
|
if err1 := rt.AddInbound(context.Background(), inbound); err1 == nil {
|
||||||
err = err1
|
logger.Debug("New inbound added on", rt.Name(), ":", inbound.Tag)
|
||||||
return inbound, false, err
|
} else {
|
||||||
|
logger.Debug("Unable to add inbound on", rt.Name(), ":", err1)
|
||||||
|
if inbound.NodeID != nil {
|
||||||
|
markDirty = true
|
||||||
|
} else {
|
||||||
|
needRestart = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
needRestart = true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -624,24 +721,31 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
|
markDirty := false
|
||||||
var ib model.Inbound
|
var ib model.Inbound
|
||||||
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
loadErr := db.Model(model.Inbound{}).Where("id = ?", id).First(&ib).Error
|
||||||
if loadErr == nil {
|
if loadErr == nil {
|
||||||
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
shouldPushToRuntime := ib.NodeID != nil || ib.Enable
|
||||||
if shouldPushToRuntime {
|
if shouldPushToRuntime {
|
||||||
rt, rterr := s.runtimeFor(&ib)
|
rt, push, dirty, perr := s.nodePushPlan(&ib)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
logger.Warning("DelInbound: runtime lookup failed, deleting central row anyway:", rterr)
|
logger.Warning("DelInbound: node lookup failed, deleting central row anyway:", perr)
|
||||||
if ib.NodeID == nil {
|
markDirty = true
|
||||||
needRestart = true
|
} else if push {
|
||||||
}
|
if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
||||||
} else if err1 := rt.DelInbound(context.Background(), &ib); err1 == nil {
|
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
||||||
logger.Debug("Inbound deleted on", rt.Name(), ":", ib.Tag)
|
} else {
|
||||||
} else {
|
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
||||||
logger.Warning("DelInbound on", rt.Name(), "failed, deleting central row anyway:", err1)
|
if ib.NodeID == nil {
|
||||||
if ib.NodeID == nil {
|
needRestart = true
|
||||||
needRestart = true
|
} else {
|
||||||
|
markDirty = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} else if ib.NodeID == nil {
|
||||||
|
needRestart = true
|
||||||
|
} else if dirty {
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
logger.Debug("DelInbound: skipping runtime push for disabled local inbound id:", id)
|
||||||
|
|
@ -657,6 +761,11 @@ func (s *InboundService) DelInbound(id int) (bool, error) {
|
||||||
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
if err := db.Delete(model.Inbound{}, id).Error; err != nil {
|
||||||
return needRestart, err
|
return needRestart, err
|
||||||
}
|
}
|
||||||
|
if markDirty && ib.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*ib.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
if !database.IsPostgres() {
|
if !database.IsPostgres() {
|
||||||
var count int64
|
var count int64
|
||||||
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
if err := db.Model(&model.Inbound{}).Count(&count).Error; err != nil {
|
||||||
|
|
@ -740,12 +849,9 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||||
inbound.Enable = enable
|
inbound.Enable = enable
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if inbound.NodeID != nil {
|
return false, perr
|
||||||
return false, rterr
|
|
||||||
}
|
|
||||||
return true, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remote nodes interpret DelInbound as a real row delete (it hits
|
// Remote nodes interpret DelInbound as a real row delete (it hits
|
||||||
|
|
@ -754,13 +860,24 @@ func (s *InboundService) SetInboundEnable(id int, enable bool) (bool, error) {
|
||||||
// PATCH the remote row via UpdateInbound instead — preserves the
|
// PATCH the remote row via UpdateInbound instead — preserves the
|
||||||
// settings/client history and just flips the enable flag.
|
// settings/client history and just flips the enable flag.
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
if push {
|
||||||
logger.Debug("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
if err := rt.UpdateInbound(context.Background(), inbound, inbound); err != nil {
|
||||||
return false, err
|
logger.Warning("SetInboundEnable: remote UpdateInbound on", rt.Name(), "failed:", err)
|
||||||
|
dirty = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !push {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
if err := rt.DelInbound(context.Background(), inbound); err != nil &&
|
||||||
!strings.Contains(err.Error(), "not found") {
|
!strings.Contains(err.Error(), "not found") {
|
||||||
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
logger.Debug("SetInboundEnable: DelInbound on", rt.Name(), "failed:", err)
|
||||||
|
|
@ -807,11 +924,17 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||||
db := database.GetDB()
|
db := database.GetDB()
|
||||||
tx := db.Begin()
|
tx := db.Begin()
|
||||||
|
|
||||||
|
markDirty := false
|
||||||
defer func() {
|
defer func() {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
} else {
|
return
|
||||||
tx.Commit()
|
}
|
||||||
|
tx.Commit()
|
||||||
|
if markDirty && oldInbound.NodeID != nil {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*oldInbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
|
@ -900,17 +1023,20 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||||
inbound.Tag = oldInbound.Tag
|
inbound.Tag = oldInbound.Tag
|
||||||
|
|
||||||
needRestart := false
|
needRestart := false
|
||||||
rt, rterr := s.runtimeFor(oldInbound)
|
rt, push, dirty, perr := s.nodePushPlan(oldInbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
if oldInbound.NodeID != nil {
|
err = perr
|
||||||
err = rterr
|
return inbound, false, err
|
||||||
return inbound, false, err
|
}
|
||||||
}
|
if dirty {
|
||||||
needRestart = true
|
markDirty = true
|
||||||
} else {
|
}
|
||||||
oldSnapshot := *oldInbound
|
if oldInbound.NodeID == nil {
|
||||||
oldSnapshot.Tag = tag
|
if !push {
|
||||||
if oldInbound.NodeID == nil {
|
needRestart = true
|
||||||
|
} else {
|
||||||
|
oldSnapshot := *oldInbound
|
||||||
|
oldSnapshot.Tag = tag
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 == nil {
|
||||||
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
logger.Debug("Old inbound deleted on", rt.Name(), ":", tag)
|
||||||
}
|
}
|
||||||
|
|
@ -926,16 +1052,18 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound,
|
||||||
needRestart = true
|
needRestart = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if !inbound.Enable {
|
} else if push {
|
||||||
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
oldSnapshot := *oldInbound
|
||||||
err = err2
|
oldSnapshot.Tag = tag
|
||||||
return inbound, false, err
|
if !inbound.Enable {
|
||||||
}
|
if err2 := rt.DelInbound(context.Background(), &oldSnapshot); err2 != nil {
|
||||||
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
logger.Warning("Unable to disable inbound on", rt.Name(), ":", err2)
|
||||||
err = err2
|
markDirty = true
|
||||||
return inbound, false, err
|
|
||||||
}
|
}
|
||||||
|
} else if err2 := rt.UpdateInbound(context.Background(), &oldSnapshot, oldInbound); err2 != nil {
|
||||||
|
logger.Warning("Unable to update inbound on", rt.Name(), ":", err2)
|
||||||
|
markDirty = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1303,17 +1431,17 @@ func (s *InboundService) upsertNodeBaseline(tx *gorm.DB, nodeID int, email strin
|
||||||
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
}).Create(&model.NodeClientTraffic{NodeId: nodeID, Email: email, Up: up, Down: down}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
func (s *InboundService) SetRemoteTraffic(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||||
var structuralChange bool
|
var structuralChange bool
|
||||||
err := submitTrafficWrite(func() error {
|
err := submitTrafficWrite(func() error {
|
||||||
var inner error
|
var inner error
|
||||||
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap)
|
structuralChange, inner = s.setRemoteTrafficLocked(nodeID, snap, dirty)
|
||||||
return inner
|
return inner
|
||||||
})
|
})
|
||||||
return structuralChange, err
|
return structuralChange, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot) (bool, error) {
|
func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.TrafficSnapshot, dirty bool) (bool, error) {
|
||||||
if snap == nil || nodeID <= 0 {
|
if snap == nil || nodeID <= 0 {
|
||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
@ -1425,6 +1553,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
|
|
||||||
c, ok := tagToCentral[snapIb.Tag]
|
c, ok := tagToCentral[snapIb.Tag]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
// Try snap.Tag first; on collision fall back to the n<id>-
|
// Try snap.Tag first; on collision fall back to the n<id>-
|
||||||
// prefixed form so local+node can both own the same port.
|
// prefixed form so local+node can both own the same port.
|
||||||
pickFreeTag := func() (string, error) {
|
pickFreeTag := func() (string, error) {
|
||||||
|
|
@ -1491,42 +1622,48 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
|
|
||||||
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
inGrace := c.LastTrafficResetTime > 0 && now-c.LastTrafficResetTime < resetGracePeriodMs
|
||||||
|
|
||||||
updates := map[string]any{
|
updates := map[string]any{}
|
||||||
"enable": snapIb.Enable,
|
if !dirty {
|
||||||
"remark": snapIb.Remark,
|
updates["enable"] = snapIb.Enable
|
||||||
"listen": snapIb.Listen,
|
updates["remark"] = snapIb.Remark
|
||||||
"port": snapIb.Port,
|
updates["listen"] = snapIb.Listen
|
||||||
"protocol": snapIb.Protocol,
|
updates["port"] = snapIb.Port
|
||||||
"total": snapIb.Total,
|
updates["protocol"] = snapIb.Protocol
|
||||||
"expiry_time": snapIb.ExpiryTime,
|
updates["total"] = snapIb.Total
|
||||||
"settings": snapIb.Settings,
|
updates["expiry_time"] = snapIb.ExpiryTime
|
||||||
"stream_settings": snapIb.StreamSettings,
|
updates["settings"] = snapIb.Settings
|
||||||
"sniffing": snapIb.Sniffing,
|
updates["stream_settings"] = snapIb.StreamSettings
|
||||||
"traffic_reset": snapIb.TrafficReset,
|
updates["sniffing"] = snapIb.Sniffing
|
||||||
|
updates["traffic_reset"] = snapIb.TrafficReset
|
||||||
}
|
}
|
||||||
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
if !inGrace || (snapIb.Up+snapIb.Down) <= (c.Up+c.Down) {
|
||||||
updates["up"] = snapIb.Up
|
updates["up"] = snapIb.Up
|
||||||
updates["down"] = snapIb.Down
|
updates["down"] = snapIb.Down
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.Settings != snapIb.Settings ||
|
if !dirty && (c.Settings != snapIb.Settings ||
|
||||||
c.Remark != snapIb.Remark ||
|
c.Remark != snapIb.Remark ||
|
||||||
c.Listen != snapIb.Listen ||
|
c.Listen != snapIb.Listen ||
|
||||||
c.Port != snapIb.Port ||
|
c.Port != snapIb.Port ||
|
||||||
c.Total != snapIb.Total ||
|
c.Total != snapIb.Total ||
|
||||||
c.ExpiryTime != snapIb.ExpiryTime ||
|
c.ExpiryTime != snapIb.ExpiryTime ||
|
||||||
c.Enable != snapIb.Enable {
|
c.Enable != snapIb.Enable) {
|
||||||
structuralChange = true
|
structuralChange = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tx.Model(model.Inbound{}).
|
if len(updates) > 0 {
|
||||||
Where("id = ?", c.Id).
|
if err := tx.Model(model.Inbound{}).
|
||||||
Updates(updates).Error; err != nil {
|
Where("id = ?", c.Id).
|
||||||
return false, err
|
Updates(updates).Error; err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, c := range central {
|
for _, c := range central {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if _, kept := snapTags[c.Tag]; kept {
|
if _, kept := snapTags[c.Tag]; kept {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1581,6 +1718,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
if _, rowExists := existingEmails[cs.Email]; !rowExists {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
row := &xray.ClientTraffic{
|
row := &xray.ClientTraffic{
|
||||||
InboundId: c.Id,
|
InboundId: c.Id,
|
||||||
Email: cs.Email,
|
Email: cs.Email,
|
||||||
|
|
@ -1642,6 +1782,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, existing := range centralCS {
|
for k, existing := range centralCS {
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if k.inboundID != c.Id {
|
if k.inboundID != c.Id {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
@ -1673,6 +1816,9 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if dirty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
var oldEmailsRows []string
|
var oldEmailsRows []string
|
||||||
if err := tx.Table("clients").
|
if err := tx.Table("clients").
|
||||||
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
Joins("JOIN client_inbounds ON client_inbounds.client_id = clients.id").
|
||||||
|
|
@ -2674,12 +2820,20 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||||
}
|
}
|
||||||
for _, client := range clients {
|
for _, client := range clients {
|
||||||
if client.Email == clientEmail && client.Enable {
|
if client.Email == clientEmail && client.Enable {
|
||||||
rt, rterr := s.runtimeFor(inbound)
|
rt, push, dirty, perr := s.nodePushPlan(inbound)
|
||||||
if rterr != nil {
|
if perr != nil {
|
||||||
|
return false, perr
|
||||||
|
}
|
||||||
|
if !push {
|
||||||
if inbound.NodeID != nil {
|
if inbound.NodeID != nil {
|
||||||
return false, rterr
|
if dirty {
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
needRestart = true
|
||||||
}
|
}
|
||||||
needRestart = true
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
cipher := ""
|
cipher := ""
|
||||||
|
|
@ -2702,6 +2856,11 @@ func (s *InboundService) resetClientTrafficLocked(id int, clientEmail string) (b
|
||||||
})
|
})
|
||||||
if err1 == nil {
|
if err1 == nil {
|
||||||
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
logger.Debug("Client enabled on", rt.Name(), "due to reset traffic:", clientEmail)
|
||||||
|
} else if inbound.NodeID != nil {
|
||||||
|
logger.Warning("Error in enabling client on", rt.Name(), ":", err1)
|
||||||
|
if dErr := (&NodeService{}).MarkNodeDirty(*inbound.NodeID); dErr != nil {
|
||||||
|
logger.Warning("mark node dirty failed:", dErr)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
logger.Debug("Error in enabling client on", rt.Name(), ":", err1)
|
||||||
needRestart = true
|
needRestart = true
|
||||||
|
|
|
||||||
|
|
@ -480,6 +480,50 @@ func (s *NodeService) UpdateHeartbeat(id int, p HeartbeatPatch) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) MarkNodeDirty(id int) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return database.GetDB().Model(model.Node{}).
|
||||||
|
Where("id = ?", id).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"config_dirty": true,
|
||||||
|
"config_dirty_at": time.Now().UnixMilli(),
|
||||||
|
}).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) ClearNodeDirty(id int, dirtyAt int64) error {
|
||||||
|
if id <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return database.GetDB().Model(model.Node{}).
|
||||||
|
Where("id = ? AND config_dirty_at = ?", id, dirtyAt).
|
||||||
|
Update("config_dirty", false).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")
|
||||||
|
}
|
||||||
|
var row model.Node
|
||||||
|
err = database.GetDB().Model(model.Node{}).
|
||||||
|
Select("enable", "status", "config_dirty", "config_dirty_at").
|
||||||
|
Where("id = ?", id).
|
||||||
|
First(&row).Error
|
||||||
|
if err != nil {
|
||||||
|
return false, "", false, 0, err
|
||||||
|
}
|
||||||
|
return row.Enable, row.Status, row.ConfigDirty, row.ConfigDirtyAt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *NodeService) IsNodePending(id int) bool {
|
||||||
|
enabled, status, dirty, _, err := s.NodeSyncState(id)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return !enabled || status != "online" || dirty
|
||||||
|
}
|
||||||
|
|
||||||
func nodeMetricKey(id int, metric string) string {
|
func nodeMetricKey(id int, metric string) string {
|
||||||
return "node:" + strconv.Itoa(id) + ":" + metric
|
return "node:" + strconv.Itoa(id) + ":" + metric
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ func syncNode(t *testing.T, svc *InboundService, nodeID int, tag string, stats .
|
||||||
snap := &runtime.TrafficSnapshot{
|
snap := &runtime.TrafficSnapshot{
|
||||||
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
Inbounds: []*model.Inbound{{Tag: tag, ClientStats: stats}},
|
||||||
}
|
}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
t.Fatalf("setRemoteTrafficLocked node %d: %v", nodeID, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
104
web/service/node_dirty_test.go
Normal file
104
web/service/node_dirty_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/database/model"
|
||||||
|
"github.com/mhsanaei/3x-ui/v3/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); 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearNodeDirty must be a compare-and-swap on config_dirty_at so a concurrent
|
||||||
|
// edit that re-dirties the node during a reconcile is not silently cleared.
|
||||||
|
func TestNodeDirty_ClearIsCASOnDirtyAt(t *testing.T) {
|
||||||
|
setupConflictDB(t)
|
||||||
|
db := database.GetDB()
|
||||||
|
|
||||||
|
node := &model.Node{Name: "n2", 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodeSvc := NodeService{}
|
||||||
|
if err := nodeSvc.MarkNodeDirty(node.Id); err != nil {
|
||||||
|
t.Fatalf("MarkNodeDirty: %v", err)
|
||||||
|
}
|
||||||
|
_, _, dirty, dirtyAt, err := nodeSvc.NodeSyncState(node.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NodeSyncState: %v", err)
|
||||||
|
}
|
||||||
|
if !dirty {
|
||||||
|
t.Fatal("node should be dirty after MarkNodeDirty")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt-1); err != nil {
|
||||||
|
t.Fatalf("ClearNodeDirty stale token: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); !stillDirty {
|
||||||
|
t.Fatal("stale-token clear must not clear the dirty flag")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := nodeSvc.ClearNodeDirty(node.Id, dirtyAt); err != nil {
|
||||||
|
t.Fatalf("ClearNodeDirty matching token: %v", err)
|
||||||
|
}
|
||||||
|
if _, _, stillDirty, _, _ := nodeSvc.NodeSyncState(node.Id); stillDirty {
|
||||||
|
t.Fatal("matching-token clear must clear the dirty flag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -46,7 +46,7 @@ func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
svc := InboundService{}
|
svc := InboundService{}
|
||||||
if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil {
|
if _, err := svc.setRemoteTrafficLocked(nodeID, snap, false); err != nil {
|
||||||
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
t.Fatalf("setRemoteTrafficLocked: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -470,6 +470,7 @@
|
||||||
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
"inboundClientAddSuccess": "Inbound client(s) have been added.",
|
||||||
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
"inboundClientDeleteSuccess": "Inbound client has been deleted.",
|
||||||
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
"inboundClientUpdateSuccess": "Inbound client has been updated.",
|
||||||
|
"savedNodeOfflineWillSync": "Saved locally. A backing node is offline or disabled — the change will sync once it reconnects.",
|
||||||
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
"delDepletedClientsSuccess": "All depleted clients have been deleted.",
|
||||||
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
"resetAllClientTrafficSuccess": "Traffic for all clients has been reset.",
|
||||||
"resetAllTrafficSuccess": "All traffic has been reset.",
|
"resetAllTrafficSuccess": "All traffic has been reset.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue