feat: broadcast Geofile updates to all worker nodes via shared DB to v1.8.0.9

This commit is contained in:
root 2026-04-27 23:44:28 +08:00
parent fdd00758fb
commit bb86dee8f6
8 changed files with 151 additions and 3 deletions

View file

@ -1 +1 @@
v1.8.0.8 v1.8.0.9

View file

@ -9,6 +9,8 @@ import (
const SharedAccountsVersionKey = "shared_accounts_version" const SharedAccountsVersionKey = "shared_accounts_version"
const SharedGeoVersionKey = "shared_geo_version"
func txOrDB(tx *gorm.DB) *gorm.DB { func txOrDB(tx *gorm.DB) *gorm.DB {
if tx != nil { if tx != nil {
return tx return tx
@ -47,6 +49,40 @@ func BumpSharedAccountsVersion(tx *gorm.DB) error {
}).Error }).Error
} }
func seedSharedGeoVersion(tx *gorm.DB) error {
state := &model.SharedState{
Key: SharedGeoVersionKey,
}
return txOrDB(tx).
Attrs(&model.SharedState{
Version: 0,
UpdatedAt: time.Now().Unix(),
}).
FirstOrCreate(state).Error
}
func GetSharedGeoVersion(tx *gorm.DB) (int64, error) {
state := &model.SharedState{
Key: SharedGeoVersionKey,
}
if err := txOrDB(tx).First(state).Error; err != nil {
return 0, err
}
return state.Version, nil
}
func BumpSharedGeoVersion(tx *gorm.DB) error {
if err := seedSharedGeoVersion(tx); err != nil {
return err
}
return txOrDB(tx).Model(&model.SharedState{}).
Where(&model.SharedState{Key: SharedGeoVersionKey}).
Updates(map[string]any{
"version": gorm.Expr("version + 1"),
"updated_at": time.Now().Unix(),
}).Error
}
func UpsertNodeState(tx *gorm.DB, state *model.NodeState) error { func UpsertNodeState(tx *gorm.DB, state *model.NodeState) error {
state.UpdatedAt = time.Now().Unix() state.UpdatedAt = time.Now().Unix()
return txOrDB(tx).Save(state).Error return txOrDB(tx).Save(state).Error

View file

@ -0,0 +1,31 @@
# Task Record
Date: 2026-04-27
Related Module: database/shared_state, web/controller/server, web/service/node_sync, web/html/index
Change Type: Add
## Background
The user needed the ability to trigger "Update Geofiles" on all worker nodes from the master node's UI. Previously, Geofile updates only operated locally on the node where the button was clicked. Workers only synced inbound configurations via `SharedAccountsVersion`; there was no mechanism for the master to broadcast arbitrary actions to workers.
## Changes
- `database/shared_state.go`: Added `SharedGeoVersionKey` constant and `GetSharedGeoVersion`/`BumpSharedGeoVersion`/`seedSharedGeoVersion` functions, following the same pattern as `SharedAccountsVersion`.
- `web/controller/server.go`: Added `POST /syncUpdateGeofile` route and `syncUpdateGeofile` handler that updates local Geofiles, then bumps `SharedGeoVersion` in the shared database if shared mode is enabled.
- `web/service/node_sync.go`: Extended `NodeSyncService` with `lastGeoVersion`, `loadGeoVersion`, `updateGeofiles` fields and a `syncGeoIfNeeded()` method. Workers check for geo version changes on each sync tick and trigger `UpdateGeofile("")` when a new version is detected.
- `web/html/index.html`: Added "Sync Update" button next to "Update all" in the Geofiles panel, plus the `syncUpdateGeofile()` JavaScript method.
- Translation files: Added i18n keys `geofileSyncUpdate`, `geofileSyncUpdateDialog`, `geofileSyncUpdateDialogDesc` in both zh_CN and en_US.
## Impact
- New API endpoint: `POST /panel/api/server/syncUpdateGeofile` (triggers local + broadcast Geofile update)
- Worker sync loop now includes geo version checking on every tick
- UI: New "同步更新" button in the Geofiles section of the Xray version modal
- Database: New `shared_geo_version` key in the `shared_state` table (on first use)
- No breaking changes to existing API or database schema
## Verification
- `gofmt -l -w .` passed with no changes
- `go vet ./...` passed with no errors
## Risks And Follow-Up
- Geofile sync uses the same polling interval as inbound sync (`SyncIntervalSeconds`); there may be a delay before workers pick up the new version
- If the shared database is unreachable when bumping the geo version, workers will not be notified but the local update will have already succeeded (handled via warning log)
- Future: could add a notification to the master's UI showing which workers have/haven't applied the geo update

View file

@ -7,6 +7,8 @@ import (
"strconv" "strconv"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/database"
"github.com/mhsanaei/3x-ui/v2/logger"
"github.com/mhsanaei/3x-ui/v2/web/global" "github.com/mhsanaei/3x-ui/v2/web/global"
"github.com/mhsanaei/3x-ui/v2/web/service" "github.com/mhsanaei/3x-ui/v2/web/service"
"github.com/mhsanaei/3x-ui/v2/web/websocket" "github.com/mhsanaei/3x-ui/v2/web/websocket"
@ -56,6 +58,7 @@ func (a *ServerController) initRouter(g *gin.RouterGroup) {
g.POST("/installXray/:version", a.installXray) g.POST("/installXray/:version", a.installXray)
g.POST("/updateGeofile", a.updateGeofile) g.POST("/updateGeofile", a.updateGeofile)
g.POST("/updateGeofile/:fileName", a.updateGeofile) g.POST("/updateGeofile/:fileName", a.updateGeofile)
g.POST("/syncUpdateGeofile", a.syncUpdateGeofile)
g.POST("/logs/:count", a.getLogs) g.POST("/logs/:count", a.getLogs)
g.POST("/xraylogs/:count", a.getXrayLogs) g.POST("/xraylogs/:count", a.getXrayLogs)
g.POST("/importDB", a.importDB) g.POST("/importDB", a.importDB)
@ -157,6 +160,23 @@ func (a *ServerController) updateGeofile(c *gin.Context) {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err) jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
} }
// syncUpdateGeofile updates local Geofiles and notifies worker nodes to do the same.
func (a *ServerController) syncUpdateGeofile(c *gin.Context) {
err := a.serverService.UpdateGeofile("")
if err != nil {
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), err)
return
}
if service.IsSharedModeEnabled() {
if bumpErr := database.BumpSharedGeoVersion(database.GetDB()); bumpErr != nil {
logger.Warning("syncUpdateGeofile: local update succeeded but failed to notify workers:", bumpErr)
}
}
jsonMsg(c, I18nWeb(c, "pages.index.geofileUpdatePopover"), nil)
}
// stopXrayService stops the Xray service. // stopXrayService stops the Xray service.
func (a *ServerController) stopXrayService(c *gin.Context) { func (a *ServerController) stopXrayService(c *gin.Context) {
err := a.serverService.StopXrayService() err := a.serverService.StopXrayService()

View file

@ -335,8 +335,10 @@
<a-icon type="reload" @click="updateGeofile(file)" class="mr-8" /> <a-icon type="reload" @click="updateGeofile(file)" class="mr-8" />
</a-list-item> </a-list-item>
</a-list> </a-list>
<div class="mt-5 d-flex justify-end"><a-button @click="updateGeofile('')">{{ i18n <div class="mt-5 d-flex justify-end">
"pages.index.geofilesUpdateAll" }}</a-button></div> <a-button @click="syncUpdateGeofile" style="margin-right: 8px;">{{ i18n "pages.index.geofileSyncUpdate" }}</a-button>
<a-button @click="updateGeofile('')">{{ i18n "pages.index.geofilesUpdateAll" }}</a-button>
</div>
</a-collapse-panel> </a-collapse-panel>
</a-collapse> </a-collapse>
</a-modal> </a-modal>
@ -1009,6 +1011,21 @@
}, },
}); });
}, },
syncUpdateGeofile() {
this.$confirm({
title: '{{ i18n "pages.index.geofileSyncUpdateDialog" }}',
content: '{{ i18n "pages.index.geofileSyncUpdateDialogDesc" }}',
okText: '{{ i18n "confirm"}}',
class: themeSwitcher.currentTheme,
cancelText: '{{ i18n "cancel"}}',
onOk: async () => {
versionModal.hide();
this.loading(true, '{{ i18n "pages.index.dontRefresh"}}');
await HttpUtil.post('/panel/api/server/syncUpdateGeofile');
this.loading(false);
},
});
},
async stopXrayService() { async stopXrayService() {
this.loading(true); this.loading(true);
const msg = await HttpUtil.post('/panel/api/server/stopXrayService'); const msg = await HttpUtil.post('/panel/api/server/stopXrayService');

View file

@ -5,6 +5,7 @@ import (
"errors" "errors"
"log" "log"
"os" "os"
"sync"
"time" "time"
"github.com/mhsanaei/3x-ui/v2/config" "github.com/mhsanaei/3x-ui/v2/config"
@ -19,6 +20,11 @@ type NodeSyncService struct {
loadVersion func() (int64, error) loadVersion func() (int64, error)
loadSnapshot func() (*SharedAccountsSnapshot, error) loadSnapshot func() (*SharedAccountsSnapshot, error)
applySnapshot func(*SharedAccountsSnapshot) error applySnapshot func(*SharedAccountsSnapshot) error
geoMu sync.Mutex
lastGeoVersion int64
loadGeoVersion func() (int64, error)
updateGeofiles func() error
} }
func NewNodeSyncService() *NodeSyncService { func NewNodeSyncService() *NodeSyncService {
@ -36,6 +42,13 @@ func NewNodeSyncService() *NodeSyncService {
return &SharedAccountsSnapshot{Inbounds: inbounds}, nil return &SharedAccountsSnapshot{Inbounds: inbounds}, nil
} }
svc.applySnapshot = svc.xrayService.ApplySharedSnapshot svc.applySnapshot = svc.xrayService.ApplySharedSnapshot
svc.loadGeoVersion = func() (int64, error) {
return database.GetSharedGeoVersion(database.GetDB())
}
svc.updateGeofiles = func() error {
s := &ServerService{}
return s.UpdateGeofile("")
}
return svc return svc
} }
@ -149,6 +162,30 @@ func (s *NodeSyncService) SyncOnce() (bool, error) {
return true, nil return true, nil
} }
func (s *NodeSyncService) syncGeoIfNeeded() {
current, err := s.loadGeoVersion()
if err != nil {
return
}
s.geoMu.Lock()
if current <= s.lastGeoVersion {
s.geoMu.Unlock()
return
}
s.geoMu.Unlock()
log.Printf("[NodeSync] geo version changed from %d to %d, updating geofiles...", s.lastGeoVersion, current)
if err := s.updateGeofiles(); err != nil {
log.Printf("[NodeSync] failed to update geofiles: %v", err)
return
}
s.geoMu.Lock()
s.lastGeoVersion = current
s.geoMu.Unlock()
}
func (s *NodeSyncService) Run(ctx context.Context, interval time.Duration) { func (s *NodeSyncService) Run(ctx context.Context, interval time.Duration) {
_ = s.BootstrapFromCache() _ = s.BootstrapFromCache()
_, _ = s.SyncOnce() _, _ = s.SyncOnce()
@ -162,6 +199,7 @@ func (s *NodeSyncService) Run(ctx context.Context, interval time.Duration) {
return return
case <-ticker.C: case <-ticker.C:
_, _ = s.SyncOnce() _, _ = s.SyncOnce()
s.syncGeoIfNeeded()
} }
} }
} }

View file

@ -162,6 +162,9 @@
"geofileUpdateDialogDesc" = "This will update the #filename# file." "geofileUpdateDialogDesc" = "This will update the #filename# file."
"geofilesUpdateDialogDesc" = "This will update all geofiles." "geofilesUpdateDialogDesc" = "This will update all geofiles."
"geofilesUpdateAll" = "Update all" "geofilesUpdateAll" = "Update all"
"geofileSyncUpdate" = "Sync Update"
"geofileSyncUpdateDialog" = "Sync update Geofiles on all nodes?"
"geofileSyncUpdateDialogDesc" = "This will update Geofiles on the master and all worker nodes. Xray will restart on each node to apply the new rule files."
"geofileUpdatePopover" = "Geofile updated successfully" "geofileUpdatePopover" = "Geofile updated successfully"
"dontRefresh" = "Installation is in progress, please do not refresh this page" "dontRefresh" = "Installation is in progress, please do not refresh this page"
"logs" = "Logs" "logs" = "Logs"

View file

@ -162,6 +162,9 @@
"geofileUpdateDialogDesc" = "这将更新 #filename# 文件。" "geofileUpdateDialogDesc" = "这将更新 #filename# 文件。"
"geofilesUpdateDialogDesc" = "这将更新所有文件。" "geofilesUpdateDialogDesc" = "这将更新所有文件。"
"geofilesUpdateAll" = "全部更新" "geofilesUpdateAll" = "全部更新"
"geofileSyncUpdate" = "同步更新"
"geofileSyncUpdateDialog" = "是否在所有节点同步更新 Geofiles"
"geofileSyncUpdateDialogDesc" = "这将更新主节点和所有子节点的 Geofiles更新完成后各节点将重启 Xray 以应用新的规则文件。"
"geofileUpdatePopover" = "地理文件更新成功" "geofileUpdatePopover" = "地理文件更新成功"
"dontRefresh" = "安装中,请勿刷新此页面" "dontRefresh" = "安装中,请勿刷新此页面"
"logs" = "日志" "logs" = "日志"