From cb8981158fa6481412f246175de13a68079d05e3 Mon Sep 17 00:00:00 2001 From: Muse Date: Tue, 20 Oct 2026 11:03:45 +0000 Subject: [PATCH] =?UTF-8?q?=E7=B2=BE=E7=AE=80P2:=20=E5=89=A5=E7=A6=BB=20xr?= =?UTF-8?q?ay-core=20API=20=E5=AE=A2=E6=88=B7=E7=AB=AF:=20StatsService=20?= =?UTF-8?q?=E6=94=B9=20protowire=20=E7=BA=BF=E7=BC=86=E7=BA=A7=E5=AE=9E?= =?UTF-8?q?=E7=8E=B0(QueryStats/GetUsersStats=20=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=E5=8F=B7=E6=8C=89=E6=A0=B8=E5=BF=83=20proto=20=E5=A4=8D?= =?UTF-8?q?=E5=88=BB,=20agent=20=E6=B5=81=E9=87=8F=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E4=B8=8E=E6=9C=AC=E5=9C=B0=E6=B5=81=E9=87=8F=E4=BB=BB=E5=8A=A1?= =?UTF-8?q?=E4=B8=8D=E5=8F=97=E5=BD=B1=E5=93=8D);=20Handler/Routing=20?= =?UTF-8?q?=E5=8F=98=E6=9B=B4=E7=B1=BB=20RPC=20=E6=97=A0=E6=B3=95=E8=84=B1?= =?UTF-8?q?=E7=A6=BB=E6=A0=B8=E5=BF=83=E7=B1=BB=E5=9E=8B=E5=8C=96=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=9E=84=E5=BB=BA,=20=E7=BB=9F=E4=B8=80=E9=99=8D?= =?UTF-8?q?=E7=BA=A7=20ErrHotApplyUnavailable(=E8=B0=83=E7=94=A8=E6=96=B9?= =?UTF-8?q?=E6=97=A2=E6=9C=89=E5=85=9C=E5=BA=95:=20=E7=83=AD=E6=9B=B4?= =?UTF-8?q?=E6=96=B0->=E9=87=8D=E5=90=AF=E6=A0=B8=E5=BF=83,=20balancer=20?= =?UTF-8?q?=E8=A7=86=E5=9B=BE=E6=8A=A5=E6=9C=AA=E8=BF=90=E8=A1=8C,=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E6=A1=A5=E6=8E=A2=E6=B5=8B=E6=94=BE=E5=BC=83?= =?UTF-8?q?,=20IP=20=E9=99=90=E8=BF=9E=E4=B8=B4=E6=97=B6=E6=96=AD=E5=BC=80?= =?UTF-8?q?=E4=BB=85=E8=AE=B0=E6=97=A5=E5=BF=97);=20ValidateOutboundConfig?= =?UTF-8?q?=20=E6=94=B9=E7=BA=AF=20JSON=20=E5=AE=9E=E7=8E=B0,=20=E7=B2=BE?= =?UTF-8?q?=E7=A1=AE=E5=A4=8D=E5=88=BB=E6=A0=B8=E5=BF=83=20v26.7.11=20?= =?UTF-8?q?=E6=9C=AA=E5=8A=A0=E5=AF=86=20vless/trojan=20=E5=85=AC=E7=BD=91?= =?UTF-8?q?=E7=A6=81=E4=BB=A4(=E5=90=AB=E7=A7=81=E6=9C=89=20IP/=E5=9F=9F?= =?UTF-8?q?=E5=90=8D=E5=8C=B9=E9=85=8D=E8=A1=A8=E4=B8=8E=E5=8E=9F=E6=96=87?= =?UTF-8?q?=E6=8A=A5=E9=94=99,=20=E4=B8=94=E5=90=8C=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E4=B8=80=E6=A0=B7=E5=8F=AA=E6=9F=A5=E6=89=81=E5=B9=B3=20addres?= =?UTF-8?q?s=20=E5=BD=A2=E5=BC=8F);=20=E5=88=A0=E9=99=A4=20buildUserAccoun?= =?UTF-8?q?t=20=E5=8F=8A=20SS/WG/e2e=20=E6=97=A7=E6=B5=8B=E8=AF=95,=20?= =?UTF-8?q?=E6=B5=81=E9=87=8F=E5=A2=9E=E9=87=8F=E6=B5=8B=E8=AF=95=E6=94=B9?= =?UTF-8?q?=E7=BA=BF=E7=BC=86=E7=BA=A7=E5=81=87=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=20stock=20protobuf=20=E6=9C=8D?= =?UTF-8?q?=E5=8A=A1=E5=99=A8=E4=BA=92=E6=93=8D=E4=BD=9C=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/xray/api.go | 1013 +++++++++++-------------- internal/xray/api_e2e_test.go | 257 ------- internal/xray/api_shadowsocks_test.go | 149 ---- internal/xray/api_stats_wire_test.go | 248 ++++++ internal/xray/api_test.go | 100 +-- internal/xray/api_traffic_test.go | 67 +- internal/xray/api_users_e2e_test.go | 446 ----------- internal/xray/api_wireguard_test.go | 129 ---- 8 files changed, 762 insertions(+), 1647 deletions(-) delete mode 100644 internal/xray/api_e2e_test.go delete mode 100644 internal/xray/api_shadowsocks_test.go create mode 100644 internal/xray/api_stats_wire_test.go delete mode 100644 internal/xray/api_users_e2e_test.go delete mode 100644 internal/xray/api_wireguard_test.go diff --git a/internal/xray/api.go b/internal/xray/api.go index 5f9fcee..fe0ec56 100644 --- a/internal/xray/api.go +++ b/internal/xray/api.go @@ -6,40 +6,48 @@ package xray import ( "context" "encoding/json" + "errors" "fmt" "math" - "net" - "os" - "path/filepath" + "net/netip" "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" - wgutil "github.com/mhsanaei/3x-ui/v3/internal/util/wireguard" - "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" - hysteriaAccount "github.com/xtls/xray-core/proxy/hysteria/account" - "github.com/xtls/xray-core/proxy/shadowsocks" - "github.com/xtls/xray-core/proxy/shadowsocks_2022" - "github.com/xtls/xray-core/proxy/trojan" - "github.com/xtls/xray-core/proxy/vless" - "github.com/xtls/xray-core/proxy/vmess" - wireguard "github.com/xtls/xray-core/proxy/wireguard" "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/encoding" "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protowire" ) +// This build no longer links xray-core. What remains of the core API client: +// +// - StatsService (QueryStats / GetUsersStats) is implemented at the +// protobuf wire level with protowire: those two RPCs carry the traffic +// accounting and online-user data, and their messages are small and +// stable. The agent's traffic reporting and the local traffic job keep +// working exactly as before. +// - The mutation RPCs (HandlerService add/remove/alter, RoutingService +// rule/balancer operations) cannot be reimplemented without the core's +// typed config builders — AddInbound hands the core a fully built +// protobuf handler config, not JSON. They return +// ErrHotApplyUnavailable instead. Every caller already treats an API +// failure as "fall back": hot-apply degrades to a full core restart, +// the balancer view reports not-running, the node bridge probe gives +// up, and the IP-limit temp-disconnect logs and moves on. On a +// control-plane master (the lite build's target) the local core never +// runs, so these paths were unreachable there to begin with. + +// ErrHotApplyUnavailable is returned by the core mutation RPCs that this +// build cannot perform without linking xray-core. Callers fall back to +// restarting the core process, which applies the same configuration. +var ErrHotApplyUnavailable = errors.New("xray core hot-apply API unavailable in this build; the change is applied by restarting the core") + // Compiled once at package load: GetTraffic runs on every traffic-stats tick, // so recompiling these per call is wasted work. var ( @@ -47,45 +55,15 @@ var ( clientTrafficRegex = regexp.MustCompile(`user>>>([^>]+)>>>traffic>>>(downlink|uplink)`) ) -// XrayAPI is a gRPC client for managing Xray core configuration, inbounds, outbounds, and statistics. +// XrayAPI is a client for the Xray core's gRPC API. Only the stats service +// is functional in this build; see the package comment. type XrayAPI struct { - HandlerServiceClient *command.HandlerServiceClient - StatsServiceClient *statsService.StatsServiceClient - RoutingServiceClient *routerService.RoutingServiceClient - grpcClient *grpc.ClientConn - isConnected bool - StatsLastValues map[string]int64 + grpcClient *grpc.ClientConn + isConnected bool + StatsLastValues map[string]int64 } -func getRequiredUserString(user map[string]any, key string) (string, error) { - value, ok := user[key] - if !ok || value == nil { - return "", fmt.Errorf("missing required user field %q", key) - } - - strValue, ok := value.(string) - if !ok { - return "", fmt.Errorf("invalid type for user field %q: %T", key, value) - } - - return strValue, nil -} - -func getOptionalUserString(user map[string]any, key string) (string, error) { - value, ok := user[key] - if !ok || value == nil { - return "", nil - } - - strValue, ok := value.(string) - if !ok { - return "", fmt.Errorf("invalid type for user field %q: %T", key, value) - } - - return strValue, nil -} - -// Init connects to the Xray API server and initializes handler and stats service clients. +// Init connects to the Xray API server. func (x *XrayAPI) Init(apiPort int) error { if apiPort <= 0 || apiPort > math.MaxUint16 { return fmt.Errorf("invalid Xray API port: %d", apiPort) @@ -103,14 +81,6 @@ func (x *XrayAPI) Init(apiPort int) error { x.StatsLastValues = make(map[string]int64) } - hsClient := command.NewHandlerServiceClient(conn) - ssClient := statsService.NewStatsServiceClient(conn) - rsClient := routerService.NewRoutingServiceClient(conn) - - x.HandlerServiceClient = &hsClient - x.StatsServiceClient = &ssClient - x.RoutingServiceClient = &rsClient - return nil } @@ -119,146 +89,272 @@ func (x *XrayAPI) Close() { if x.grpcClient != nil { x.grpcClient.Close() } - x.HandlerServiceClient = nil - x.StatsServiceClient = nil - x.RoutingServiceClient = nil x.isConnected = false } -// handlerRPCTimeout bounds per-call gRPC handler operations (add/remove inbound, -// alter user) so a hung core connection cannot block the caller indefinitely — -// for example while the process restart lock is held. -const handlerRPCTimeout = 10 * time.Second +// rawBytes is a pre-encoded protobuf message passed through the gRPC stack +// untouched by rawCodec. +type rawBytes []byte -// AddInbound adds a new inbound configuration to the Xray core via gRPC. +// rawCodec is a gRPC codec that forwards rawBytes as-is. Its name keeps the +// standard "proto" content subtype the core's server expects. +type rawCodec struct{} + +func (rawCodec) Marshal(v any) ([]byte, error) { + b, ok := v.(rawBytes) + if !ok { + return nil, fmt.Errorf("xray api: raw codec cannot marshal %T", v) + } + return b, nil +} + +func (rawCodec) Unmarshal(data []byte, v any) error { + p, ok := v.(*rawBytes) + if !ok { + return fmt.Errorf("xray api: raw codec cannot unmarshal into %T", v) + } + *p = append((*p)[:0], data...) + return nil +} + +func (rawCodec) Name() string { return "proto" } + +var _ encoding.Codec = rawCodec{} + +const ( + statsServiceQueryStats = "/xray.app.stats.command.StatsService/QueryStats" + statsServiceGetUsersStats = "/xray.app.stats.command.StatsService/GetUsersStats" +) + +// statEntry is one counter from a QueryStats response +// (xray.app.stats.command.Stat: name = 1, value = 2). +type statEntry struct { + Name string + Value int64 +} + +// queryStats calls StatsService.QueryStats with an empty pattern and no +// reset — the same request the typed client sent. Both fields are proto3 +// zero values, so the encoded request is empty. +func (x *XrayAPI) queryStats(ctx context.Context) ([]statEntry, error) { + var resp rawBytes + if err := x.grpcClient.Invoke(ctx, statsServiceQueryStats, rawBytes{}, &resp, grpc.ForceCodec(rawCodec{})); err != nil { + return nil, err + } + return parseQueryStatsResponse(resp) +} + +func parseQueryStatsResponse(b []byte) ([]statEntry, error) { + var out []statEntry + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + b = b[n:] + if num == 1 && typ == protowire.BytesType { + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + st, err := parseStat(v) + if err != nil { + return nil, err + } + out = append(out, st) + b = b[n:] + continue + } + n = protowire.ConsumeFieldValue(num, typ, b) + if n < 0 { + return nil, protowire.ParseError(n) + } + b = b[n:] + } + return out, nil +} + +func parseStat(b []byte) (statEntry, error) { + var st statEntry + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + if n < 0 { + return st, protowire.ParseError(n) + } + b = b[n:] + switch { + case num == 1 && typ == protowire.BytesType: + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return st, protowire.ParseError(n) + } + st.Name = string(v) + b = b[n:] + case num == 2 && typ == protowire.VarintType: + v, n := protowire.ConsumeVarint(b) + if n < 0 { + return st, protowire.ParseError(n) + } + st.Value = int64(v) + b = b[n:] + default: + n := protowire.ConsumeFieldValue(num, typ, b) + if n < 0 { + return st, protowire.ParseError(n) + } + b = b[n:] + } + } + return st, nil +} + +// userStatEntry is one user from a GetUsersStats response +// (xray.app.stats.command.UserStat: email = 1, ips = 2; +// OnlineIPEntry: ip = 1, last_seen = 2). +type userStatEntry struct { + Email string + IPs []OnlineIP +} + +// getUsersStats calls StatsService.GetUsersStats without traffic and +// without reset — again all proto3 zero values, so the request is empty. +func (x *XrayAPI) getUsersStats(ctx context.Context) ([]userStatEntry, error) { + var resp rawBytes + if err := x.grpcClient.Invoke(ctx, statsServiceGetUsersStats, rawBytes{}, &resp, grpc.ForceCodec(rawCodec{})); err != nil { + return nil, err + } + return parseGetUsersStatsResponse(resp) +} + +func parseGetUsersStatsResponse(b []byte) ([]userStatEntry, error) { + var out []userStatEntry + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + b = b[n:] + if num == 1 && typ == protowire.BytesType { + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return nil, protowire.ParseError(n) + } + u, err := parseUserStat(v) + if err != nil { + return nil, err + } + out = append(out, u) + b = b[n:] + continue + } + n = protowire.ConsumeFieldValue(num, typ, b) + if n < 0 { + return nil, protowire.ParseError(n) + } + b = b[n:] + } + return out, nil +} + +func parseUserStat(b []byte) (userStatEntry, error) { + var u userStatEntry + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + if n < 0 { + return u, protowire.ParseError(n) + } + b = b[n:] + switch { + case num == 1 && typ == protowire.BytesType: + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return u, protowire.ParseError(n) + } + u.Email = string(v) + b = b[n:] + case num == 2 && typ == protowire.BytesType: + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return u, protowire.ParseError(n) + } + ip, err := parseOnlineIPEntry(v) + if err != nil { + return u, err + } + u.IPs = append(u.IPs, ip) + b = b[n:] + default: + n := protowire.ConsumeFieldValue(num, typ, b) + if n < 0 { + return u, protowire.ParseError(n) + } + b = b[n:] + } + } + return u, nil +} + +func parseOnlineIPEntry(b []byte) (OnlineIP, error) { + var ip OnlineIP + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + if n < 0 { + return ip, protowire.ParseError(n) + } + b = b[n:] + switch { + case num == 1 && typ == protowire.BytesType: + v, n := protowire.ConsumeBytes(b) + if n < 0 { + return ip, protowire.ParseError(n) + } + ip.IP = string(v) + b = b[n:] + case num == 2 && typ == protowire.VarintType: + v, n := protowire.ConsumeVarint(b) + if n < 0 { + return ip, protowire.ParseError(n) + } + ip.LastSeen = int64(v) + b = b[n:] + default: + n := protowire.ConsumeFieldValue(num, typ, b) + if n < 0 { + return ip, protowire.ParseError(n) + } + b = b[n:] + } + } + return ip, nil +} + +// AddInbound is unavailable in this build: it handed the core a protobuf +// handler built by xray-core's config loader. Configuration changes are +// applied by restarting the core (see ErrHotApplyUnavailable). func (x *XrayAPI) AddInbound(inbound []byte) error { - if x.HandlerServiceClient == nil { - return common.NewError("xray HandlerServiceClient is not initialized") - } - client := *x.HandlerServiceClient - - conf := new(conf.InboundDetourConfig) - err := json.Unmarshal(inbound, conf) - if err != nil { - logger.Debug("Failed to unmarshal inbound:", err) - return err - } - config, err := conf.Build() - if err != nil { - logger.Debug("Failed to build inbound Detur:", err) - return err - } - inboundConfig := command.AddInboundRequest{Inbound: config} - - ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout) - defer cancel() - _, err = client.AddInbound(ctx, &inboundConfig) - - return err + return ErrHotApplyUnavailable } -// DelInbound removes an inbound configuration from the Xray core by tag. +// DelInbound is unavailable in this build; see AddInbound. func (x *XrayAPI) DelInbound(tag string) error { - if x.HandlerServiceClient == nil { - return common.NewError("xray HandlerServiceClient is not initialized") - } - client := *x.HandlerServiceClient - ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout) - defer cancel() - _, err := client.RemoveInbound(ctx, &command.RemoveInboundRequest{ - Tag: tag, - }) - return err + return ErrHotApplyUnavailable } -// ValidateOutboundConfig builds an outbound JSON object through the vendored -// xray-core config loader, surfacing the exact error the core would raise at -// startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds -// whose server address is a public IP or domain. -func ValidateOutboundConfig(outbound []byte) error { - ensureXrayAssetLocation() - - detour := new(conf.OutboundDetourConfig) - if err := json.Unmarshal(outbound, detour); err != nil { - return err - } - _, err := detour.Build() - return err -} - -// AddOutbound adds a new outbound configuration to the Xray core via gRPC. +// AddOutbound is unavailable in this build; see AddInbound. func (x *XrayAPI) AddOutbound(outbound []byte) error { - if x.HandlerServiceClient == nil { - return common.NewError("xray HandlerServiceClient is not initialized") - } - client := *x.HandlerServiceClient - - ensureXrayAssetLocation() - - 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 + return ErrHotApplyUnavailable } -// DelOutbound removes an outbound configuration from the Xray core by tag. +// DelOutbound is unavailable in this build; see AddInbound. 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 + return ErrHotApplyUnavailable } -// 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. +// ApplyRoutingConfig is unavailable in this build: it handed the core a +// protobuf router config built by xray-core's config loader. Routing +// changes are applied by restarting the core. 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 + return ErrHotApplyUnavailable } // BalancerInfo is the live state of one balancer inside the running core. @@ -272,48 +368,15 @@ type BalancerInfo struct { Selected []string `json:"selected"` } -// GetBalancerInfo queries the running core for a balancer's current override -// and the targets its strategy would pick right now. +// GetBalancerInfo is unavailable in this build; callers report the +// balancer as not running, exactly as they do for a stopped core. 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 + return nil, ErrHotApplyUnavailable } -// SetBalancerTarget forces a balancer to always pick the given outbound tag. -// An empty target clears the override and hands control back to the strategy. +// SetBalancerTarget is unavailable in this build; see GetBalancerInfo. 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 + return ErrHotApplyUnavailable } // RouteTestRequest describes a synthetic connection to ask the running core @@ -338,62 +401,23 @@ type RouteTestResult struct { 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. +// TestRoute is unavailable in this build: it needs the RoutingService, +// whose messages embed a fully built routing context. The panel's route +// test already requires a running core and reports this error verbatim. func (x *XrayAPI) TestRoute(req RouteTestRequest) (*RouteTestResult, error) { - if x.RoutingServiceClient == nil { - return nil, common.NewError("xray RoutingServiceClient is not initialized") - } + return nil, ErrHotApplyUnavailable +} - if req.Port < 0 || req.Port > math.MaxUint16 { - return nil, common.NewErrorf("invalid port: %d", req.Port) - } +// AddUser is unavailable in this build: it handed the core a typed +// per-protocol account built by xray-core. User changes are applied by +// restarting the core (see ErrHotApplyUnavailable). +func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error { + return ErrHotApplyUnavailable +} - 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 +// RemoveUser is unavailable in this build; see AddUser. +func (x *XrayAPI) RemoveUser(inboundTag, email string) error { + return ErrHotApplyUnavailable } // IsMissingHandlerErr reports whether err is xray's response to removing a @@ -427,305 +451,144 @@ func IsUserExistsErr(err error) bool { return strings.Contains(strings.ToLower(err.Error()), "already exists") } -// 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 +// ValidateOutboundConfig checks an outbound JSON object the way the core +// would at startup for the one refusal the panel must surface early: +// since xray-core v26.7.11, an unencrypted VLESS or Trojan outbound whose +// server address is a public IP or domain fails to build. Everything else +// the core's typed loader used to check (per-protocol field shapes) is no +// longer validated in-process; the core still enforces it when it starts +// on the node. The replication below mirrors the core's check exactly, +// including that it only inspects the flat "address" settings field — +// the vnext/servers forms were never covered by it. +// +// The error texts are the core's own, verbatim: the settings layer matches +// on them for its legacy-core version gate. +func ValidateOutboundConfig(outbound []byte) error { + var parsed struct { + Protocol string `json:"protocol"` + Settings json.RawMessage + StreamSettings *struct { + Security string `json:"security"` + } `json:"streamSettings"` } - if abs, err := filepath.Abs(config.GetBinFolderPath()); err == nil { - os.Setenv("XRAY_LOCATION_ASSET", abs) + if err := json.Unmarshal(outbound, &parsed); err != nil { + return err } -} -// collectStringSlice normalizes a JSON-decoded value into a slice of non-empty -// strings, accepting both []string (typed maps) and []any (json.Unmarshal output). -func collectStringSlice(value any) []string { - switch v := value.(type) { - case []string: - out := make([]string, 0, len(v)) - for _, s := range v { - if s != "" { - out = append(out, s) - } - } - return out - case []any: - out := make([]string, 0, len(v)) - for _, e := range v { - if s, ok := e.(string); ok && s != "" { - out = append(out, s) - } - } - return out - default: + // A secured stream (tls/reality/…) satisfies the rule outright. + if parsed.StreamSettings != nil && parsed.StreamSettings.Security != "" && parsed.StreamSettings.Security != "none" { return nil } -} -// legacyShadowsocksAccountType is the type URL serial.ToTypedMessage stamps on -// a pre-2022 shadowsocks account, which identifies the one inbound whose user -// list tolerates duplicate emails. -const legacyShadowsocksAccountType = "xray.proxy.shadowsocks.Account" - -// shadowsocks2022Ciphers are the methods that select xray's shadowsocks-2022 -// inbound (sing's shadowaead_2022 list). They take a different account type -// than the legacy AEAD ciphers, and the running inbound casts the account it -// receives without checking, so a wrong guess takes the whole core down. -var shadowsocks2022Ciphers = map[string]struct{}{ - "2022-blake3-aes-128-gcm": {}, - "2022-blake3-aes-256-gcm": {}, - "2022-blake3-chacha20-poly1305": {}, -} - -// shadowsocksCipherName resolves the cipher a shadowsocks user's account must -// be built for. Panel-built user maps carry it under "cipher"; client objects -// taken verbatim from an inbound's settings carry the inbound's method under -// "method" instead (HealShadowsocksClientMethods writes it onto every -// legacy-cipher client). -func shadowsocksCipherName(user map[string]any) (string, error) { - cipher, err := getOptionalUserString(user, "cipher") - if err != nil { - return "", err - } - if cipher != "" { - return cipher, nil - } - return getOptionalUserString(user, "method") -} - -// shadowsocksCipherType mirrors xray-core's infra/conf cipherFromString, -// aliases and case-insensitivity included, so the account the panel builds for -// a live user matches the one the core built for that inbound from its config. -func shadowsocksCipherType(cipher string) shadowsocks.CipherType { - switch strings.ToLower(cipher) { - case "aes-128-gcm", "aead_aes_128_gcm": - return shadowsocks.CipherType_AES_128_GCM - case "aes-256-gcm", "aead_aes_256_gcm": - return shadowsocks.CipherType_AES_256_GCM - case "chacha20-poly1305", "aead_chacha20_poly1305", "chacha20-ietf-poly1305": - return shadowsocks.CipherType_CHACHA20_POLY1305 - case "xchacha20-poly1305", "aead_xchacha20_poly1305", "xchacha20-ietf-poly1305": - return shadowsocks.CipherType_XCHACHA20_POLY1305 - default: - return shadowsocks.CipherType_UNKNOWN - } -} - -// isShadowsocks2022Cipher reports whether the method selects the -// shadowsocks-2022 inbound rather than the legacy AEAD one. -func isShadowsocks2022Cipher(cipher string) bool { - _, ok := shadowsocks2022Ciphers[strings.ToLower(cipher)] - return ok -} - -// buildUserAccount constructs the typed xray account for a user of the given -// protocol. It returns (nil, nil) for protocols that cannot be altered live so -// callers skip the AlterInbound call. WireGuard keys must be converted to the -// hex form xray's wireguard proxy expects (its ParseKey uses hex.DecodeString), -// unlike the file-config path which accepts base64 and converts internally. -// Shadowsocks is resolved strictly from the inbound's cipher: the legacy and -// 2022 inbounds take different account types and cast whatever they receive -// without checking, so an unrecognized cipher is an error rather than a guess -// that would panic the core and kill every connection on the server. -func buildUserAccount(protocolName string, user map[string]any) (*serial.TypedMessage, error) { - switch protocolName { - case "vmess": - userID, err := getRequiredUserString(user, "id") - if err != nil { - return nil, err - } - - return serial.ToTypedMessage(&vmess.Account{ - Id: userID, - }), nil + switch strings.ToLower(parsed.Protocol) { case "vless": - userID, err := getRequiredUserString(user, "id") - if err != nil { - return nil, err + var settings struct { + Encryption string `json:"encryption"` + Address json.RawMessage `json:"address"` } - - userFlow, err := getOptionalUserString(user, "flow") - if err != nil { - return nil, err + if err := json.Unmarshal(parsed.Settings, &settings); err != nil { + return err } - - vlessAccount := &vless.Account{ - Id: userID, - Flow: userFlow, + if settings.Encryption != "" && settings.Encryption != "none" { + return nil } - if testseedVal, ok := user["testseed"]; ok { - if testseedArr, ok := testseedVal.([]any); ok && len(testseedArr) >= 4 { - testseed := make([]uint32, len(testseedArr)) - for i, v := range testseedArr { - if num, ok := v.(float64); ok { - testseed[i] = uint32(num) - } - } - vlessAccount.Testseed = testseed - } else if testseedArr, ok := testseedVal.([]uint32); ok && len(testseedArr) >= 4 { - vlessAccount.Testseed = testseedArr - } + if address := flatAddress(settings.Address); address != "" && requiresTransportSecurity(address) { + return errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain") } - if testpreVal, ok := user["testpre"]; ok { - if testpre, ok := testpreVal.(float64); ok && testpre > 0 { - vlessAccount.Testpre = uint32(testpre) - } else if testpre, ok := testpreVal.(uint32); ok && testpre > 0 { - vlessAccount.Testpre = testpre - } - } - return serial.ToTypedMessage(vlessAccount), nil case "trojan": - password, err := getRequiredUserString(user, "password") - if err != nil { - return nil, err + var settings struct { + Address json.RawMessage `json:"address"` } - - return serial.ToTypedMessage(&trojan.Account{ - Password: password, - }), nil - case "shadowsocks": - cipher, err := shadowsocksCipherName(user) - if err != nil { - return nil, err + if err := json.Unmarshal(parsed.Settings, &settings); err != nil { + return err } - - password, err := getRequiredUserString(user, "password") - if err != nil { - return nil, err + if address := flatAddress(settings.Address); address != "" && requiresTransportSecurity(address) { + return errors.New("trojan without TLS is prohibited unless the server address is a private IP or domain") } - - if isShadowsocks2022Cipher(cipher) { - return serial.ToTypedMessage(&shadowsocks_2022.Account{ - Key: password, - }), nil - } - - ssCipherType := shadowsocksCipherType(cipher) - if ssCipherType == shadowsocks.CipherType_UNKNOWN { - return nil, common.NewErrorf("shadowsocks: unknown cipher %q, cannot build an account for the running inbound", cipher) - } - return serial.ToTypedMessage(&shadowsocks.Account{ - Password: password, - CipherType: ssCipherType, - }), nil - case "hysteria": - auth, err := getRequiredUserString(user, "auth") - if err != nil { - return nil, err - } - - return serial.ToTypedMessage(&hysteriaAccount.Account{ - Auth: auth, - }), nil - case "wireguard": - pubB64, err := getRequiredUserString(user, "publicKey") - if err != nil { - return nil, err - } - pubHex, err := wgutil.KeyToHex(pubB64) - if err != nil { - return nil, fmt.Errorf("wireguard publicKey: %w", err) - } - - pskB64, err := getOptionalUserString(user, "preSharedKey") - if err != nil { - return nil, err - } - pskHex, err := wgutil.KeyToHex(pskB64) - if err != nil { - return nil, fmt.Errorf("wireguard preSharedKey: %w", err) - } - - allowed := collectStringSlice(user["allowedIPs"]) - if len(allowed) == 0 { - return nil, common.NewError("wireguard: allowedIPs required") - } - - keepAlive, err := getOptionalUserString(user, "keepAlive") - if err != nil { - return nil, err - } - - return serial.ToTypedMessage(&wireguard.PeerConfig{ - PublicKey: pubHex, - PreSharedKey: pskHex, - AllowedIps: allowed, - KeepAlive: keepAlive, - }), nil - default: - return nil, nil } -} - -// AddUser adds a user to an inbound in the Xray core using the specified -// protocol and user data. On a legacy shadowsocks inbound the add first drops -// any existing holder of the email: that is the one inbound whose validator -// does not reject a duplicate email, and a later removal would then drop just -// one of the two registrations, leaving a disabled client able to connect. -func (x *XrayAPI) AddUser(Protocol string, inboundTag string, user map[string]any) error { - userEmail, err := getRequiredUserString(user, "email") - if err != nil { - return err - } - - account, err := buildUserAccount(Protocol, user) - if err != nil { - return err - } - if account == nil { - return nil - } - - if x.HandlerServiceClient == nil { - return common.NewError("xray HandlerServiceClient is not initialized") - } - client := *x.HandlerServiceClient - - if account.Type == legacyShadowsocksAccountType { - _ = x.RemoveUser(inboundTag, userEmail) - } - - ctx, cancel := context.WithTimeout(context.Background(), handlerRPCTimeout) - defer cancel() - _, err = client.AlterInbound(ctx, &command.AlterInboundRequest{ - Tag: inboundTag, - Operation: serial.ToTypedMessage(&command.AddUserOperation{ - User: &protocol.User{ - Email: userEmail, - Account: account, - }, - }), - }) - return err -} - -// RemoveUser removes a user from an inbound in the Xray core by email. -func (x *XrayAPI) RemoveUser(inboundTag, email string) error { - if x.HandlerServiceClient == nil { - return common.NewError("xray HandlerServiceClient is not initialized") - } - client := *x.HandlerServiceClient - - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - op := &command.RemoveUserOperation{Email: email} - req := &command.AlterInboundRequest{ - Tag: inboundTag, - Operation: serial.ToTypedMessage(op), - } - - _, err := client.AlterInbound(ctx, req) - if err != nil { - return fmt.Errorf("failed to remove user: %w", err) - } - return nil } +// flatAddress extracts the flat outbound "address" field, which the core's +// prohibition check reads. The field is a plain string in config JSON; any +// other shape means the outbound uses the vnext/servers form the core's +// check does not inspect. +func flatAddress(raw json.RawMessage) string { + if len(raw) == 0 { + return "" + } + var address string + if err := json.Unmarshal(raw, &address); err != nil { + return "" + } + return address +} + +// privateIPPrefixes is xray-core's private-IP matcher list, verbatim +// (common/geodata consts): documentation ranges, CGNAT, multicast and +// reserved space count as private for the prohibition check. +var privateIPPrefixes = []netip.Prefix{ + netip.MustParsePrefix("0.0.0.0/8"), + netip.MustParsePrefix("10.0.0.0/8"), + netip.MustParsePrefix("100.64.0.0/10"), + netip.MustParsePrefix("127.0.0.0/8"), + netip.MustParsePrefix("169.254.0.0/16"), + netip.MustParsePrefix("172.16.0.0/12"), + netip.MustParsePrefix("192.0.0.0/24"), + netip.MustParsePrefix("192.0.2.0/24"), + netip.MustParsePrefix("192.88.99.0/24"), + netip.MustParsePrefix("192.168.0.0/16"), + netip.MustParsePrefix("198.18.0.0/15"), + netip.MustParsePrefix("198.51.100.0/24"), + netip.MustParsePrefix("203.0.113.0/24"), + netip.MustParsePrefix("224.0.0.0/3"), + netip.MustParsePrefix("::/127"), + netip.MustParsePrefix("fc00::/7"), + netip.MustParsePrefix("fe80::/10"), + netip.MustParsePrefix("ff00::/8"), +} + +// privateDomainSuffixes is xray-core's private-domain matcher list, +// verbatim: each entry matches the domain itself and any subdomain. +// Dotless domains are private too (the core matches them by regexp). +var privateDomainSuffixes = []string{ + "lan", + "localdomain", + "example", + "invalid", + "localhost", + "test", + "local", + "home.arpa", + "internal", +} + +var dotlessDomainRegex = regexp.MustCompile(`^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// requiresTransportSecurity mirrors the core's check of the same name: an +// IP must fall outside every private range; a domain must fall outside +// every private suffix (or be dotless). +func requiresTransportSecurity(address string) bool { + if ip, err := netip.ParseAddr(address); err == nil { + for _, prefix := range privateIPPrefixes { + if prefix.Contains(ip) { + return false + } + } + return true + } + domain := strings.TrimSuffix(strings.ToLower(address), ".") + if dotlessDomainRegex.MatchString(domain) { + return false + } + for _, suffix := range privateDomainSuffixes { + if domain == suffix || strings.HasSuffix(domain, "."+suffix) { + return false + } + } + return true +} + // GetTraffic queries traffic statistics from the Xray core and reports what // accrued since the previous call; the counters themselves are never reset. // The first call of a process only records baselines, since it may be reading @@ -741,11 +604,7 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) { ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - if x.StatsServiceClient == nil { - return nil, nil, common.NewError("xray StatusServiceClient is not initialized") - } - - resp, err := (*x.StatsServiceClient).QueryStats(ctx, &statsService.QueryStatsRequest{Reset_: false}) + stats, err := x.queryStats(ctx) if err != nil { logger.Debug("Failed to query Xray stats:", err) return nil, nil, err @@ -754,9 +613,12 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) { tagTrafficMap := make(map[string]*Traffic) emailTrafficMap := make(map[string]*ClientTraffic) + if x.StatsLastValues == nil { + x.StatsLastValues = make(map[string]int64) + } baselinePass := len(x.StatsLastValues) == 0 - for _, stat := range resp.GetStat() { + for _, stat := range stats { lastValue, ok := x.StatsLastValues[stat.Name] x.StatsLastValues[stat.Name] = stat.Value if baselinePass { @@ -777,9 +639,9 @@ func (x *XrayAPI) GetTraffic() ([]*Traffic, []*ClientTraffic, error) { // clients), which otherwise linger until the next Xray restart. Only rebuild // when the map has drifted past 2x the live set, so the steady-state hot path // stays allocation-free. - if n := len(resp.GetStat()); n > 0 && len(x.StatsLastValues) > 2*n { + if n := len(stats); n > 0 && len(x.StatsLastValues) > 2*n { pruned := make(map[string]int64, n) - for _, stat := range resp.GetStat() { + for _, stat := range stats { pruned[stat.Name] = x.StatsLastValues[stat.Name] } x.StatsLastValues = pruned @@ -809,31 +671,28 @@ func (x *XrayAPI) GetOnlineUsers() ([]OnlineUser, error) { if x.grpcClient == nil { return nil, common.NewError("xray api is not initialized") } - if x.StatsServiceClient == nil { - return nil, common.NewError("xray StatsServiceClient is not initialized") - } ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) defer cancel() - resp, err := (*x.StatsServiceClient).GetUsersStats(ctx, &statsService.GetUsersStatsRequest{}) + stats, err := x.getUsersStats(ctx) if err != nil { return nil, err } - users := make([]OnlineUser, 0, len(resp.GetUsers())) - for _, u := range resp.GetUsers() { - if u == nil || u.GetEmail() == "" { + users := make([]OnlineUser, 0, len(stats)) + for _, u := range stats { + if u.Email == "" { continue } - ips := make([]OnlineIP, 0, len(u.GetIps())) - for _, entry := range u.GetIps() { - if entry == nil || entry.GetIp() == "" { + ips := make([]OnlineIP, 0, len(u.IPs)) + for _, entry := range u.IPs { + if entry.IP == "" { continue } - ips = append(ips, OnlineIP{IP: entry.GetIp(), LastSeen: entry.GetLastSeen()}) + ips = append(ips, entry) } - users = append(users, OnlineUser{Email: u.GetEmail(), IPs: ips}) + users = append(users, OnlineUser{Email: u.Email, IPs: ips}) } return users, nil } diff --git a/internal/xray/api_e2e_test.go b/internal/xray/api_e2e_test.go deleted file mode 100644 index 0d01eeb..0000000 --- a/internal/xray/api_e2e_test.go +++ /dev/null @@ -1,257 +0,0 @@ -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@ -// 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{ - "levels": map[string]any{ - "0": map[string]any{"statsUserOnline": true}, - }, - }, - "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) - } - - // --- online-stats API --- - // statsUserOnline is enabled in the policy above; with no client - // connections the call must succeed and return an empty set. This proves - // the GetUsersStats plumbing against a real core (an older binary would - // return Unimplemented here — see IsUnimplementedErr). - online, err := api.GetOnlineUsers() - if err != nil { - t.Fatalf("GetOnlineUsers: %v", err) - } - if len(online) != 0 { - t.Fatalf("expected no online users on an idle core, got %+v", online) - } - - // --- 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) -} diff --git a/internal/xray/api_shadowsocks_test.go b/internal/xray/api_shadowsocks_test.go deleted file mode 100644 index 69186a5..0000000 --- a/internal/xray/api_shadowsocks_test.go +++ /dev/null @@ -1,149 +0,0 @@ -package xray - -import ( - "strings" - "testing" - - "github.com/xtls/xray-core/proxy/shadowsocks" - "github.com/xtls/xray-core/proxy/shadowsocks_2022" - "google.golang.org/protobuf/proto" -) - -// decodeSSAccount decodes the typed message buildUserAccount produced for a -// shadowsocks user. The type URL is what the running inbound casts on, so the -// test asserts on it directly. -func decodeSSAccount(t *testing.T, user map[string]any) (typeURL string, legacy *shadowsocks.Account, modern *shadowsocks_2022.Account) { - t.Helper() - tm, err := buildUserAccount("shadowsocks", user) - if err != nil { - t.Fatalf("buildUserAccount: %v", err) - } - if tm == nil { - t.Fatal("buildUserAccount returned no account for shadowsocks") - } - typeURL = tm.Type - switch { - case strings.Contains(typeURL, "shadowsocks_2022"): - modern = new(shadowsocks_2022.Account) - if err := proto.Unmarshal(tm.Value, modern); err != nil { - t.Fatalf("unmarshal shadowsocks_2022 account: %v", err) - } - case strings.Contains(typeURL, "shadowsocks"): - legacy = new(shadowsocks.Account) - if err := proto.Unmarshal(tm.Value, legacy); err != nil { - t.Fatalf("unmarshal shadowsocks account: %v", err) - } - default: - t.Fatalf("unexpected account type %q", typeURL) - } - return typeURL, legacy, modern -} - -func TestBuildUserAccountShadowsocksLegacyCiphers(t *testing.T) { - tests := []struct { - cipher string - want shadowsocks.CipherType - }{ - {"aes-128-gcm", shadowsocks.CipherType_AES_128_GCM}, - {"aead_aes_128_gcm", shadowsocks.CipherType_AES_128_GCM}, - {"aes-256-gcm", shadowsocks.CipherType_AES_256_GCM}, - {"AES-256-GCM", shadowsocks.CipherType_AES_256_GCM}, - {"aead_aes_256_gcm", shadowsocks.CipherType_AES_256_GCM}, - {"chacha20-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305}, - {"chacha20-ietf-poly1305", shadowsocks.CipherType_CHACHA20_POLY1305}, - {"aead_chacha20_poly1305", shadowsocks.CipherType_CHACHA20_POLY1305}, - {"xchacha20-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305}, - {"xchacha20-ietf-poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305}, - {"aead_xchacha20_poly1305", shadowsocks.CipherType_XCHACHA20_POLY1305}, - } - for _, tt := range tests { - t.Run(tt.cipher, func(t *testing.T) { - user := map[string]any{"email": "a@example.test", "password": "pw", "cipher": tt.cipher} - typeURL, legacy, modern := decodeSSAccount(t, user) - if modern != nil { - t.Fatalf("cipher %q built a shadowsocks-2022 account (%s); the legacy inbound casts to *shadowsocks.MemoryAccount and panics the core", tt.cipher, typeURL) - } - if legacy.CipherType != tt.want { - t.Fatalf("CipherType = %v, want %v", legacy.CipherType, tt.want) - } - if legacy.Password != "pw" { - t.Fatalf("Password = %q, want %q", legacy.Password, "pw") - } - }) - } -} - -func TestBuildUserAccountShadowsocks2022Ciphers(t *testing.T) { - for _, cipher := range []string{ - "2022-blake3-aes-128-gcm", - "2022-blake3-aes-256-gcm", - "2022-blake3-chacha20-poly1305", - } { - t.Run(cipher, func(t *testing.T) { - user := map[string]any{"email": "a@example.test", "password": b64Key(7), "cipher": cipher} - typeURL, _, modern := decodeSSAccount(t, user) - if modern == nil { - t.Fatalf("cipher %q built a legacy account (%s), want shadowsocks-2022", cipher, typeURL) - } - if modern.Key != b64Key(7) { - t.Fatalf("Key = %q, want the client password", modern.Key) - } - }) - } -} - -// TestBuildUserAccountShadowsocksReadsMethodKey covers the client maps taken -// verbatim from an inbound's settings (the auto-renew path): they carry the -// inbound's cipher under "method", never "cipher". -func TestBuildUserAccountShadowsocksReadsMethodKey(t *testing.T) { - user := map[string]any{"email": "a@example.test", "password": "pw", "method": "aes-256-gcm"} - _, legacy, modern := decodeSSAccount(t, user) - if modern != nil { - t.Fatal("a client carrying only \"method\" must still build a legacy account") - } - if legacy.CipherType != shadowsocks.CipherType_AES_256_GCM { - t.Fatalf("CipherType = %v, want AES_256_GCM", legacy.CipherType) - } -} - -func TestBuildUserAccountShadowsocksCipherKeyWins(t *testing.T) { - user := map[string]any{ - "email": "a@example.test", - "password": b64Key(3), - "cipher": "2022-blake3-aes-128-gcm", - "method": "aes-256-gcm", - } - _, _, modern := decodeSSAccount(t, user) - if modern == nil { - t.Fatal("the explicit \"cipher\" must win over a stale \"method\"") - } -} - -// TestBuildUserAccountShadowsocksUnknownCipherErrors is the regression for the -// account-type guess that killed the core: an unrecognized cipher used to fall -// through to a shadowsocks-2022 account, which xray's legacy inbound casts -// without checking, panicking the whole process. -func TestBuildUserAccountShadowsocksUnknownCipherErrors(t *testing.T) { - for _, cipher := range []string{"", "rc4-md5", "2022-blake3-future-gcm", "none"} { - t.Run("cipher="+cipher, func(t *testing.T) { - user := map[string]any{"email": "a@example.test", "password": "pw"} - if cipher != "" { - user["cipher"] = cipher - } - account, err := buildUserAccount("shadowsocks", user) - if err == nil { - t.Fatalf("cipher %q built account %v, want an error instead of an account-type guess", cipher, account) - } - if !strings.Contains(err.Error(), "unknown cipher") { - t.Fatalf("error = %q, want it to name the unknown cipher", err) - } - }) - } -} - -func TestBuildUserAccountShadowsocksMissingPassword(t *testing.T) { - user := map[string]any{"email": "a@example.test", "cipher": "aes-256-gcm"} - if _, err := buildUserAccount("shadowsocks", user); err == nil { - t.Fatal("expected an error for a shadowsocks user without a password") - } -} diff --git a/internal/xray/api_stats_wire_test.go b/internal/xray/api_stats_wire_test.go new file mode 100644 index 0000000..0f627ac --- /dev/null +++ b/internal/xray/api_stats_wire_test.go @@ -0,0 +1,248 @@ +package xray + +import ( + "context" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protodesc" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/dynamicpb" +) + +// This file proves the wire-level stats client against a STOCK gRPC server: +// the server side runs grpc's default protobuf codec with dynamicpb messages +// built from the xray.app.stats.command descriptor (rebuilt here from the +// .proto field numbers), so what interops is the real wire contract — field +// numbers, message shapes, the "proto" content subtype — not just this +// package's own codec talking to itself. + +func fieldDesc(name string, num int32, typ descriptorpb.FieldDescriptorProto_Type, label descriptorpb.FieldDescriptorProto_Label, typeName string) *descriptorpb.FieldDescriptorProto { + f := &descriptorpb.FieldDescriptorProto{ + Name: proto.String(name), + Number: proto.Int32(num), + Type: typ.Enum(), + Label: label.Enum(), + } + if typeName != "" { + f.TypeName = proto.String(typeName) + } + return f +} + +func statsFileDescriptor(t *testing.T) protoreflect.FileDescriptor { + t.Helper() + const ( + strT = descriptorpb.FieldDescriptorProto_TYPE_STRING + i64T = descriptorpb.FieldDescriptorProto_TYPE_INT64 + boolT = descriptorpb.FieldDescriptorProto_TYPE_BOOL + msgT = descriptorpb.FieldDescriptorProto_TYPE_MESSAGE + opt = descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL + rep = descriptorpb.FieldDescriptorProto_LABEL_REPEATED + pkg = ".xray.app.stats.command." + ) + msg := func(name string, fields ...*descriptorpb.FieldDescriptorProto) *descriptorpb.DescriptorProto { + return &descriptorpb.DescriptorProto{Name: proto.String(name), Field: fields} + } + fdp := &descriptorpb.FileDescriptorProto{ + Name: proto.String("stats.proto"), + Package: proto.String("xray.app.stats.command"), + Syntax: proto.String("proto3"), + MessageType: []*descriptorpb.DescriptorProto{ + msg("Stat", + fieldDesc("name", 1, strT, opt, ""), + fieldDesc("value", 2, i64T, opt, "")), + msg("QueryStatsRequest", + fieldDesc("pattern", 1, strT, opt, ""), + fieldDesc("reset", 2, boolT, opt, "")), + msg("QueryStatsResponse", + fieldDesc("stat", 1, msgT, rep, pkg+"Stat")), + msg("OnlineIPEntry", + fieldDesc("ip", 1, strT, opt, ""), + fieldDesc("last_seen", 2, i64T, opt, "")), + msg("TrafficUserStat", + fieldDesc("uplink", 1, i64T, opt, ""), + fieldDesc("downlink", 2, i64T, opt, "")), + msg("UserStat", + fieldDesc("email", 1, strT, opt, ""), + fieldDesc("ips", 2, msgT, rep, pkg+"OnlineIPEntry"), + fieldDesc("traffic", 3, msgT, opt, pkg+"TrafficUserStat")), + msg("GetUsersStatsRequest", + fieldDesc("include_traffic", 1, boolT, opt, ""), + fieldDesc("reset", 2, boolT, opt, "")), + msg("GetUsersStatsResponse", + fieldDesc("users", 1, msgT, rep, pkg+"UserStat")), + }, + Service: []*descriptorpb.ServiceDescriptorProto{ + { + Name: proto.String("StatsService"), + Method: []*descriptorpb.MethodDescriptorProto{ + {Name: proto.String("QueryStats"), InputType: proto.String(pkg + "QueryStatsRequest"), OutputType: proto.String(pkg + "QueryStatsResponse")}, + {Name: proto.String("GetUsersStats"), InputType: proto.String(pkg + "GetUsersStatsRequest"), OutputType: proto.String(pkg + "GetUsersStatsResponse")}, + }, + }, + }, + } + fd, err := protodesc.NewFile(fdp, nil) + if err != nil { + t.Fatalf("build stats descriptor: %v", err) + } + return fd +} + +// stockStats is a StatsService served by grpc's stock codec from dynamicpb +// messages, replaying queued responses per method. +type stockStats struct { + fd protoreflect.FileDescriptor + querySeq []*dynamicpb.Message + usersSeq []*dynamicpb.Message + qCalls int + uCalls int +} + +func (s *stockStats) newMsg(name protoreflect.Name) *dynamicpb.Message { + return dynamicpb.NewMessage(s.fd.Messages().ByName(name)) +} + +func (s *stockStats) method(name string, reqName protoreflect.Name, pick func() *dynamicpb.Message) grpc.MethodDesc { + return grpc.MethodDesc{ + MethodName: name, + Handler: func(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + req := s.newMsg(reqName) + if err := dec(req); err != nil { + return nil, err + } + return pick(), nil + }, + } +} + +func startStockStats(t *testing.T, st *stockStats) *XrayAPI { + t.Helper() + st.fd = statsFileDescriptor(t) + lis, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + srv := grpc.NewServer() // stock codec: the real interop surface + srv.RegisterService(&grpc.ServiceDesc{ + ServiceName: "xray.app.stats.command.StatsService", + HandlerType: (*statsServiceIface)(nil), + Methods: []grpc.MethodDesc{ + st.method("QueryStats", "QueryStatsRequest", func() *dynamicpb.Message { + i := st.qCalls + st.qCalls++ + if i >= len(st.querySeq) { + i = len(st.querySeq) - 1 + } + return st.querySeq[i] + }), + st.method("GetUsersStats", "GetUsersStatsRequest", func() *dynamicpb.Message { + i := st.uCalls + st.uCalls++ + if i >= len(st.usersSeq) { + i = len(st.usersSeq) - 1 + } + return st.usersSeq[i] + }), + }, + }, st) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + api := &XrayAPI{} + if err := api.Init(lis.Addr().(*net.TCPAddr).Port); err != nil { + t.Fatalf("api init: %v", err) + } + t.Cleanup(api.Close) + return api +} + +func (s *stockStats) statMsg(name string, value int64) *dynamicpb.Message { + m := s.newMsg("Stat") + d := m.Descriptor() + m.Set(d.Fields().ByName("name"), protoreflect.ValueOfString(name)) + m.Set(d.Fields().ByName("value"), protoreflect.ValueOfInt64(value)) + return m +} + +func (s *stockStats) queryResp(stats ...*dynamicpb.Message) *dynamicpb.Message { + m := s.newMsg("QueryStatsResponse") + list := m.Mutable(m.Descriptor().Fields().ByName("stat")).List() + for _, st := range stats { + list.Append(protoreflect.ValueOfMessage(st.ProtoReflect())) + } + return m +} + +// TestStatsAgainstStockProtoServer drives GetTraffic and GetOnlineUsers +// against the stock-codec server: the same deltas and user shapes the typed +// client produced must come out of the wire-level one. +func TestStatsAgainstStockProtoServer(t *testing.T) { + st := &stockStats{} + st.fd = statsFileDescriptor(t) + st.querySeq = []*dynamicpb.Message{ + st.queryResp(st.statMsg("user>>>alice>>>traffic>>>uplink", 100)), + st.queryResp( + st.statMsg("user>>>alice>>>traffic>>>uplink", 175), + st.statMsg("inbound>>>in-443>>>traffic>>>downlink", 2048), + ), + } + + // One online user with two IPs and a traffic block (field 3 — present in + // the message, ignored by the panel's OnlineUser shape). + user := st.newMsg("UserStat") + ud := user.Descriptor() + user.Set(ud.Fields().ByName("email"), protoreflect.ValueOfString("alice")) + ipList := user.Mutable(ud.Fields().ByName("ips")).List() + for _, e := range []struct { + ip string + seen int64 + }{{"203.0.113.7", 111}, {"2001:db8::5", 222}} { + entry := st.newMsg("OnlineIPEntry") + ed := entry.Descriptor() + entry.Set(ed.Fields().ByName("ip"), protoreflect.ValueOfString(e.ip)) + entry.Set(ed.Fields().ByName("last_seen"), protoreflect.ValueOfInt64(e.seen)) + ipList.Append(protoreflect.ValueOfMessage(entry.ProtoReflect())) + } + traffic := st.newMsg("TrafficUserStat") + td := traffic.Descriptor() + traffic.Set(td.Fields().ByName("uplink"), protoreflect.ValueOfInt64(10)) + traffic.Set(td.Fields().ByName("downlink"), protoreflect.ValueOfInt64(20)) + user.Set(ud.Fields().ByName("traffic"), protoreflect.ValueOfMessage(traffic.ProtoReflect())) + usersResp := st.newMsg("GetUsersStatsResponse") + uList := usersResp.Mutable(usersResp.Descriptor().Fields().ByName("users")).List() + uList.Append(protoreflect.ValueOfMessage(user.ProtoReflect())) + st.usersSeq = []*dynamicpb.Message{usersResp} + + api := startStockStats(t, st) + + if _, _, err := api.GetTraffic(); err != nil { + t.Fatalf("GetTraffic (baseline): %v", err) + } + tags, clients, err := api.GetTraffic() + if err != nil { + t.Fatalf("GetTraffic: %v", err) + } + if len(clients) != 1 || clients[0].Email != "alice" || clients[0].Up != 75 { + t.Fatalf("clients = %+v, want alice up=75", clients) + } + if len(tags) != 1 || tags[0].Tag != "in-443" || tags[0].Down != 2048 { + t.Fatalf("tags = %+v, want in-443 down=2048", tags) + } + + users, err := api.GetOnlineUsers() + if err != nil { + t.Fatalf("GetOnlineUsers: %v", err) + } + if len(users) != 1 || users[0].Email != "alice" || len(users[0].IPs) != 2 { + t.Fatalf("users = %+v, want alice with 2 IPs", users) + } + if users[0].IPs[0].IP != "203.0.113.7" || users[0].IPs[0].LastSeen != 111 || + users[0].IPs[1].IP != "2001:db8::5" || users[0].IPs[1].LastSeen != 222 { + t.Fatalf("ips = %+v", users[0].IPs) + } +} diff --git a/internal/xray/api_test.go b/internal/xray/api_test.go index d49fc54..c9ff458 100644 --- a/internal/xray/api_test.go +++ b/internal/xray/api_test.go @@ -1,89 +1,41 @@ package xray import ( - "strings" "testing" ) +// TestRemoveUserGuardsNilHandlerClient pins that mutation calls on an +// uninitialized client fail instead of panicking. In this build they fail +// unconditionally — hot-apply is unavailable without xray-core — and every +// caller treats that as "fall back to a core restart". func TestRemoveUserGuardsNilHandlerClient(t *testing.T) { err := (&XrayAPI{}).RemoveUser("in-443-tcp", "user@example.com") if err == nil { - t.Fatal("RemoveUser with an uninitialized HandlerServiceClient must return an error") + t.Fatal("RemoveUser with an uninitialized client must return an error") } } -func TestGetRequiredUserString_Present(t *testing.T) { - user := map[string]any{"email": "alice@example.com"} - got, err := getRequiredUserString(user, "email") - if err != nil { - t.Fatalf("unexpected error: %v", err) +func TestHotApplyMutationsAreUnavailable(t *testing.T) { + api := &XrayAPI{} + calls := map[string]func() error{ + "AddInbound": func() error { return api.AddInbound([]byte("{}")) }, + "DelInbound": func() error { return api.DelInbound("in-443-tcp") }, + "AddOutbound": func() error { return api.AddOutbound([]byte("{}")) }, + "DelOutbound": func() error { return api.DelOutbound("direct") }, + "ApplyRoutingConfig": func() error { return api.ApplyRoutingConfig([]byte("{}")) }, + "AddUser": func() error { return api.AddUser("vless", "in-443-tcp", map[string]any{"email": "a@b.c"}) }, + "RemoveUser": func() error { return api.RemoveUser("in-443-tcp", "a@b.c") }, + "SetBalancerTarget": func() error { return api.SetBalancerTarget("lb", "direct") }, } - if got != "alice@example.com" { - t.Fatalf("got %q, want %q", got, "alice@example.com") - } -} - -func TestGetRequiredUserString_Missing(t *testing.T) { - user := map[string]any{} - if _, err := getRequiredUserString(user, "email"); err == nil { - t.Fatal("expected error for missing key") - } -} - -func TestGetRequiredUserString_NilValue(t *testing.T) { - user := map[string]any{"email": nil} - if _, err := getRequiredUserString(user, "email"); err == nil { - t.Fatal("expected error for nil value") - } -} - -func TestGetRequiredUserString_WrongType(t *testing.T) { - user := map[string]any{"email": 42} - _, err := getRequiredUserString(user, "email") - if err == nil { - t.Fatal("expected error for non-string value") - } - if !strings.Contains(err.Error(), "invalid type") { - t.Fatalf("expected %q in error, got: %v", "invalid type", err) - } -} - -func TestGetOptionalUserString_Present(t *testing.T) { - user := map[string]any{"flow": "xtls-rprx-vision"} - got, err := getOptionalUserString(user, "flow") - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if got != "xtls-rprx-vision" { - t.Fatalf("got %q, want %q", got, "xtls-rprx-vision") - } -} - -func TestGetOptionalUserString_MissingReturnsEmptyNoError(t *testing.T) { - user := map[string]any{} - got, err := getOptionalUserString(user, "flow") - if err != nil { - t.Fatalf("unexpected error for missing optional field: %v", err) - } - if got != "" { - t.Fatalf("got %q, want empty string", got) - } -} - -func TestGetOptionalUserString_NilReturnsEmptyNoError(t *testing.T) { - user := map[string]any{"flow": nil} - got, err := getOptionalUserString(user, "flow") - if err != nil { - t.Fatalf("unexpected error for nil optional field: %v", err) - } - if got != "" { - t.Fatalf("got %q, want empty string", got) - } -} - -func TestGetOptionalUserString_WrongTypeErrors(t *testing.T) { - user := map[string]any{"flow": []string{"a", "b"}} - if _, err := getOptionalUserString(user, "flow"); err == nil { - t.Fatal("expected error for non-string optional value") + for name, call := range calls { + if err := call(); err != ErrHotApplyUnavailable { + t.Fatalf("%s = %v, want ErrHotApplyUnavailable", name, err) + } + } + if _, err := api.GetBalancerInfo("lb"); err != ErrHotApplyUnavailable { + t.Fatalf("GetBalancerInfo = %v, want ErrHotApplyUnavailable", err) + } + if _, err := api.TestRoute(RouteTestRequest{}); err != ErrHotApplyUnavailable { + t.Fatalf("TestRoute = %v, want ErrHotApplyUnavailable", err) } } diff --git a/internal/xray/api_traffic_test.go b/internal/xray/api_traffic_test.go index 8682356..9616ffa 100644 --- a/internal/xray/api_traffic_test.go +++ b/internal/xray/api_traffic_test.go @@ -5,41 +5,78 @@ import ( "net" "testing" - statsService "github.com/xtls/xray-core/app/stats/command" "google.golang.org/grpc" + "google.golang.org/protobuf/encoding/protowire" ) // fakeStatsServer serves a scripted sequence of QueryStats responses, one per -// call, so the delta bookkeeping in GetTraffic can be driven exactly. +// call, so the delta bookkeeping in GetTraffic can be driven exactly. It +// speaks the stats service's wire format directly: this build has no typed +// protobuf client, so the fake encodes responses with protowire and the +// server runs with the client's raw codec. type fakeStatsServer struct { - statsService.UnimplementedStatsServiceServer - rounds [][]*statsService.Stat + rounds [][]statEntry calls int } -func (f *fakeStatsServer) QueryStats(context.Context, *statsService.QueryStatsRequest) (*statsService.QueryStatsResponse, error) { +func (f *fakeStatsServer) queryStats() rawBytes { round := f.calls f.calls++ if round >= len(f.rounds) { round = len(f.rounds) - 1 } - return &statsService.QueryStatsResponse{Stat: f.rounds[round]}, nil + return encodeQueryStatsResponse(f.rounds[round]) } -func stat(name string, value int64) *statsService.Stat { - return &statsService.Stat{Name: name, Value: value} +// encodeQueryStatsResponse builds a QueryStatsResponse message +// (stat = 1, repeated Stat{name = 1, value = 2}). +func encodeQueryStatsResponse(stats []statEntry) rawBytes { + var out []byte + for _, st := range stats { + var statMsg []byte + statMsg = protowire.AppendTag(statMsg, 1, protowire.BytesType) + statMsg = protowire.AppendString(statMsg, st.Name) + statMsg = protowire.AppendTag(statMsg, 2, protowire.VarintType) + statMsg = protowire.AppendVarint(statMsg, uint64(st.Value)) + out = protowire.AppendTag(out, 1, protowire.BytesType) + out = protowire.AppendBytes(out, statMsg) + } + return rawBytes(out) + } +func stat(name string, value int64) statEntry { + return statEntry{Name: name, Value: value} +} + +type statsServiceIface interface{} + // startFakeStats runs the scripted stats service and returns an XrayAPI wired // to it. -func startFakeStats(t *testing.T, rounds [][]*statsService.Stat) *XrayAPI { +func startFakeStats(t *testing.T, rounds [][]statEntry) *XrayAPI { t.Helper() lis, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } - srv := grpc.NewServer() - statsService.RegisterStatsServiceServer(srv, &fakeStatsServer{rounds: rounds}) + fake := &fakeStatsServer{rounds: rounds} + srv := grpc.NewServer(grpc.ForceServerCodec(rawCodec{})) + srv.RegisterService(&grpc.ServiceDesc{ + ServiceName: "xray.app.stats.command.StatsService", + HandlerType: (*statsServiceIface)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "QueryStats", + Handler: func(_ any, _ context.Context, dec func(any) error, _ grpc.UnaryServerInterceptor) (any, error) { + var req rawBytes + if err := dec(&req); err != nil { + return nil, err + } + return fake.queryStats(), nil + }, + }, + }, + }, fake) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) @@ -64,7 +101,7 @@ func clientTrafficByEmail(t *testing.T, traffics []*ClientTraffic) map[string]*C // the panel may attach to counters that already hold traffic it cannot // attribute, so the first poll of a client only records baselines. func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) { - api := startFakeStats(t, [][]*statsService.Stat{ + api := startFakeStats(t, [][]statEntry{ {stat("user>>>alice>>>traffic>>>uplink", 5000)}, }) @@ -84,7 +121,7 @@ func TestGetTrafficFirstPollIsBaselineOnly(t *testing.T) { // traffic being dropped: xray creates a counter on first use, so a name that // appears after the baseline poll starts at zero and every byte in it is new. func TestGetTrafficCountsNewStatFromZero(t *testing.T) { - api := startFakeStats(t, [][]*statsService.Stat{ + api := startFakeStats(t, [][]statEntry{ {stat("user>>>alice>>>traffic>>>uplink", 100)}, { stat("user>>>alice>>>traffic>>>uplink", 180), @@ -122,7 +159,7 @@ func TestGetTrafficCountsNewStatFromZero(t *testing.T) { // start over at zero, so a value below the recorded baseline is all new // traffic rather than something to drop. func TestGetTrafficCountsAfterCounterReset(t *testing.T) { - api := startFakeStats(t, [][]*statsService.Stat{ + api := startFakeStats(t, [][]statEntry{ {stat("user>>>alice>>>traffic>>>uplink", 100)}, {stat("user>>>alice>>>traffic>>>uplink", 900)}, {stat("user>>>alice>>>traffic>>>uplink", 250)}, @@ -152,7 +189,7 @@ func TestGetTrafficCountsAfterCounterReset(t *testing.T) { // inbound is the panel's own gRPC channel and is never a user-facing inbound, // and baselines for vanished stats are dropped once they outgrow the live set. func TestGetTrafficSkipsAPIInboundAndPrunes(t *testing.T) { - api := startFakeStats(t, [][]*statsService.Stat{ + api := startFakeStats(t, [][]statEntry{ { stat("inbound>>>api>>>traffic>>>uplink", 10), stat("inbound>>>in-443>>>traffic>>>uplink", 10), diff --git a/internal/xray/api_users_e2e_test.go b/internal/xray/api_users_e2e_test.go deleted file mode 100644 index 5b5e1bf..0000000 --- a/internal/xray/api_users_e2e_test.go +++ /dev/null @@ -1,446 +0,0 @@ -package xray - -import ( - "encoding/base64" - "encoding/json" - "fmt" - "io" - "maps" - "net" - "net/http" - "net/url" - "os" - "os/exec" - "path/filepath" - "testing" - "time" -) - -// e2eCore is a real xray-core process plus a connected panel API client. -type e2eCore struct { - api *XrayAPI - cmd *exec.Cmd - port int -} - -// alive reports whether the core is still serving its API port. A gRPC call -// that hands the core an account type its inbound does not expect takes the -// whole process down, so every user probe checks this. -func (c *e2eCore) alive() bool { - if c.cmd.ProcessState != nil { - return false - } - conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", c.port), time.Second) - if err != nil { - return false - } - conn.Close() - return true -} - -// startE2ECore boots xray-core with the given inbounds plus the api inbound the -// panel talks through, and returns a connected client. Skips unless -// XRAY_E2E_BINARY points at an xray built from the version go.mod pins. -func startE2ECore(t *testing.T, inbounds []any) *e2eCore { - t.Helper() - 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) - all := []any{ - map[string]any{ - "listen": "127.0.0.1", - "port": apiPort, - "protocol": "tunnel", - "settings": map[string]any{"rewriteAddress": "127.0.0.1"}, - "tag": "api", - }, - } - all = append(all, inbounds...) - - cfg := map[string]any{ - "log": map[string]any{"loglevel": "warning"}, - "api": map[string]any{ - "services": []string{"HandlerService", "StatsService", "RoutingService"}, - "tag": "api", - }, - "inbounds": all, - "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{ - "levels": map[string]any{ - "0": map[string]any{"statsUserUplink": true, "statsUserDownlink": true, "statsUserOnline": true}, - }, - "system": map[string]any{"statsInboundUplink": true, "statsInboundDownlink": true}, - }, - "stats": map[string]any{}, - } - raw, err := json.MarshalIndent(cfg, "", " ") - if err != nil { - t.Fatal(err) - } - cfgPath := filepath.Join(t.TempDir(), "config.json") - if err := os.WriteFile(cfgPath, raw, 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) - } - t.Cleanup(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) - } - t.Cleanup(api.Close) - return &e2eCore{api: api, cmd: cmd, port: apiPort} -} - -// ssKey builds a base64 shadowsocks-2022 key of the given byte length. -func ssKey(n int, seed byte) string { - raw := make([]byte, n) - for i := range raw { - raw[i] = seed + byte(i) - } - return base64.StdEncoding.EncodeToString(raw) -} - -// panelUser mirrors the map shape the panel's client-apply paths hand to -// AddUser: every field is always present, unused ones are empty. -func panelUser(email string, fields map[string]any) map[string]any { - user := map[string]any{ - "email": email, - "id": "", - "auth": "", - "security": "", - "flow": "", - "password": "", - "cipher": "", - "publicKey": "", - "allowedIPs": nil, - "preSharedKey": "", - "keepAlive": "", - } - maps.Copy(user, fields) - return user -} - -// TestXrayAPI_E2E_Users runs the panel's add/remove user surface against a live -// core for every protocol it builds accounts for, pinning the error texts -// IsUserExistsErr and IsMissingHandlerErr match on. -func TestXrayAPI_E2E_Users(t *testing.T) { - c := startE2ECore(t, []any{ - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "vmess", "tag": "vmess-in", - "settings": map[string]any{"clients": []any{ - map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30811", "email": "seed-vmess"}, - }}, - }, - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "vless", "tag": "vless-in", - "settings": map[string]any{"clients": []any{ - map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30812", "email": "seed-vless"}, - }, "decryption": "none"}, - }, - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "trojan", "tag": "trojan-in", - "settings": map[string]any{"clients": []any{ - map[string]any{"password": "seed-pw", "email": "seed-trojan"}, - }}, - }, - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in", - "settings": map[string]any{ - "method": "aes-256-gcm", "network": "tcp,udp", - "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}}, - }, - }, - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in", - "settings": map[string]any{ - "method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp", - "clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}}, - }, - }, - }) - - tests := []struct { - name string - protocol string - tag string - user map[string]any - // rejectsDuplicateEmail is false for the legacy shadowsocks inbound, - // whose validator does not dedupe emails at all. - rejectsDuplicateEmail bool - }{ - { - name: "vmess", protocol: "vmess", tag: "vmess-in", - user: panelUser("e2e-vmess", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30821", "security": "auto"}), - rejectsDuplicateEmail: true, - }, - { - name: "vless", protocol: "vless", tag: "vless-in", - user: panelUser("e2e-vless", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30822", "flow": "xtls-rprx-vision"}), - rejectsDuplicateEmail: true, - }, - { - name: "trojan", protocol: "trojan", tag: "trojan-in", - user: panelUser("e2e-trojan", map[string]any{"password": "e2e-pw"}), - rejectsDuplicateEmail: true, - }, - { - name: "shadowsocks legacy", protocol: "shadowsocks", tag: "ss-in", - user: panelUser("e2e-ss", map[string]any{"password": "e2e-pw", "cipher": "aes-256-gcm"}), - rejectsDuplicateEmail: false, - }, - { - name: "shadowsocks 2022", protocol: "shadowsocks", tag: "ss2022-in", - user: panelUser("e2e-ss2022", map[string]any{"password": ssKey(32, 40), "cipher": "2022-blake3-aes-256-gcm"}), - rejectsDuplicateEmail: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - email := tt.user["email"].(string) - if err := c.api.AddUser(tt.protocol, tt.tag, tt.user); err != nil { - t.Fatalf("AddUser: %v", err) - } - if !c.alive() { - t.Fatal("core died on AddUser: the account type does not match the running inbound") - } - - if tt.rejectsDuplicateEmail { - err := c.api.AddUser(tt.protocol, tt.tag, tt.user) - if err == nil { - t.Fatal("adding a user whose email is taken must fail") - } - if !IsUserExistsErr(err) { - t.Fatalf("duplicate-email error not matched by IsUserExistsErr: %q", err) - } - } - - if err := c.api.RemoveUser(tt.tag, email); err != nil { - t.Fatalf("RemoveUser: %v", err) - } - err := c.api.RemoveUser(tt.tag, email) - if err == nil { - t.Fatal("the user is still registered after RemoveUser") - } - if !IsMissingHandlerErr(err) { - t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err) - } - }) - } - - t.Run("unknown inbound tag", func(t *testing.T) { - err := c.api.AddUser("vmess", "no-such-tag", panelUser("e2e-ghost", map[string]any{"id": "b831381d-6324-4d53-ad4f-8cda48b30899"})) - if err == nil { - t.Fatal("AddUser on an unknown tag must fail") - } - if !IsMissingHandlerErr(err) { - t.Fatalf("unknown-tag error not matched by IsMissingHandlerErr: %q", err) - } - }) -} - -// TestXrayAPI_E2E_ShadowsocksAccountTypeGuess is the regression for the crash -// that took the whole core down: a shadowsocks user whose cipher the panel -// could not resolve used to be sent as a shadowsocks-2022 account, which the -// legacy inbound casts without checking. Every case here must leave the core -// running. -func TestXrayAPI_E2E_ShadowsocksAccountTypeGuess(t *testing.T) { - c := startE2ECore(t, []any{ - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in", - "settings": map[string]any{ - "method": "aes-256-gcm", "network": "tcp,udp", - "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}}, - }, - }, - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss2022-in", - "settings": map[string]any{ - "method": "2022-blake3-aes-256-gcm", "password": ssKey(32, 1), "network": "tcp,udp", - "clients": []any{map[string]any{"password": ssKey(32, 9), "email": "seed-ss2022"}}, - }, - }, - }) - - // The auto-renew path hands over the client object straight out of the - // inbound's settings, which carries the cipher under "method". - t.Run("client object from settings", func(t *testing.T) { - user := map[string]any{"email": "renew-ss", "password": "pw", "method": "aes-256-gcm", "enable": true} - if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil { - t.Fatalf("AddUser with the settings-shaped client: %v", err) - } - if !c.alive() { - t.Fatal("core died: a legacy shadowsocks client was sent as a 2022 account") - } - if err := c.api.RemoveUser("ss-in", "renew-ss"); err != nil { - t.Fatalf("RemoveUser: %v", err) - } - }) - - t.Run("cipher missing entirely", func(t *testing.T) { - err := c.api.AddUser("shadowsocks", "ss-in", map[string]any{"email": "nocipher", "password": "pw"}) - if err == nil { - t.Fatal("a shadowsocks user with no resolvable cipher must be refused, not guessed") - } - if !c.alive() { - t.Fatal("core died on a shadowsocks user with no cipher") - } - }) - - t.Run("legacy cipher against a 2022 inbound", func(t *testing.T) { - // The reverse mismatch is only reachable when the panel's view of the - // inbound has drifted from the running one; the account type is still - // the one the cipher names, so the panel never guesses here. - user := map[string]any{"email": "drift", "password": ssKey(32, 50), "cipher": "2022-blake3-aes-256-gcm"} - if err := c.api.AddUser("shadowsocks", "ss2022-in", user); err != nil { - t.Fatalf("AddUser: %v", err) - } - if !c.alive() { - t.Fatal("core died adding a 2022 user to a 2022 inbound") - } - if err := c.api.RemoveUser("ss2022-in", "drift"); err != nil { - t.Fatalf("RemoveUser: %v", err) - } - }) -} - -// TestXrayAPI_E2E_ShadowsocksAddIsIdempotent covers the legacy shadowsocks -// inbound accepting a second user under an email it already holds: one removal -// then left the client connectable. Adding twice must leave exactly one -// registration, so a single removal fully revokes the client. -func TestXrayAPI_E2E_ShadowsocksAddIsIdempotent(t *testing.T) { - c := startE2ECore(t, []any{ - map[string]any{ - "listen": "127.0.0.1", "port": freePort(t), "protocol": "shadowsocks", "tag": "ss-in", - "settings": map[string]any{ - "method": "aes-256-gcm", "network": "tcp,udp", - "clients": []any{map[string]any{"method": "aes-256-gcm", "password": "seed-pw", "email": "seed-ss"}}, - }, - }, - }) - - user := map[string]any{"email": "dup-ss", "password": "pw", "cipher": "aes-256-gcm"} - for i := range 2 { - if err := c.api.AddUser("shadowsocks", "ss-in", user); err != nil { - t.Fatalf("AddUser #%d: %v", i+1, err) - } - } - - if err := c.api.RemoveUser("ss-in", "dup-ss"); err != nil { - t.Fatalf("RemoveUser: %v", err) - } - err := c.api.RemoveUser("ss-in", "dup-ss") - if err == nil { - t.Fatal("a second registration survived removal: a disabled client would keep connecting") - } - if !IsMissingHandlerErr(err) { - t.Fatalf("missing-user error not matched by IsMissingHandlerErr: %q", err) - } -} - -// TestXrayAPI_E2E_NewClientTrafficIsCounted proves against a live core that a -// counter appearing after the baseline poll is reported in full: xray creates a -// user's counter on first use, and dropping that first sample lost a new -// client's traffic for a whole polling interval. -func TestXrayAPI_E2E_NewClientTrafficIsCounted(t *testing.T) { - const payload = 64 * 1024 - - backendLn, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatal(err) - } - backend := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - _, _ = w.Write(make([]byte, payload)) - })} - go func() { _ = backend.Serve(backendLn) }() - t.Cleanup(func() { _ = backend.Close() }) - - proxyPort := freePort(t) - c := startE2ECore(t, []any{ - map[string]any{ - "listen": "127.0.0.1", "port": proxyPort, "protocol": "http", "tag": "http-in", - "settings": map[string]any{ - "accounts": []any{map[string]any{"user": "e2e-fresh", "pass": "e2e-pass"}}, - }, - }, - }) - - // Baseline poll: the client's counter does not exist yet. - if _, _, err := c.api.GetTraffic(); err != nil { - t.Fatalf("GetTraffic (baseline): %v", err) - } - - proxyURL, err := url.Parse(fmt.Sprintf("http://e2e-fresh:e2e-pass@127.0.0.1:%d", proxyPort)) - if err != nil { - t.Fatal(err) - } - client := &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}} - resp, err := client.Get(fmt.Sprintf("http://%s/", backendLn.Addr().String())) - if err != nil { - t.Fatalf("proxied request: %v", err) - } - n, err := io.Copy(io.Discard, resp.Body) - resp.Body.Close() - if err != nil { - t.Fatalf("reading the proxied response: %v", err) - } - if n != payload { - t.Fatalf("proxied %d bytes, want %d", n, payload) - } - time.Sleep(500 * time.Millisecond) - - _, clients, err := c.api.GetTraffic() - if err != nil { - t.Fatalf("GetTraffic: %v", err) - } - var got *ClientTraffic - for _, ct := range clients { - if ct.Email == "e2e-fresh" { - got = ct - } - } - if got == nil { - t.Fatalf("the new client's traffic was dropped; reported clients: %+v", clients) - } - if got.Down < payload { - t.Fatalf("downlink = %d, want at least the %d bytes that were proxied", got.Down, payload) - } -} - -// TestXrayAPI_E2E_TestRoutePortRange keeps TestRoute from wrapping an -// out-of-range port into the uint32 the core is asked about. -func TestXrayAPI_E2E_TestRoutePortRange(t *testing.T) { - c := startE2ECore(t, nil) - - for _, port := range []int{-1, 65536, 70000} { - if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: port}); err == nil { - t.Fatalf("TestRoute accepted the out-of-range port %d", port) - } - } - if _, err := c.api.TestRoute(RouteTestRequest{Domain: "example.com", Port: 65535}); err != nil { - t.Fatalf("TestRoute rejected the valid port 65535: %v", err) - } -} diff --git a/internal/xray/api_wireguard_test.go b/internal/xray/api_wireguard_test.go deleted file mode 100644 index c116e01..0000000 --- a/internal/xray/api_wireguard_test.go +++ /dev/null @@ -1,129 +0,0 @@ -package xray - -import ( - "encoding/base64" - "encoding/hex" - "testing" - - wireguard "github.com/xtls/xray-core/proxy/wireguard" - "google.golang.org/protobuf/proto" -) - -func b64Key(seed byte) string { - raw := make([]byte, 32) - for i := range raw { - raw[i] = seed + byte(i) - } - return base64.StdEncoding.EncodeToString(raw) -} - -func decodeWgAccount(t *testing.T, user map[string]any) *wireguard.PeerConfig { - t.Helper() - tm, err := buildUserAccount("wireguard", user) - if err != nil { - t.Fatalf("buildUserAccount: %v", err) - } - if tm == nil { - t.Fatal("buildUserAccount returned nil account for wireguard") - } - var pc wireguard.PeerConfig - if err := proto.Unmarshal(tm.Value, &pc); err != nil { - t.Fatalf("unmarshal PeerConfig: %v", err) - } - return &pc -} - -func assertHexKey(t *testing.T, label, value string) { - t.Helper() - if len(value) != 64 { - t.Fatalf("%s = %q, want 64-char hex", label, value) - } - if raw, err := hex.DecodeString(value); err != nil || len(raw) != 32 { - t.Fatalf("%s is not a 32-byte hex key: err=%v len=%d", label, err, len(raw)) - } -} - -func TestBuildUserAccountWireGuardHexConversion(t *testing.T) { - pub := b64Key(1) - psk := b64Key(100) - user := map[string]any{ - "email": "alice@example.test", - "publicKey": pub, - "preSharedKey": psk, - "allowedIPs": []any{"10.0.0.2/32", "fd00::2/128"}, - "keepAlive": "25", - } - - pc := decodeWgAccount(t, user) - assertHexKey(t, "PublicKey", pc.PublicKey) - assertHexKey(t, "PreSharedKey", pc.PreSharedKey) - - wantPubHex, _ := hex.DecodeString(pc.PublicKey) - gotPub, _ := base64.StdEncoding.DecodeString(pub) - if string(wantPubHex) != string(gotPub) { - t.Fatal("PublicKey hex does not match the base64 input bytes") - } - - if len(pc.AllowedIps) != 2 || pc.AllowedIps[0] != "10.0.0.2/32" || pc.AllowedIps[1] != "fd00::2/128" { - t.Fatalf("AllowedIps = %v, want [10.0.0.2/32 fd00::2/128]", pc.AllowedIps) - } - if pc.KeepAlive != "25" { - t.Fatalf("KeepAlive = %q, want %q", pc.KeepAlive, "25") - } -} - -func TestBuildUserAccountWireGuardNoPSK(t *testing.T) { - user := map[string]any{ - "email": "bob@example.test", - "publicKey": b64Key(2), - "allowedIPs": []string{"10.0.0.3/32"}, - } - pc := decodeWgAccount(t, user) - if pc.PreSharedKey != "" { - t.Fatalf("PreSharedKey = %q, want empty", pc.PreSharedKey) - } - if pc.KeepAlive != "" { - t.Fatalf("KeepAlive = %q, want empty", pc.KeepAlive) - } -} - -func TestBuildUserAccountWireGuardMissingPublicKey(t *testing.T) { - user := map[string]any{ - "email": "c@example.test", - "allowedIPs": []any{"10.0.0.4/32"}, - } - if _, err := buildUserAccount("wireguard", user); err == nil { - t.Fatal("expected error for missing publicKey") - } -} - -func TestBuildUserAccountWireGuardMissingAllowedIPs(t *testing.T) { - user := map[string]any{ - "email": "d@example.test", - "publicKey": b64Key(3), - } - if _, err := buildUserAccount("wireguard", user); err == nil { - t.Fatal("expected error for missing allowedIPs") - } -} - -func TestBuildUserAccountWireGuardBadKey(t *testing.T) { - user := map[string]any{ - "email": "e@example.test", - "publicKey": "not-a-valid-key", - "allowedIPs": []any{"10.0.0.5/32"}, - } - if _, err := buildUserAccount("wireguard", user); err == nil { - t.Fatal("expected error for invalid publicKey") - } -} - -func TestBuildUserAccountUnknownProtocolReturnsNil(t *testing.T) { - tm, err := buildUserAccount("mtproto", map[string]any{"email": "x@example.test"}) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if tm != nil { - t.Fatal("expected nil account for unsupported protocol") - } -}