Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 3 additions & 47 deletions app/router/webhook.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,60 +7,16 @@ import (
"io"
"net"
"net/http"
"path/filepath"
"runtime"
"strings"
"sync"
"syscall"
"time"

"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/utils"
"github.com/xtls/xray-core/features/routing"
routing_session "github.com/xtls/xray-core/features/routing/session"
)

// parseURL splits a webhook URL into an HTTP URL and an optional Unix socket
// path. For regular http/https URLs the input is returned unchanged with an
// empty socketPath. For Unix sockets the format is:
//
// /path/to/socket.sock:/http/path
// @abstract:/http/path
// @@padded:/http/path
//
// The :/ separator after the socket path delimits the HTTP request path.
// If omitted, "/" is used.
func parseURL(raw string) (httpURL, socketPath string) {
if len(raw) == 0 || (!filepath.IsAbs(raw) && raw[0] != '@') {
return raw, ""
}
if idx := strings.Index(raw, ":/"); idx >= 0 {
return "http://localhost" + raw[idx+1:], raw[:idx]
}
return "http://localhost/", raw
}

// resolveSocketPath applies platform-specific transformations to a Unix
// socket path, matching the behaviour of the listen side in
// transport/internet/system_listener.go.
//
// For abstract sockets (prefix @) on Linux/Android:
// - single @ — used as-is (lock-free abstract socket)
// - double @@ — stripped to single @ and padded to
// syscall.RawSockaddrUnix{}.Path length (HAProxy compat)
func resolveSocketPath(path string) string {
if len(path) == 0 || path[0] != '@' {
return path
}
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
return path
}
if len(path) > 1 && path[1] == '@' {
fullAddr := make([]byte, len(syscall.RawSockaddrUnix{}.Path))
copy(fullAddr, path[1:])
return string(fullAddr)
}
return path
}

func ptr[T any](v T) *T { return &v }

Expand Down Expand Up @@ -96,7 +52,7 @@ func NewWebhookNotifier(cfg *WebhookConfig) (*WebhookNotifier, error) {
return nil, nil
}

httpURL, socketPath := parseURL(cfg.Url)
httpURL, socketPath := utils.SplitHTTPUnixURL(cfg.Url)
h := &WebhookNotifier{
url: httpURL,
deduplication: cfg.Deduplication,
Expand All @@ -107,7 +63,7 @@ func NewWebhookNotifier(cfg *WebhookConfig) (*WebhookNotifier, error) {
}

if socketPath != "" {
dialAddr := resolveSocketPath(socketPath)
dialAddr := utils.ResolveSocketPath(socketPath)
h.client.Transport = &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
Expand Down
55 changes: 55 additions & 0 deletions common/utils/unixsocket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package utils

import (
"path/filepath"
"runtime"
"strings"
"syscall"
)

// ResolveSocketPath applies platform-specific transformations to a Unix
// socket path, matching the listen-side behaviour in
// transport/internet/system_listener.go.
//
// For abstract sockets (prefix @) on Linux/Android:
// - single @ — used as-is (lock-free abstract socket)
// - double @@ — stripped to single @ and padded to
// syscall.RawSockaddrUnix{}.Path length (HAProxy compat)
//
// Filesystem paths and abstract sockets on other platforms are returned
// unchanged.
func ResolveSocketPath(path string) string {
if len(path) == 0 || path[0] != '@' {
return path
}
if runtime.GOOS != "linux" && runtime.GOOS != "android" {
return path
}
if len(path) > 1 && path[1] == '@' {
fullAddr := make([]byte, len(syscall.RawSockaddrUnix{}.Path))
copy(fullAddr, path[1:])
return string(fullAddr)
}
return path
}

// SplitHTTPUnixURL splits a target into an HTTP URL and an optional Unix
// socket path. For regular http(s) URLs the input is returned unchanged
// with an empty socketPath. For Unix sockets the format is:
//
// /path/to/socket.sock[:/http/path]
// @abstract[:/http/path]
// @@padded[:/http/path]
//
// The :/ separator delimits the socket path from the HTTP request path.
// If omitted, "/" is used.
func SplitHTTPUnixURL(raw string) (httpURL, socketPath string) {
if len(raw) == 0 || (!filepath.IsAbs(raw) && raw[0] != '@') {
return raw, ""
}
if idx := strings.Index(raw, ":/"); idx >= 0 {
return "http://localhost" + raw[idx+1:], raw[:idx]
}
return "http://localhost/", raw
}

117 changes: 63 additions & 54 deletions main/confloader/external/external.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ package external
import (
"bytes"
"context"
"net"
"io"
"net"
"net/http"
"net/url"
"os"
Expand All @@ -13,16 +13,18 @@ import (

"github.com/xtls/xray-core/common/buf"
"github.com/xtls/xray-core/common/errors"
"github.com/xtls/xray-core/common/utils"
"github.com/xtls/xray-core/main/confloader"
)

func ConfigLoader(arg string) (out io.Reader, err error) {
var data []byte
switch {
case strings.HasPrefix(arg, "http+unix://"):
data, err = FetchUnixSocketHTTPContent(arg)
errors.PrintDeprecatedFeatureWarning(`"http+unix://" prefix`, `direct Unix socket path (e.g. /path/socket.sock:/api or @abstract:/api)`)
data, err = FetchHTTPContent(httpUnixToCanonical(arg))

case strings.HasPrefix(arg, "http://"), strings.HasPrefix(arg, "https://"):
case isRemoteSource(arg):
data, err = FetchHTTPContent(arg)

case arg == "stdin:":
Expand All @@ -39,19 +41,38 @@ func ConfigLoader(arg string) (out io.Reader, err error) {
return
}

// FetchHTTPContent issues an HTTP GET against either a regular HTTP(S) URL
// or a Unix socket HTTP endpoint.
//
// http(s)://host/api regular HTTP(S)
// /path/to/socket.sock[:/api] filesystem socket
// @abstract[:/api] abstract socket (Linux/Android)
// @@padded[:/api] padded abstract socket (HAProxy compat)
//
// When the ":/" separator is omitted on a socket target, the request is
// made to "/".
func FetchHTTPContent(target string) ([]byte, error) {
parsedTarget, err := url.Parse(target)
httpURL, socketPath := utils.SplitHTTPUnixURL(target)

parsedTarget, err := url.Parse(httpURL)
if err != nil {
return nil, errors.New("invalid URL: ", target).Base(err)
}

if s := strings.ToLower(parsedTarget.Scheme); s != "http" && s != "https" {
return nil, errors.New("invalid scheme: ", parsedTarget.Scheme)
}

client := &http.Client{
Timeout: 30 * time.Second,
}

if socketPath != "" {
dialAddr := utils.ResolveSocketPath(socketPath)
client.Transport = &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", dialAddr)
},
}
}

resp, err := client.Do(&http.Request{
Method: "GET",
URL: parsedTarget,
Expand All @@ -74,58 +95,46 @@ func FetchHTTPContent(target string) ([]byte, error) {
return content, nil
}

// Format: http+unix:///path/to/socket.sock/api/endpoint
func FetchUnixSocketHTTPContent(target string) ([]byte, error) {
path := strings.TrimPrefix(target, "http+unix://")

if !strings.HasPrefix(path, "/") {
return nil, errors.New("unix socket path must be absolute")

// isRemoteSource reports whether arg should be fetched via HTTP (regular
// network or Unix socket) rather than read from the local filesystem.
// Recognized forms:
//
// - http(s)://... regular HTTP(S)
// - @abstract[:/api] abstract socket (Linux/Android)
// - /abs/path:/api filesystem socket, explicit HTTP path
// - /abs/path filesystem socket detected via os.ModeSocket
func isRemoteSource(arg string) bool {
if arg == "" {
return false
}

var socketPath, httpPath string

sockIdx := strings.Index(path, ".sock")
if sockIdx != -1 {
socketPath = path[:sockIdx+5]
httpPath = path[sockIdx+5:]
if httpPath == "" {
httpPath = "/"
}
} else {
return nil, errors.New("cannot determine socket path, socket file should have .sock extension")
if strings.HasPrefix(arg, "http://") || strings.HasPrefix(arg, "https://") {
return true
}

if _, err := os.Stat(socketPath); err != nil {
return nil, errors.New("socket file not found: ", socketPath).Base(err)
if arg[0] == '@' {
return true
}

client := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
var d net.Dialer
return d.DialContext(ctx, "unix", socketPath)
},
},
if arg[0] != '/' {
return false
}
defer client.CloseIdleConnections()

resp, err := client.Get("http://localhost" + httpPath)
if err != nil {
return nil, errors.New("failed to fetch from unix socket: ", socketPath).Base(err)
if strings.Contains(arg, ":/") {
return true
}
defer resp.Body.Close()

if resp.StatusCode != 200 {
return nil, errors.New("unexpected HTTP status code: ", resp.StatusCode)
}

content, err := buf.ReadAllToBytes(resp.Body)
if err != nil {
return nil, errors.New("failed to read response").Base(err)
info, err := os.Stat(arg)
return err == nil && info.Mode()&os.ModeSocket != 0
}


// httpUnixToCanonical converts the deprecated http+unix:///path/to/socket.sock/api
// URL into the canonical /path/to/socket.sock:/api form by inserting ":"
// between the ".sock" extension and the HTTP path. Inputs without a path
// after ".sock" are returned with just the "http+unix://" prefix stripped.
func httpUnixToCanonical(target string) string {
raw := strings.TrimPrefix(target, "http+unix://")
if i := strings.Index(raw, ".sock/"); i >= 0 {
raw = raw[:i+5] + ":" + raw[i+5:]
}

return content, nil
return raw
}

func init() {
Expand Down