feat: add 3x-ui Xray rule synchronizer

This commit is contained in:
2026-09-18 21:19:47 +03:00
commit ca150de0f8
8 changed files with 605 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
config.yaml
3x-rule-sync
+47
View File
@@ -0,0 +1,47 @@
# 3x-rule-sync
[Русский](#русский) · [English](#english)
## Русский
CLI синхронизирует управляемые Xray routing rules между панелями 3x-ui, сохраняя правила и балансировщики, созданные вручную.
```sh
cp config.example.yaml config.yaml
go run . -config config.yaml -once
go run . -config config.yaml -once -dry-run -debug
go run . -config config.yaml
```
`tokenEnv` задаёт имя переменной окружения и имеет приоритет над непустым `token`.
- `disabled: true` записывает в правило `enabled: false`; правило остаётся в конфигурации.
- Один совпавший `outbound.tag` используется напрямую; несколько создают балансировщик.
- `leastPing` и `leastLoad` требуют Xray `observatory`.
- Порядок правил в YAML сохраняется; `sync.position` выбирает размещение блока: `first` или `last`.
- В YAML используйте одинарные кавычки для regex с `\`: `tagRegex: '^[a-z\-]+$'`.
- `-debug` выводит доступные outbound tags. Теги outbound-подписок участвуют в regex, но не записываются в редактируемый Xray template.
Не добавляйте `config.yaml` с токенами в репозиторий.
## English
CLI for synchronizing managed Xray routing rules across 3x-ui panels while preserving manually maintained rules and balancers.
```sh
cp config.example.yaml config.yaml
go run . -config config.yaml -once
go run . -config config.yaml -once -dry-run -debug
go run . -config config.yaml
```
`tokenEnv` names an environment variable and overrides a non-empty `token`.
- `disabled: true` writes `enabled: false` to the rule while retaining it in configuration.
- One matching `outbound.tag` is used directly; several matches create a balancer.
- `leastPing` and `leastLoad` require an Xray `observatory`.
- YAML rule order is retained; `sync.position` places the managed block at `first` or `last`.
- Use single YAML quotes for regexes containing `\`: `tagRegex: '^[a-z\-]+$'`.
- `-debug` logs available outbound tags. Subscription outbound tags participate in regex matching but are not saved into the editable Xray template.
Do not commit `config.yaml` containing tokens.
+22
View File
@@ -0,0 +1,22 @@
servers:
- name: eu-1
url: https://eu-1.example.com
# Choose one authentication method:
# token: paste-the-3x-ui-API-token-here
tokenEnv: THREE_X_UI_EU_1_TOKEN
rules:
- name: google
# disabled: true # writes enabled: false in the Xray rule
match:
domain: [geosite:google]
inboundTag: [vless-in]
outbound:
tagRegex: "^proxy-google-"
strategy: leastPing
sync:
# Omit interval for a one-off run, or pass -once to override it.
interval: 5m
# Managed rules go after manually maintained rules by default.
position: last
+5
View File
@@ -0,0 +1,5 @@
module github.com/kachu/3x-rule-sync
go 1.24
require gopkg.in/yaml.v3 v3.0.1
+4
View File
@@ -0,0 +1,4 @@
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+453
View File
@@ -0,0 +1,453 @@
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"reflect"
"regexp"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
)
const managedPrefix = "3x-rule-sync:"
type Config struct {
Servers []Server `yaml:"servers"`
Rules []Rule `yaml:"rules"`
Sync Sync `yaml:"sync"`
}
type Server struct {
Name string `yaml:"name"`
URL string `yaml:"url"`
Token string `yaml:"token"`
TokenEnv string `yaml:"tokenEnv"`
}
type Rule struct {
Name string `yaml:"name"`
Disabled bool `yaml:"disabled"`
Match map[string]any `yaml:"match"`
Outbound Outbound `yaml:"outbound"`
}
type Outbound struct {
TagRegex string `yaml:"tagRegex"`
Strategy string `yaml:"strategy"`
}
type Sync struct {
Interval string `yaml:"interval"`
Position string `yaml:"position"`
}
type apiResponse struct {
Success bool `json:"success"`
Msg string `json:"msg"`
Obj json.RawMessage `json:"obj"`
}
type xrayTemplate struct {
config map[string]any
subscriptionOutboundTags []string
outboundTestURL string
}
func main() {
configPath := flag.String("config", "config.yaml", "path to YAML configuration")
once := flag.Bool("once", false, "run one synchronization and exit")
dryRun := flag.Bool("dry-run", false, "show changes without saving")
debug := flag.Bool("debug", false, "log available outbound tags")
flag.Parse()
cfg, err := loadConfig(*configPath)
if err != nil {
log.Fatal(err)
}
if err := syncAll(context.Background(), cfg, *dryRun, *debug); err != nil {
log.Fatal(err)
}
if *once || cfg.Sync.Interval == "" {
return
}
interval, err := time.ParseDuration(cfg.Sync.Interval)
if err != nil || interval <= 0 {
log.Fatalf("sync.interval must be a positive duration: %q", cfg.Sync.Interval)
}
for range time.Tick(interval) {
if err := syncAll(context.Background(), cfg, *dryRun, *debug); err != nil {
log.Printf("sync failed: %v", err)
}
}
}
func loadConfig(path string) (Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
var cfg Config
decoder := yaml.NewDecoder(bytes.NewReader(b))
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
return Config{}, fmt.Errorf("parse config: %w", err)
}
if len(cfg.Servers) == 0 {
return Config{}, errors.New("servers must not be empty")
}
if cfg.Sync.Position == "" {
cfg.Sync.Position = "last"
}
if cfg.Sync.Position != "first" && cfg.Sync.Position != "last" {
return Config{}, errors.New("sync.position must be first or last")
}
seen := map[string]bool{}
for _, rule := range cfg.Rules {
if rule.Name == "" {
return Config{}, errors.New("each rule needs name")
}
if seen[rule.Name] {
return Config{}, fmt.Errorf("duplicate rule name %q", rule.Name)
}
seen[rule.Name] = true
if rule.Outbound.TagRegex == "" {
return Config{}, fmt.Errorf("rule %q needs outbound.tagRegex", rule.Name)
}
if _, err := regexp.Compile(rule.Outbound.TagRegex); err != nil {
return Config{}, fmt.Errorf("rule %q: invalid tagRegex: %w", rule.Name, err)
}
if rule.Outbound.Strategy == "" {
continue
}
if !validStrategy(rule.Outbound.Strategy) {
return Config{}, fmt.Errorf("rule %q: unsupported strategy %q", rule.Name, rule.Outbound.Strategy)
}
}
for _, server := range cfg.Servers {
if server.Name == "" || server.URL == "" {
return Config{}, errors.New("each server needs name and url")
}
if server.Token == "" && server.TokenEnv == "" {
return Config{}, fmt.Errorf("server %q needs token or tokenEnv", server.Name)
}
}
return cfg, nil
}
func syncAll(ctx context.Context, cfg Config, dryRun, debug bool) error {
var failures []string
for _, server := range cfg.Servers {
if err := syncServer(ctx, server, cfg.Rules, cfg.Sync.Position, dryRun, debug); err != nil {
failures = append(failures, fmt.Sprintf("%s: %v", server.Name, err))
} else {
log.Printf("%s: synchronized", server.Name)
}
}
if len(failures) > 0 {
return errors.New(strings.Join(failures, "; "))
}
return nil
}
func syncServer(ctx context.Context, server Server, rules []Rule, position string, dryRun, debug bool) error {
token := server.Token
if server.TokenEnv != "" && os.Getenv(server.TokenEnv) != "" {
token = os.Getenv(server.TokenEnv)
}
if token == "" {
return fmt.Errorf("token is empty")
}
client := &panelClient{baseURL: strings.TrimRight(server.URL, "/"), token: token, http: &http.Client{Timeout: 30 * time.Second}}
template, err := client.getTemplate(ctx)
if err != nil {
return err
}
config := template.config
available, err := tags(config["outbounds"])
if err != nil {
return err
}
available = uniqueTags(append(available, template.subscriptionOutboundTags...))
if len(available) == 0 {
return errors.New("runtime Xray JSON contains no outbounds")
}
if debug {
log.Printf("%s: available outbounds: %s", server.Name, strings.Join(available, ", "))
}
updated, err := reconcileWithOutbounds(config, rules, position, available)
if err != nil {
return err
}
if reflect.DeepEqual(config, updated) {
log.Printf("%s: already up to date", server.Name)
return nil
}
if dryRun {
log.Printf("%s: would update Xray configuration", server.Name)
return nil
}
return client.updateConfig(ctx, updated, template.outboundTestURL)
}
type panelClient struct {
baseURL, token string
http *http.Client
}
func (c *panelClient) getTemplate(ctx context.Context) (xrayTemplate, error) {
var response apiResponse
if err := c.post(ctx, "/panel/api/xray/", nil, &response); err != nil {
return xrayTemplate{}, err
}
if !response.Success {
return xrayTemplate{}, fmt.Errorf("panel API: %s", response.Msg)
}
return parseTemplate(response.Obj)
}
func parseTemplate(raw json.RawMessage) (xrayTemplate, error) {
var text string
if json.Unmarshal(raw, &text) == nil {
raw = json.RawMessage(text)
}
var envelope map[string]json.RawMessage
if err := json.Unmarshal(raw, &envelope); err != nil {
return xrayTemplate{}, fmt.Errorf("decode Xray settings response: %w", err)
}
setting, wrapped := envelope["xraySetting"]
if !wrapped {
setting = raw
}
config, err := decodeConfig(setting)
if err != nil {
return xrayTemplate{}, err
}
result := xrayTemplate{config: config}
if value, ok := envelope["subscriptionOutboundTags"]; ok {
_ = json.Unmarshal(value, &result.subscriptionOutboundTags)
}
if value, ok := envelope["outboundTestUrl"]; ok {
_ = json.Unmarshal(value, &result.outboundTestURL)
}
return result, nil
}
func decodeConfig(raw json.RawMessage) (map[string]any, error) {
var text string
if json.Unmarshal(raw, &text) == nil {
raw = json.RawMessage(text)
}
var config map[string]any
if err := json.Unmarshal(raw, &config); err != nil {
return nil, fmt.Errorf("decode Xray JSON: %w", err)
}
return config, nil
}
func (c *panelClient) updateConfig(ctx context.Context, config map[string]any, outboundTestURL string) error {
b, err := json.Marshal(config)
if err != nil {
return err
}
var response apiResponse
err = c.post(ctx, "/panel/api/xray/update", url.Values{"xraySetting": {string(b)}, "outboundTestUrl": {outboundTestURL}}, &response)
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if !response.Success {
return fmt.Errorf("panel API: %s", response.Msg)
}
return nil
}
func (c *panelClient) post(ctx context.Context, path string, form url.Values, out any) error {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, body)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.token)
if form != nil {
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode > 299 {
return fmt.Errorf("HTTP %s", resp.Status)
}
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("POST %s: decode response: %w", path, err)
}
return nil
}
func reconcile(config map[string]any, desired []Rule, position string) (map[string]any, error) {
outboundTags, err := tags(config["outbounds"])
if err != nil {
return nil, err
}
return reconcileWithOutbounds(config, desired, position, outboundTags)
}
func reconcileWithOutbounds(config map[string]any, desired []Rule, position string, outboundTags []string) (map[string]any, error) {
updated := clone(config)
routing := object(updated, "routing")
existingRules, err := objects(routing["rules"])
if err != nil {
return nil, err
}
manualRules := filterUnmanaged(existingRules, "ruleTag")
existingBalancers, err := objects(routing["balancers"])
if err != nil {
return nil, err
}
manualBalancers := filterUnmanaged(existingBalancers, "tag")
managedRules := make([]map[string]any, 0, len(desired))
managedBalancers := []map[string]any{}
for _, rule := range desired {
matches := matchingTags(outboundTags, rule.Outbound.TagRegex)
if len(matches) == 0 {
return nil, fmt.Errorf("rule %q: no outbound tag matches %q", rule.Name, rule.Outbound.TagRegex)
}
item := clone(rule.Match)
delete(item, "outboundTag")
delete(item, "balancerTag")
delete(item, "ruleTag")
item["ruleTag"] = managedPrefix + rule.Name
item["enabled"] = !rule.Disabled
if len(matches) == 1 {
item["outboundTag"] = matches[0]
} else {
balancerTag := managedPrefix + "balancer:" + rule.Name
item["balancerTag"] = balancerTag
strategy := rule.Outbound.Strategy
if strategy == "" {
strategy = "leastPing"
}
if (strategy == "leastPing" || strategy == "leastLoad") && updated["observatory"] == nil {
return nil, errors.New("leastPing and leastLoad require an Xray observatory")
}
managedBalancers = append(managedBalancers, map[string]any{"tag": balancerTag, "selector": matches, "strategy": map[string]any{"type": strategy}})
}
managedRules = append(managedRules, item)
}
if position == "first" {
routing["rules"] = append(managedRules, manualRules...)
} else {
routing["rules"] = append(manualRules, managedRules...)
}
routing["balancers"] = append(manualBalancers, managedBalancers...)
return updated, nil
}
func clone(value map[string]any) map[string]any {
if value == nil {
return map[string]any{}
}
b, _ := json.Marshal(value)
var copy map[string]any
_ = json.Unmarshal(b, &copy)
return copy
}
func object(parent map[string]any, key string) map[string]any {
if value, ok := parent[key].(map[string]any); ok {
return value
}
value := map[string]any{}
parent[key] = value
return value
}
func objects(value any) ([]map[string]any, error) {
if value == nil {
return nil, nil
}
if list, ok := value.([]map[string]any); ok {
return list, nil
}
list, ok := value.([]any)
if !ok {
return nil, errors.New("expected JSON array")
}
result := make([]map[string]any, 0, len(list))
for _, item := range list {
object, ok := item.(map[string]any)
if !ok {
return nil, errors.New("expected JSON object in array")
}
result = append(result, object)
}
return result, nil
}
func tags(value any) ([]string, error) {
items, err := objects(value)
if err != nil {
return nil, err
}
result := []string{}
for _, item := range items {
if tag, ok := item["tag"].(string); ok {
result = append(result, tag)
}
}
return result, nil
}
func filterUnmanaged(items []map[string]any, key string) []map[string]any {
result := []map[string]any{}
for _, item := range items {
value, _ := item[key].(string)
if !strings.HasPrefix(value, managedPrefix) {
result = append(result, item)
}
}
return result
}
func matchingTags(tags []string, pattern string) []string {
re := regexp.MustCompile(pattern)
var result []string
for _, tag := range tags {
if re.MatchString(tag) {
result = append(result, tag)
}
}
sort.Strings(result)
return result
}
func uniqueTags(tags []string) []string {
set := map[string]bool{}
for _, tag := range tags {
if tag != "" {
set[tag] = true
}
}
result := make([]string, 0, len(set))
for tag := range set {
result = append(result, tag)
}
sort.Strings(result)
return result
}
func validStrategy(value string) bool {
return value == "random" || value == "roundRobin" || value == "leastPing" || value == "leastLoad"
}
+71
View File
@@ -0,0 +1,71 @@
package main
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestParseTemplateUnwrapsPanelResponse(t *testing.T) {
template, err := parseTemplate(json.RawMessage(`"{\"xraySetting\":{\"outbounds\":[{\"tag\":\"direct\"}]},\"subscriptionOutboundTags\":[\"sub\"]}"`))
if err != nil || template.config["outbounds"] == nil || len(template.subscriptionOutboundTags) != 1 {
t.Fatalf("template=%#v err=%v", template, err)
}
}
func TestUpdateConfigAcceptsEmptySuccessResponse(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/panel/api/xray/update" {
t.Fatalf("path: %s", r.URL.Path)
}
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client := panelClient{baseURL: server.URL, token: "test", http: server.Client()}
if err := client.updateConfig(t.Context(), map[string]any{}, ""); err != nil {
t.Fatal(err)
}
}
func TestReconcilePreservesManualObjects(t *testing.T) {
config := map[string]any{
"outbounds": []any{map[string]any{"tag": "proxy-a"}, map[string]any{"tag": "proxy-b"}},
"observatory": map[string]any{},
"routing": map[string]any{
"rules": []any{
map[string]any{"ruleTag": "manual", "outboundTag": "direct"},
map[string]any{"ruleTag": "3x-rule-sync:removed", "outboundTag": "proxy-a"},
},
"balancers": []any{
map[string]any{"tag": "manual-balancer"},
map[string]any{"tag": "3x-rule-sync:balancer:removed"},
},
},
}
updated, err := reconcile(config, []Rule{{Name: "test", Match: map[string]any{"domain": []any{"example.com"}}, Outbound: Outbound{TagRegex: "^proxy-"}}}, "last")
if err != nil {
t.Fatal(err)
}
routing := updated["routing"].(map[string]any)
rules, _ := objects(routing["rules"])
if len(rules) != 2 || rules[0]["ruleTag"] != "manual" || rules[1]["balancerTag"] != "3x-rule-sync:balancer:test" {
t.Fatalf("unexpected rules: %#v", rules)
}
balancers, _ := objects(routing["balancers"])
if len(balancers) != 2 || balancers[0]["tag"] != "manual-balancer" {
t.Fatalf("manual balancer lost: %#v", balancers)
}
}
func TestDisabledRuleWritesEnabledFalse(t *testing.T) {
config := map[string]any{"outbounds": []any{map[string]any{"tag": "proxy-a"}}, "routing": map[string]any{"rules": []any{map[string]any{"ruleTag": "3x-rule-sync:test", "outboundTag": "proxy-a"}}}}
updated, err := reconcile(config, []Rule{{Name: "test", Disabled: true, Outbound: Outbound{TagRegex: "proxy-a"}}}, "last")
if err != nil {
t.Fatal(err)
}
rules, _ := objects(updated["routing"].(map[string]any)["rules"])
if len(rules) != 1 || rules[0]["enabled"] != false {
t.Fatalf("disabled rule: %#v", rules)
}
}
+1
View File
File diff suppressed because one or more lines are too long