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