mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-02 08:27:14 +00:00
feat: apply inbound/outbound/routing changes live via Xray gRPC API
Add a hot-apply layer that computes a diff between the old and new generated config and applies only the changed parts through the Xray gRPC HandlerService and RoutingService, avoiding a full process restart whenever possible. A restart is still performed when sections that have no reload API (log, dns, policy, observatory, ...) actually change. Key additions: - internal/xray/hot_diff.go: ComputeHotDiff with canonical-JSON comparison (sorted keys, null=absent, full number precision) so UI reformatting never triggers a spurious restart - internal/xray/api.go: AddOutbound/DelOutbound, ApplyRoutingConfig, GetBalancerInfo, SetBalancerTarget, TestRoute gRPC wrappers - internal/web/service/xray.go: tryHotApply, ensureAPIServices, GetBalancersStatus, OverrideBalancer, TestRoute service methods - internal/web/controller/xray_setting.go: balancerStatus, balancerOverride, routeTest API endpoints - frontend: BalancersTab live-status/override columns, RouteTester component, Restart button removed (Save now hot-applies) - balancer-helpers.ts: syncObservatories never creates observatory sections for random/roundRobin balancers (no reload API → restart) - i18n: balancerLive/Override/routeTester keys added to all 13 locales
This commit is contained in:
@@ -8,14 +8,21 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/config"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/common"
|
||||
|
||||
"github.com/xtls/xray-core/app/proxyman/command"
|
||||
routerService "github.com/xtls/xray-core/app/router/command"
|
||||
statsService "github.com/xtls/xray-core/app/stats/command"
|
||||
xnet "github.com/xtls/xray-core/common/net"
|
||||
"github.com/xtls/xray-core/common/protocol"
|
||||
"github.com/xtls/xray-core/common/serial"
|
||||
"github.com/xtls/xray-core/infra/conf"
|
||||
@@ -33,6 +40,7 @@ import (
|
||||
type XrayAPI struct {
|
||||
HandlerServiceClient *command.HandlerServiceClient
|
||||
StatsServiceClient *statsService.StatsServiceClient
|
||||
RoutingServiceClient *routerService.RoutingServiceClient
|
||||
grpcClient *grpc.ClientConn
|
||||
isConnected bool
|
||||
StatsLastValues map[string]int64
|
||||
@@ -86,9 +94,11 @@ func (x *XrayAPI) Init(apiPort int) error {
|
||||
|
||||
hsClient := command.NewHandlerServiceClient(conn)
|
||||
ssClient := statsService.NewStatsServiceClient(conn)
|
||||
rsClient := routerService.NewRoutingServiceClient(conn)
|
||||
|
||||
x.HandlerServiceClient = &hsClient
|
||||
x.StatsServiceClient = &ssClient
|
||||
x.RoutingServiceClient = &rsClient
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -100,6 +110,7 @@ func (x *XrayAPI) Close() {
|
||||
}
|
||||
x.HandlerServiceClient = nil
|
||||
x.StatsServiceClient = nil
|
||||
x.RoutingServiceClient = nil
|
||||
x.isConnected = false
|
||||
}
|
||||
|
||||
@@ -134,6 +145,245 @@ func (x *XrayAPI) DelInbound(tag string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// AddOutbound adds a new outbound configuration to the Xray core via gRPC.
|
||||
func (x *XrayAPI) AddOutbound(outbound []byte) error {
|
||||
if x.HandlerServiceClient == nil {
|
||||
return common.NewError("xray HandlerServiceClient is not initialized")
|
||||
}
|
||||
client := *x.HandlerServiceClient
|
||||
|
||||
conf := new(conf.OutboundDetourConfig)
|
||||
if err := json.Unmarshal(outbound, conf); err != nil {
|
||||
logger.Debug("Failed to unmarshal outbound:", err)
|
||||
return err
|
||||
}
|
||||
config, err := conf.Build()
|
||||
if err != nil {
|
||||
logger.Debug("Failed to build outbound detour:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = client.AddOutbound(ctx, &command.AddOutboundRequest{Outbound: config})
|
||||
return err
|
||||
}
|
||||
|
||||
// DelOutbound removes an outbound configuration from the Xray core by tag.
|
||||
func (x *XrayAPI) DelOutbound(tag string) error {
|
||||
if x.HandlerServiceClient == nil {
|
||||
return common.NewError("xray HandlerServiceClient is not initialized")
|
||||
}
|
||||
client := *x.HandlerServiceClient
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := client.RemoveOutbound(ctx, &command.RemoveOutboundRequest{Tag: tag})
|
||||
return err
|
||||
}
|
||||
|
||||
// ApplyRoutingConfig replaces the routing rules and balancers of the running
|
||||
// Xray core with the given routing section (the JSON value of the top-level
|
||||
// "routing" key) via the RoutingService gRPC API. Note that this cannot change
|
||||
// routing.domainStrategy/domainMatcher — those are fixed at process start.
|
||||
func (x *XrayAPI) ApplyRoutingConfig(routing []byte) error {
|
||||
if x.RoutingServiceClient == nil {
|
||||
return common.NewError("xray RoutingServiceClient is not initialized")
|
||||
}
|
||||
|
||||
// Rules referencing geoip:/geosite: need the dat files; point xray-core's
|
||||
// in-process loader at the panel's bin folder where they live.
|
||||
ensureXrayAssetLocation()
|
||||
|
||||
routerConf := new(conf.RouterConfig)
|
||||
if err := json.Unmarshal(routing, routerConf); err != nil {
|
||||
logger.Debug("Failed to unmarshal routing config:", err)
|
||||
return err
|
||||
}
|
||||
config, err := routerConf.Build()
|
||||
if err != nil {
|
||||
logger.Debug("Failed to build routing config:", err)
|
||||
return err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err = (*x.RoutingServiceClient).AddRule(ctx, &routerService.AddRuleRequest{
|
||||
ShouldAppend: false,
|
||||
Config: serial.ToTypedMessage(config),
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// BalancerInfo is the live state of one balancer inside the running core.
|
||||
type BalancerInfo struct {
|
||||
Tag string `json:"tag"`
|
||||
// Override is the outbound tag an admin forced via the API; empty when
|
||||
// the strategy is in control.
|
||||
Override string `json:"override"`
|
||||
// Selected are the outbound tags the strategy currently prefers, best
|
||||
// first (xray's "principle target" list).
|
||||
Selected []string `json:"selected"`
|
||||
}
|
||||
|
||||
// GetBalancerInfo queries the running core for a balancer's current override
|
||||
// and the targets its strategy would pick right now.
|
||||
func (x *XrayAPI) GetBalancerInfo(tag string) (*BalancerInfo, error) {
|
||||
if x.RoutingServiceClient == nil {
|
||||
return nil, common.NewError("xray RoutingServiceClient is not initialized")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := (*x.RoutingServiceClient).GetBalancerInfo(ctx, &routerService.GetBalancerInfoRequest{Tag: tag})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info := &BalancerInfo{Tag: tag}
|
||||
if balancer := resp.GetBalancer(); balancer != nil {
|
||||
if balancer.Override != nil {
|
||||
info.Override = balancer.Override.Target
|
||||
}
|
||||
if balancer.PrincipleTarget != nil {
|
||||
info.Selected = balancer.PrincipleTarget.Tag
|
||||
}
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// SetBalancerTarget forces a balancer to always pick the given outbound tag.
|
||||
// An empty target clears the override and hands control back to the strategy.
|
||||
func (x *XrayAPI) SetBalancerTarget(tag, target string) error {
|
||||
if x.RoutingServiceClient == nil {
|
||||
return common.NewError("xray RoutingServiceClient is not initialized")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := (*x.RoutingServiceClient).OverrideBalancerTarget(ctx, &routerService.OverrideBalancerTargetRequest{
|
||||
BalancerTag: tag,
|
||||
Target: target,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
// RouteTestRequest describes a synthetic connection to ask the running core
|
||||
// which outbound its router would pick for it.
|
||||
type RouteTestRequest struct {
|
||||
InboundTag string // optional: simulate arrival on this inbound
|
||||
Domain string // target domain (sniffed/SOCKS-style destination)
|
||||
IP string // target IP, used when Domain is empty or alongside it
|
||||
Port int
|
||||
Network string // "tcp" (default) or "udp"
|
||||
Protocol string // optional sniffed protocol: http, tls, bittorrent, ...
|
||||
Email string // optional user attribution for user-based rules
|
||||
}
|
||||
|
||||
// RouteTestResult is the routing decision the core reported.
|
||||
type RouteTestResult struct {
|
||||
// Matched is false when no routing rule matched — traffic would use the
|
||||
// default (first) outbound and OutboundTag is empty.
|
||||
Matched bool `json:"matched"`
|
||||
OutboundTag string `json:"outboundTag"`
|
||||
// GroupTags lists the balancer chain the decision went through, when any.
|
||||
GroupTags []string `json:"groupTags,omitempty"`
|
||||
}
|
||||
|
||||
// TestRoute asks the running core's router which outbound it would pick for
|
||||
// the described connection, without sending any traffic.
|
||||
func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) {
|
||||
if x.RoutingServiceClient == nil {
|
||||
return nil, common.NewError("xray RoutingServiceClient is not initialized")
|
||||
}
|
||||
|
||||
network := xnet.Network_TCP
|
||||
if strings.EqualFold(req.Network, "udp") {
|
||||
network = xnet.Network_UDP
|
||||
}
|
||||
rc := &routerService.RoutingContext{
|
||||
InboundTag: req.InboundTag,
|
||||
Network: network,
|
||||
TargetDomain: req.Domain,
|
||||
TargetPort: uint32(req.Port),
|
||||
Protocol: req.Protocol,
|
||||
User: req.Email,
|
||||
}
|
||||
if req.IP != "" {
|
||||
parsed := net.ParseIP(req.IP)
|
||||
if parsed == nil {
|
||||
return nil, common.NewErrorf("invalid IP address: %s", req.IP)
|
||||
}
|
||||
if v4 := parsed.To4(); v4 != nil {
|
||||
rc.TargetIPs = [][]byte{v4}
|
||||
} else {
|
||||
rc.TargetIPs = [][]byte{parsed.To16()}
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
resp, err := (*x.RoutingServiceClient).TestRoute(ctx, &routerService.TestRouteRequest{
|
||||
RoutingContext: rc,
|
||||
PublishResult: false,
|
||||
})
|
||||
if err != nil {
|
||||
// The router reports "no rule matched" as an error; for the caller
|
||||
// that simply means the default outbound takes the traffic.
|
||||
if strings.Contains(strings.ToLower(err.Error()), "not enough information") {
|
||||
return &RouteTestResult{Matched: false}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &RouteTestResult{
|
||||
Matched: true,
|
||||
OutboundTag: resp.GetOutboundTag(),
|
||||
GroupTags: resp.GetOutboundGroupTags(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IsMissingHandlerErr reports whether err is xray's response to removing a
|
||||
// handler (inbound/outbound) that does not exist — e.g. it was already
|
||||
// removed through the runtime API while the panel's config snapshot was
|
||||
// stale. Safe to treat as success for removal operations.
|
||||
func IsMissingHandlerErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "not found") ||
|
||||
strings.Contains(msg, "not enough information")
|
||||
}
|
||||
|
||||
// IsExistingTagErr reports whether err is xray's response to adding a handler
|
||||
// whose tag is already taken by a running handler.
|
||||
func IsExistingTagErr(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(err.Error()), "existing tag")
|
||||
}
|
||||
|
||||
// ensureXrayAssetLocation makes geoip.dat/geosite.dat resolvable when xray-core
|
||||
// config builders run inside the panel process. The xray binary resolves assets
|
||||
// relative to its own executable, but the panel binary lives one level above
|
||||
// the bin folder, so an explicit location is required.
|
||||
func ensureXrayAssetLocation() {
|
||||
if os.Getenv("XRAY_LOCATION_ASSET") != "" || os.Getenv("xray.location.asset") != "" {
|
||||
return
|
||||
}
|
||||
if abs, err := filepath.Abs(config.GetBinFolderPath()); err == nil {
|
||||
os.Setenv("XRAY_LOCATION_ASSET", abs)
|
||||
}
|
||||
}
|
||||
|
||||
// AddUser adds a user to an inbound in the Xray core using the specified protocol and user data.
|
||||
func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error {
|
||||
userEmail, err := getRequiredUserString(user, "email")
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestXrayAPI_E2E exercises the gRPC hot-apply surface (outbounds, inbounds,
|
||||
// routing) against a real xray-core process. It validates the exact error
|
||||
// texts IsMissingHandlerErr/IsExistingTagErr rely on, and that replacing the
|
||||
// routing config keeps the api rule working.
|
||||
//
|
||||
// Skipped unless XRAY_E2E_BINARY points at an xray executable built from the
|
||||
// same xray-core version as go.mod, e.g.:
|
||||
//
|
||||
// go install github.com/xtls/xray-core/main@<version from go.mod>
|
||||
// XRAY_E2E_BINARY=$GOBIN/main go test ./internal/xray -run TestXrayAPI_E2E -v
|
||||
func TestXrayAPI_E2E(t *testing.T) {
|
||||
bin := os.Getenv("XRAY_E2E_BINARY")
|
||||
if bin == "" {
|
||||
t.Skip("set XRAY_E2E_BINARY to an xray binary to run this test")
|
||||
}
|
||||
|
||||
apiPort := freePort(t)
|
||||
cfg := map[string]any{
|
||||
"log": map[string]any{"loglevel": "warning"},
|
||||
"api": map[string]any{
|
||||
"services": []string{"HandlerService", "StatsService", "RoutingService"},
|
||||
"tag": "api",
|
||||
},
|
||||
"inbounds": []any{
|
||||
map[string]any{
|
||||
"listen": "127.0.0.1",
|
||||
"port": apiPort,
|
||||
"protocol": "tunnel",
|
||||
"settings": map[string]any{"rewriteAddress": "127.0.0.1"},
|
||||
"tag": "api",
|
||||
},
|
||||
},
|
||||
"outbounds": []any{
|
||||
map[string]any{"protocol": "freedom", "settings": map[string]any{}, "tag": "direct"},
|
||||
map[string]any{"protocol": "blackhole", "settings": map[string]any{}, "tag": "blocked"},
|
||||
},
|
||||
"routing": map[string]any{
|
||||
"domainStrategy": "AsIs",
|
||||
"rules": []any{
|
||||
map[string]any{"type": "field", "inboundTag": []string{"api"}, "outboundTag": "api"},
|
||||
},
|
||||
},
|
||||
"policy": map[string]any{},
|
||||
"stats": map[string]any{},
|
||||
}
|
||||
cfgBytes, err := json.MarshalIndent(cfg, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfgPath := filepath.Join(t.TempDir(), "config.json")
|
||||
if err := os.WriteFile(cfgPath, cfgBytes, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cmd := exec.Command(bin, "-c", cfgPath)
|
||||
cmd.Stdout = os.Stderr
|
||||
cmd.Stderr = os.Stderr
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatalf("failed to start xray: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
_ = cmd.Process.Kill()
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
|
||||
waitForPort(t, apiPort)
|
||||
|
||||
api := XrayAPI{}
|
||||
if err := api.Init(apiPort); err != nil {
|
||||
t.Fatalf("api init: %v", err)
|
||||
}
|
||||
defer api.Close()
|
||||
|
||||
// --- outbounds ---
|
||||
socksOutbound := []byte(`{"protocol":"socks","settings":{"servers":[{"address":"127.0.0.1","port":10808}]},"tag":"test-out"}`)
|
||||
if err := api.AddOutbound(socksOutbound); err != nil {
|
||||
t.Fatalf("AddOutbound: %v", err)
|
||||
}
|
||||
err = api.AddOutbound(socksOutbound)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate AddOutbound must fail")
|
||||
}
|
||||
if !IsExistingTagErr(err) {
|
||||
t.Fatalf("duplicate AddOutbound error not matched by IsExistingTagErr: %q", err)
|
||||
}
|
||||
if err := api.DelOutbound("test-out"); err != nil {
|
||||
t.Fatalf("DelOutbound: %v", err)
|
||||
}
|
||||
// xray's outbound manager treats removal of an unknown tag as a no-op.
|
||||
if err := api.DelOutbound("test-out"); err != nil && !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("removing a missing outbound: unexpected error %q", err)
|
||||
}
|
||||
|
||||
// --- inbounds ---
|
||||
vlessPort := freePort(t)
|
||||
vlessInbound := fmt.Appendf(nil,
|
||||
`{"listen":"127.0.0.1","port":%d,"protocol":"vless","settings":{"clients":[{"id":"a17e367c-2074-4d3e-aaeb-fbef5dfde7e7","email":"e2e"}],"decryption":"none"},"tag":"test-in"}`,
|
||||
vlessPort)
|
||||
if err := api.AddInbound(vlessInbound); err != nil {
|
||||
t.Fatalf("AddInbound: %v", err)
|
||||
}
|
||||
err = api.AddInbound(vlessInbound)
|
||||
if err == nil {
|
||||
t.Fatal("duplicate AddInbound must fail")
|
||||
}
|
||||
if !IsExistingTagErr(err) {
|
||||
t.Fatalf("duplicate AddInbound error not matched by IsExistingTagErr: %q", err)
|
||||
}
|
||||
if err := api.DelInbound("test-in"); err != nil {
|
||||
t.Fatalf("DelInbound: %v", err)
|
||||
}
|
||||
err = api.DelInbound("test-in")
|
||||
if err == nil {
|
||||
t.Fatal("removing a missing inbound must fail")
|
||||
}
|
||||
if !IsMissingHandlerErr(err) {
|
||||
t.Fatalf("missing inbound error not matched by IsMissingHandlerErr: %q", err)
|
||||
}
|
||||
|
||||
// --- routing (rules + balancers replace) ---
|
||||
newRouting := []byte(`{
|
||||
"domainStrategy": "AsIs",
|
||||
"balancers": [{"tag":"b1","selector":["direct"]}],
|
||||
"rules": [
|
||||
{"type":"field","inboundTag":["api"],"outboundTag":"api"},
|
||||
{"type":"field","port":"6666","outboundTag":"blocked","ruleTag":"e2e-rule"},
|
||||
{"type":"field","port":"7777","balancerTag":"b1","ruleTag":"e2e-balancer-rule"}
|
||||
]
|
||||
}`)
|
||||
if err := api.ApplyRoutingConfig(newRouting); err != nil {
|
||||
t.Fatalf("ApplyRoutingConfig: %v", err)
|
||||
}
|
||||
// The replaced rule set still contains the api rule — the gRPC channel
|
||||
// must keep working after the swap.
|
||||
if err := api.AddOutbound([]byte(`{"protocol":"blackhole","settings":{},"tag":"post-routing"}`)); err != nil {
|
||||
t.Fatalf("api unusable after routing replace (api rule lost?): %v", err)
|
||||
}
|
||||
if err := api.DelOutbound("post-routing"); err != nil {
|
||||
t.Fatalf("DelOutbound after routing replace: %v", err)
|
||||
}
|
||||
|
||||
// --- route testing ---
|
||||
res, err := api.TestRoute(RouteTestRequest{IP: "1.2.3.4", Port: 6666, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(port rule): %v", err)
|
||||
}
|
||||
if !res.Matched || res.OutboundTag != "blocked" {
|
||||
t.Fatalf("TestRoute(port rule) = %+v, want matched blocked", res)
|
||||
}
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(balancer rule): %v", err)
|
||||
}
|
||||
if !res.Matched || res.OutboundTag != "direct" {
|
||||
t.Fatalf("TestRoute(balancer rule) = %+v, want matched direct", res)
|
||||
}
|
||||
// Note: current xray-core never populates OutboundGroupTags in PickRoute,
|
||||
// so GroupTags stays empty even for balancer rules — don't assert on it.
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 9999, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(no match): %v", err)
|
||||
}
|
||||
if res.Matched {
|
||||
t.Fatalf("TestRoute(no match) = %+v, want unmatched (default outbound)", res)
|
||||
}
|
||||
|
||||
// --- balancer info + override ---
|
||||
info, err := api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo: %v", err)
|
||||
}
|
||||
if info.Override != "" {
|
||||
t.Fatalf("fresh balancer must have no override, got %q", info.Override)
|
||||
}
|
||||
if err := api.SetBalancerTarget("b1", "blocked"); err != nil {
|
||||
t.Fatalf("SetBalancerTarget: %v", err)
|
||||
}
|
||||
info, err = api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo after override: %v", err)
|
||||
}
|
||||
if info.Override != "blocked" {
|
||||
t.Fatalf("override = %q, want blocked", info.Override)
|
||||
}
|
||||
res, err = api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 7777, Network: "tcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("TestRoute(overridden balancer): %v", err)
|
||||
}
|
||||
if res.OutboundTag != "blocked" {
|
||||
t.Fatalf("overridden balancer must route to blocked, got %+v", res)
|
||||
}
|
||||
if err := api.SetBalancerTarget("b1", ""); err != nil {
|
||||
t.Fatalf("SetBalancerTarget(clear): %v", err)
|
||||
}
|
||||
info, err = api.GetBalancerInfo("b1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetBalancerInfo after clear: %v", err)
|
||||
}
|
||||
if info.Override != "" {
|
||||
t.Fatalf("override after clear = %q, want empty", info.Override)
|
||||
}
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func waitForPort(t *testing.T, port int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
addr := fmt.Sprintf("127.0.0.1:%d", port)
|
||||
for time.Now().Before(deadline) {
|
||||
conn, err := net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("xray api port %d did not open in time", port)
|
||||
}
|
||||
@@ -66,6 +66,12 @@ func (c *Config) Equals(other *Config) bool {
|
||||
if !bytes.Equal(c.FakeDNS, other.FakeDNS) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(c.Observatory, other.Observatory) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(c.BurstObservatory, other.BurstObservatory) {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(c.Metrics, other.Metrics) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
)
|
||||
|
||||
// HotDiff describes the gRPC API operations needed to bring a running Xray
|
||||
// instance from one generated config to another without restarting the
|
||||
// process. It only covers the sections Xray can reload at runtime: inbounds,
|
||||
// outbounds and routing rules/balancers.
|
||||
type HotDiff struct {
|
||||
RemovedInboundTags []string
|
||||
AddedInbounds [][]byte
|
||||
RemovedOutboundTags []string
|
||||
AddedOutbounds [][]byte
|
||||
RoutingConfig []byte // full new routing section; nil when unchanged
|
||||
}
|
||||
|
||||
// Empty reports whether the diff contains no operations.
|
||||
func (d *HotDiff) Empty() bool {
|
||||
return len(d.RemovedInboundTags) == 0 &&
|
||||
len(d.AddedInbounds) == 0 &&
|
||||
len(d.RemovedOutboundTags) == 0 &&
|
||||
len(d.AddedOutbounds) == 0 &&
|
||||
d.RoutingConfig == nil
|
||||
}
|
||||
|
||||
// ComputeHotDiff compares two generated configs and returns the API operations
|
||||
// that transform a running instance from oldCfg to newCfg. ok is false when
|
||||
// the change touches anything that has no runtime reload API (log, dns,
|
||||
// policy, ...) and therefore requires a full process restart.
|
||||
func ComputeHotDiff(oldCfg, newCfg *Config) (*HotDiff, bool) {
|
||||
if oldCfg == nil || newCfg == nil {
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// Sections without a reload API must be semantically identical.
|
||||
// Comparison is whitespace-insensitive: a template save that merely
|
||||
// reformats the JSON (frontend textarea, API clients) must not be
|
||||
// mistaken for a real change that forces a restart.
|
||||
static := []struct {
|
||||
name string
|
||||
old, new json_util.RawMessage
|
||||
}{
|
||||
{"log", oldCfg.LogConfig, newCfg.LogConfig},
|
||||
{"dns", oldCfg.DNSConfig, newCfg.DNSConfig},
|
||||
{"transport", oldCfg.Transport, newCfg.Transport},
|
||||
{"policy", oldCfg.Policy, newCfg.Policy},
|
||||
{"api", oldCfg.API, newCfg.API},
|
||||
{"stats", oldCfg.Stats, newCfg.Stats},
|
||||
{"reverse", oldCfg.Reverse, newCfg.Reverse},
|
||||
{"fakedns", oldCfg.FakeDNS, newCfg.FakeDNS},
|
||||
{"observatory", oldCfg.Observatory, newCfg.Observatory},
|
||||
{"burstObservatory", oldCfg.BurstObservatory, newCfg.BurstObservatory},
|
||||
{"metrics", oldCfg.Metrics, newCfg.Metrics},
|
||||
{"geodata", oldCfg.Geodata, newCfg.Geodata},
|
||||
}
|
||||
for _, section := range static {
|
||||
if !rawEqualNormalized(section.old, section.new) {
|
||||
logger.Debug("hot diff: section [", section.name, "] changed and has no reload API")
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
diff := &HotDiff{}
|
||||
|
||||
if ok := diffInbounds(oldCfg, newCfg, diff); !ok {
|
||||
logger.Debug("hot diff: inbound change is not API-applicable")
|
||||
return nil, false
|
||||
}
|
||||
if ok := diffOutbounds(oldCfg, newCfg, diff); !ok {
|
||||
logger.Debug("hot diff: outbound change is not API-applicable (default outbound or tags)")
|
||||
return nil, false
|
||||
}
|
||||
if ok := diffRouting(oldCfg, newCfg, diff); !ok {
|
||||
logger.Debug("hot diff: routing change is not API-applicable (domainStrategy or section shape)")
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return diff, true
|
||||
}
|
||||
|
||||
// diffInbounds fills diff with inbound removals/additions (a changed inbound
|
||||
// becomes remove+add). The api inbound carries the gRPC server the panel is
|
||||
// talking through, so any change touching it forces a restart.
|
||||
func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
||||
oldByTag, ok := inboundsByTag(oldCfg.InboundConfigs)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
newByTag, ok := inboundsByTag(newCfg.InboundConfigs)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
apiTag := apiTagFromConfig(newCfg.API)
|
||||
|
||||
for i := range oldCfg.InboundConfigs {
|
||||
oldIb := &oldCfg.InboundConfigs[i]
|
||||
newIb, exists := newByTag[oldIb.Tag]
|
||||
if exists && inboundEqualNormalized(oldIb, newIb) {
|
||||
continue
|
||||
}
|
||||
if oldIb.Tag == apiTag || oldIb.Tag == "api" {
|
||||
return false
|
||||
}
|
||||
diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
|
||||
if exists {
|
||||
raw, err := json.Marshal(newIb)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
diff.AddedInbounds = append(diff.AddedInbounds, raw)
|
||||
}
|
||||
}
|
||||
for i := range newCfg.InboundConfigs {
|
||||
newIb := &newCfg.InboundConfigs[i]
|
||||
if _, exists := oldByTag[newIb.Tag]; exists {
|
||||
continue
|
||||
}
|
||||
if newIb.Tag == apiTag || newIb.Tag == "api" {
|
||||
return false
|
||||
}
|
||||
raw, err := json.Marshal(newIb)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
diff.AddedInbounds = append(diff.AddedInbounds, raw)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// diffOutbounds fills diff with outbound removals/additions keyed by tag.
|
||||
// The first outbound is xray's default handler and the API can only append,
|
||||
// so any change to its identity or content forces a restart. Reordering of
|
||||
// the remaining outbounds is ignored — routing addresses them by tag.
|
||||
func diffOutbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
||||
oldOut, ok := parseOutbounds(oldCfg.OutboundConfigs)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
newOut, ok := parseOutbounds(newCfg.OutboundConfigs)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if (len(oldOut) == 0) != (len(newOut) == 0) {
|
||||
return false
|
||||
}
|
||||
if len(oldOut) > 0 {
|
||||
if oldOut[0].tag != newOut[0].tag || !bytes.Equal(oldOut[0].norm, newOut[0].norm) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
oldByTag := make(map[string]outboundEntry, len(oldOut))
|
||||
for _, e := range oldOut {
|
||||
oldByTag[e.tag] = e
|
||||
}
|
||||
newByTag := make(map[string]outboundEntry, len(newOut))
|
||||
for _, e := range newOut {
|
||||
newByTag[e.tag] = e
|
||||
}
|
||||
|
||||
for _, oldE := range oldOut {
|
||||
newE, exists := newByTag[oldE.tag]
|
||||
if exists && bytes.Equal(oldE.norm, newE.norm) {
|
||||
continue
|
||||
}
|
||||
diff.RemovedOutboundTags = append(diff.RemovedOutboundTags, oldE.tag)
|
||||
if exists {
|
||||
diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
|
||||
}
|
||||
}
|
||||
for _, newE := range newOut {
|
||||
if _, exists := oldByTag[newE.tag]; !exists {
|
||||
diff.AddedOutbounds = append(diff.AddedOutbounds, newE.raw)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// diffRouting decides whether the routing change is limited to rules and
|
||||
// balancers — the only parts RoutingService.AddRule can replace at runtime.
|
||||
// domainStrategy/domainMatcher and any other key in the section are fixed at
|
||||
// process start.
|
||||
func diffRouting(oldCfg, newCfg *Config, diff *HotDiff) bool {
|
||||
if bytes.Equal(oldCfg.RouterConfig, newCfg.RouterConfig) {
|
||||
return true
|
||||
}
|
||||
// No routing section at start likely means no router feature (and no
|
||||
// RoutingService) in the running instance — only a restart can add it.
|
||||
if len(oldCfg.RouterConfig) == 0 || len(newCfg.RouterConfig) == 0 {
|
||||
return false
|
||||
}
|
||||
oldRest, ok := routingWithoutReloadable(oldCfg.RouterConfig)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
newRest, ok := routingWithoutReloadable(newCfg.RouterConfig)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if !bytes.Equal(oldRest, newRest) {
|
||||
return false
|
||||
}
|
||||
diff.RoutingConfig = newCfg.RouterConfig
|
||||
return true
|
||||
}
|
||||
|
||||
// routingWithoutReloadable returns the routing section normalized with the
|
||||
// runtime-reloadable keys removed, for comparing the restart-only remainder.
|
||||
func routingWithoutReloadable(raw []byte) ([]byte, bool) {
|
||||
parsed := map[string]any{}
|
||||
if len(raw) > 0 {
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
if err := decoder.Decode(&parsed); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
delete(parsed, "rules")
|
||||
delete(parsed, "balancers")
|
||||
out, err := json.Marshal(parsed)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// inboundEqualNormalized compares two inbounds ignoring JSON formatting in
|
||||
// their raw sections, so a reformatted template does not read as a changed
|
||||
// inbound.
|
||||
func inboundEqualNormalized(a, b *InboundConfig) bool {
|
||||
return a.Port == b.Port &&
|
||||
a.Protocol == b.Protocol &&
|
||||
a.Tag == b.Tag &&
|
||||
rawEqualNormalized(a.Listen, b.Listen) &&
|
||||
rawEqualNormalized(a.Settings, b.Settings) &&
|
||||
rawEqualNormalized(a.StreamSettings, b.StreamSettings) &&
|
||||
rawEqualNormalized(a.Sniffing, b.Sniffing)
|
||||
}
|
||||
|
||||
// rawEqualNormalized reports whether two raw JSON values are semantically
|
||||
// equal: whitespace, object key order and an explicit `null` versus an
|
||||
// absent section are all ignored. UI editors rebuild objects on save (new
|
||||
// key order) and emit `null` for switched-off sections — none of that is a
|
||||
// reason to restart the core. Number precision is preserved via json.Number,
|
||||
// so genuinely different values never compare equal. Unparsable values only
|
||||
// compare equal byte-for-byte.
|
||||
func rawEqualNormalized(a, b json_util.RawMessage) bool {
|
||||
if bytes.Equal(a, b) {
|
||||
return true
|
||||
}
|
||||
na, ok := canonicalJSON(a)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
nb, ok := canonicalJSON(b)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return bytes.Equal(na, nb)
|
||||
}
|
||||
|
||||
// canonicalJSON renders a JSON value in canonical form: sorted object keys,
|
||||
// no insignificant whitespace, exact number digits (json.Number). Empty
|
||||
// input and JSON null both canonicalize to nil.
|
||||
func canonicalJSON(raw json_util.RawMessage) ([]byte, bool) {
|
||||
if len(raw) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if value == nil {
|
||||
return nil, true
|
||||
}
|
||||
out, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
// inboundsByTag indexes inbounds by tag; ok is false when a tag is empty or
|
||||
// duplicated, since such handlers can't be addressed through the API.
|
||||
func inboundsByTag(inbounds []InboundConfig) (map[string]*InboundConfig, bool) {
|
||||
byTag := make(map[string]*InboundConfig, len(inbounds))
|
||||
for i := range inbounds {
|
||||
tag := inbounds[i].Tag
|
||||
if tag == "" {
|
||||
return nil, false
|
||||
}
|
||||
if _, dup := byTag[tag]; dup {
|
||||
return nil, false
|
||||
}
|
||||
byTag[tag] = &inbounds[i]
|
||||
}
|
||||
return byTag, true
|
||||
}
|
||||
|
||||
type outboundEntry struct {
|
||||
tag string
|
||||
raw []byte // original JSON, used for AddOutbound
|
||||
norm []byte // canonical JSON, used for change detection
|
||||
}
|
||||
|
||||
// parseOutbounds splits the outbounds array into per-entry raw/normalized
|
||||
// JSON. ok is false when the array is unparsable or an entry has an empty or
|
||||
// duplicate tag — those can't be addressed through the API.
|
||||
func parseOutbounds(raw json_util.RawMessage) ([]outboundEntry, bool) {
|
||||
if len(raw) == 0 {
|
||||
return nil, true
|
||||
}
|
||||
var elems []json.RawMessage
|
||||
if err := json.Unmarshal(raw, &elems); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
entries := make([]outboundEntry, 0, len(elems))
|
||||
seen := make(map[string]struct{}, len(elems))
|
||||
for _, elem := range elems {
|
||||
var meta struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if err := json.Unmarshal(elem, &meta); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
if meta.Tag == "" {
|
||||
return nil, false
|
||||
}
|
||||
if _, dup := seen[meta.Tag]; dup {
|
||||
return nil, false
|
||||
}
|
||||
seen[meta.Tag] = struct{}{}
|
||||
norm, ok := canonicalJSON(json_util.RawMessage(elem))
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
entries = append(entries, outboundEntry{tag: meta.Tag, raw: elem, norm: norm})
|
||||
}
|
||||
return entries, true
|
||||
}
|
||||
|
||||
// apiTagFromConfig extracts api.tag from the api section, defaulting to "api".
|
||||
func apiTagFromConfig(api json_util.RawMessage) string {
|
||||
var parsed struct {
|
||||
Tag string `json:"tag"`
|
||||
}
|
||||
if len(api) > 0 && json.Unmarshal(api, &parsed) == nil && parsed.Tag != "" {
|
||||
return parsed.Tag
|
||||
}
|
||||
return "api"
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
package xray
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
xuilogger "github.com/mhsanaei/3x-ui/v3/internal/logger"
|
||||
"github.com/mhsanaei/3x-ui/v3/internal/util/json_util"
|
||||
|
||||
"github.com/op/go-logging"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// ComputeHotDiff logs the section that blocks a hot apply; the package
|
||||
// logger must exist before any test exercises a blocked path.
|
||||
xuilogger.InitLogger(logging.ERROR)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
func makeHotConfig() *Config {
|
||||
return &Config{
|
||||
LogConfig: json_util.RawMessage(`{"loglevel":"warning"}`),
|
||||
RouterConfig: json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`),
|
||||
OutboundConfigs: json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`),
|
||||
Policy: json_util.RawMessage(`{}`),
|
||||
API: json_util.RawMessage(`{"services":["HandlerService","StatsService","RoutingService"],"tag":"api"}`),
|
||||
Stats: json_util.RawMessage(`{}`),
|
||||
Metrics: json_util.RawMessage(`{}`),
|
||||
InboundConfigs: []InboundConfig{
|
||||
{
|
||||
Port: 62789,
|
||||
Protocol: "tunnel",
|
||||
Tag: "api",
|
||||
Listen: json_util.RawMessage(`"127.0.0.1"`),
|
||||
Settings: json_util.RawMessage(`{}`),
|
||||
},
|
||||
{
|
||||
Port: 1080,
|
||||
Protocol: "vless",
|
||||
Tag: "inbound-1080",
|
||||
Listen: json_util.RawMessage(`"0.0.0.0"`),
|
||||
Settings: json_util.RawMessage(`{"clients":[]}`),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_NoChanges(t *testing.T) {
|
||||
diff, ok := ComputeHotDiff(makeHotConfig(), makeHotConfig())
|
||||
if !ok {
|
||||
t.Fatal("identical configs must be hot-appliable")
|
||||
}
|
||||
if !diff.Empty() {
|
||||
t.Fatalf("identical configs must produce an empty diff, got %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_FormattingOnlyChangeIsEmptyDiff(t *testing.T) {
|
||||
oldCfg := makeHotConfig()
|
||||
newCfg := makeHotConfig()
|
||||
// Reformat every section the way a frontend textarea save would.
|
||||
newCfg.LogConfig = json_util.RawMessage("{\n \"loglevel\": \"warning\"\n}")
|
||||
newCfg.Policy = json_util.RawMessage("{ }")
|
||||
newCfg.API = json_util.RawMessage("{\n \"services\": [\"HandlerService\", \"StatsService\", \"RoutingService\"],\n \"tag\": \"api\"\n}")
|
||||
newCfg.OutboundConfigs = json_util.RawMessage("[\n {\"protocol\": \"freedom\", \"tag\": \"direct\"},\n {\"protocol\": \"blackhole\", \"tag\": \"blocked\"}\n]")
|
||||
newCfg.InboundConfigs[1].Settings = json_util.RawMessage("{\n \"clients\": []\n}")
|
||||
|
||||
diff, ok := ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
t.Fatal("formatting-only change must be hot-appliable")
|
||||
}
|
||||
if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 ||
|
||||
len(diff.RemovedOutboundTags) != 0 || len(diff.AddedOutbounds) != 0 {
|
||||
t.Fatalf("formatting-only change must produce no handler ops, got %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_CanonicalEquality(t *testing.T) {
|
||||
// Key reorder in a static section (the DNS editor rebuilds the object on
|
||||
// save) must not read as a change.
|
||||
oldCfg := makeHotConfig()
|
||||
oldCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"],"queryStrategy":"UseIP","tag":"dns-in"}`)
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.DNSConfig = json_util.RawMessage(`{"tag":"dns-in","queryStrategy":"UseIP","servers":["1.1.1.1"]}`)
|
||||
diff, ok := ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok || !diff.Empty() {
|
||||
t.Fatalf("dns key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
|
||||
}
|
||||
|
||||
// Explicit null and an absent section are the same thing.
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.FakeDNS = json_util.RawMessage(`null`)
|
||||
diff, ok = ComputeHotDiff(makeHotConfig(), newCfg)
|
||||
if !ok || !diff.Empty() {
|
||||
t.Fatalf("fakedns null vs absent must be an empty hot diff, ok=%v diff=%+v", ok, diff)
|
||||
}
|
||||
|
||||
// A real DNS change still forces a restart — there is no reload API.
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.DNSConfig = json_util.RawMessage(`{"servers":["8.8.8.8"]}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("real dns change must force a restart")
|
||||
}
|
||||
|
||||
// Large integers keep full precision during normalization: two values
|
||||
// that only differ past float64 precision must still read as a change.
|
||||
oldCfg = makeHotConfig()
|
||||
oldCfg.Policy = json_util.RawMessage(`{"big":9007199254740993}`)
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.Policy = json_util.RawMessage(`{"big":9007199254740992}`)
|
||||
if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
|
||||
t.Fatal("values differing past float64 precision must not compare equal")
|
||||
}
|
||||
|
||||
// Reordered keys inside the first (default) outbound must not force a
|
||||
// restart — the form editor rebuilds the object on save.
|
||||
oldCfg = makeHotConfig()
|
||||
oldCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"AsIs"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.OutboundConfigs = json_util.RawMessage(`[{"tag":"direct","settings":{"domainStrategy":"AsIs"},"protocol":"freedom"},{"protocol":"blackhole","tag":"blocked"}]`)
|
||||
diff, ok = ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok || !diff.Empty() {
|
||||
t.Fatalf("first outbound key reorder must be an empty hot diff, ok=%v diff=%+v", ok, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_StaticSectionChangeNeedsRestart(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.LogConfig = json_util.RawMessage(`{"loglevel":"debug"}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("log change must force a restart")
|
||||
}
|
||||
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.DNSConfig = json_util.RawMessage(`{"servers":["1.1.1.1"]}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("dns change must force a restart")
|
||||
}
|
||||
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.Observatory = json_util.RawMessage(`{"subjectSelector":["wg"]}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("observatory change must force a restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_InboundAddRemoveChange(t *testing.T) {
|
||||
oldCfg := makeHotConfig()
|
||||
newCfg := makeHotConfig()
|
||||
// change existing
|
||||
newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a"}]}`)
|
||||
// add new
|
||||
newCfg.InboundConfigs = append(newCfg.InboundConfigs, InboundConfig{
|
||||
Port: 2080, Protocol: "vmess", Tag: "inbound-2080",
|
||||
Settings: json_util.RawMessage(`{}`),
|
||||
})
|
||||
|
||||
diff, ok := ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
t.Fatal("inbound-only change must be hot-appliable")
|
||||
}
|
||||
if len(diff.RemovedInboundTags) != 1 || diff.RemovedInboundTags[0] != "inbound-1080" {
|
||||
t.Fatalf("expected changed inbound to be removed, got %v", diff.RemovedInboundTags)
|
||||
}
|
||||
if len(diff.AddedInbounds) != 2 {
|
||||
t.Fatalf("expected re-add + new add, got %d", len(diff.AddedInbounds))
|
||||
}
|
||||
if diff.RoutingConfig != nil || len(diff.AddedOutbounds) != 0 || len(diff.RemovedOutboundTags) != 0 {
|
||||
t.Fatalf("unexpected non-inbound operations: %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_ApiInboundChangeNeedsRestart(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.InboundConfigs[0].Port = 62790
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("api inbound change must force a restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_OutboundChangeAndReorder(t *testing.T) {
|
||||
oldCfg := makeHotConfig()
|
||||
newCfg := makeHotConfig()
|
||||
// change a non-first outbound + add one
|
||||
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","settings":{},"tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
|
||||
|
||||
diff, ok := ComputeHotDiff(oldCfg, newCfg)
|
||||
if !ok {
|
||||
t.Fatal("outbound-only change must be hot-appliable")
|
||||
}
|
||||
if len(diff.RemovedOutboundTags) != 1 || diff.RemovedOutboundTags[0] != "blocked" {
|
||||
t.Fatalf("expected changed outbound to be removed, got %v", diff.RemovedOutboundTags)
|
||||
}
|
||||
if len(diff.AddedOutbounds) != 2 {
|
||||
t.Fatalf("expected re-add + new add, got %d", len(diff.AddedOutbounds))
|
||||
}
|
||||
for _, raw := range diff.AddedOutbounds {
|
||||
if !strings.Contains(string(raw), `"tag"`) {
|
||||
t.Fatalf("added outbound JSON must be the raw element, got %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
// pure reorder of non-first outbounds must be a no-op
|
||||
reordered := makeHotConfig()
|
||||
reordered.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"socks","tag":"warp"},{"protocol":"blackhole","tag":"blocked"}]`)
|
||||
base := makeHotConfig()
|
||||
base.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole","tag":"blocked"},{"protocol":"socks","tag":"warp"}]`)
|
||||
diff, ok = ComputeHotDiff(base, reordered)
|
||||
if !ok || !diff.Empty() {
|
||||
t.Fatalf("reorder of non-first outbounds must be an empty hot diff, ok=%v diff=%+v", ok, diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_FirstOutboundChangeNeedsRestart(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
// change the default (first) outbound content
|
||||
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","settings":{"domainStrategy":"UseIP"},"tag":"direct"},{"protocol":"blackhole","tag":"blocked"}]`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("changing the default outbound must force a restart")
|
||||
}
|
||||
|
||||
// swap which outbound comes first
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"blackhole","tag":"blocked"},{"protocol":"freedom","tag":"direct"}]`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("changing the first outbound must force a restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_TaglessOutboundNeedsRestart(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.OutboundConfigs = json_util.RawMessage(`[{"protocol":"freedom","tag":"direct"},{"protocol":"blackhole"}]`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("tagless outbound must force a restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_RoutingRulesChange(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"},{"type":"field","ip":["geoip:private"],"outboundTag":"blocked"}]}`)
|
||||
|
||||
diff, ok := ComputeHotDiff(makeHotConfig(), newCfg)
|
||||
if !ok {
|
||||
t.Fatal("rules-only routing change must be hot-appliable")
|
||||
}
|
||||
if diff.RoutingConfig == nil {
|
||||
t.Fatal("routing diff must carry the new routing section")
|
||||
}
|
||||
|
||||
// balancers are reloadable too
|
||||
newCfg = makeHotConfig()
|
||||
newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"AsIs","rules":[],"balancers":[{"tag":"b1","selector":["wg"]}]}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); !ok {
|
||||
t.Fatal("balancer-only routing change must be hot-appliable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
|
||||
newCfg := makeHotConfig()
|
||||
newCfg.RouterConfig = json_util.RawMessage(`{"domainStrategy":"IPIfNonMatch","rules":[{"type":"field","inboundTag":["api"],"outboundTag":"api"}]}`)
|
||||
if _, ok := ComputeHotDiff(makeHotConfig(), newCfg); ok {
|
||||
t.Fatal("domainStrategy change must force a restart")
|
||||
}
|
||||
}
|
||||
@@ -249,6 +249,13 @@ func (p *Process) GetConfig() *Config {
|
||||
return p.config
|
||||
}
|
||||
|
||||
// SetConfig replaces the stored configuration snapshot after the running
|
||||
// process has been reconciled with it through the gRPC API (hot apply), so
|
||||
// later change detection compares against what is actually running.
|
||||
func (p *Process) SetConfig(config *Config) {
|
||||
p.config = config
|
||||
}
|
||||
|
||||
// GetOnlineClients returns the union of locally-online clients and
|
||||
// node-online clients from every registered remote panel. Dedupes by
|
||||
// email so a client connected to both a local and a node-managed inbound
|
||||
|
||||
Reference in New Issue
Block a user