feat: add 3x-ui Xray rule synchronizer
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
config.yaml
|
||||
3x-rule-sync
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
module github.com/kachu/3x-rule-sync
|
||||
|
||||
go 1.24
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -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=
|
||||
@@ -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, ©)
|
||||
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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user