- Replace plain textarea with CodeMirror editor (YAML syntax highlighting, line numbers, auto-indent) for Clash subscription template - Fix confAlerts crash when subClashURI/subURI/subJsonURI is null/undefined (prevented save button from enabling) - Add yaml.js CodeMirror mode asset - Include docs and .gitignore cleanup
19 KiB
Trojan-Go Style MariaDB Sync Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a minimal multi-VPS master / worker sync model on top of the existing MariaDB support, with install-time role selection, runtime role switching, MariaDB as the default for fresh installs, and SQLite compatibility preserved.
Architecture: Keep MariaDB as the shared source of truth and keep local Xray config generation unchanged. Add node-role and sync settings into the JSON config, expose them through CLI and shell scripts, enforce write restrictions in backend services, and add a polling plus traffic-delta sync loop that workers use against MariaDB-backed shared state.
Tech Stack: Go, GORM, Gin, shell scripts (install.sh, x-ui.sh), MariaDB, SQLite, Go tests, bash -n
File Map
Modify
config/config.goconfig/config_test.goweb/service/setting.goweb/service/setting_test.goweb/entity/entity.gomain.gox-ui.shinstall.shdatabase/model/model.godatabase/db.goweb/service/inbound.goweb/service/server.goweb/service/xray.go
Create
web/service/node_sync.goweb/service/node_sync_test.go
Reference
docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md
Task 1: Add node-role settings and MariaDB-first defaults
Files:
-
Modify:
web/service/setting.go -
Modify:
config/config.go -
Modify:
config/config_test.go -
Modify:
web/service/setting_test.go -
Step 1: Write the failing config tests
func TestGetNodeConfigFromJSONSupportsModulePurposeLayout(t *testing.T) {
tmpDir := t.TempDir()
t.Setenv("XUI_DB_FOLDER", tmpDir)
settings := map[string]any{
"_meta": map[string]any{"layout": "按模块-用途来归类"},
"databaseConnection": map[string]any{
"dbType": "mariadb",
},
"systemIntegration": map[string]any{
"nodeRole": "worker",
"nodeId": "vps-01",
"syncInterval": "30",
"trafficFlushInterval": "60",
},
}
data, _ := json.MarshalIndent(settings, "", " ")
if err := os.WriteFile(GetSettingPath(), data, 0644); err != nil {
t.Fatalf("WriteFile error: %v", err)
}
cfg := GetNodeConfigFromJSON()
if cfg.Role != "worker" || cfg.ID != "vps-01" || cfg.SyncInterval != "30" || cfg.TrafficFlushInterval != "60" {
t.Fatalf("unexpected node config: %+v", cfg)
}
}
func TestLoadSettingsUsesMariaDBAsFreshInstallDefault(t *testing.T) {
setupTestSettings(t)
settings, err := loadSettings()
if err != nil {
t.Fatalf("loadSettings() error: %v", err)
}
if settings["dbType"] != "mariadb" {
t.Fatalf("expected default dbType=mariadb, got %s", settings["dbType"])
}
if settings["nodeRole"] != "master" {
t.Fatalf("expected default nodeRole=master, got %s", settings["nodeRole"])
}
}
- Step 2: Run tests to verify they fail
Run: go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v
Expected: FAIL because GetNodeConfigFromJSON, nodeRole, and the new defaults do not exist yet.
- Step 3: Add defaults and JSON readers
type NodeConfig struct {
Role string
ID string
SyncInterval string
TrafficFlushInterval string
}
func GetNodeConfigFromJSON() NodeConfig {
data, err := os.ReadFile(GetSettingPath())
if err != nil {
return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
}
var settings map[string]any
if err := json.Unmarshal(data, &settings); err != nil {
return NodeConfig{Role: "master", ID: "", SyncInterval: "30", TrafficFlushInterval: "60"}
}
return NodeConfig{
Role: readGroupedString(settings, "nodeRole"),
ID: readGroupedString(settings, "nodeId"),
SyncInterval: readGroupedString(settings, "syncInterval"),
TrafficFlushInterval: readGroupedString(settings, "trafficFlushInterval"),
}
}
// web/service/setting.go
"dbType": "mariadb",
"nodeRole": "master",
"nodeId": "",
"syncInterval": "30",
"trafficFlushInterval": "60",
- Step 4: Add setting groups and getters/setters
"systemIntegration": {
"nodeRole": "nodeRole",
"nodeId": "nodeId",
"syncInterval": "syncInterval",
"trafficFlushInterval": "trafficFlushInterval",
},
func (s *SettingService) GetNodeRole() (string, error) { return s.getString("nodeRole") }
func (s *SettingService) SetNodeRole(value string) error { return s.setString("nodeRole", value) }
func (s *SettingService) GetNodeID() (string, error) { return s.getString("nodeId") }
func (s *SettingService) SetNodeID(value string) error { return s.setString("nodeId", value) }
- Step 5: Run tests to verify they pass
Run: go test ./config ./web/service -run 'Test(GetNodeConfigFromJSONSupportsModulePurposeLayout|LoadSettingsUsesMariaDBAsFreshInstallDefault)' -v
Expected: PASS
- Step 6: Commit
git add config/config.go config/config_test.go web/service/setting.go web/service/setting_test.go
git commit -m "feat: add node sync settings and mariadb defaults"
Task 2: Expose node-role configuration through the Go CLI
Files:
-
Modify:
main.go -
Modify:
web/entity/entity.go -
Modify:
web/service/setting_test.go -
Step 1: Write the failing validation and CLI tests
func TestSettingEntityAcceptsNodeRoleValues(t *testing.T) {
s := AllSetting{DBType: "mariadb", NodeRole: "worker"}
if err := s.Check(); err != nil {
t.Fatalf("expected worker nodeRole to be accepted: %v", err)
}
}
func TestSettingServiceSetAndGetNodeRole(t *testing.T) {
setupTestSettings(t)
svc := &SettingService{}
if err := svc.SetNodeRole("worker"); err != nil {
t.Fatalf("SetNodeRole error: %v", err)
}
role, err := svc.GetNodeRole()
if err != nil {
t.Fatalf("GetNodeRole error: %v", err)
}
if role != "worker" {
t.Fatalf("expected worker, got %s", role)
}
}
- Step 2: Run tests to verify they fail
Run: go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v
Expected: FAIL because NodeRole is not part of the validated setting payload and service API yet.
- Step 3: Extend the CLI flags and JSON write path
var nodeRole string
var nodeID string
var syncInterval string
var trafficFlushInterval string
settingCmd.StringVar(&nodeRole, "nodeRole", "", "Set node role (master or worker)")
settingCmd.StringVar(&nodeID, "nodeId", "", "Set node identifier")
settingCmd.StringVar(&syncInterval, "syncInterval", "", "Set account sync interval in seconds")
settingCmd.StringVar(&trafficFlushInterval, "trafficFlushInterval", "", "Set traffic flush interval in seconds")
if nodeRole != "" {
if err := config.WriteSettingToJSON("nodeRole", nodeRole); err != nil {
fmt.Println("Failed to set nodeRole:", err)
} else {
fmt.Println("nodeRole set to:", nodeRole)
}
}
- Step 4: Validate
nodeRolein the setting entity
if s.NodeRole != "" && s.NodeRole != "master" && s.NodeRole != "worker" {
return common.NewError("node role must be master or worker, got:", s.NodeRole)
}
- Step 5: Run tests to verify they pass
Run: go test ./web/service ./web/entity -run 'Test(SettingEntityAcceptsNodeRoleValues|SettingServiceSetAndGetNodeRole)' -v
Expected: PASS
- Step 6: Commit
git add main.go web/entity/entity.go web/service/setting_test.go
git commit -m "feat: expose node role settings in cli"
Task 3: Add node management to x-ui.sh
Files:
-
Modify:
x-ui.sh -
Step 1: Write a shell syntax safety checkpoint
Run: bash -n x-ui.sh
Expected: PASS before changes, establishing a clean syntax baseline.
- Step 2: Add helpers for reading and writing node settings
read_json_noderole() {
local node_role
node_role=$(${xui_folder}/x-ui setting -show true 2>/dev/null | grep '^nodeRole:' | awk -F': ' '{print $2}' | tr -d '[:space:]')
if [ -z "$node_role" ]; then
echo "master"
else
echo "$node_role"
fi
}
switch_node_role() {
local role="$1"
${xui_folder}/x-ui setting -nodeRole "$role" >/dev/null 2>&1
}
- Step 3: Add a node management menu
node_menu() {
local current_role=$(read_json_noderole)
echo -e "
╔────────────────────────────────────────────────╗
│ ${green}节点管理${plain} │
│────────────────────────────────────────────────│
│ ${green}0.${plain} 返回主菜单 │
│ ${green}1.${plain} 查看当前节点角色(当前: ${current_role}) │
│ ${green}2.${plain} 切换到 master │
│ ${green}3.${plain} 切换到 worker │
│ ${green}4.${plain} 设置 nodeId │
╚════════════════════════════════════════════════╝
"
}
- Step 4: Wire the menu into
show_menuand preserve existing database menu
│ ${green}27.${plain} 数据库管理 │
│ ${green}28.${plain} 节点管理 │
27)
check_install && db_menu
;;
28)
check_install && node_menu
;;
- Step 5: Re-run shell syntax verification
Run: bash -n x-ui.sh
Expected: PASS
- Step 6: Commit
git add x-ui.sh
git commit -m "feat: add node role management to x-ui shell"
Task 4: Add install-time role selection and MariaDB-first bootstrap
Files:
-
Modify:
install.sh -
Step 1: Write a shell syntax safety checkpoint
Run: bash -n install.sh
Expected: PASS before changes.
- Step 2: Add fresh-install prompts for database type and node role
read -rp "请选择数据库类型 [默认 mariadb,可选 sqlite/mariadb]:" install_db_type
install_db_type="${install_db_type// /}"
install_db_type="${install_db_type,,}"
if [[ -z "$install_db_type" ]]; then
install_db_type="mariadb"
fi
read -rp "请选择节点角色 [默认 master,可选 master/worker]:" install_node_role
install_node_role="${install_node_role// /}"
install_node_role="${install_node_role,,}"
if [[ -z "$install_node_role" ]]; then
install_node_role="master"
fi
- Step 3: Persist fresh-install settings through the existing CLI
${xui_folder}/x-ui setting -dbType "${install_db_type}" -nodeRole "${install_node_role}"
if [[ -n "${install_node_id}" ]]; then
${xui_folder}/x-ui setting -nodeId "${install_node_id}"
fi
if [[ "${install_db_type}" == "mariadb" ]]; then
XUI_DB_PASSWORD="${db_pass}" ${xui_folder}/x-ui setting \
-dbHost "${db_host}" \
-dbPort "${db_port}" \
-dbUser "${db_user}" \
-dbName "${db_name}"
fi
- Step 4: Preserve SQLite compatibility for existing installs
if [[ "$is_fresh_install" != "true" ]]; then
echo -e "${green}检测到现有安装,保留当前数据库类型与节点角色。${plain}"
else
# prompt for install_db_type and install_node_role only on fresh install
fi
- Step 5: Re-run shell syntax verification
Run: bash -n install.sh
Expected: PASS
- Step 6: Commit
git add install.sh
git commit -m "feat: prompt for node role and mariadb on install"
Task 5: Enforce master / worker write boundaries in Go services
Files:
-
Create:
web/service/node_sync.go -
Create:
web/service/node_sync_test.go -
Modify:
web/service/inbound.go -
Modify:
web/service/server.go -
Modify:
web/service/user_test.go -
Step 1: Write the failing role-enforcement tests
func TestRequireMasterAllowsMaster(t *testing.T) {
setupTestSettings(t)
if err := config.WriteSettingToJSON("nodeRole", "master"); err != nil {
t.Fatalf("WriteSettingToJSON error: %v", err)
}
if err := RequireMaster(); err != nil {
t.Fatalf("expected master to pass: %v", err)
}
}
func TestRequireMasterRejectsWorker(t *testing.T) {
setupTestSettings(t)
if err := config.WriteSettingToJSON("nodeRole", "worker"); err != nil {
t.Fatalf("WriteSettingToJSON error: %v", err)
}
if err := RequireMaster(); err == nil {
t.Fatal("expected worker role to be rejected")
}
}
- Step 2: Run tests to verify they fail
Run: go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v
Expected: FAIL because no role gate exists yet.
- Step 3: Implement a shared guard helper
func CurrentNodeRole() string {
cfg := config.GetNodeConfigFromJSON()
if cfg.Role == "" {
return "master"
}
return cfg.Role
}
func RequireMaster() error {
if CurrentNodeRole() != "master" {
return common.NewError("write operations are only allowed on master nodes")
}
return nil
}
- Step 4: Call the guard in shared-state write paths
func (s *InboundService) AddInbound(inbound *model.Inbound) error {
if err := RequireMaster(); err != nil {
return err
}
// existing logic
}
func (s *InboundService) DelInbound(id int) error {
if err := RequireMaster(); err != nil {
return err
}
// existing logic
}
- Step 5: Re-run the tests and a focused service package pass
Run: go test ./web/service -run 'TestRequireMaster(AllowsMaster|RejectsWorker)' -v
Expected: PASS
Run: go test ./web/service/...
Expected: PASS
- Step 6: Commit
git add web/service/node_sync.go web/service/node_sync_test.go web/service/inbound.go web/service/server.go web/service/user_test.go
git commit -m "feat: enforce master-only shared writes"
Task 6: Add account polling and local cache refresh
Files:
-
Create:
web/service/node_sync.go -
Create:
web/service/node_sync_test.go -
Modify:
web/service/xray.go -
Modify:
main.go -
Step 1: Write the failing sync-interval and version tests
func TestShouldRefreshAccountsWhenVersionChanges(t *testing.T) {
state := syncState{lastVersion: 2}
if !state.shouldRefresh(3) {
t.Fatal("expected newer version to trigger refresh")
}
}
func TestShouldNotRefreshAccountsWhenVersionMatches(t *testing.T) {
state := syncState{lastVersion: 3}
if state.shouldRefresh(3) {
t.Fatal("expected same version to skip refresh")
}
}
- Step 2: Run tests to verify they fail
Run: go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v
Expected: FAIL because the sync state object and refresh logic do not exist.
- Step 3: Implement the sync state and poller skeleton
type syncState struct {
lastVersion int64
}
func (s syncState) shouldRefresh(version int64) bool {
return version > s.lastVersion
}
func StartNodeSyncLoop() {
cfg := config.GetNodeConfigFromJSON()
if config.GetDBTypeFromJSON() != "mariadb" {
return
}
if cfg.Role != "worker" && cfg.Role != "master" {
return
}
go runAccountSyncLoop(cfg)
}
- Step 4: Hook the poller into process startup without changing local Xray ownership
func runWebServer() {
if err := database.InitDB(); err != nil {
log.Fatal(err)
}
service.StartNodeSyncLoop()
// existing startup
}
- Step 5: Re-run focused tests
Run: go test ./web/service -run 'TestShould(RefreshAccountsWhenVersionChanges|NotRefreshAccountsWhenVersionMatches)' -v
Expected: PASS
- Step 6: Commit
git add web/service/node_sync.go web/service/node_sync_test.go web/service/xray.go main.go
git commit -m "feat: add account sync polling skeleton"
Task 7: Add traffic delta writeback and worker-safe accounting
Files:
-
Modify:
database/model/model.go -
Modify:
database/db.go -
Modify:
web/service/node_sync.go -
Modify:
web/service/node_sync_test.go -
Step 1: Write the failing delta accounting tests
func TestApplyTrafficDeltaAccumulatesUsage(t *testing.T) {
current := trafficTotals{Upload: 100, Download: 200}
next := current.applyDelta(20, 30)
if next.Upload != 120 || next.Download != 230 {
t.Fatalf("unexpected totals: %+v", next)
}
}
- Step 2: Run tests to verify they fail
Run: go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v
Expected: FAIL because the delta helper does not exist.
- Step 3: Implement atomic delta write helpers
type trafficTotals struct {
Upload int64
Download int64
}
func (t trafficTotals) applyDelta(uploadDelta, downloadDelta int64) trafficTotals {
t.Upload += uploadDelta
t.Download += downloadDelta
return t
}
func ApplyTrafficDelta(clientID int64, uploadDelta, downloadDelta int64) error {
return GetDB().Model(&model.ClientTraffic{}).
Where("client_id = ?", clientID).
Updates(map[string]any{
"up": gorm.Expr("up + ?", uploadDelta),
"down": gorm.Expr("down + ?", downloadDelta),
}).Error
}
- Step 4: Flush only deltas from worker nodes
func flushTrafficDeltas(deltas map[int64]trafficTotals) error {
for clientID, delta := range deltas {
if err := database.ApplyTrafficDelta(clientID, delta.Upload, delta.Download); err != nil {
return err
}
}
return nil
}
- Step 5: Re-run focused tests and database package tests
Run: go test ./web/service -run TestApplyTrafficDeltaAccumulatesUsage -v
Expected: PASS
Run: go test ./database/...
Expected: PASS
- Step 6: Commit
git add database/model/model.go database/db.go web/service/node_sync.go web/service/node_sync_test.go
git commit -m "feat: add worker traffic delta writeback"
Task 8: Final verification and docs alignment
Files:
-
Modify:
docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md -
Modify:
docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md -
Step 1: Run the targeted Go test suites
Run: go test ./config ./database/... ./web/service/...
Expected: PASS
- Step 2: Run shell syntax checks
Run: bash -n x-ui.sh
Expected: PASS
Run: bash -n install.sh
Expected: PASS
- Step 3: Manually verify fresh-install and runtime flows
Run:
./x-ui setting -showDbType
./x-ui setting -nodeRole worker -nodeId vps-02
./x-ui setting -dbType sqlite
Expected:
-
first command prints the configured backend
-
second command updates the JSON config without touching the DB schema
-
third command remains accepted for compatibility
-
Step 4: Update docs if the final implementation diverges from the spec
- document the final JSON keys
- document the exact `x-ui.sh` menu entries
- document the fresh-install MariaDB default and SQLite compatibility rule
- Step 5: Commit
git add docs/superpowers/specs/2026-04-09-trojan-go-style-mariadb-sync-design.md docs/superpowers/plans/2026-04-09-trojan-go-style-mariadb-sync.md
git commit -m "docs: finalize node sync implementation notes"