mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-02 11:15:03 +03:00
fix(outbound): keep imported links to remote-server settings
A share link describes a remote server, but extra=, fm= and the vmess JSON copy provider objects verbatim, so a link could carry keys that only make sense on the importing host. Both link parsers now drop dial-redirect and host file keys (matched case-folded, as the core binds them). Outbound subscriptions also refuse outbounds that dial this host or its network without allowPrivate, any that target the core's own API or metrics port, and any that take their server from DNS records. Rows stored earlier are filtered the same way when the config is built.
This commit is contained in:
@@ -7,8 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -423,13 +425,19 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
||||
}
|
||||
}
|
||||
|
||||
// Drop core-rejected links before tagging: prevTagByIndex indexes the persisted
|
||||
// (filtered) list, so positions must be counted in that same list.
|
||||
var droppedByCore []string
|
||||
// Drop refused and core-rejected links before tagging: prevTagByIndex indexes the
|
||||
// persisted (filtered) list, so positions must be counted in that same list.
|
||||
var dropped []string
|
||||
ownPorts := xrayOwnPorts()
|
||||
keptLinks, keptIdentities := parsed[:0], identities[:0]
|
||||
for i, ob := range parsed {
|
||||
if _, dropped := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(dropped) > 0 {
|
||||
droppedByCore = append(droppedByCore, dropped...)
|
||||
if reason := internalDialTarget(map[string]any(ob), sub.AllowPrivate, ownPorts); reason != "" {
|
||||
logger.Warningf("outbound sub %d: dropping outbound %.64q: %s", sub.Id, ob["tag"], reason)
|
||||
dropped = append(dropped, fmt.Sprintf("%.64s: %s", ob["tag"], reason))
|
||||
continue
|
||||
}
|
||||
if _, rejected := filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), []any{map[string]any(ob)}); len(rejected) > 0 {
|
||||
dropped = append(dropped, rejected...)
|
||||
continue
|
||||
}
|
||||
keptLinks = append(keptLinks, ob)
|
||||
@@ -461,8 +469,8 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
|
||||
sub.LinkIdentities = string(identJSON)
|
||||
sub.LastUpdated = time.Now().Unix()
|
||||
sub.LastError = ""
|
||||
if len(droppedByCore) > 0 {
|
||||
sub.LastError = fmt.Sprintf("dropped %d outbound(s) the xray core rejects: %s", len(droppedByCore), droppedByCore[0])
|
||||
if len(dropped) > 0 {
|
||||
sub.LastError = fmt.Sprintf("dropped %d outbound(s): %s", len(dropped), dropped[0])
|
||||
}
|
||||
|
||||
if err := database.GetDB().Save(sub).Error; err != nil {
|
||||
@@ -561,6 +569,7 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app
|
||||
if err := db.Where("enabled = ?", true).Order("priority asc, id asc").Find(&subs).Error; err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
ownPorts := xrayOwnPorts()
|
||||
for _, sub := range subs {
|
||||
if strings.TrimSpace(sub.LastFetchedOutbounds) == "" {
|
||||
continue
|
||||
@@ -570,6 +579,14 @@ func (s *OutboundSubscriptionService) activeOutboundsSplit() (prepend []any, app
|
||||
logger.Warningf("outbound sub %d has corrupt LastFetchedOutbounds: %v", sub.Id, err)
|
||||
continue
|
||||
}
|
||||
// Rows stored before the fetch refused these, or while allowPrivate was on.
|
||||
arr = slices.DeleteFunc(arr, func(ob any) bool {
|
||||
reason := internalDialTarget(ob, sub.AllowPrivate, ownPorts)
|
||||
if reason != "" {
|
||||
logger.Warningf("outbound sub %d: dropping a stored outbound: %s", sub.Id, reason)
|
||||
}
|
||||
return reason != ""
|
||||
})
|
||||
arr, _ = filterOutboundsRejectedByCore(fmt.Sprintf("outbound sub %d", sub.Id), arr)
|
||||
if sub.Prepend {
|
||||
prepend = append(prepend, arr...)
|
||||
@@ -672,3 +689,75 @@ Consequences for balancers / routing:
|
||||
We deliberately do *not* mutate the saved xrayTemplateConfig. Subscription
|
||||
outbounds are always injected at runtime in GetXrayConfig.
|
||||
*/
|
||||
|
||||
// xrayOwnPorts lists the ports of Xray's gRPC API and metrics listeners, which ask
|
||||
// for no credentials: being reachable from loopback only is their whole protection.
|
||||
func xrayOwnPorts() []string {
|
||||
ports := []string{strconv.Itoa(reservedAPIPort())}
|
||||
metrics, _ := (&XrayMetricsService{}).discoverListen()
|
||||
if _, port, err := net.SplitHostPort(metrics); err == nil {
|
||||
ports = append(ports, port)
|
||||
}
|
||||
return ports
|
||||
}
|
||||
|
||||
// internalDialTarget says why a subscription outbound must not be loaded, or "". Keys
|
||||
// match at any depth (xhttp downloadSettings is a second server) and case-folded like the core.
|
||||
func internalDialTarget(outbound any, allowPrivate bool, ownPorts []string) string {
|
||||
switch v := outbound.(type) {
|
||||
case map[string]any:
|
||||
for key, child := range v {
|
||||
text, isText := child.(string)
|
||||
switch {
|
||||
case strings.EqualFold(key, "port"):
|
||||
// A public name may resolve to loopback by dial time, so the port alone decides.
|
||||
if slices.Contains(ownPorts, fmt.Sprint(child)) {
|
||||
return "its port belongs to Xray's own API or metrics listener"
|
||||
}
|
||||
case strings.EqualFold(key, "addressPortStrategy"):
|
||||
if text != "" && !strings.EqualFold(text, "none") {
|
||||
return "addressPortStrategy takes its server from DNS records"
|
||||
}
|
||||
case strings.EqualFold(key, "address"), strings.EqualFold(key, "endpoint"):
|
||||
// WireGuard's settings.address is a list of tunnel addresses, never dialled.
|
||||
if !isText {
|
||||
break
|
||||
}
|
||||
if _, port, err := net.SplitHostPort(strings.TrimSpace(text)); err == nil && slices.Contains(ownPorts, port) {
|
||||
return "its port belongs to Xray's own API or metrics listener"
|
||||
}
|
||||
if !allowPrivate && namesInternalHost(text) {
|
||||
return fmt.Sprintf("it connects to the private/internal address %.64q and allowPrivate is off", text)
|
||||
}
|
||||
}
|
||||
if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range v {
|
||||
if reason := internalDialTarget(child, allowPrivate, ownPorts); reason != "" {
|
||||
return reason
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// namesInternalHost reports whether host, with or without a port, names this host or
|
||||
// its network once xray-core and Go's dialer have read it.
|
||||
func namesInternalHost(host string) bool {
|
||||
host = strings.TrimSpace(host)
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
host = strings.Trim(host, "[] ")
|
||||
host, _, _ = strings.Cut(host, "%")
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return isBlockedIP(ip)
|
||||
}
|
||||
// An empty host is the local system and a name without a dot comes from
|
||||
// /etc/hosts or the search domain ("env:" has none either).
|
||||
host = strings.ToLower(strings.TrimSuffix(host, "."))
|
||||
return !strings.Contains(host, ".") || host == "localhost" || strings.HasSuffix(host, ".localhost")
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -453,3 +454,53 @@ func outboundsContainTag(outbounds []any, tag string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// The core dials a subscription outbound's server itself, so a provider must not point
|
||||
// one at Xray's credential-less API, whatever allowPrivate says, nor steer the dial past the check.
|
||||
func TestOutboundSubscriptionRefreshDropsLinksIntoXrayListeners(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
const uuid = "00000000-0000-4000-8000-000000000000"
|
||||
srv := url.QueryEscape(`{"downloadSettings":{"address":"8.8.8.8","port":443,"network":"xhttp","sockopt":{"addressPortStrategy":"SrvPortAndAddress"}}}`)
|
||||
subID := serveOutboundSubscription(t, "p-", func(int) string {
|
||||
return "vless://" + uuid + "@127.0.0.1:62789?security=tls&type=tcp#Api\n" +
|
||||
"vless://" + uuid + "@8.8.8.8:443?security=tls&type=tcp#B\n" +
|
||||
"vless://" + uuid + "@8.8.8.8:443?security=tls&type=xhttp&extra=" + srv + "#Srv"
|
||||
})
|
||||
tags, _ := refreshOutboundTags(t, subID)
|
||||
if !slices.Equal(tags, []string{"p-b"}) {
|
||||
t.Fatalf("stored tags = %v, want only p-b", tags)
|
||||
}
|
||||
sub, err := (&OutboundSubscriptionService{}).Get(subID)
|
||||
if err != nil || !strings.Contains(sub.LastError, "dropped 2 outbound(s)") {
|
||||
t.Fatalf("lastError = %q (%v), want the two refused links reported", sub.LastError, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActiveOutboundsDropStoredPrivateDialTargets(t *testing.T) {
|
||||
setupSettingTestDB(t)
|
||||
stored := `[{"tag":"p-lan","protocol":"vless","settings":{"address":"10.0.0.7","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}},` +
|
||||
`{"tag":"p-pub","protocol":"vless","settings":{"address":"8.8.8.8","port":443,"id":"00000000-0000-4000-8000-000000000000","encryption":"none"},"streamSettings":{"network":"tcp","security":"tls"}}]`
|
||||
sub := &model.OutboundSubscription{Url: "https://provider.example/sub", TagPrefix: "p-", Enabled: true, LastFetchedOutbounds: stored}
|
||||
if err := database.GetDB().Create(sub).Error; err != nil {
|
||||
t.Fatalf("seed subscription: %v", err)
|
||||
}
|
||||
obs, err := (&OutboundSubscriptionService{}).AllActiveOutbounds()
|
||||
if err != nil {
|
||||
t.Fatalf("AllActiveOutbounds: %v", err)
|
||||
}
|
||||
if outboundsContainTag(obs, "p-lan") || !outboundsContainTag(obs, "p-pub") {
|
||||
t.Fatalf("active outbounds = %v, want the stored LAN target dropped and the public one kept", obs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesInternalHost(t *testing.T) {
|
||||
for host, want := range map[string]bool{
|
||||
"127.0.0.1": true, " [::1]:62789 ": true, "fe80::1%eth0": true, "10.1.2.3:80": true,
|
||||
"localhost": true, "api.localhost.": true, "intranet": true, "": true, "env:HOME": true,
|
||||
"8.8.8.8": false, "cdn.example.com": false, "[2001:4860:4860::8888]:443": false,
|
||||
} {
|
||||
if got := namesInternalHost(host); got != want {
|
||||
t.Errorf("namesInternalHost(%q) = %v, want %v", host, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user