fix(xray): keep legacy-private unencrypted outbounds on cores before 26.10.29

xray-core 26.10.29 (#7249) narrowed the "private" addresses an
unencrypted vless/trojan outbound may reach to RFC 1918, loopback and
link-local, so 100.64.0.0/10 (CGNAT, Tailscale), 198.18.0.0/15 and the
other ranges of the old geoip:private list are now refused. The
embedded validator follows the new core, which made the template save
and outbound subscriptions refuse such an outbound even when the panel
still runs an older core that accepts it.

The legacy skip now also lets through, on a core from 26.7.11 up to
26.10.28, an outbound whose server address (flat, vnext or servers) is
an IP in that core's wider private set. Unknown versions stay strict.
This commit is contained in:
MHSanaei
2026-11-01 14:55:29 +01:00
parent 72c9092d4a
commit e789583b63
3 changed files with 94 additions and 22 deletions
@@ -41,7 +41,7 @@ func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []stri
for _, ob := range outbounds { for _, ob := range outbounds {
raw, err := json.Marshal(ob) raw, err := json.Marshal(ob)
if err == nil { if err == nil {
if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) { if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, raw, buildErr) {
tag := "" tag := ""
if m, ok := ob.(map[string]any); ok { if m, ok := ob.(map[string]any); ok {
tag, _ = m["tag"].(string) tag, _ = m["tag"].(string)
+71 -8
View File
@@ -4,6 +4,7 @@ import (
_ "embed" _ "embed"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"net/netip"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -23,8 +24,32 @@ type XraySettingService struct {
const ( const (
unencryptedOutboundProhibitedError = "without TLS or other encryption is prohibited unless the server address is a private IP or domain" unencryptedOutboundProhibitedError = "without TLS or other encryption is prohibited unless the server address is a private IP or domain"
unencryptedOutboundMinimumVersion = "26.7.11" unencryptedOutboundMinimumVersion = "26.7.11"
narrowedPrivateIPMinimumVersion = "26.10.29"
) )
// legacyPrivateIPPrefixes is the "private" set xray-core 26.7.11 to 26.10.28 let an unencrypted
// vless/trojan outbound reach; 26.10.29 (#7249) narrowed it to RFC 1918, loopback and link-local.
var legacyPrivateIPPrefixes = []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"),
}
func (s *XraySettingService) SaveXraySetting(newXraySettings string) error { func (s *XraySettingService) SaveXraySetting(newXraySettings string) error {
// The frontend round-trips the whole getXraySetting response back // The frontend round-trips the whole getXraySetting response back
// through the textarea, so if it has ever received a wrapped // through the textarea, so if it has ever received a wrapped
@@ -103,7 +128,7 @@ func (s *XraySettingService) CheckXrayConfig(XrayTemplateConfig string) error {
return common.NewError("xray template outbound \""+probe.Tag+"\":", err) return common.NewError("xray template outbound \""+probe.Tag+"\":", err)
} }
if err := xray.ValidateOutboundConfig(outbound); err != nil { if err := xray.ValidateOutboundConfig(outbound); err != nil {
if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, err) { if shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, outbound, err) {
continue continue
} }
tagged := struct { tagged := struct {
@@ -133,16 +158,54 @@ func checkTemplateLuaScripts(cfg *xray.Config) error {
return nil return nil
} }
// shouldSkipLegacyUnencryptedOutboundRejection lets an older running Xray // shouldSkipLegacyUnencryptedOutboundRejection keeps an unencrypted outbound the running core still
// core accept an outbound that the newer embedded validator rejects solely // allows: any address before 26.7.11, a legacy-private IP before 26.10.29; unknown versions stay strict.
// because it is unencrypted and targets a public address. Unknown or malformed func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, outbound []byte, err error) bool {
// versions preserve the embedded validator's strict behavior.
func shouldSkipLegacyUnencryptedOutboundRejection(coreVersion string, err error) bool {
if err == nil || !strings.Contains(err.Error(), unencryptedOutboundProhibitedError) { if err == nil || !strings.Contains(err.Error(), unencryptedOutboundProhibitedError) {
return false return false
} }
comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion) if comparison, ok := compareXrayCoreVersions(coreVersion, unencryptedOutboundMinimumVersion); !ok {
return ok && comparison < 0 return false
} else if comparison < 0 {
return true
}
comparison, _ := compareXrayCoreVersions(coreVersion, narrowedPrivateIPMinimumVersion)
if comparison >= 0 {
return false
}
addr, parseErr := netip.ParseAddr(unencryptedOutboundServerAddress(outbound))
if parseErr != nil {
return false
}
addr = addr.Unmap()
return slices.ContainsFunc(legacyPrivateIPPrefixes, func(p netip.Prefix) bool { return p.Contains(addr) })
}
// unencryptedOutboundServerAddress reads the server address of a vless or trojan outbound in
// any of the shapes the core loader accepts: flat, vless "vnext" or trojan "servers".
func unencryptedOutboundServerAddress(outbound []byte) string {
type server struct {
Address string `json:"address"`
}
var probe struct {
Settings struct {
Address string `json:"address"`
Vnext []server `json:"vnext"`
Servers []server `json:"servers"`
} `json:"settings"`
}
if json.Unmarshal(outbound, &probe) != nil {
return ""
}
switch {
case probe.Settings.Address != "":
return probe.Settings.Address
case len(probe.Settings.Vnext) > 0:
return probe.Settings.Vnext[0].Address
case len(probe.Settings.Servers) > 0:
return probe.Settings.Servers[0].Address
}
return ""
} }
func compareXrayCoreVersions(a, b string) (int, bool) { func compareXrayCoreVersions(a, b string) (int, bool) {
+22 -13
View File
@@ -11,26 +11,35 @@ import (
func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) { func TestShouldSkipLegacyUnencryptedOutboundRejection(t *testing.T) {
prohibited := errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain") prohibited := errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain")
public := []byte(`{"protocol":"vless","settings":{"address":"8.8.8.8","port":443}}`)
cgnat := []byte(`{"protocol":"vless","settings":{"address":"100.64.1.2","port":443}}`)
cgnatVnext := []byte(`{"protocol":"vless","settings":{"vnext":[{"address":"100.100.1.1","port":443}]}}`)
benchmarkTrojan := []byte(`{"protocol":"trojan","settings":{"servers":[{"address":"198.18.0.9","port":443}]}}`)
tests := []struct { tests := []struct {
name string name string
version string version string
err error outbound []byte
want bool err error
want bool
}{ }{
{name: "older core", version: "26.4.25", err: prohibited, want: true}, {name: "older core", version: "26.4.25", outbound: public, err: prohibited, want: true},
{name: "boundary version", version: "26.7.11", err: prohibited, want: false}, {name: "boundary version", version: "26.7.11", outbound: public, err: prohibited, want: false},
{name: "newer core", version: "26.10.0", err: prohibited, want: false}, {name: "newer core", version: "26.10.0", outbound: public, err: prohibited, want: false},
{name: "unknown version", version: "Unknown", err: prohibited, want: false}, {name: "unknown version", version: "Unknown", outbound: public, err: prohibited, want: false},
{name: "unparseable version", version: "26.7", err: prohibited, want: false}, {name: "unparseable version", version: "26.7", outbound: public, err: prohibited, want: false},
{name: "empty version", version: "", err: prohibited, want: false}, {name: "empty version", version: "", outbound: public, err: prohibited, want: false},
{name: "unrelated validation error", version: "26.4.25", err: errors.New("invalid outbound"), want: false}, {name: "unrelated validation error", version: "26.4.25", outbound: public, err: errors.New("invalid outbound"), want: false},
{name: "cgnat on a core that still counts it private", version: "26.10.21", outbound: cgnat, err: prohibited, want: true},
{name: "cgnat vnext on a core that still counts it private", version: "26.10.21", outbound: cgnatVnext, err: prohibited, want: true},
{name: "benchmark range trojan on a core that still counts it private", version: "26.10.21", outbound: benchmarkTrojan, err: prohibited, want: true},
{name: "cgnat on the core that narrowed private", version: "26.10.29", outbound: cgnat, err: prohibited, want: false},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.err); got != tt.want { if got := shouldSkipLegacyUnencryptedOutboundRejection(tt.version, tt.outbound, tt.err); got != tt.want {
t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %v) = %v, want %v", tt.version, tt.err, got, tt.want) t.Fatalf("shouldSkipLegacyUnencryptedOutboundRejection(%q, %s, %v) = %v, want %v", tt.version, tt.outbound, tt.err, got, tt.want)
} }
}) })
} }