From 987290ba480d3bb4cf57454decee3b338b1c8deb Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:03:24 +0800 Subject: [PATCH 01/59] Routing: `process` supports macOS as well (#6447) https://github.com/XTLS/Xray-core/pull/6434#issuecomment-4903384018 --- common/net/find_process_darwin.go | 348 +++++++++++++++++++++++++ common/net/find_process_darwin.s | 18 ++ common/net/find_process_darwin_test.go | 293 +++++++++++++++++++++ common/net/find_process_others.go | 2 +- 4 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 common/net/find_process_darwin.go create mode 100644 common/net/find_process_darwin.s create mode 100644 common/net/find_process_darwin_test.go diff --git a/common/net/find_process_darwin.go b/common/net/find_process_darwin.go new file mode 100644 index 000000000..dc0593c26 --- /dev/null +++ b/common/net/find_process_darwin.go @@ -0,0 +1,348 @@ +//go:build darwin + +package net + +import ( + "bytes" + "net" + "net/netip" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/unix" + + "github.com/xtls/xray-core/common/errors" +) + +const ( + darwinProcPIDListFDs = 1 + darwinProcPIDFDSocketInfo = 3 + darwinProcFDTypeSocket = 2 + darwinProcFDInfoSize = 8 + darwinSocketFDInfoSize = 792 + darwinSocketFDInfoPSIOff = 24 + darwinSocketInfoProtoOff = darwinSocketFDInfoPSIOff + 156 + darwinSocketInfoFamilyOff = darwinSocketFDInfoPSIOff + 160 + darwinSocketInfoKindOff = darwinSocketFDInfoPSIOff + 232 + darwinSocketInfoInSockOff = darwinSocketFDInfoPSIOff + 240 + darwinInSockInfoFPortOff = darwinSocketInfoInSockOff + darwinInSockInfoLPortOff = darwinSocketInfoInSockOff + 4 + darwinInSockInfoVFlagOff = darwinSocketInfoInSockOff + 24 + darwinInSockInfoFAddrOff = darwinSocketInfoInSockOff + 32 + darwinInSockInfoLAddrOff = darwinSocketInfoInSockOff + 48 + darwinInSockInfoSize = 80 + darwinInSockInfoIPv4 = 0x1 + darwinInSockInfoIPv6 = 0x2 + darwinSockInfoIN = 1 + darwinSockInfoTCP = 2 +) + +type darwinSocketMatchLevel int + +const ( + darwinSocketNoMatch darwinSocketMatchLevel = iota + darwinSocketPortMatch + darwinSocketRemoteMatch + darwinSocketLocalMatch + darwinSocketExactMatch +) + +func FindProcess(network, srcIP string, srcPort uint16, destIP string, destPort uint16) (PID int, Name string, AbsolutePath string, err error) { + isLocal, err := IsLocal(net.ParseIP(srcIP)) + if err != nil { + return 0, "", "", errors.New("failed to determine if address is local: ", err) + } + if !isLocal { + return 0, "", "", ErrNotLocal + } + if network != "tcp" && network != "udp" { + panic("Unsupported network type for process lookup.") + } + + srcAddr, err := netip.ParseAddr(srcIP) + if err != nil { + return 0, "", "", errors.New("invalid source IP address: ", srcIP) + } + srcAddr = srcAddr.Unmap() + + var dstAddr netip.Addr + hasDstAddr := false + if destIP != "" && destPort != 0 { + dstAddr, err = netip.ParseAddr(destIP) + if err != nil { + return 0, "", "", errors.New("invalid destination IP address: ", destIP) + } + dstAddr = dstAddr.Unmap() + hasDstAddr = true + } + + processes, err := unix.SysctlKinfoProcSlice("kern.proc.all") + if err != nil { + return 0, "", "", errors.New("failed to list processes").Base(err) + } + + var bestPID int32 + bestLevel := darwinSocketNoMatch + ambiguousBest := false + + for _, process := range processes { + pid := process.Proc.P_pid + if pid <= 0 { + continue + } + + matchLevel, err := darwinProcessSocketMatchLevel(pid, network, srcAddr, srcPort, dstAddr, destPort, hasDstAddr) + if err != nil || matchLevel == darwinSocketNoMatch { + continue + } + if matchLevel == darwinSocketExactMatch { + bestPID = pid + bestLevel = matchLevel + ambiguousBest = false + break + } + if matchLevel > bestLevel { + bestPID = pid + bestLevel = matchLevel + ambiguousBest = false + continue + } + if matchLevel == bestLevel { + ambiguousBest = true + } + } + + if bestLevel == darwinSocketNoMatch { + return 0, "", "", errors.New("process not found for ", network, " connection from ", srcIP, ":", srcPort, " to ", destIP, ":", destPort) + } + if ambiguousBest { + return 0, "", "", errors.New("ambiguous process match for ", network, " connection from ", srcIP, ":", srcPort, " to ", destIP, ":", destPort) + } + + absPath, err := darwinProcessPath(bestPID) + if err != nil { + return 0, "", "", errors.New("could not get process path for PID ", bestPID, ": ", err) + } + + absPath = filepath.ToSlash(absPath) + return int(bestPID), filepath.Base(absPath), absPath, nil +} + +func darwinProcessSocketMatchLevel(pid int32, network string, srcAddr netip.Addr, srcPort uint16, dstAddr netip.Addr, dstPort uint16, hasDstAddr bool) (darwinSocketMatchLevel, error) { + fds, err := darwinProcessFDs(pid) + if err != nil { + return darwinSocketNoMatch, err + } + + bestLevel := darwinSocketNoMatch + info := make([]byte, darwinSocketFDInfoSize) + for fd := 0; fd+darwinProcFDInfoSize <= len(fds); fd += darwinProcFDInfoSize { + fdNumber := int32(darwinReadNativeUint32(fds[fd : fd+4])) + fdType := darwinReadNativeUint32(fds[fd+4 : fd+8]) + if fdType != darwinProcFDTypeSocket { + continue + } + + n, err := darwinProcPIDFDInfo(pid, fdNumber, darwinProcPIDFDSocketInfo, info) + if err != nil || n < darwinSocketInfoInSockOff+darwinInSockInfoSize { + continue + } + level := darwinSocketInfoMatchLevel(info[:n], network, srcAddr, srcPort, dstAddr, dstPort, hasDstAddr) + if level == darwinSocketExactMatch { + return level, nil + } + if level > bestLevel { + bestLevel = level + } + } + + return bestLevel, nil +} + +func darwinProcessFDs(pid int32) ([]byte, error) { + n, err := darwinProcPIDInfo(pid, darwinProcPIDListFDs, 0, nil) + if err != nil { + return nil, err + } + if n <= 0 { + return nil, nil + } + + buf := make([]byte, n) + n, err = darwinProcPIDInfo(pid, darwinProcPIDListFDs, 0, buf) + if err != nil { + return nil, err + } + return buf[:n], nil +} + +func darwinSocketInfoMatchLevel(info []byte, network string, srcAddr netip.Addr, srcPort uint16, dstAddr netip.Addr, dstPort uint16, hasDstAddr bool) darwinSocketMatchLevel { + protocol := int(darwinReadNativeUint32(info[darwinSocketInfoProtoOff : darwinSocketInfoProtoOff+4])) + family := int(darwinReadNativeUint32(info[darwinSocketInfoFamilyOff : darwinSocketInfoFamilyOff+4])) + kind := int(darwinReadNativeUint32(info[darwinSocketInfoKindOff : darwinSocketInfoKindOff+4])) + + switch network { + case "tcp": + if protocol != unix.IPPROTO_TCP || kind != darwinSockInfoTCP { + return darwinSocketNoMatch + } + case "udp": + if protocol != unix.IPPROTO_UDP || kind != darwinSockInfoIN { + return darwinSocketNoMatch + } + default: + return darwinSocketNoMatch + } + + vflag := info[darwinInSockInfoVFlagOff] + if srcAddr.Is4() { + if family != unix.AF_INET || vflag&darwinInSockInfoIPv4 == 0 { + return darwinSocketNoMatch + } + } else { + if family != unix.AF_INET6 || vflag&darwinInSockInfoIPv6 == 0 { + return darwinSocketNoMatch + } + } + + localPort := int32(darwinReadNativeUint32(info[darwinInSockInfoLPortOff : darwinInSockInfoLPortOff+4])) + if !darwinPortMatches(localPort, srcPort) { + return darwinSocketNoMatch + } + localAddrMatches := darwinAddrMatchesOrUnspecified(info[darwinInSockInfoLAddrOff:darwinInSockInfoLAddrOff+16], srcAddr) + + foreignAddrRaw := info[darwinInSockInfoFAddrOff : darwinInSockInfoFAddrOff+16] + foreignPort := int32(darwinReadNativeUint32(info[darwinInSockInfoFPortOff : darwinInSockInfoFPortOff+4])) + + if !hasDstAddr { + if localAddrMatches { + return darwinSocketExactMatch + } + return darwinSocketNoMatch + } + + remoteMatches := darwinPortMatches(foreignPort, dstPort) && darwinAddrMatches(foreignAddrRaw, dstAddr) + if network == "udp" && darwinEndpointIsZero(foreignAddrRaw, foreignPort) && localAddrMatches { + return darwinSocketExactMatch + } + switch { + case localAddrMatches && remoteMatches: + return darwinSocketExactMatch + case localAddrMatches: + return darwinSocketLocalMatch + case remoteMatches: + return darwinSocketRemoteMatch + default: + return darwinSocketPortMatch + } +} + +func darwinPortMatches(value int32, port uint16) bool { + raw := uint16(value) + return raw == port || darwinNtohs(raw) == port +} + +func darwinNtohs(value uint16) uint16 { + return value<<8 | value>>8 +} + +func darwinAddrMatches(raw []byte, addr netip.Addr) bool { + if addr.Is4() { + ip := addr.As4() + return bytes.Equal(raw[12:16], ip[:]) + } + ip := addr.As16() + return bytes.Equal(raw, ip[:]) +} + +func darwinAddrMatchesOrUnspecified(raw []byte, addr netip.Addr) bool { + if darwinAddrMatches(raw, addr) { + return true + } + if addr.Is4() { + return darwinBytesAreZero(raw[12:16]) + } + return darwinBytesAreZero(raw) +} + +func darwinEndpointIsZero(rawAddr []byte, port int32) bool { + return uint32(port) == 0 && darwinBytesAreZero(rawAddr) +} + +func darwinBytesAreZero(raw []byte) bool { + for _, value := range raw { + if value != 0 { + return false + } + } + return true +} + +func darwinReadNativeUint32(b []byte) uint32 { + return *(*uint32)(unsafe.Pointer(&b[0])) +} + +func darwinProcessPath(pid int32) (string, error) { + buf := make([]byte, unix.PathMax) + n, err := darwinProcPIDPath(pid, buf) + if err != nil { + return "", err + } + if n <= 0 { + return "", errors.New("empty process path") + } + return strings.TrimRight(string(buf[:n]), "\x00"), nil +} + +func darwinProcPIDInfo(pid int32, flavor int, arg uint64, buf []byte) (int, error) { + var ptr unsafe.Pointer + if len(buf) > 0 { + ptr = unsafe.Pointer(&buf[0]) + } + + r0, _, errno := syscall_syscall6(libc_proc_pidinfo_trampoline_addr, uintptr(pid), uintptr(flavor), uintptr(arg), uintptr(ptr), uintptr(len(buf)), 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +func darwinProcPIDFDInfo(pid int32, fd int32, flavor int, buf []byte) (int, error) { + var ptr unsafe.Pointer + if len(buf) > 0 { + ptr = unsafe.Pointer(&buf[0]) + } + + r0, _, errno := syscall_syscall6(libc_proc_pidfdinfo_trampoline_addr, uintptr(pid), uintptr(fd), uintptr(flavor), uintptr(ptr), uintptr(len(buf)), 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +func darwinProcPIDPath(pid int32, buf []byte) (int, error) { + r0, _, errno := syscall_syscall6(libc_proc_pidpath_trampoline_addr, uintptr(pid), uintptr(unsafe.Pointer(&buf[0])), uintptr(len(buf)), 0, 0, 0) + if errno != 0 { + return 0, errno + } + return int(r0), nil +} + +var libc_proc_pidinfo_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidinfo proc_pidinfo "/usr/lib/libproc.dylib" + +var libc_proc_pidfdinfo_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidfdinfo proc_pidfdinfo "/usr/lib/libproc.dylib" + +var libc_proc_pidpath_trampoline_addr uintptr + +//go:cgo_import_dynamic libc_proc_pidpath proc_pidpath "/usr/lib/libproc.dylib" + +// Implemented in the runtime package (runtime/sys_darwin.go). +func syscall_syscall6(fn, a1, a2, a3, a4, a5, a6 uintptr) (r1, r2 uintptr, err syscall.Errno) + +//go:linkname syscall_syscall6 syscall.syscall6 diff --git a/common/net/find_process_darwin.s b/common/net/find_process_darwin.s new file mode 100644 index 000000000..5e915476a --- /dev/null +++ b/common/net/find_process_darwin.s @@ -0,0 +1,18 @@ +//go:build darwin + +#include "textflag.h" + +TEXT libc_proc_pidinfo_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidinfo(SB) +GLOBL ·libc_proc_pidinfo_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidinfo_trampoline_addr(SB)/8, $libc_proc_pidinfo_trampoline<>(SB) + +TEXT libc_proc_pidfdinfo_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidfdinfo(SB) +GLOBL ·libc_proc_pidfdinfo_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidfdinfo_trampoline_addr(SB)/8, $libc_proc_pidfdinfo_trampoline<>(SB) + +TEXT libc_proc_pidpath_trampoline<>(SB),NOSPLIT,$0-0 + JMP libc_proc_pidpath(SB) +GLOBL ·libc_proc_pidpath_trampoline_addr(SB), RODATA, $8 +DATA ·libc_proc_pidpath_trampoline_addr(SB)/8, $libc_proc_pidpath_trampoline<>(SB) diff --git a/common/net/find_process_darwin_test.go b/common/net/find_process_darwin_test.go new file mode 100644 index 000000000..7eb8eb34d --- /dev/null +++ b/common/net/find_process_darwin_test.go @@ -0,0 +1,293 @@ +//go:build darwin + +package net + +import ( + stdnet "net" + "net/netip" + "os" + "testing" + "time" + "unsafe" + + "golang.org/x/sys/unix" +) + +func TestFindProcessDarwinTCP(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.Dial("tcp", listener.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + remote := conn.RemoteAddr().(*stdnet.TCPAddr) + + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), remote.IP.String(), uint16(remote.Port)) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinUDP(t *testing.T) { + conn, err := stdnet.ListenUDP("udp", &stdnet.UDPAddr{IP: stdnet.ParseIP("127.0.0.1")}) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + local := conn.LocalAddr().(*stdnet.UDPAddr) + + pid, name, path, err := FindProcess("udp", local.IP.String(), uint16(local.Port), "", 0) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinNonLocal(t *testing.T) { + _, _, _, err := FindProcess("tcp", "203.0.113.1", 80, "", 0) + if err != ErrNotLocal { + t.Fatalf("expected ErrNotLocal, got %v", err) + } +} + +func TestFindProcessDarwinUnsupportedNetwork(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("expected panic") + } + }() + + _, _, _, _ = FindProcess("icmp", "127.0.0.1", 0, "", 0) +} + +func assertCurrentProcess(t *testing.T, pid int, name string, path string) { + t.Helper() + + if pid != os.Getpid() { + t.Fatalf("expected pid %d, got %d (%s, %s)", os.Getpid(), pid, name, path) + } + + executable, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if path == "" || name == "" { + t.Fatalf("expected process path and name, got name=%q path=%q", name, path) + } + if sameFile(executable, path) { + return + } + t.Fatalf("expected executable %q, got %q", executable, path) +} + +func sameFile(left string, right string) bool { + leftInfo, leftErr := os.Stat(left) + rightInfo, rightErr := os.Stat(right) + if leftErr != nil || rightErr != nil { + return false + } + return os.SameFile(leftInfo, rightInfo) +} + +func TestFindProcessDarwinTCPWithoutDestination(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.DialTimeout("tcp", listener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), "", 0) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestFindProcessDarwinTCPWithDifferentDestination(t *testing.T) { + listener, err := stdnet.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + conn, err := stdnet.DialTimeout("tcp", listener.Addr().String(), time.Second) + if err != nil { + t.Fatal(err) + } + defer conn.Close() + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local := conn.LocalAddr().(*stdnet.TCPAddr) + pid, name, path, err := FindProcess("tcp", local.IP.String(), uint16(local.Port), "203.0.113.10", 443) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + +func TestDarwinSocketInfoMatchLevelFallbacks(t *testing.T) { + src := netip.MustParseAddr("198.18.0.2") + dst := netip.MustParseAddr("203.0.113.10") + otherLocal := netip.MustParseAddr("192.168.1.10") + otherRemote := netip.MustParseAddr("198.51.100.10") + unspecifiedLocal := netip.MustParseAddr("0.0.0.0") + + tests := []struct { + name string + local netip.Addr + remote netip.Addr + hasDst bool + wantLevel darwinSocketMatchLevel + }{ + { + name: "exact", + local: src, + remote: dst, + hasDst: true, + wantLevel: darwinSocketExactMatch, + }, + { + name: "unspecified local with matching remote", + local: unspecifiedLocal, + remote: dst, + hasDst: true, + wantLevel: darwinSocketExactMatch, + }, + { + name: "unspecified local without destination", + local: unspecifiedLocal, + remote: otherRemote, + hasDst: false, + wantLevel: darwinSocketExactMatch, + }, + { + name: "local match with different remote", + local: src, + remote: otherRemote, + hasDst: true, + wantLevel: darwinSocketLocalMatch, + }, + { + name: "remote match with different local", + local: otherLocal, + remote: dst, + hasDst: true, + wantLevel: darwinSocketRemoteMatch, + }, + { + name: "port only with destination", + local: otherLocal, + remote: otherRemote, + hasDst: true, + wantLevel: darwinSocketPortMatch, + }, + { + name: "different local without destination", + local: otherLocal, + remote: otherRemote, + hasDst: false, + wantLevel: darwinSocketNoMatch, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + info := newDarwinSocketInfo("tcp", test.local, 12345, test.remote, 443) + level := darwinSocketInfoMatchLevel(info, "tcp", src, 12345, dst, 443, test.hasDst) + if level != test.wantLevel { + t.Fatalf("unexpected match level: got %d, want %d", level, test.wantLevel) + } + }) + } +} + +func newDarwinSocketInfo(network string, local netip.Addr, localPort uint16, remote netip.Addr, remotePort uint16) []byte { + info := make([]byte, darwinSocketFDInfoSize) + switch network { + case "tcp": + writeDarwinNativeUint32(info, darwinSocketInfoProtoOff, uint32(unix.IPPROTO_TCP)) + writeDarwinNativeUint32(info, darwinSocketInfoKindOff, uint32(darwinSockInfoTCP)) + case "udp": + writeDarwinNativeUint32(info, darwinSocketInfoProtoOff, uint32(unix.IPPROTO_UDP)) + writeDarwinNativeUint32(info, darwinSocketInfoKindOff, uint32(darwinSockInfoIN)) + } + writeDarwinNativeUint32(info, darwinSocketInfoFamilyOff, uint32(unix.AF_INET)) + info[darwinInSockInfoVFlagOff] = darwinInSockInfoIPv4 + writeDarwinNativeUint32(info, darwinInSockInfoLPortOff, uint32(localPort)) + writeDarwinNativeUint32(info, darwinInSockInfoFPortOff, uint32(remotePort)) + copyDarwinIPv4(info[darwinInSockInfoLAddrOff:darwinInSockInfoLAddrOff+16], local) + copyDarwinIPv4(info[darwinInSockInfoFAddrOff:darwinInSockInfoFAddrOff+16], remote) + return info +} + +func writeDarwinNativeUint32(b []byte, offset int, value uint32) { + *(*uint32)(unsafe.Pointer(&b[offset])) = value +} + +func copyDarwinIPv4(dst []byte, addr netip.Addr) { + ip := addr.As4() + copy(dst[12:16], ip[:]) +} diff --git a/common/net/find_process_others.go b/common/net/find_process_others.go index 22e42bdc2..f4cdaf3e9 100644 --- a/common/net/find_process_others.go +++ b/common/net/find_process_others.go @@ -1,4 +1,4 @@ -//go:build !windows && !linux && !android +//go:build !windows && !linux && !android && !darwin package net From e4e7614c627f0bf5548c21e468eef31de615672f Mon Sep 17 00:00:00 2001 From: James Liu <2211002+jamesliu96@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:12:37 +0800 Subject: [PATCH 02/59] TLS ECH: Update ECH configuration parsing logic to be more robust (#6441) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6441#issuecomment-4903624643 --------- Co-authored-by: 风扇滑翔翼 --- transport/internet/tls/config.go | 2 +- transport/internet/tls/ech.go | 31 +++++++++++++++--------------- transport/internet/tls/ech_test.go | 6 ++---- 3 files changed, 19 insertions(+), 20 deletions(-) diff --git a/transport/internet/tls/config.go b/transport/internet/tls/config.go index 9c8604699..75360989e 100644 --- a/transport/internet/tls/config.go +++ b/transport/internet/tls/config.go @@ -450,7 +450,7 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config { for _, s := range tls.CipherSuites() { id[s.Name] = s.ID } - for _, n := range strings.Split(c.CipherSuites, ":") { + for n := range strings.SplitSeq(c.CipherSuites, ":") { if id[n] != 0 { config.CipherSuites = append(config.CipherSuites, id[n]) } diff --git a/transport/internet/tls/ech.go b/transport/internet/tls/ech.go index 4755cd4fd..b9b7deb2c 100644 --- a/transport/internet/tls/ech.go +++ b/transport/internet/tls/ech.go @@ -55,19 +55,16 @@ func ApplyECH(c *Config, config *tls.Config) error { } config.EncryptedClientHelloConfigList = ECHConfig }() - // direct base64 config + // query config from dns if strings.Contains(c.EchConfigList, "://") { - // query config from dns - parts := strings.Split(c.EchConfigList, "+") + // parse ECH DNS server in format of "example.com+https://1.1.1.1/dns-query" + parts := strings.SplitN(c.EchConfigList, "+", 2) if len(parts) == 2 { - // parse ECH DNS server in format of "example.com+https://1.1.1.1/dns-query" nameToQuery = parts[0] DNSServer = parts[1] - } else if len(parts) == 1 { + } else { // normal format DNSServer = parts[0] - } else { - return errors.New("Invalid ECH DNS server format: ", c.EchConfigList) } if nameToQuery == "" { return errors.New("Using DNS for ECH Config needs serverName or use Server format example.com+https://1.1.1.1/dns-query") @@ -77,6 +74,7 @@ func ApplyECH(c *Config, config *tls.Config) error { return errors.New("Failed to query ECH DNS record for domain: ", nameToQuery, " at server: ", DNSServer).Base(err) } } else { + // direct base64 config ECHConfig, err = base64.StdEncoding.DecodeString(c.EchConfigList) if err != nil { return errors.New("Failed to unmarshal ECHConfigList: ", err) @@ -157,7 +155,7 @@ func QueryRecord(domain string, server string, sockopt *internet.SocketConfig) ( // If expire is zero value, it means we are in initial state, wait for the query to finish // otherwise return old value immediately and update in a goroutine // but if the cache is too old, wait for update - if configRecord.expire == (time.Time{}) || configRecord.expire.Add(time.Hour*4).Before(time.Now()) { + if configRecord.expire.IsZero() || configRecord.expire.Add(time.Hour*4).Before(time.Now()) { return echConfigCache.Update(domain, server, false, sockopt) } else { // If someone already acquired the lock, it means it is updating, do not start another update goroutine @@ -256,14 +254,17 @@ func dnsQuery(server string, domain string, sockopt *internet.SocketConfig) ([]b } dnsResolve = respBody } else if strings.HasPrefix(server, "udp://") { // for classic udp dns server - udpServerAddr := server[len("udp://"):] - // default port 53 if not specified - if !strings.Contains(udpServerAddr, ":") { - udpServerAddr = udpServerAddr + ":53" - } - dest, err := net.ParseDestination("udp" + ":" + udpServerAddr) + udpServerURL, err := url.Parse(server) if err != nil { - return nil, 0, errors.New("failed to parse udp dns server ", udpServerAddr, " for ECH: ", err) + return nil, 0, errors.New("failed to parse udp dns server ", server, " for ECH: ", err) + } + // default port 53 if not specified + if udpServerURL.Port() == "" { + udpServerURL.Host = udpServerURL.Host + ":53" + } + dest, err := net.ParseDestination("udp" + ":" + udpServerURL.Host) + if err != nil { + return nil, 0, errors.New("failed to parse udp dns server ", udpServerURL.Host, " for ECH: ", err) } dnsTimeoutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() diff --git a/transport/internet/tls/ech_test.go b/transport/internet/tls/ech_test.go index ed67cedec..cf0d0095c 100644 --- a/transport/internet/tls/ech_test.go +++ b/transport/internet/tls/ech_test.go @@ -19,8 +19,7 @@ func TestECHDial(t *testing.T) { // test concurrent Dial(to test cache problem) wg := sync.WaitGroup{} for range 10 { - wg.Add(1) - go func() { + wg.Go(func() { TLSConfig := config.GetTLSConfig() TLSConfig.NextProtos = []string{"http/1.1"} client := &http.Client{ @@ -36,8 +35,7 @@ func TestECHDial(t *testing.T) { if !strings.Contains(string(body), "sni=encrypted") { t.Error("ECH Dial success but SNI is not encrypted") } - wg.Done() - }() + }) } wg.Wait() // check cache From 1aabe7ea78eca88deedc234a3e668895fa9bc08c Mon Sep 17 00:00:00 2001 From: ivolfram <45515661+dudkin-2005@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:59:19 +0300 Subject: [PATCH 03/59] XHTTP transport: Do not force trailing path `/` when both sessionID and seq are placed elsewhere (#6307) https://github.com/XTLS/Xray-core/discussions/6385#discussioncomment-17454120 https://github.com/XTLS/Xray-core/pull/6307#issuecomment-4917498294 Closes https://github.com/XTLS/Xray-core/issues/6410 --- transport/internet/splithttp/config.go | 7 +- transport/internet/splithttp/config_test.go | 73 +++++++++++++++++++-- 2 files changed, 72 insertions(+), 8 deletions(-) diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index 9acf5e4ff..c2441386c 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -24,8 +24,11 @@ func (c *Config) GetNormalizedPath() string { path = "/" + path } - if path[len(path)-1] != '/' { - path = path + "/" + if c.GetNormalizedSessionPlacement() == PlacementPath || + c.GetNormalizedSeqPlacement() == PlacementPath { + if path[len(path)-1] != '/' { + path = path + "/" + } } return path diff --git a/transport/internet/splithttp/config_test.go b/transport/internet/splithttp/config_test.go index 39c3fd95a..2cb1e5e9d 100644 --- a/transport/internet/splithttp/config_test.go +++ b/transport/internet/splithttp/config_test.go @@ -3,16 +3,77 @@ package splithttp_test import ( "testing" + "github.com/stretchr/testify/assert" . "github.com/xtls/xray-core/transport/internet/splithttp" ) func Test_GetNormalizedPath(t *testing.T) { - c := Config{ - Path: "/?world", + tests := []struct { + TestName string + Path string + SessionIDPlacement string + SeqPlacement string + Expected string + }{ + { + TestName: "default placement keeps trailing slash", + Path: "/sh", + Expected: "/sh/", + }, + { + TestName: "query string is stripped", + Path: "/?world", + Expected: "/", + }, + { + TestName: "both off path drops trailing slash", + Path: "/stream", + SessionIDPlacement: "query", + SeqPlacement: "query", + Expected: "/stream", + }, + { + TestName: "both off path keeps file-like path", + Path: "/stream/filename.extension", + SessionIDPlacement: "query", + SeqPlacement: "header", + Expected: "/stream/filename.extension", + }, + { + TestName: "seq in path keeps trailing slash", + Path: "/stream", + SessionIDPlacement: "query", + Expected: "/stream/", + }, + { + TestName: "session in path keeps trailing slash", + Path: "/stream", + SeqPlacement: "cookie", + Expected: "/stream/", + }, + { + TestName: "existing trailing slash preserved", + Path: "/stream/", + SessionIDPlacement: "query", + SeqPlacement: "query", + Expected: "/stream/", + }, + { + TestName: "root unchanged", + Path: "/", + SessionIDPlacement: "query", + SeqPlacement: "query", + Expected: "/", + }, } - - path := c.GetNormalizedPath() - if path != "/" { - t.Error("Unexpected: ", path) + for _, test := range tests { + t.Run(test.TestName, func(t *testing.T) { + c := Config{ + Path: test.Path, + SessionIDPlacement: test.SessionIDPlacement, + SeqPlacement: test.SeqPlacement, + } + assert.Equal(t, test.Expected, c.GetNormalizedPath()) + }) } } From 5c62d50d43a8ddafc4a607e39a536772ec07b7b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 14:11:38 +0000 Subject: [PATCH 04/59] Bump golang.org/x/net from 0.56.0 to 0.57.0 (#6455) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0. - [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.57.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 14 +++++++------- go.sum | 28 ++++++++++++++-------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/go.mod b/go.mod index d5af7f407..c9c4eb775 100644 --- a/go.mod +++ b/go.mod @@ -23,11 +23,11 @@ require ( github.com/vishvananda/netlink v1.3.1 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.53.0 + golang.org/x/crypto v0.54.0 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 - golang.org/x/net v0.56.0 - golang.org/x/sync v0.21.0 - golang.org/x/sys v0.46.0 + golang.org/x/net v0.57.0 + golang.org/x/sync v0.22.0 + golang.org/x/sys v0.47.0 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/windows v1.0.1 @@ -52,10 +52,10 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.36.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.45.0 // indirect + golang.org/x/tools v0.47.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 92ccc6bb3..144b80d6a 100644 --- a/go.sum +++ b/go.sum @@ -98,22 +98,22 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= -golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= -golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -121,21 +121,21 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= -golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= -golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 412898fed781e151a46dd25241c78fe014c8e35c Mon Sep 17 00:00:00 2001 From: LjhAUMEM Date: Fri, 10 Jul 2026 04:40:02 +0800 Subject: [PATCH 05/59] WireGuard outbound: Fix a race condition (#6461) Fixes https://github.com/XTLS/Xray-core/pull/6321#issuecomment-4881463309 --- proxy/wireguard/client.go | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/proxy/wireguard/client.go b/proxy/wireguard/client.go index 4491886ab..c50dd995f 100644 --- a/proxy/wireguard/client.go +++ b/proxy/wireguard/client.go @@ -139,13 +139,7 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte ob.Name = "wireguard" ob.CanSpliceCopy = 3 - if h.dev == nil { - if err := h.init(ctx); err != nil { - return err - } - } - - if err := h.dev.Up(); err != nil { + if err := h.init(ctx); err != nil { return err } @@ -258,8 +252,11 @@ func (h *Handler) Close() (err error) { func (h *Handler) init(ctx context.Context) error { h.mu.Lock() defer h.mu.Unlock() + if h.tun == nil { + return errors.New("closed") + } if h.dev != nil { - return nil + return h.dev.Up() } resolveFunc := h.resolveLocal listenFunc := func() (net.PacketConn, error) { From c320e89108c73b3d83ace6c2f32db1da7a48e5d4 Mon Sep 17 00:00:00 2001 From: Ran The <197520740+ranthe21@users.noreply.github.com> Date: Thu, 9 Jul 2026 23:55:40 +0300 Subject: [PATCH 06/59] HTTPUpgrade server: Keep accepting after transient errors as RAW does; Add handshake read deadline as WS does (#6463) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6463#issuecomment-4929183257 --------- Co-authored-by: 风扇滑翔翼 --- transport/internet/httpupgrade/hub.go | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/transport/internet/httpupgrade/hub.go b/transport/internet/httpupgrade/hub.go index cb9a79d9a..cbf6a0d47 100644 --- a/transport/internet/httpupgrade/hub.go +++ b/transport/internet/httpupgrade/hub.go @@ -4,8 +4,10 @@ import ( "bufio" "context" "crypto/tls" + "io" "net/http" "strings" + "time" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/errors" @@ -43,7 +45,11 @@ func (s *server) Handle(conn net.Conn) { // upgrade execute a fake websocket upgrade process and return the available connection func (s *server) upgrade(conn net.Conn) (stat.Connection, error) { - connReader := bufio.NewReader(conn) + // timeout and header limit are the same as websocket + conn.SetReadDeadline(time.Now().Add(time.Second * 4)) + defer conn.SetReadDeadline(time.Time{}) + connReader := bufio.NewReader(io.LimitReader(conn, 12288)) + req, err := http.ReadRequest(connReader) if err != nil { return nil, err @@ -94,7 +100,15 @@ func (s *server) keepAccepting() { for { conn, err := s.innnerListener.Accept() if err != nil { - return + errStr := err.Error() + if strings.Contains(errStr, "closed") { + break + } + errors.LogWarningInner(context.Background(), err, "failed to accept raw connections") + if strings.Contains(errStr, "too many") { + time.Sleep(time.Millisecond * 500) + } + continue } go s.Handle(conn) } From e2ad0acf603a2eb8be9ec4724098b700f0db4cf1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 01:56:16 +0000 Subject: [PATCH 07/59] Bump github.com/pires/go-proxyproto from 0.14.0 to 0.15.0 (#6464) Bumps [github.com/pires/go-proxyproto](https://github.com/pires/go-proxyproto) from 0.14.0 to 0.15.0. - [Release notes](https://github.com/pires/go-proxyproto/releases) - [Commits](https://github.com/pires/go-proxyproto/compare/v0.14.0...v0.15.0) --- updated-dependencies: - dependency-name: github.com/pires/go-proxyproto dependency-version: 0.15.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c9c4eb775..8267c1fce 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/miekg/dns v1.1.72 github.com/pelletier/go-toml v1.9.5 github.com/pion/stun/v3 v3.1.6 - github.com/pires/go-proxyproto v0.14.0 + github.com/pires/go-proxyproto v0.15.0 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af github.com/robfig/cron/v3 v3.0.1 github.com/sagernet/sing v0.5.1 diff --git a/go.sum b/go.sum index 144b80d6a..183207423 100644 --- a/go.sum +++ b/go.sum @@ -53,8 +53,8 @@ github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= -github.com/pires/go-proxyproto v0.14.0 h1:2vIGIfVG8eVRsKF0xukEoeT5RWhDXxBU0uv6smLOKdI= -github.com/pires/go-proxyproto v0.14.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= +github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= From c18b39ed80eee07eecb20c4a0eeb3d6b3be2b724 Mon Sep 17 00:00:00 2001 From: patterniha <71074308+patterniha@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:32:31 +0330 Subject: [PATCH 08/59] TLS client: Support more `cipherSuites` for "unsafe" (golang) `fingerprint` for anti-NIN (#6450) https://github.com/XTLS/Xray-core/pull/6450#issuecomment-4929495504 --- transport/internet/tls/config.go | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/transport/internet/tls/config.go b/transport/internet/tls/config.go index 75360989e..ec5659a0c 100644 --- a/transport/internet/tls/config.go +++ b/transport/internet/tls/config.go @@ -450,9 +450,13 @@ func (c *Config) GetTLSConfig(opts ...Option) *tls.Config { for _, s := range tls.CipherSuites() { id[s.Name] = s.ID } + for _, s := range tls.InsecureCipherSuites() { + id[s.Name] = s.ID + } for n := range strings.SplitSeq(c.CipherSuites, ":") { - if id[n] != 0 { - config.CipherSuites = append(config.CipherSuites, id[n]) + n = strings.TrimSpace(n) + if v, ok := id[n]; ok { + config.CipherSuites = append(config.CipherSuites, v) } } } From d5bc58dc6b7608e664515f970a9742e67e5f9623 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:56:49 +0800 Subject: [PATCH 09/59] Root config: Add `env` config (#6400) https://github.com/XTLS/Xray-core/pull/6400#issuecomment-4880198100 https://github.com/XTLS/Xray-core/pull/6400#issuecomment-4916892425 https://github.com/XTLS/Xray-core/pull/6400#issuecomment-4929136670 --- .github/docker/Dockerfile | 1 + .github/docker/Dockerfile.usa | 1 + common/buf/io.go | 2 +- common/buf/readv_reader.go | 18 +++++-- common/buf/readv_reader_stub.go | 4 +- common/platform/env_reload.go | 40 ++++++++++++++ common/xudp/xudp.go | 52 +++++++++++------- core/xray.go | 3 ++ features/policy/policy.go | 26 ++++++--- infra/conf/env_test.go | 59 +++++++++++++++++++++ infra/conf/xray.go | 22 ++++++++ main/run.go | 4 ++ proxy/freedom/freedom.go | 24 ++++++--- proxy/vmess/outbound/outbound.go | 19 ++++--- transport/internet/browser_dialer/dialer.go | 18 +++++-- 15 files changed, 241 insertions(+), 52 deletions(-) create mode 100644 common/platform/env_reload.go create mode 100644 infra/conf/env_test.go diff --git a/.github/docker/Dockerfile b/.github/docker/Dockerfile index 2bb5de51d..e85706922 100644 --- a/.github/docker/Dockerfile +++ b/.github/docker/Dockerfile @@ -37,6 +37,7 @@ RUN echo '{}' >/tmp/usr/local/etc/xray/08_fakedns.json RUN echo '{}' >/tmp/usr/local/etc/xray/09_metrics.json RUN echo '{}' >/tmp/usr/local/etc/xray/10_observatory.json RUN echo '{}' >/tmp/usr/local/etc/xray/11_geodata.json +RUN echo '{}' >/tmp/usr/local/etc/xray/12_env.json RUN echo '{}' >/tmp/usr/local/etc/xray/99_version.json # Create log files diff --git a/.github/docker/Dockerfile.usa b/.github/docker/Dockerfile.usa index 432433b46..8aa096f8a 100644 --- a/.github/docker/Dockerfile.usa +++ b/.github/docker/Dockerfile.usa @@ -37,6 +37,7 @@ RUN echo '{}' >/tmp/usr/local/etc/xray/08_fakedns.json RUN echo '{}' >/tmp/usr/local/etc/xray/09_metrics.json RUN echo '{}' >/tmp/usr/local/etc/xray/10_observatory.json RUN echo '{}' >/tmp/usr/local/etc/xray/11_geodata.json +RUN echo '{}' >/tmp/usr/local/etc/xray/12_env.json RUN echo '{}' >/tmp/usr/local/etc/xray/99_version.json # Create log files diff --git a/common/buf/io.go b/common/buf/io.go index 75565e538..6b35052d2 100644 --- a/common/buf/io.go +++ b/common/buf/io.go @@ -122,7 +122,7 @@ func NewReader(reader io.Reader) Reader { } _, isFile := reader.(*os.File) - if !isFile && useReadv { + if !isFile && useReadV() { if sc, ok := reader.(syscall.Conn); ok { rawConn, err := sc.SyscallConn() if err != nil { diff --git a/common/buf/readv_reader.go b/common/buf/readv_reader.go index 7d7b3ead3..6ce44623e 100644 --- a/common/buf/readv_reader.go +++ b/common/buf/readv_reader.go @@ -5,6 +5,7 @@ package buf import ( "io" + "sync/atomic" "syscall" "github.com/xtls/xray-core/common/platform" @@ -143,13 +144,24 @@ func (r *ReadVReader) ReadMultiBuffer() (MultiBuffer, error) { return mb, nil } -var useReadv bool +var useReadv atomic.Bool -func init() { +func useReadV() bool { + return useReadv.Load() +} + +func reloadEnvSettings() error { const defaultFlagValue = "NOT_DEFINED_AT_ALL" value := platform.NewEnvFlag(platform.UseReadV).GetValue(func() string { return defaultFlagValue }) + enabled := false switch value { case defaultFlagValue, "auto", "enable": - useReadv = true + enabled = true } + useReadv.Store(enabled) + return nil +} + +func init() { + platform.RegisterEnvReload(reloadEnvSettings) } diff --git a/common/buf/readv_reader_stub.go b/common/buf/readv_reader_stub.go index b2be9825f..fcb6154f6 100644 --- a/common/buf/readv_reader_stub.go +++ b/common/buf/readv_reader_stub.go @@ -10,7 +10,9 @@ import ( "github.com/xtls/xray-core/features/stats" ) -const useReadv = false +func useReadV() bool { + return false +} func NewReadVReader(reader io.Reader, rawConn syscall.RawConn, counter stats.Counter) Reader { panic("not implemented") diff --git a/common/platform/env_reload.go b/common/platform/env_reload.go new file mode 100644 index 000000000..eafc884ef --- /dev/null +++ b/common/platform/env_reload.go @@ -0,0 +1,40 @@ +package platform + +import ( + "errors" + "sync" +) + +var envReloadRegistry = struct { + sync.RWMutex + handlers []func() error +}{} + +// RegisterEnvReload registers an environment reload handler and runs it once +// immediately so package defaults keep the same behavior as init-time reads. +func RegisterEnvReload(handler func() error) { + if handler == nil { + return + } + envReloadRegistry.Lock() + envReloadRegistry.handlers = append(envReloadRegistry.handlers, handler) + envReloadRegistry.Unlock() + if err := handler(); err != nil { + panic(err) + } +} + +// ReloadEnvSettings refreshes all registered environment-backed package state. +func ReloadEnvSettings() error { + envReloadRegistry.RLock() + handlers := append([]func() error{}, envReloadRegistry.handlers...) + envReloadRegistry.RUnlock() + + var errs []error + for _, handler := range handlers { + if err := handler(); err != nil { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} diff --git a/common/xudp/xudp.go b/common/xudp/xudp.go index afaa602d2..30841b1e1 100644 --- a/common/xudp/xudp.go +++ b/common/xudp/xudp.go @@ -8,7 +8,7 @@ import ( "io" "strconv" "strings" - "time" + "sync/atomic" "github.com/xtls/xray-core/common/buf" "github.com/xtls/xray-core/common/errors" @@ -27,25 +27,39 @@ var AddrParser = protocol.NewAddressParser( ) var ( - Show bool - BaseKey []byte + Show atomic.Bool + baseKey atomic.Value ) -func init() { - if strings.ToLower(platform.NewEnvFlag(platform.XUDPLog).GetValue(func() string { return "" })) == "true" { - Show = true +func reloadEnvSettings() error { + Show.Store(strings.ToLower(platform.NewEnvFlag(platform.XUDPLog).GetValue(func() string { return "" })) == "true") + raw := platform.NewEnvFlag(platform.XUDPBaseKey).GetValue(func() string { return "" }) + if raw == "" { + ensureBaseKey() + return nil } - BaseKey = make([]byte, 32) - rand.Read(BaseKey) - go func() { - time.Sleep(100 * time.Millisecond) // this is not nice, but need to give some time for Android to setup ENV - if raw := platform.NewEnvFlag(platform.XUDPBaseKey).GetValue(func() string { return "" }); raw != "" { - if BaseKey, _ = base64.RawURLEncoding.DecodeString(raw); len(BaseKey) == 32 { - return - } - panic(platform.XUDPBaseKey + ": invalid value (BaseKey must be 32 bytes): " + raw + " len " + strconv.Itoa(len(BaseKey))) - } - }() + key, _ := base64.RawURLEncoding.DecodeString(raw) + if len(key) != 32 { + return errors.New(platform.XUDPBaseKey + ": invalid value (BaseKey must be 32 bytes): " + raw + " len " + strconv.Itoa(len(key))) + } + baseKey.Store(append([]byte(nil), key...)) + return nil +} + +func ensureBaseKey() []byte { + if key := baseKey.Load(); key != nil { + return key.([]byte) + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + panic(err) + } + baseKey.Store(key) + return key +} + +func init() { + platform.RegisterEnvReload(reloadEnvSettings) } func GetGlobalID(ctx context.Context) (globalID [8]byte) { @@ -54,10 +68,10 @@ func GetGlobalID(ctx context.Context) (globalID [8]byte) { } if inbound := session.InboundFromContext(ctx); inbound != nil && inbound.Source.Network == net.Network_UDP && (inbound.Name == "dokodemo-door" || inbound.Name == "socks" || inbound.Name == "shadowsocks" || inbound.Name == "tun") { - h := blake3.New(8, BaseKey) + h := blake3.New(8, ensureBaseKey()) h.Write([]byte(inbound.Source.String())) copy(globalID[:], h.Sum(nil)) - if Show { + if Show.Load() { errors.LogInfo(ctx, fmt.Sprintf("XUDP inbound.Source.String(): %v\tglobalID: %v\n", inbound.Source.String(), globalID)) } } diff --git a/core/xray.go b/core/xray.go index 58135c96b..a70507068 100644 --- a/core/xray.go +++ b/core/xray.go @@ -187,6 +187,9 @@ func NewWithContext(ctx context.Context, config *Config) (*Instance, error) { } func initInstanceWithConfig(config *Config, server *Instance) (bool, error) { + if err := platform.ReloadEnvSettings(); err != nil { + return true, errors.New("failed to reload environment settings").Base(err) + } server.ctx = context.WithValue(server.ctx, "cone", platform.NewEnvFlag(platform.UseCone).GetValue(func() string { return "" }) != "true") diff --git a/features/policy/policy.go b/features/policy/policy.go index d6fd20d06..cfbd6207b 100644 --- a/features/policy/policy.go +++ b/features/policy/policy.go @@ -3,6 +3,7 @@ package policy import ( "context" "runtime" + "sync/atomic" "time" "github.com/xtls/xray-core/common/platform" @@ -82,32 +83,41 @@ func ManagerType() interface{} { return (*Manager)(nil) } -var defaultBufferSize int32 +var defaultBufferSize atomic.Int32 -func init() { +func reloadEnvSettings() error { + defaultBufferSize.Store(readDefaultBufferSize()) + return nil +} + +func readDefaultBufferSize() int32 { const defaultValue = -17 size := platform.NewEnvFlag(platform.BufferSize).GetValueAsInt(defaultValue) switch size { case 0: - defaultBufferSize = -1 // For pipe to use unlimited size + return -1 // For pipe to use unlimited size case defaultValue: // Env flag not defined. Use default values per CPU-arch. switch runtime.GOARCH { case "arm", "mips", "mipsle": - defaultBufferSize = 0 + return 0 case "arm64", "mips64", "mips64le": - defaultBufferSize = 4 * 1024 // 4k cache for low-end devices + return 4 * 1024 // 4k cache for low-end devices default: - defaultBufferSize = 512 * 1024 + return 512 * 1024 } default: - defaultBufferSize = int32(size) * 1024 * 1024 + return int32(size) * 1024 * 1024 } } +func init() { + platform.RegisterEnvReload(reloadEnvSettings) +} + func defaultBufferPolicy() Buffer { return Buffer{ - PerConnection: defaultBufferSize, + PerConnection: defaultBufferSize.Load(), } } diff --git a/infra/conf/env_test.go b/infra/conf/env_test.go new file mode 100644 index 000000000..ae499a5ee --- /dev/null +++ b/infra/conf/env_test.go @@ -0,0 +1,59 @@ +package conf + +import ( + "encoding/json" + "os" + "testing" +) + +func TestRootEnvAppliesArbitraryValues(t *testing.T) { + const ( + valueKey = "XRAY_TEST_CONFIG_ENV" + emptyKey = "XRAY_TEST_CONFIG_EMPTY" + ) + t.Setenv(valueKey, "before") + t.Setenv(emptyKey, "before") + + config := new(Config) + if err := json.Unmarshal([]byte(`{ + "env": { + "XRAY_TEST_CONFIG_ENV": "configured", + "XRAY_TEST_CONFIG_EMPTY": "" + } + }`), config); err != nil { + t.Fatal(err) + } + if _, err := config.Build(); err != nil { + t.Fatal(err) + } + + if got := os.Getenv(valueKey); got != "configured" { + t.Fatalf("env %q = %q, want %q", valueKey, got, "configured") + } + if got := os.Getenv(emptyKey); got != "" { + t.Fatalf("env %q = %q, want empty", emptyKey, got) + } +} + +func TestEnvConfigOverride(t *testing.T) { + base := EnvConfig{ + "ONE": "one", + "TWO": "old", + } + override := EnvConfig{ + "TWO": "new", + "THREE": "three", + } + base.Override(override) + + want := map[string]string{ + "ONE": "one", + "TWO": "new", + "THREE": "three", + } + for key, value := range want { + if got := base[key]; got != value { + t.Fatalf("env %q = %q, want %q", key, got, value) + } + } +} diff --git a/infra/conf/xray.go b/infra/conf/xray.go index 91d161183..a0846a817 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -3,6 +3,7 @@ package conf import ( "context" "encoding/json" + "os" "path/filepath" "strings" @@ -379,11 +380,20 @@ func (c *StatsConfig) Build() (*stats.Config, error) { return &stats.Config{}, nil } +type EnvConfig map[string]string + +func (c EnvConfig) Override(o EnvConfig) { + for key, value := range o { + c[key] = value + } +} + type Config struct { // Deprecated: Global transport config is no longer used // left for returning error Transport map[string]json.RawMessage `json:"transport"` + Env EnvConfig `json:"env"` LogConfig *LogConfig `json:"log"` RouterConfig *RouterConfig `json:"routing"` DNSConfig *DNSConfig `json:"dns"` @@ -439,6 +449,12 @@ func (c *Config) Override(o *Config, fn string) { if o.Transport != nil { c.Transport = o.Transport } + if o.Env != nil { + if c.Env == nil { + c.Env = EnvConfig{} + } + c.Env.Override(o.Env) + } if o.Policy != nil { c.Policy = o.Policy } @@ -514,6 +530,12 @@ func (c *Config) Override(o *Config, fn string) { // Build implements Buildable. func (c *Config) Build() (*core.Config, error) { + for key, value := range c.Env { + if err := os.Setenv(key, value); err != nil { + return nil, errors.New("failed to apply environment configuration").Base(err) + } + } + if err := PostProcessConfigureFile(c); err != nil { return nil, errors.New("failed to post-process configuration file").Base(err) } diff --git a/main/run.go b/main/run.go index 2655f7833..b87ca6356 100644 --- a/main/run.go +++ b/main/run.go @@ -36,6 +36,10 @@ The -confdir=dir flag sets a dir with multiple json config The -format=json flag sets the format of config files. Default "auto". +The config root env object sets process environment variables after all config +files are parsed. Variables needed to locate or parse config files must be set +in the process environment before Xray starts. + The -test flag tells Xray to test config files only, without launching the server. diff --git a/proxy/freedom/freedom.go b/proxy/freedom/freedom.go index 51c90a7ab..00ed3f4ca 100644 --- a/proxy/freedom/freedom.go +++ b/proxy/freedom/freedom.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "io" "strings" + "sync/atomic" "time" "github.com/pires/go-proxyproto" @@ -31,12 +32,24 @@ import ( ) var ( - useSplice bool + useSplice atomic.Bool allNetworks [8]bool defaultBlockPrivateRule *FinalRule defaultBlockAllRule *FinalRule ) +func reloadEnvSettings() error { + const defaultFlagValue = "NOT_DEFINED_AT_ALL" + value := platform.NewEnvFlag(platform.UseFreedomSplice).GetValue(func() string { return defaultFlagValue }) + enabled := false + switch value { + case defaultFlagValue, "auto", "enable": + enabled = true + } + useSplice.Store(enabled) + return nil +} + func init() { common.Must(common.RegisterConfig((*Config)(nil), func(ctx context.Context, config interface{}) (interface{}, error) { h := new(Handler) @@ -48,12 +61,7 @@ func init() { return h, nil })) - const defaultFlagValue = "NOT_DEFINED_AT_ALL" - value := platform.NewEnvFlag(platform.UseFreedomSplice).GetValue(func() string { return defaultFlagValue }) - switch value { - case defaultFlagValue, "auto", "enable": - useSplice = true - } + platform.RegisterEnvReload(reloadEnvSettings) for i := range allNetworks { allNetworks[i] = true @@ -422,7 +430,7 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte responseDone := func() error { defer timer.SetTimeout(plcy.Timeouts.UplinkOnly) - if destination.Network == net.Network_TCP && useSplice && proxy.IsRAWTransportWithoutSecurity(conn) { // it would be tls conn in special use case of MITM, we need to let link handle traffic + if destination.Network == net.Network_TCP && useSplice.Load() && proxy.IsRAWTransportWithoutSecurity(conn) { // it would be tls conn in special use case of MITM, we need to let link handle traffic var writeConn net.Conn var inTimer *signal.ActivityTimer if inbound := session.InboundFromContext(ctx); inbound != nil && inbound.Conn != nil { diff --git a/proxy/vmess/outbound/outbound.go b/proxy/vmess/outbound/outbound.go index bdc3ecc25..db5f94abb 100644 --- a/proxy/vmess/outbound/outbound.go +++ b/proxy/vmess/outbound/outbound.go @@ -5,6 +5,7 @@ import ( "crypto/hmac" "crypto/sha256" "hash/crc64" + "sync/atomic" "time" "github.com/xtls/xray-core/common" @@ -218,10 +219,17 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte return nil } -var enablePadding = false +var enablePadding atomic.Bool func shouldEnablePadding(s protocol.SecurityType) bool { - return enablePadding || s == protocol.SecurityType_AES128_GCM || s == protocol.SecurityType_CHACHA20_POLY1305 || s == protocol.SecurityType_AUTO + return enablePadding.Load() || s == protocol.SecurityType_AES128_GCM || s == protocol.SecurityType_CHACHA20_POLY1305 || s == protocol.SecurityType_AUTO +} + +func reloadEnvSettings() error { + const defaultFlagValue = "NOT_DEFINED_AT_ALL" + paddingValue := platform.NewEnvFlag(platform.UseVmessPadding).GetValue(func() string { return defaultFlagValue }) + enablePadding.Store(paddingValue != defaultFlagValue) + return nil } func init() { @@ -229,10 +237,5 @@ func init() { return New(ctx, config.(*Config)) })) - const defaultFlagValue = "NOT_DEFINED_AT_ALL" - - paddingValue := platform.NewEnvFlag(platform.UseVmessPadding).GetValue(func() string { return defaultFlagValue }) - if paddingValue != defaultFlagValue { - enablePadding = true - } + platform.RegisterEnvReload(reloadEnvSettings) } diff --git a/transport/internet/browser_dialer/dialer.go b/transport/internet/browser_dialer/dialer.go index 5705f9503..ee3828020 100644 --- a/transport/internet/browser_dialer/dialer.go +++ b/transport/internet/browser_dialer/dialer.go @@ -27,9 +27,10 @@ type task struct { } var ( - conns chan *websocket.Conn - server *http.Server - mu sync.Mutex + conns chan *websocket.Conn + server *http.Server + currentAddr string + mu sync.Mutex ) var upgrader = &websocket.Upgrader{ @@ -47,8 +48,13 @@ func Reload() { mu.Lock() defer mu.Unlock() + if addr == currentAddr && (addr == "" || server != nil) { + return + } + if server != nil { server.Close() + server = nil } if HasBrowserDialer() { for len(conns) > 0 { @@ -60,6 +66,7 @@ func Reload() { } conns = nil } + currentAddr = addr if addr != "" { token := uuid.New() csrfToken := token.String() @@ -220,5 +227,8 @@ func CheckOK(conn *websocket.Conn) error { } func init() { - Reload() + platform.RegisterEnvReload(func() error { + Reload() + return nil + }) } From 0bafca94868a4ec38cd3dba0f2ba7ec6f44bbf18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Sat, 11 Jul 2026 04:34:27 +0800 Subject: [PATCH 10/59] Stats: Fix GetOrRegister*() races (#6468) Fixes https://github.com/XTLS/Xray-core/pull/6467#issue-4856851059 --------- Co-authored-by: Joshua Su --- app/dispatcher/default.go | 10 +++---- app/proxyman/inbound/always.go | 4 +-- app/proxyman/outbound/handler.go | 4 +-- app/stats/stats.go | 48 ++++++++++++++++++++++++++++++ features/stats/stats.go | 51 +++++++++++++------------------- proxy/tun/handler.go | 4 +-- proxy/wireguard/client.go | 4 +-- proxy/wireguard/server.go | 4 +-- 8 files changed, 84 insertions(+), 45 deletions(-) diff --git a/app/dispatcher/default.go b/app/dispatcher/default.go index f6cfd76eb..2717df5fe 100644 --- a/app/dispatcher/default.go +++ b/app/dispatcher/default.go @@ -162,7 +162,7 @@ func (d *DefaultDispatcher) getLink(ctx context.Context) (*transport.Link, *tran p := d.policy.ForLevel(user.Level) if p.Stats.UserUplink { name := "user>>>" + user.Email + ">>>traffic>>>uplink" - if c, _ := stats.GetOrRegisterCounter(d.stats, name); c != nil { + if c, _ := d.stats.GetOrRegisterCounter(name); c != nil { inboundLink.Writer = &SizeStatWriter{ Counter: c, Writer: inboundLink.Writer, @@ -171,7 +171,7 @@ func (d *DefaultDispatcher) getLink(ctx context.Context) (*transport.Link, *tran } if p.Stats.UserDownlink { name := "user>>>" + user.Email + ">>>traffic>>>downlink" - if c, _ := stats.GetOrRegisterCounter(d.stats, name); c != nil { + if c, _ := d.stats.GetOrRegisterCounter(name); c != nil { outboundLink.Writer = &SizeStatWriter{ Counter: c, Writer: outboundLink.Writer, @@ -200,13 +200,13 @@ func WrapLink(ctx context.Context, policyManager policy.Manager, statsManager st p := policyManager.ForLevel(user.Level) if p.Stats.UserUplink { name := "user>>>" + user.Email + ">>>traffic>>>uplink" - if c, _ := stats.GetOrRegisterCounter(statsManager, name); c != nil { + if c, _ := statsManager.GetOrRegisterCounter(name); c != nil { link.Reader.(*buf.TimeoutWrapperReader).Counter = c } } if p.Stats.UserDownlink { name := "user>>>" + user.Email + ">>>traffic>>>downlink" - if c, _ := stats.GetOrRegisterCounter(statsManager, name); c != nil { + if c, _ := statsManager.GetOrRegisterCounter(name); c != nil { link.Writer = &SizeStatWriter{ Counter: c, Writer: link.Writer, @@ -223,7 +223,7 @@ func WrapLink(ctx context.Context, policyManager policy.Manager, statsManager st func trackOnlineIP(ctx context.Context, sm stats.Manager, email, ip string) { name := "user>>>" + email + ">>>online" - if om, _ := stats.GetOrRegisterOnlineMap(sm, name); om != nil { + if om, _ := sm.GetOrRegisterOnlineMap(name); om != nil { om.AddIP(ip) context.AfterFunc(ctx, func() { om.RemoveIP(ip) }) } diff --git a/app/proxyman/inbound/always.go b/app/proxyman/inbound/always.go index 462f894f5..e0d6c0925 100644 --- a/app/proxyman/inbound/always.go +++ b/app/proxyman/inbound/always.go @@ -26,7 +26,7 @@ func getStatCounter(v *core.Instance, tag string) (stats.Counter, stats.Counter) if len(tag) > 0 && policy.ForSystem().Stats.InboundUplink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + tag + ">>>traffic>>>uplink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { uplinkCounter = c } @@ -34,7 +34,7 @@ func getStatCounter(v *core.Instance, tag string) (stats.Counter, stats.Counter) if len(tag) > 0 && policy.ForSystem().Stats.InboundDownlink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + tag + ">>>traffic>>>downlink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { downlinkCounter = c } diff --git a/app/proxyman/outbound/handler.go b/app/proxyman/outbound/handler.go index 2fe0fec55..5f4833e9b 100644 --- a/app/proxyman/outbound/handler.go +++ b/app/proxyman/outbound/handler.go @@ -39,7 +39,7 @@ func getStatCounter(v *core.Instance, tag string) (stats.Counter, stats.Counter) if len(tag) > 0 && policy.ForSystem().Stats.OutboundUplink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "outbound>>>" + tag + ">>>traffic>>>uplink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { uplinkCounter = c } @@ -47,7 +47,7 @@ func getStatCounter(v *core.Instance, tag string) (stats.Counter, stats.Counter) if len(tag) > 0 && policy.ForSystem().Stats.OutboundDownlink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "outbound>>>" + tag + ">>>traffic>>>downlink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { downlinkCounter = c } diff --git a/app/stats/stats.go b/app/stats/stats.go index 68fb9be18..6df8c499e 100644 --- a/app/stats/stats.go +++ b/app/stats/stats.go @@ -48,6 +48,20 @@ func (m *Manager) RegisterCounter(name string) (stats.Counter, error) { return c, nil } +// GetOrRegisterCounter implements stats.Manager. +func (m *Manager) GetOrRegisterCounter(name string) (stats.Counter, error) { + m.access.Lock() + defer m.access.Unlock() + + if c, found := m.counters[name]; found { + return c, nil + } + errors.LogDebug(context.Background(), "create new counter ", name) + c := new(Counter) + m.counters[name] = c + return c, nil +} + // UnregisterCounter implements stats.Manager. func (m *Manager) UnregisterCounter(name string) error { m.access.Lock() @@ -97,6 +111,20 @@ func (m *Manager) RegisterOnlineMap(name string) (stats.OnlineMap, error) { return om, nil } +// GetOrRegisterOnlineMap implements stats.Manager. +func (m *Manager) GetOrRegisterOnlineMap(name string) (stats.OnlineMap, error) { + m.access.Lock() + defer m.access.Unlock() + + if om, found := m.onlineMaps[name]; found { + return om, nil + } + errors.LogDebug(context.Background(), "create new OnlineMap ", name) + om := NewOnlineMap() + m.onlineMaps[name] = om + return om, nil +} + // UnregisterOnlineMap implements stats.Manager. func (m *Manager) UnregisterOnlineMap(name string) error { m.access.Lock() @@ -149,6 +177,26 @@ func (m *Manager) RegisterChannel(name string) (stats.Channel, error) { return c, nil } +// GetOrRegisterChannel implements stats.Manager. +func (m *Manager) GetOrRegisterChannel(name string) (stats.Channel, error) { + m.access.Lock() + defer m.access.Unlock() + + if c, found := m.channels[name]; found { + return c, nil + } + errors.LogDebug(context.Background(), "create new channel ", name) + c := NewChannel(&ChannelConfig{BufferSize: 64, Blocking: false}) + if m.running { + // Start before publishing so no goroutine can observe an unstarted channel. + if err := c.Start(); err != nil { + return nil, err + } + } + m.channels[name] = c + return c, nil +} + // UnregisterChannel implements stats.Manager. func (m *Manager) UnregisterChannel(name string) error { m.access.Lock() diff --git a/features/stats/stats.go b/features/stats/stats.go index 1d490c10b..e1333c32a 100644 --- a/features/stats/stats.go +++ b/features/stats/stats.go @@ -81,6 +81,8 @@ type Manager interface { // RegisterCounter registers a new counter to the manager. The identifier string must not be empty, and unique among other counters. RegisterCounter(string) (Counter, error) + // GetOrRegisterCounter returns the counter by its identifier, atomically creating and registering it if absent. + GetOrRegisterCounter(string) (Counter, error) // UnregisterCounter unregisters a counter from the manager by its identifier. UnregisterCounter(string) error // GetCounter returns a counter by its identifier. @@ -91,6 +93,8 @@ type Manager interface { // RegisterOnlineMap registers a new OnlineMap to the manager. The identifier string must not be empty, and unique among other OnlineMaps. RegisterOnlineMap(string) (OnlineMap, error) + // GetOrRegisterOnlineMap returns the OnlineMap by its identifier, atomically creating and registering it if absent. + GetOrRegisterOnlineMap(string) (OnlineMap, error) // UnregisterOnlineMap unregisters an OnlineMap from the manager by its identifier. UnregisterOnlineMap(string) error // GetOnlineMap returns an OnlineMap by its identifier. @@ -101,6 +105,8 @@ type Manager interface { // RegisterChannel registers a new channel to the manager. The identifier string must not be empty, and unique among other channels. RegisterChannel(string) (Channel, error) + // GetOrRegisterChannel returns the channel by its identifier, atomically creating and registering it if absent. + GetOrRegisterChannel(string) (Channel, error) // UnregisterChannel unregisters a channel from the manager by its identifier. UnregisterChannel(string) error // GetChannel returns a channel by its identifier. @@ -110,36 +116,6 @@ type Manager interface { GetAllOnlineUsers() []string } -// GetOrRegisterCounter tries to get the StatCounter first. If not exist, it then tries to create a new counter. -func GetOrRegisterCounter(m Manager, name string) (Counter, error) { - counter := m.GetCounter(name) - if counter != nil { - return counter, nil - } - - return m.RegisterCounter(name) -} - -// GetOrRegisterOnlineMap tries to get the OnlineMap first. If not exist, it then tries to create a new OnlineMap. -func GetOrRegisterOnlineMap(m Manager, name string) (OnlineMap, error) { - onlineMap := m.GetOnlineMap(name) - if onlineMap != nil { - return onlineMap, nil - } - - return m.RegisterOnlineMap(name) -} - -// GetOrRegisterChannel tries to get the StatChannel first. If not exist, it then tries to create a new channel. -func GetOrRegisterChannel(m Manager, name string) (Channel, error) { - channel := m.GetChannel(name) - if channel != nil { - return channel, nil - } - - return m.RegisterChannel(name) -} - // ManagerType returns the type of Manager interface. Can be used to implement common.HasType. // // xray:api:stable @@ -160,6 +136,11 @@ func (NoopManager) RegisterCounter(string) (Counter, error) { return nil, errors.New("not implemented") } +// GetOrRegisterCounter implements Manager. +func (NoopManager) GetOrRegisterCounter(string) (Counter, error) { + return nil, errors.New("not implemented") +} + // UnregisterCounter implements Manager. func (NoopManager) UnregisterCounter(string) error { return nil @@ -178,6 +159,11 @@ func (NoopManager) RegisterOnlineMap(string) (OnlineMap, error) { return nil, errors.New("not implemented") } +// GetOrRegisterOnlineMap implements Manager. +func (NoopManager) GetOrRegisterOnlineMap(string) (OnlineMap, error) { + return nil, errors.New("not implemented") +} + // UnregisterOnlineMap implements Manager. func (NoopManager) UnregisterOnlineMap(string) error { return nil @@ -196,6 +182,11 @@ func (NoopManager) RegisterChannel(string) (Channel, error) { return nil, errors.New("not implemented") } +// GetOrRegisterChannel implements Manager. +func (NoopManager) GetOrRegisterChannel(string) (Channel, error) { + return nil, errors.New("not implemented") +} + // UnregisterChannel implements Manager. func (NoopManager) UnregisterChannel(string) error { return nil diff --git a/proxy/tun/handler.go b/proxy/tun/handler.go index 5bf8f231f..56fe5653a 100644 --- a/proxy/tun/handler.go +++ b/proxy/tun/handler.go @@ -65,7 +65,7 @@ func (t *Handler) Init(ctx context.Context, pm policy.Manager, dispatcher routin if len(t.tag) > 0 && pm.ForSystem().Stats.InboundUplink { statsManager := core.MustFromContext(ctx).GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + t.tag + ">>>traffic>>>uplink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { t.uplinkCounter = c } @@ -73,7 +73,7 @@ func (t *Handler) Init(ctx context.Context, pm policy.Manager, dispatcher routin if len(t.tag) > 0 && pm.ForSystem().Stats.InboundDownlink { statsManager := core.MustFromContext(ctx).GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + t.tag + ">>>traffic>>>downlink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { t.downlinkCounter = c } diff --git a/proxy/wireguard/client.go b/proxy/wireguard/client.go index c50dd995f..cae846077 100644 --- a/proxy/wireguard/client.go +++ b/proxy/wireguard/client.go @@ -57,7 +57,7 @@ func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { if len(tag) > 0 && p.ForSystem().Stats.OutboundUplink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "outbound>>>" + tag + ">>>traffic>>>uplink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { uplinkCounter = c } @@ -65,7 +65,7 @@ func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { if len(tag) > 0 && p.ForSystem().Stats.OutboundDownlink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "outbound>>>" + tag + ">>>traffic>>>downlink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { downlinkCounter = c } diff --git a/proxy/wireguard/server.go b/proxy/wireguard/server.go index e26479ad6..51416c435 100644 --- a/proxy/wireguard/server.go +++ b/proxy/wireguard/server.go @@ -66,7 +66,7 @@ func NewServer(ctx context.Context, conf *DeviceConfig) (*Server, error) { if len(tag) > 0 && p.ForSystem().Stats.InboundUplink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + tag + ">>>traffic>>>uplink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { uplinkCounter = c } @@ -74,7 +74,7 @@ func NewServer(ctx context.Context, conf *DeviceConfig) (*Server, error) { if len(tag) > 0 && p.ForSystem().Stats.InboundDownlink { statsManager := v.GetFeature(stats.ManagerType()).(stats.Manager) name := "inbound>>>" + tag + ">>>traffic>>>downlink" - c, _ := stats.GetOrRegisterCounter(statsManager, name) + c, _ := statsManager.GetOrRegisterCounter(name) if c != nil { downlinkCounter = c } From 64fada32b5b9e6ae064a038fa0e4e2b766499bd5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Sat, 11 Jul 2026 05:03:35 +0800 Subject: [PATCH 11/59] TLS client: Pinning CA must have `serverName` (or higher-priority `verifyPeerCertByName` or outbound's `address`) for `pinnedPeerCertSha256` (#6472) Fixes https://github.com/XTLS/Xray-core/security/advisories/GHSA-5wf9-h793-w73c --- transport/internet/grpc/dial.go | 5 +---- transport/internet/hysteria/dialer.go | 2 +- transport/internet/tls/config.go | 3 +++ 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/transport/internet/grpc/dial.go b/transport/internet/grpc/dial.go index 4883f30ab..a54d7c02f 100644 --- a/transport/internet/grpc/dial.go +++ b/transport/internet/grpc/dial.go @@ -136,10 +136,7 @@ func getGrpcClient(ctx context.Context, dest net.Destination, streamSettings *in } if tlsConfig != nil { - config := tlsConfig.GetTLSConfig() - if config.ServerName == "" && address.Family().IsDomain() { - config.ServerName = address.Domain() - } + config := tlsConfig.GetTLSConfig(tls.WithDestination(dest)) if fingerprint := tls.GetFingerprint(tlsConfig.Fingerprint); fingerprint != nil { return tls.UClient(c, config, fingerprint), nil } else { // Fallback to normal gRPC TLS diff --git a/transport/internet/hysteria/dialer.go b/transport/internet/hysteria/dialer.go index a928cac72..ed7dd4b2c 100644 --- a/transport/internet/hysteria/dialer.go +++ b/transport/internet/hysteria/dialer.go @@ -345,7 +345,7 @@ func Dial(ctx context.Context, dest net.Destination, streamSettings *internet.Me c = &client{ dest: dest, config: streamSettings.ProtocolSettings.(*Config), - tlsConfig: tlsConfig.GetTLSConfig(), + tlsConfig: tlsConfig.GetTLSConfig(tls.WithDestination(dest)), socketConfig: streamSettings.SocketSettings, udpmaskManager: streamSettings.UdpmaskManager, quicParams: streamSettings.QuicParams, diff --git a/transport/internet/tls/config.go b/transport/internet/tls/config.go index ec5659a0c..9038cd281 100644 --- a/transport/internet/tls/config.go +++ b/transport/internet/tls/config.go @@ -331,6 +331,9 @@ func (r *RandCarrier) verifyPeerCert(rawCerts [][]byte, verifiedChains [][]*x509 } if verifyResult == foundCA { // if found CA, we need to verify here + if len(r.Config.ServerName) == 0 { + return errors.New("Pinning CA needs a valid ServerName") + } opts := x509.VerifyOptions{ Roots: CAs, CurrentTime: time.Now(), From 8f15190c230fe6975c9b18e31316c6bc494f3863 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Sat, 11 Jul 2026 05:40:50 +0800 Subject: [PATCH 12/59] QUIC sniffer: Fix potential panic on malformed QUIC packets again (#6471) Fixes https://github.com/XTLS/Xray-core/security/advisories/GHSA-xqmr-94vq-cxvx Fixes https://github.com/XTLS/Xray-core/security/advisories/GHSA-9h6x-9r9m-5qw2 --- common/protocol/quic/sniff.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/common/protocol/quic/sniff.go b/common/protocol/quic/sniff.go index 0c20c3f61..eee3640f9 100644 --- a/common/protocol/quic/sniff.go +++ b/common/protocol/quic/sniff.go @@ -143,7 +143,9 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { if err != nil { return nil, err } - + if len(b) < hdrLen+4+block.BlockSize() { + return nil, errNotQuic + } cache.Clear() mask := cache.Extend(int32(block.BlockSize())) block.Encrypt(mask, b[hdrLen+4:hdrLen+4+len(mask)]) From 35387572e0d30999b6213cd0d3924a54e82985d7 Mon Sep 17 00:00:00 2001 From: Exclude0122 <173652916+Exclude0122@users.noreply.github.com> Date: Sat, 11 Jul 2026 09:59:24 -0400 Subject: [PATCH 13/59] Finalmask: Add XMC (TCP, Minecraft) (#6210) https://github.com/XTLS/Xray-core/pull/6210#issuecomment-4573680059 https://github.com/XTLS/Xray-core/pull/6210#issuecomment-4582554889 https://github.com/XTLS/Xray-core/pull/6210#issuecomment-4937008835 --- infra/conf/transport_finalmask.go | 38 ++ transport/internet/finalmask/xmc/cfb8.go | 138 ++++++++ transport/internet/finalmask/xmc/client.go | 236 ++++++++++++ transport/internet/finalmask/xmc/config.go | 27 ++ transport/internet/finalmask/xmc/config.pb.go | 160 +++++++++ transport/internet/finalmask/xmc/config.proto | 16 + .../internet/finalmask/xmc/derivation.go | 103 ++++++ .../internet/finalmask/xmc/derivation_test.go | 58 +++ .../internet/finalmask/xmc/handshake_test.go | 158 +++++++++ transport/internet/finalmask/xmc/protocol.go | 335 ++++++++++++++++++ transport/internet/finalmask/xmc/server.go | 289 +++++++++++++++ transport/internet/finalmask/xmc/stream.go | 68 ++++ 12 files changed, 1626 insertions(+) create mode 100644 transport/internet/finalmask/xmc/cfb8.go create mode 100644 transport/internet/finalmask/xmc/client.go create mode 100644 transport/internet/finalmask/xmc/config.go create mode 100644 transport/internet/finalmask/xmc/config.pb.go create mode 100644 transport/internet/finalmask/xmc/config.proto create mode 100644 transport/internet/finalmask/xmc/derivation.go create mode 100644 transport/internet/finalmask/xmc/derivation_test.go create mode 100644 transport/internet/finalmask/xmc/handshake_test.go create mode 100644 transport/internet/finalmask/xmc/protocol.go create mode 100644 transport/internet/finalmask/xmc/server.go create mode 100644 transport/internet/finalmask/xmc/stream.go diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index bdcf8294e..24c3f0afa 100644 --- a/infra/conf/transport_finalmask.go +++ b/infra/conf/transport_finalmask.go @@ -1,9 +1,11 @@ package conf import ( + "crypto/x509" "encoding/base64" "encoding/hex" "encoding/json" + "fmt" "net/netip" "net/url" "regexp" @@ -22,6 +24,7 @@ import ( "github.com/xtls/xray-core/transport/internet/finalmask/sudoku" "github.com/xtls/xray-core/transport/internet/finalmask/xdns" "github.com/xtls/xray-core/transport/internet/finalmask/xicmp" + "github.com/xtls/xray-core/transport/internet/finalmask/xmc" "github.com/xtls/xray-core/transport/internet/tls" "google.golang.org/protobuf/proto" ) @@ -67,6 +70,7 @@ var ( "header-custom": func() interface{} { return new(HeaderCustomTCP) }, "fragment": func() interface{} { return new(FragmentMask) }, "sudoku": func() interface{} { return new(Sudoku) }, + "xmc": func() interface{} { return new(XMC) }, }, "type", "settings") udpmaskLoader = NewJSONConfigLoader(ConfigCreatorCache{ @@ -715,6 +719,40 @@ func (c *Xdns) Build() (proto.Message, error) { }, nil } +type XMC struct { + Hostname string `json:"hostname"` + Usernames []string `json:"usernames"` + Password string `json:"password"` +} + +func (c *XMC) Build() (proto.Message, error) { + if len(c.Usernames) == 0 { + c.Usernames = []string{"Dream"} + } + + if c.Password == "" { + return nil, fmt.Errorf("empty password") + } + + rsaPrivateKey, err := xmc.DeriveRSAKey(c.Password) + if err != nil { + return nil, fmt.Errorf("derive minecraft rsa key: %w", err) + } + + rsaPublicKey, err := x509.MarshalPKIXPublicKey(&rsaPrivateKey.PublicKey) + if err != nil { + return nil, fmt.Errorf("marshal minecraft rsa public key: %w", err) + } + + return &xmc.Config{ + Password: c.Password, + Usernames: c.Usernames, + Hostname: c.Hostname, + RsaPrivateKey: x509.MarshalPKCS1PrivateKey(rsaPrivateKey), + RsaPublicKey: rsaPublicKey, + }, nil +} + type Xicmp struct { DGRAM bool `json:"dgram"` IPs []string `json:"ips"` diff --git a/transport/internet/finalmask/xmc/cfb8.go b/transport/internet/finalmask/xmc/cfb8.go new file mode 100644 index 000000000..ed9cc1c53 --- /dev/null +++ b/transport/internet/finalmask/xmc/cfb8.go @@ -0,0 +1,138 @@ +package xmc + +// Copied from https://github.com/Tnze/go-mc/blob/539b4a3a7f030332eb58b8a946116ae7907630d2/net/CFB8/cfb8.go + +import ( + "crypto/cipher" + "crypto/subtle" + "unsafe" +) + +type cfb8 struct { + c cipher.Block + blockSize int + ivPos int + iv []byte + de bool +} + +func newCFB8Decrypt(c cipher.Block, iv []byte) *cfb8 { + return newCFB8(c, iv, true) +} + +func newCFB8Encrypt(c cipher.Block, iv []byte) *cfb8 { + return newCFB8(c, iv, false) +} + +func newCFB8(c cipher.Block, iv []byte, de bool) *cfb8 { + cp := make([]byte, len(iv)*3) + copy(cp, iv) + return &cfb8{ + c: c, + blockSize: c.BlockSize(), + iv: cp, + de: de, + } +} + +func (cf *cfb8) XORKeyStream(dst, src []byte) { + if len(src) == 0 { + return + } + if len(dst) < len(src) { + panic("cfb8: output smaller than input") + } + + // If dst and src does not overlap in first block size, + // and the length of src is greater than 2*blockSize, + // we can use an optimized implementation. + if len(src) > cf.blockSize<<1 && + (uintptr(unsafe.Pointer(&dst[0]))+uintptr(cf.blockSize) <= uintptr(unsafe.Pointer(&src[0])) || + uintptr(unsafe.Pointer(&src[0]))+uintptr(len(src)) <= uintptr(unsafe.Pointer(&dst[0]))) { + // encrypt/decrypt first blockSize bytes + // After this, the IV will come to the same as + // the last blockSize of ciphertext, so + // we can reuse them without copy. + cf.xorKeyStream(dst, src[:cf.blockSize]) + var ciphertext []byte + if cf.de { + ciphertext = src + } else { + ciphertext = dst + } + dst = dst[cf.blockSize:] + src = src[cf.blockSize:] + iv := cf.iv + _ = iv[0] // bounds check hint to compiler; see golang.org/issue/14808 + var ( + i int + val byte + ) + dst = dst[:len(src)] + if cf.de && // and requires to be non-overlapping at all + uintptr(unsafe.Pointer(&dst[0])) <= uintptr(unsafe.Pointer(&src[len(src)-1])) && + uintptr(unsafe.Pointer(&src[0])) <= uintptr(unsafe.Pointer(&dst[len(dst)-1])) { + for i = 0; i < len(src)-cf.blockSize; i += 1 { + cf.c.Encrypt(dst[i:], ciphertext[i:]) + } + subtle.XORBytes(dst, src[:i], dst) + for ; i < len(src); i += 1 { + cf.c.Encrypt(iv, ciphertext[i:]) + dst[i] = src[i] ^ iv[0] + } + } else { + _ = ciphertext[len(src)] + for i, val = range src { + cf.c.Encrypt(iv, ciphertext[i:]) + dst[i] = val ^ iv[0] + } + // for-range does not increase i in the last loop, + // compared to the classic for clause + i += 1 + } + // copy the current IV for next operation + copy(iv, ciphertext[i:i+cf.blockSize]) + cf.ivPos = 0 + return + } + + cf.xorKeyStream(dst, src) +} + +func (cf *cfb8) xorKeyStream(dst, src []byte) { + dst = dst[:len(src)] // remove bounds check in loop + for i, val := range src { + posPlusBlockSize := cf.ivPos + cf.blockSize + // fast mod; 2*blockSize must be a non-negative integer power of 2 + tempPos := posPlusBlockSize & (cf.blockSize<<1 - 1) + // reuse space to store encrypted block + cf.c.Encrypt(cf.iv[tempPos:], cf.iv[cf.ivPos:]) + // Only the first byte of the encrypted block is used + // for encryption/decryption, other bytes are ignored. + val ^= cf.iv[tempPos] + + if cf.ivPos == cf.blockSize<<1 { + // bound reached; move to next round for next operation + // copy next block to the start of the ring buffer + copy(cf.iv, cf.iv[cf.ivPos+1:]) + // insert the encrypted byte to the end of IV + if cf.de { + cf.iv[cf.blockSize-1] = src[i] + } else { + cf.iv[cf.blockSize-1] = val + } + cf.ivPos = 0 + } else { + // insert the encrypted byte to the end of IV + if cf.de { + cf.iv[posPlusBlockSize] = src[i] + } else { + cf.iv[posPlusBlockSize] = val + } + // move to next block + cf.ivPos += 1 + } + + dst[i] = val + } +} diff --git a/transport/internet/finalmask/xmc/client.go b/transport/internet/finalmask/xmc/client.go new file mode 100644 index 000000000..c0d1ee2b4 --- /dev/null +++ b/transport/internet/finalmask/xmc/client.go @@ -0,0 +1,236 @@ +package xmc + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "fmt" + "io" + "math/big" + "net" + "strconv" + "sync" + "time" +) + +type clientConn struct { + reader io.Reader + writer io.Writer + c net.Conn + + state clientState + + handshakeLock sync.Mutex + usernames []string + password string + rsaPublicKey []byte + hostname string +} + +type clientState int + +var ( + clientStateHandshake clientState = 1 + clientStateProxy clientState = 2 +) + +func newClientConn(c net.Conn, usernames []string, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) { + if len(rsaPublicKey) == 0 { + return nil, fmt.Errorf("empty rsa public key") + } + + return &clientConn{ + reader: bufio.NewReader(c), + writer: c, + c: c, + state: clientStateHandshake, + handshakeLock: sync.Mutex{}, + usernames: usernames, + password: password, + rsaPublicKey: rsaPublicKey, + hostname: hostname, + }, nil +} + +func (c *clientConn) handshake() error { + c.handshakeLock.Lock() + defer c.handshakeLock.Unlock() + + if c.state != clientStateHandshake { + return nil + } + + // Handshake timeout + err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) + if err != nil { + return fmt.Errorf("set deadline: %w", err) + } + defer c.c.SetDeadline(time.Time{}) + + var ( + protocolVersion Varint = Varint(775) + serverAddress String = String(c.hostname) + serverPort UnsignedShort = UnsignedShort(25565) + nextState Varint = Varint(2) + ) + + host, portString, err := net.SplitHostPort(c.c.RemoteAddr().String()) + if err == nil { + port, err := strconv.Atoi(portString) + if err == nil { + serverPort = UnsignedShort(port) + } + + if serverAddress == "" { + serverAddress = String(host) + } + } + + err = writePacket(c.writer, 0x00, &protocolVersion, &serverAddress, &serverPort, &nextState) + if err != nil { + return fmt.Errorf("write handshake packet: %w", err) + } + + // Login Start + var ( + username string + offlineUUID UUID + ) + + randomUsername, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames)))) + username = c.usernames[randomUsername.Int64()] + generateOfflineUUID(&offlineUUID, string(username)) + + err = writePacket(c.writer, 0x00, new(String(username)), &offlineUUID) + if err != nil { + return fmt.Errorf("write login start: %w", err) + } + + // Encryption Request + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read encryption request: %w", err) + } + + if pkt.packetID != 0x01 { + return fmt.Errorf("bad encrypt request packet id") + } + + var ( + serverId String + publicKey Bytes + verifyToken Bytes + ) + + err = pkt.readFields(&serverId, &publicKey, &verifyToken) + if err != nil { + return fmt.Errorf("read encryption request fields: %w", err) + } + + if !bytes.Equal(publicKey, c.rsaPublicKey) { + return fmt.Errorf("server public key mismatch") + } + + k, err := x509.ParsePKIXPublicKey(publicKey) + if err != nil { + return fmt.Errorf("parse server public key: %w", err) + } + + rsaPublicKey, ok := k.(*rsa.PublicKey) + if !ok { + return fmt.Errorf("parse server public key: not rsa") + } + + sharedSecret := make([]byte, 16) + rand.Read(sharedSecret) + + encryptedSharedSecret, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, sharedSecret) + if err != nil { + return fmt.Errorf("encrypt shared secret: %w", err) + } + + verifyToken = append(verifyToken, []byte(c.password)...) // append pre-shared password + + encryptedVerifyToken, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, verifyToken) + if err != nil { + return fmt.Errorf("encrypt verify token: %w", err) + } + + // Send Encryption Response + err = writePacket( + c.writer, + 0x01, + (*Bytes)(&encryptedSharedSecret), + (*Bytes)(&encryptedVerifyToken), + ) + if err != nil { + return fmt.Errorf("write encryption response: %w", err) + } + + // Enable encryption + c.reader, err = newCryptoReader(c.reader, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto reader: %w", err) + } + + c.writer, err = newCryptoWriter(c.writer, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto writer: %w", err) + } + + c.state = clientStateProxy + + return nil +} + +func (c *clientConn) Read(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.reader.Read(b) +} + +func (c *clientConn) Write(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.writer.Write(b) +} + +func (c *clientConn) Close() error { + return c.c.Close() +} + +func (c *clientConn) LocalAddr() net.Addr { + return c.c.LocalAddr() +} + +func (c *clientConn) RemoteAddr() net.Addr { + return c.c.RemoteAddr() +} + +func (c *clientConn) SetDeadline(t time.Time) error { + return c.c.SetDeadline(t) +} + +func (c *clientConn) SetReadDeadline(t time.Time) error { + return c.c.SetReadDeadline(t) +} + +func (c *clientConn) SetWriteDeadline(t time.Time) error { + return c.c.SetWriteDeadline(t) +} + +func generateOfflineUUID(uuid *UUID, username string) { + h := sha256.Sum256([]byte("OfflinePlayer:" + username)) + copy(uuid[:], h[:16]) + uuid[6] = (uuid[6] & 0x0f) | 0x30 // UUID version 3 + uuid[8] = (uuid[8] & 0x3f) | 0x80 // UUID variant +} diff --git a/transport/internet/finalmask/xmc/config.go b/transport/internet/finalmask/xmc/config.go new file mode 100644 index 000000000..d0f414a7c --- /dev/null +++ b/transport/internet/finalmask/xmc/config.go @@ -0,0 +1,27 @@ +package xmc + +import ( + "fmt" + "net" +) + +func (c *Config) TCP() { +} + +func (c *Config) WrapConnClient(conn net.Conn) (net.Conn, error) { + cc, err := newClientConn(conn, c.Usernames, c.Password, c.RsaPublicKey, c.Hostname) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + + return cc, nil +} + +func (c *Config) WrapConnServer(conn net.Conn) (net.Conn, error) { + cc, err := wrapConnServer(conn, c.Password, c.RsaPrivateKey, c.RsaPublicKey) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + + return cc, nil +} diff --git a/transport/internet/finalmask/xmc/config.pb.go b/transport/internet/finalmask/xmc/config.pb.go new file mode 100644 index 000000000..f4c983b84 --- /dev/null +++ b/transport/internet/finalmask/xmc/config.pb.go @@ -0,0 +1,160 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc v6.33.5 +// source: transport/internet/finalmask/xmc/config.proto + +package xmc + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type Config struct { + state protoimpl.MessageState `protogen:"open.v1"` + Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` + Usernames []string `protobuf:"bytes,2,rep,name=usernames,proto3" json:"usernames,omitempty"` + RsaPrivateKey []byte `protobuf:"bytes,8,opt,name=rsa_private_key,json=rsaPrivateKey,proto3" json:"rsa_private_key,omitempty"` + RsaPublicKey []byte `protobuf:"bytes,9,opt,name=rsa_public_key,json=rsaPublicKey,proto3" json:"rsa_public_key,omitempty"` + Hostname string `protobuf:"bytes,10,opt,name=hostname,proto3" json:"hostname,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Config) Reset() { + *x = Config{} + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Config) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Config) ProtoMessage() {} + +func (x *Config) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Config.ProtoReflect.Descriptor instead. +func (*Config) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Config) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +func (x *Config) GetUsernames() []string { + if x != nil { + return x.Usernames + } + return nil +} + +func (x *Config) GetRsaPrivateKey() []byte { + if x != nil { + return x.RsaPrivateKey + } + return nil +} + +func (x *Config) GetRsaPublicKey() []byte { + if x != nil { + return x.RsaPublicKey + } + return nil +} + +func (x *Config) GetHostname() string { + if x != nil { + return x.Hostname + } + return "" +} + +var File_transport_internet_finalmask_xmc_config_proto protoreflect.FileDescriptor + +const file_transport_internet_finalmask_xmc_config_proto_rawDesc = "" + + "\n" + + "-transport/internet/finalmask/xmc/config.proto\x12%xray.transport.internet.finalmask.xmc\"\xac\x01\n" + + "\x06Config\x12\x1a\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\x12\x1c\n" + + "\tusernames\x18\x02 \x03(\tR\tusernames\x12&\n" + + "\x0frsa_private_key\x18\b \x01(\fR\rrsaPrivateKey\x12$\n" + + "\x0ersa_public_key\x18\t \x01(\fR\frsaPublicKey\x12\x1a\n" + + "\bhostname\x18\n" + + " \x01(\tR\bhostnameB\x91\x01\n" + + ")com.xray.transport.internet.finalmask.xmcP\x01Z:github.com/xtls/xray-core/transport/internet/finalmask/xmc\xaa\x02%Xray.Transport.Internet.Finalmask.XMCb\x06proto3" + +var ( + file_transport_internet_finalmask_xmc_config_proto_rawDescOnce sync.Once + file_transport_internet_finalmask_xmc_config_proto_rawDescData []byte +) + +func file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP() []byte { + file_transport_internet_finalmask_xmc_config_proto_rawDescOnce.Do(func() { + file_transport_internet_finalmask_xmc_config_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_xmc_config_proto_rawDesc), len(file_transport_internet_finalmask_xmc_config_proto_rawDesc))) + }) + return file_transport_internet_finalmask_xmc_config_proto_rawDescData +} + +var file_transport_internet_finalmask_xmc_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_transport_internet_finalmask_xmc_config_proto_goTypes = []any{ + (*Config)(nil), // 0: xray.transport.internet.finalmask.xmc.Config +} +var file_transport_internet_finalmask_xmc_config_proto_depIdxs = []int32{ + 0, // [0:0] is the sub-list for method output_type + 0, // [0:0] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_transport_internet_finalmask_xmc_config_proto_init() } +func file_transport_internet_finalmask_xmc_config_proto_init() { + if File_transport_internet_finalmask_xmc_config_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_xmc_config_proto_rawDesc), len(file_transport_internet_finalmask_xmc_config_proto_rawDesc)), + NumEnums: 0, + NumMessages: 1, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_transport_internet_finalmask_xmc_config_proto_goTypes, + DependencyIndexes: file_transport_internet_finalmask_xmc_config_proto_depIdxs, + MessageInfos: file_transport_internet_finalmask_xmc_config_proto_msgTypes, + }.Build() + File_transport_internet_finalmask_xmc_config_proto = out.File + file_transport_internet_finalmask_xmc_config_proto_goTypes = nil + file_transport_internet_finalmask_xmc_config_proto_depIdxs = nil +} diff --git a/transport/internet/finalmask/xmc/config.proto b/transport/internet/finalmask/xmc/config.proto new file mode 100644 index 000000000..25fe29d4b --- /dev/null +++ b/transport/internet/finalmask/xmc/config.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +package xray.transport.internet.finalmask.xmc; +option csharp_namespace = "Xray.Transport.Internet.Finalmask.XMC"; +option go_package = "github.com/xtls/xray-core/transport/internet/finalmask/xmc"; +option java_package = "com.xray.transport.internet.finalmask.xmc"; +option java_multiple_files = true; + + +message Config { + string password = 1; + repeated string usernames = 2; + bytes rsa_private_key = 8; + bytes rsa_public_key = 9; + string hostname = 10; +} diff --git a/transport/internet/finalmask/xmc/derivation.go b/transport/internet/finalmask/xmc/derivation.go new file mode 100644 index 000000000..9dcb6a92a --- /dev/null +++ b/transport/internet/finalmask/xmc/derivation.go @@ -0,0 +1,103 @@ +package xmc + +import ( + "crypto/rsa" + "crypto/sha256" + "fmt" + "math/big" +) + +type sha256Stream struct { + seed []byte + counter uint64 + buf []byte +} + +func newSHA256Stream(seed []byte) *sha256Stream { + return &sha256Stream{ + seed: seed, + } +} + +func (s *sha256Stream) Read(p []byte) (n int, err error) { + for len(p) > len(s.buf) { + h := sha256.New() + h.Write(s.seed) + h.Write([]byte(fmt.Sprintf("-%d", s.counter))) + s.counter++ + s.buf = append(s.buf, h.Sum(nil)...) + } + n = copy(p, s.buf) + s.buf = s.buf[n:] + return n, nil +} + +func derivePrime(stream *sha256Stream) *big.Int { + pBytes := make([]byte, 64) // 512 bits + _, _ = stream.Read(pBytes) + pBytes[0] |= 0xc0 // ensure it is big enough so p*q is 1024 bits + pBytes[63] |= 0x01 // ensure odd + + p := new(big.Int).SetBytes(pBytes) + for { + if p.ProbablyPrime(20) { + pMinus1 := new(big.Int).Sub(p, big.NewInt(1)) + e := big.NewInt(65537) + gcd := new(big.Int).GCD(nil, nil, pMinus1, e) + if gcd.Cmp(big.NewInt(1)) == 0 { + return p + } + } + p.Add(p, big.NewInt(2)) + } +} + +// DeriveRSAKey derives a 1024-bit RSA private key from a password. +func DeriveRSAKey(password string) (*rsa.PrivateKey, error) { + seed := []byte(password) + + pStream := newSHA256Stream(append(seed, []byte("-p-prime")...)) + qStream := newSHA256Stream(append(seed, []byte("-q-prime")...)) + + p := derivePrime(pStream) + q := derivePrime(qStream) + + // ensure p != q (if they are, let's search q further) + for p.Cmp(q) == 0 { + q.Add(q, big.NewInt(2)) + for { + if q.ProbablyPrime(20) { + qMinus1 := new(big.Int).Sub(q, big.NewInt(1)) + e := big.NewInt(65537) + gcd := new(big.Int).GCD(nil, nil, qMinus1, e) + if gcd.Cmp(big.NewInt(1)) == 0 { + break + } + } + q.Add(q, big.NewInt(2)) + } + } + + n := new(big.Int).Mul(p, q) + pMinus1 := new(big.Int).Sub(p, big.NewInt(1)) + qMinus1 := new(big.Int).Sub(q, big.NewInt(1)) + totient := new(big.Int).Mul(pMinus1, qMinus1) + + e := big.NewInt(65537) + d := new(big.Int).ModInverse(e, totient) + if d == nil { + return nil, fmt.Errorf("failed to compute mod inverse") + } + + priv := &rsa.PrivateKey{ + PublicKey: rsa.PublicKey{ + N: n, + E: 65537, + }, + D: d, + Primes: []*big.Int{p, q}, + } + priv.Precompute() + + return priv, nil +} diff --git a/transport/internet/finalmask/xmc/derivation_test.go b/transport/internet/finalmask/xmc/derivation_test.go new file mode 100644 index 000000000..b436d46dc --- /dev/null +++ b/transport/internet/finalmask/xmc/derivation_test.go @@ -0,0 +1,58 @@ +package xmc + +import ( + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "testing" +) + +func TestDeriveRSAKey(t *testing.T) { + password := "my-very-secret-password-12345" + + key1, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + + err = key1.Validate() + if err != nil { + t.Fatalf("key is not valid RSA key: %v", err) + } + + key2, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key second time: %v", err) + } + + // Verify determinism + if key1.D.Cmp(key2.D) != 0 || key1.N.Cmp(key2.N) != 0 { + t.Errorf("derived keys are not identical for the same password") + } + + // Verify different passwords yield different keys + keyDifferent, err := DeriveRSAKey(password + "-different") + if err != nil { + t.Fatalf("failed to derive different key: %v", err) + } + + if key1.D.Cmp(keyDifferent.D) == 0 || key1.N.Cmp(keyDifferent.N) == 0 { + t.Errorf("derived keys are identical for different passwords") + } +} + +func TestDeriveRSAKeyGoldenPrivateKey(t *testing.T) { + const password = "deterministic-rsa-key-golden" + const wantPKCS1DERHash = "3a8c4ad56a6fb42dab73c4d5fc3af754460a2db1441edc0970cbc7f4e0798d2f" + + key, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive key: %v", err) + } + + gotHash := sha256.Sum256(x509.MarshalPKCS1PrivateKey(key)) + got := hex.EncodeToString(gotHash[:]) + if got != wantPKCS1DERHash { + t.Fatalf("derived private key changed\nwant sha256: %s\n got sha256: %s", wantPKCS1DERHash, got) + } +} diff --git a/transport/internet/finalmask/xmc/handshake_test.go b/transport/internet/finalmask/xmc/handshake_test.go new file mode 100644 index 000000000..2e48c309c --- /dev/null +++ b/transport/internet/finalmask/xmc/handshake_test.go @@ -0,0 +1,158 @@ +package xmc + +import ( + "bytes" + "crypto/x509" + "net" + "sync" + "testing" +) + +func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) { + t.Helper() + + key, err := DeriveRSAKey(password) + if err != nil { + t.Fatalf("failed to derive rsa key: %v", err) + } + + publicKey, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatalf("failed to marshal public key: %v", err) + } + + return x509.MarshalPKCS1PrivateKey(key), publicKey +} + +func TestHandshakeSuccess(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + password := "super-secure-shared-key-12345" + usernames := []string{"test_user"} + privateKey, publicKey := deriveTestRSAKey(t, password) + + go func() { + rawConn, err := ln.Accept() + if err != nil { + return + } + defer rawConn.Close() + + server, err := wrapConnServer(rawConn, password, privateKey, publicKey) + if err != nil { + t.Errorf("failed to wrap server: %v", err) + return + } + + buf := make([]byte, 1024) + n, err := server.Read(buf) + if err != nil { + t.Errorf("server read error: %v", err) + return + } + + if !bytes.Equal(buf[:n], []byte("hello server")) { + t.Errorf("unexpected payload from client: %s", string(buf[:n])) + return + } + + _, err = server.Write([]byte("hello client")) + if err != nil { + t.Errorf("server write error: %v", err) + return + } + }() + + clientRaw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer clientRaw.Close() + + client, err := newClientConn(clientRaw, usernames, password, publicKey, "localhost") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + _, err = client.Write([]byte("hello server")) + if err != nil { + t.Fatalf("client write error: %v", err) + } + + buf := make([]byte, 1024) + n, err := client.Read(buf) + if err != nil { + t.Fatalf("client read error: %v", err) + } + + if !bytes.Equal(buf[:n], []byte("hello client")) { + t.Errorf("unexpected payload from server: %s", string(buf[:n])) + } +} + +func TestHandshakePasswordMismatch(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("failed to listen: %v", err) + } + defer ln.Close() + + clientPassword := "client-secret-123" + serverPassword := "server-secret-456" + usernames := []string{"test_user"} + serverPrivateKey, serverPublicKey := deriveTestRSAKey(t, serverPassword) + + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + + rawConn, err := ln.Accept() + if err != nil { + return + } + defer rawConn.Close() + + server, err := wrapConnServer(rawConn, serverPassword, serverPrivateKey, serverPublicKey) + if err != nil { + // Wrapping is synchronous and shouldn't fail initially simply because key derivation works with any string + t.Logf("wrapped server: %v", err) + } + + // When client sends data, handshake happens and should fail + buf := make([]byte, 1024) + _, err = server.Read(buf) + if err == nil { + t.Errorf("expected handshake to fail due to password mismatch, but it succeeded") + } else { + t.Logf("server read failed as expected: %v", err) + } + }() + + clientRaw, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatalf("failed to dial: %v", err) + } + defer clientRaw.Close() + + client, err := newClientConn(clientRaw, usernames, clientPassword, serverPublicKey, "localhost") + if err != nil { + t.Fatalf("failed to create client: %v", err) + } + + err = client.handshake() + if err != nil { + t.Fatalf("client handshake err: %v", err) + } + + _, _ = client.Write([]byte{0x1, 0x2, 0x3, 0x4}) + + wg.Wait() + + // Check if we lost connection or received error + t.Log("Handshake mismatch tested") +} diff --git a/transport/internet/finalmask/xmc/protocol.go b/transport/internet/finalmask/xmc/protocol.go new file mode 100644 index 000000000..68a4fb74d --- /dev/null +++ b/transport/internet/finalmask/xmc/protocol.go @@ -0,0 +1,335 @@ +// Minecraft protocol +package xmc + +import ( + "bytes" + "fmt" + "io" +) + +type field interface { + readFrom(r io.Reader) error + writeTo(w io.Writer) error +} + +type mcPacket struct { + packetID int + data []byte +} + +func readPacket(b io.Reader) (*mcPacket, error) { + var packetLength Varint + err := packetLength.readFrom(b) + if err != nil { + return nil, fmt.Errorf("read packet length: %w", err) + } + + var packetID Varint + err = packetID.readFrom(b) + if err != nil { + return nil, fmt.Errorf("read packet ID: %w", err) + } + + dataLength := int(packetLength) - varintSize(packetID) + if dataLength < 0 || dataLength > 1024*32 { + return nil, fmt.Errorf("read packet: bad length: %d", dataLength) + } + + data := make([]byte, dataLength) + _, err = io.ReadFull(b, data) + if err != nil { + return nil, fmt.Errorf("read packet data: %w", err) + } + + return &mcPacket{ + packetID: int(packetID), + data: data, + }, nil +} + +func (p *mcPacket) readFields(fields ...field) error { + r := bytes.NewReader(p.data) + + for _, field := range fields { + err := field.readFrom(r) + if err != nil { + return fmt.Errorf("read packet field: %w", err) + } + } + + return nil +} + +type Varint int32 + +func (v *Varint) readFrom(r io.Reader) error { + SEGMENT_BITS := byte(0x7F) + CONTINUE_BIT := byte(0x80) + + var err error + + var value int32 = 0 + var position int32 = 0 + var currentByte byte + + for true { + currentByte, err = readByte(r) + if err != nil { + return fmt.Errorf("read varint: %w", err) + } + value |= int32(currentByte&SEGMENT_BITS) << position + + if (currentByte & CONTINUE_BIT) == 0 { + break + } + + position += 7 + + if position >= 32 { + return fmt.Errorf("read varint: too large") + } + } + + *v = Varint(value) + + return nil +} + +func (v *Varint) writeTo(w io.Writer) error { + SEGMENT_BITS := byte(0x7F) + CONTINUE_BIT := byte(0x80) + + value := int32(*v) + + for { + currentByte := byte(value & int32(SEGMENT_BITS)) + value >>= 7 + if value != 0 { + currentByte |= CONTINUE_BIT + } + + _, err := w.Write([]byte{currentByte}) + if err != nil { + return fmt.Errorf("write varint: %w", err) + } + + if value == 0 { + break + } + } + + return nil +} + +func varintSize(value Varint) int { + size := 0 + for { + size++ + value >>= 7 + if value == 0 { + break + } + } + return size +} + +type String string + +func (v *String) readFrom(r io.Reader) error { + var length Varint = 0 + + err := length.readFrom(r) + if err != nil { + return fmt.Errorf("read string: %w", err) + } + + if length < 0 || length > 4096 { + return fmt.Errorf("read string: bad length: %d", length) + } + + buf := make([]byte, length) + _, err = io.ReadFull(r, buf) + if err != nil { + return fmt.Errorf("read string: %w", err) + } + + *v = String(string(buf)) + + return nil +} + +func (v *String) writeTo(w io.Writer) error { + strBytes := []byte(*v) + length := Varint(len(strBytes)) + + err := length.writeTo(w) + if err != nil { + return fmt.Errorf("write string: %w", err) + } + + _, err = w.Write(strBytes) + if err != nil { + return fmt.Errorf("write string: %w", err) + } + + return nil +} + +type UnsignedShort uint16 + +func (v *UnsignedShort) readFrom(r io.Reader) error { + var buf [2]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return fmt.Errorf("read unsigned short: %w", err) + } + + *v = UnsignedShort(buf[0])<<8 | UnsignedShort(buf[1]) + + return nil +} + +func (v *UnsignedShort) writeTo(w io.Writer) error { + buf := []byte{byte(*v >> 8), byte(*v & 0xFF)} + _, err := w.Write(buf) + if err != nil { + return fmt.Errorf("write unsigned short: %w", err) + } + return nil +} + +type Long int64 + +func (v *Long) readFrom(r io.Reader) error { + var buf [8]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return fmt.Errorf("read long: %w", err) + } + + *v = Long(buf[0])<<56 | Long(buf[1])<<48 | Long(buf[2])<<40 | Long(buf[3])<<32 | + Long(buf[4])<<24 | Long(buf[5])<<16 | Long(buf[6])<<8 | Long(buf[7]) + + return nil +} + +func (v *Long) writeTo(w io.Writer) error { + buf := []byte{ + byte(*v >> 56), byte((*v >> 48) & 0xFF), byte((*v >> 40) & 0xFF), byte((*v >> 32) & 0xFF), + byte((*v >> 24) & 0xFF), byte((*v >> 16) & 0xFF), byte((*v >> 8) & 0xFF), byte(*v & 0xFF), + } + + _, err := w.Write(buf) + if err != nil { + return fmt.Errorf("write long: %w", err) + } + + return nil +} + +type UUID [16]byte + +func (v *UUID) readFrom(r io.Reader) error { + _, err := io.ReadFull(r, v[:]) + if err != nil { + return fmt.Errorf("read UUID: %w", err) + } + + return nil +} + +func (v *UUID) writeTo(w io.Writer) error { + _, err := w.Write(v[:]) + if err != nil { + return fmt.Errorf("write UUID: %w", err) + } + return nil +} + +type Bytes []byte + +func (v *Bytes) readFrom(r io.Reader) error { + var length Varint + err := length.readFrom(r) + if err != nil { + return fmt.Errorf("read bytes: %w", err) + } + + if length < 0 || length >= 1024 { + return fmt.Errorf("read bytes: invalid size: %d", err) + } + + buf := make([]byte, length) + + _, err = io.ReadFull(r, buf) + if err != nil { + return fmt.Errorf("read bytes: %w", err) + } + + *v = append([]byte(*v), buf...) + + return nil +} + +func (v *Bytes) writeTo(w io.Writer) error { + length := Varint(len(*v)) + err := length.writeTo(w) + if err != nil { + return fmt.Errorf("write bytes length: %w", err) + } + + _, err = w.Write(*v) + if err != nil { + return fmt.Errorf("write bytes: %w", err) + } + + return nil +} + +func readByte(r io.Reader) (byte, error) { + var buf [1]byte + _, err := io.ReadFull(r, buf[:]) + if err != nil { + return 0, fmt.Errorf("read byte: %w", err) + } + + return buf[0], nil +} + +func writePacket(w io.Writer, packetID int, fields ...field) error { + var dataBuf bytes.Buffer + + for _, field := range fields { + err := field.writeTo(&dataBuf) + if err != nil { + return fmt.Errorf("write packet field: %w", err) + } + } + + var buf bytes.Buffer + + var packetLength Varint = Varint(varintSize(Varint(packetID)) + dataBuf.Len()) + err := packetLength.writeTo(&buf) + if err != nil { + return fmt.Errorf("write packet length: %w", err) + } + + var packetIDVarint Varint = Varint(packetID) + err = packetIDVarint.writeTo(&buf) + if err != nil { + return fmt.Errorf("write packet ID: %w", err) + } + + buf.Write(dataBuf.Bytes()) + + _, err = w.Write(buf.Bytes()) + if err != nil { + return fmt.Errorf("write packet data: %w", err) + } + + return nil +} + +func writeDisconnectPacket(w io.Writer, reason string) error { + return writePacket(w, 0x00, new(String(reason))) +} diff --git a/transport/internet/finalmask/xmc/server.go b/transport/internet/finalmask/xmc/server.go new file mode 100644 index 000000000..c79be767d --- /dev/null +++ b/transport/internet/finalmask/xmc/server.go @@ -0,0 +1,289 @@ +package xmc + +import ( + "bufio" + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/subtle" + "crypto/x509" + "fmt" + "io" + "net" + "sync" + "time" +) + +// Response by vanilla 26.1.2 server. +var statusResponse = `{"description":"A Minecraft Server","players":{"max":20,"online":0},"version":{"name":"26.1.2","protocol":775},"enforcesSecureChat":true}` + +type serverState int + +var ( + serverStateHandshake serverState = 1 + serverStateProxy serverState = 3 +) + +type serverConn struct { + reader io.Reader + writer io.Writer + c net.Conn + + state serverState + + handshakeLock sync.Mutex + password string + rsaPrivateKey *rsa.PrivateKey + rsaPublicKey []byte +} + +func (c *serverConn) handshake() error { + c.handshakeLock.Lock() + defer c.handshakeLock.Unlock() + + if c.state != serverStateHandshake { + return nil + } + + // handshake timeout + err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) + if err != nil { + return fmt.Errorf("set deadline: %w", err) + } + defer c.c.SetDeadline(time.Time{}) + + var ( + protocolVersion Varint + serverAddress String + serverPort UnsignedShort + nextState Varint + ) + + // handshake packet + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read handshake packet: %w", err) + } + + if pkt.packetID != 0 { + return fmt.Errorf("bad handshake packet id") + } + + err = pkt.readFields(&protocolVersion, &serverAddress, &serverPort, &nextState) + if err != nil { + return fmt.Errorf("read handshake packet: %w", err) + } + + switch nextState { + case 1: + + // Ping + + for range 2 { + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read packet: %w", err) + } + + switch pkt.packetID { + case 0: // Status Request + + err = writePacket(c.writer, 0, new(String(statusResponse))) + if err != nil { + return fmt.Errorf("write status response: %w", err) + } + + case 1: // Ping + + var payload Long + err = pkt.readFields(&payload) + if err != nil { + return fmt.Errorf("read ping packet: %w", err) + } + + err = writePacket(c.writer, 1, &payload) + if err != nil { + return fmt.Errorf("write ping response: %w", err) + } + + } + + } + + return fmt.Errorf("ping") + + case 2: + + // Login + + // login start + + pkt, err := readPacket(c.reader) + if err != nil { + return fmt.Errorf("read login start packet: %w", err) + } + + if pkt.packetID != 0 { + return fmt.Errorf("bad login start packet id") + } + + var ( + username String + uuid UUID + ) + + err = pkt.readFields(&username, &uuid) + if err != nil { + return fmt.Errorf("read login start packet: %w", err) + } + + // encrypt request + + var ( + serverId String = String("") + publicKey Bytes = Bytes(c.rsaPublicKey) + verifyToken Bytes = Bytes(make([]byte, 4)) + shouldAuthenticate Varint = Varint(1) + ) + + rand.Read(verifyToken) + + err = writePacket(c.writer, 0x01, &serverId, &publicKey, &verifyToken, &shouldAuthenticate) + if err != nil { + return fmt.Errorf("write encryption request: %w", err) + } + + // encrypt response + + var ( + encryptedSharedSecret Bytes + encryptedVerifyToken Bytes + + sharedSecret []byte + decryptedVerifyToken []byte + ) + + pkt, err = readPacket(c.reader) + if err != nil { + return fmt.Errorf("read encrypt response: %w", err) + } + + if pkt.packetID != 0x01 { + return fmt.Errorf("bad encrypt response packet id") + } + + err = pkt.readFields(&encryptedSharedSecret, &encryptedVerifyToken) + if err != nil { + return fmt.Errorf("read encrypt response: %w", err) + } + + sharedSecret, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedSharedSecret) + if err != nil { + return fmt.Errorf("decrypt shared secret: %w", err) + } + + decryptedVerifyToken, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedVerifyToken) + if err != nil { + return fmt.Errorf("decrypt verify token: %w", err) + } + + if len(decryptedVerifyToken) < 4 || !bytes.Equal(verifyToken, decryptedVerifyToken[:4]) { + return fmt.Errorf("verify token mismatch") + } + + c.reader, err = newCryptoReader(c.reader, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto reader: %w", err) + } + + c.writer, err = newCryptoWriter(c.writer, sharedSecret) + if err != nil { + return fmt.Errorf("new crypto writer: %w", err) + } + + // verify password + receivedPassword := decryptedVerifyToken[4:] + + if subtle.ConstantTimeCompare(receivedPassword, []byte(c.password)) != 1 { + writeDisconnectPacket(c.writer, `{"type":"translatable","translate":"multiplayer.disconnect.authservers_down"}`) + return fmt.Errorf("bad password") + } + + c.state = serverStateProxy + + return nil + + default: + return fmt.Errorf("bad handshake packet: bad next state: %d", nextState) + } +} + +func (c *serverConn) Read(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.reader.Read(b) +} + +func (c *serverConn) Write(b []byte) (int, error) { + err := c.handshake() + if err != nil { + return 0, fmt.Errorf("handshake: %w", err) + } + + return c.writer.Write(b) +} + +func (c *serverConn) Close() error { + return c.c.Close() +} + +func (c *serverConn) LocalAddr() net.Addr { + return c.c.LocalAddr() +} + +func (c *serverConn) RemoteAddr() net.Addr { + return c.c.RemoteAddr() +} + +func (c *serverConn) SetDeadline(t time.Time) error { + return c.c.SetDeadline(t) +} + +func (c *serverConn) SetReadDeadline(t time.Time) error { + return c.c.SetReadDeadline(t) +} + +func (c *serverConn) SetWriteDeadline(t time.Time) error { + return c.c.SetWriteDeadline(t) +} + +func wrapConnServer(c net.Conn, password string, rsaPrivateKeyDER []byte, rsaPublicKey []byte) (*serverConn, error) { + if len(rsaPrivateKeyDER) == 0 { + return nil, fmt.Errorf("empty rsa private key") + } + if len(rsaPublicKey) == 0 { + return nil, fmt.Errorf("empty rsa public key") + } + + rsaPrivateKey, err := x509.ParsePKCS1PrivateKey(rsaPrivateKeyDER) + if err != nil { + return nil, fmt.Errorf("parse rsa private key: %w", err) + } + + s := &serverConn{ + reader: bufio.NewReader(c), + writer: c, + c: c, + state: serverStateHandshake, + password: password, + rsaPrivateKey: rsaPrivateKey, + rsaPublicKey: rsaPublicKey, + } + + return s, nil +} diff --git a/transport/internet/finalmask/xmc/stream.go b/transport/internet/finalmask/xmc/stream.go new file mode 100644 index 000000000..2849a55ea --- /dev/null +++ b/transport/internet/finalmask/xmc/stream.go @@ -0,0 +1,68 @@ +// Minecraft stream cipher +package xmc + +import ( + "crypto/aes" + "crypto/cipher" + "fmt" + "io" +) + +type cryptoStream struct { + stream cipher.Stream + r io.Reader + w io.Writer +} + +func newCryptoReader(r io.Reader, sharedSecret []byte) (*cryptoStream, error) { + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("new aes cipher: %w", err) + } + + stream := newCFB8Decrypt(blockCipher, sharedSecret) + + return &cryptoStream{stream: stream, r: r}, nil +} + +func (c *cryptoStream) Read(b []byte) (int, error) { + if c.r == nil { + panic("read on a write-only crypto stream") + } + + n, err := c.r.Read(b) + if err != nil { + return 0, fmt.Errorf("crypto reader: read: %w", err) + } + + c.stream.XORKeyStream(b[:n], b[:n]) + + return n, nil +} + +func newCryptoWriter(w io.Writer, sharedSecret []byte) (*cryptoStream, error) { + blockCipher, err := aes.NewCipher(sharedSecret) + if err != nil { + return nil, fmt.Errorf("new aes cipher: %w", err) + } + + stream := newCFB8Encrypt(blockCipher, sharedSecret) + + return &cryptoStream{stream: stream, w: w}, nil +} + +func (c *cryptoStream) Write(b []byte) (int, error) { + if c.w == nil { + panic("write on a read-only crypto stream") + } + + encrypted := make([]byte, len(b)) + c.stream.XORKeyStream(encrypted, b) + + n, err := c.w.Write(encrypted) + if err != nil { + return 0, fmt.Errorf("crypto writer: write: %w", err) + } + + return n, nil +} From af7eb68028732a8ee3c0e5d6ab2b8a657bb2e770 Mon Sep 17 00:00:00 2001 From: RPRX <63339210+RPRX@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:30:54 +0000 Subject: [PATCH 14/59] REALITY server: Set default `"minClientVer": "26.3.27"` (change it at your own risk) https://github.com/XTLS/Xray-core/pull/6181#issuecomment-4567373533 https://t.me/projectXtls/3373 https://t.me/projectXtls/3378 --- infra/conf/transport_security.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/infra/conf/transport_security.go b/infra/conf/transport_security.go index 7d0e0529d..21f3702a6 100644 --- a/infra/conf/transport_security.go +++ b/infra/conf/transport_security.go @@ -113,6 +113,8 @@ func (c *REALITYConfig) Build() (proto.Message, error) { config.MinClientVer[i] = byte(u) } } + } else { + config.MinClientVer = []byte{26, 3, 27} // change it at your own risk: https://github.com/XTLS/Xray-core/pull/6181#issuecomment-4567373533 } if c.MaxClientVer != "" { config.MaxClientVer = make([]byte, 3) From 1f74c480d6943c8becda16ce726c66a58052c2b8 Mon Sep 17 00:00:00 2001 From: hanselime <253621328+hanselime@users.noreply.github.com> Date: Sat, 11 Jul 2026 19:15:59 +0330 Subject: [PATCH 15/59] Docker: Add `pre-release` tag (#6470) https://github.com/XTLS/Xray-core/pull/6470#issuecomment-4947190027 --------- Co-authored-by: KobeArthurScofield <26771058+KobeArthurScofield@users.noreply.github.com> Co-authored-by: Meow <197331664+Meo597@users.noreply.github.com> --- .github/workflows/docker.yml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index a540f36a7..57a2bc65c 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -64,6 +64,14 @@ jobs: echo "Latest: '$LATEST'." echo "LATEST=$LATEST" >>${GITHUB_ENV} + NEWEST=false + if [[ "${{ github.event_name }}" == "release" ]]; then + NEWEST=true + fi + + echo "Newest: '$NEWEST'." + echo "NEWEST=$NEWEST" >>${GITHUB_ENV} + - name: Checkout code uses: actions/checkout@v7 @@ -124,6 +132,13 @@ jobs: ${{ env.FULL_IMAGE_NAME }}:${{ env.IMAGE_TAG }} fi + if [[ "${{ env.NEWEST }}" == "true" ]]; then + echo "Adding 'pre-release' tag to manifest: '${{ env.FULL_IMAGE_NAME }}:pre-release'." + docker buildx imagetools create \ + --tag ${{ env.FULL_IMAGE_NAME }}:pre-release \ + ${{ env.FULL_IMAGE_NAME }}:${{ env.IMAGE_TAG }} + fi + - name: Inspect image run: | docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:${{ env.IMAGE_TAG }} @@ -131,3 +146,7 @@ jobs: if [[ "${{ env.LATEST }}" == "true" ]]; then docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:latest fi + + if [[ "${{ env.NEWEST }}" == "true" ]]; then + docker buildx imagetools inspect ${{ env.FULL_IMAGE_NAME }}:pre-release + fi From 50231eaff98ccc31b5cbd247a721c16e97fe5ec1 Mon Sep 17 00:00:00 2001 From: RPRX <63339210+RPRX@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:51:51 +0000 Subject: [PATCH 16/59] Xray-core v26.7.11 Sponsor & Donation & NFTs: https://github.com/XTLS/Xray-core/issues/3668 Project X Channel: https://t.me/projectXtls Announcement of NFTs by Project X: https://github.com/XTLS/Xray-core/discussions/3633 Project X NFT: https://opensea.io/assets/ethereum/0x5ee362866001613093361eb8569d59c4141b76d1/1 VLESS Post-Quantum Encryption: https://github.com/XTLS/Xray-core/pull/5067 VLESS NFT: https://opensea.io/collection/vless XHTTP: Beyond REALITY: https://github.com/XTLS/Xray-core/discussions/4113 REALITY NFT: https://opensea.io/assets/ethereum/0x5ee362866001613093361eb8569d59c4141b76d1/2 --- core/core.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/core.go b/core/core.go index 3cc9f7802..737079272 100644 --- a/core/core.go +++ b/core/core.go @@ -19,8 +19,8 @@ import ( var ( Version_x byte = 26 - Version_y byte = 6 - Version_z byte = 27 + Version_y byte = 7 + Version_z byte = 11 ) var ( From 035d43897925cde32639c77f84d64a79d5f4cde7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 18:04:03 +0000 Subject: [PATCH 17/59] Bump google.golang.org/grpc from 1.82.0 to 1.82.1 (#6502) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.0 to 1.82.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.82.0...v1.82.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.82.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 8267c1fce..259e29a1c 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/windows v1.0.1 - google.golang.org/grpc v1.82.0 + google.golang.org/grpc v1.82.1 google.golang.org/protobuf v1.36.11 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h12.io/socks v1.0.3 diff --git a/go.sum b/go.sum index 183207423..25dfa4179 100644 --- a/go.sum +++ b/go.sum @@ -149,8 +149,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= -google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= +google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 6ce924ad5681000b54ac8b2c096fec49783da757 Mon Sep 17 00:00:00 2001 From: Jasper344612 <282825138+Jasper344612@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:53:04 +0800 Subject: [PATCH 18/59] TUN inbound: Change default `name` to "utunN" (10~1024) (#6485) https://github.com/XTLS/Xray-docs-next/pull/872#issuecomment-4936853064 --- infra/conf/tun.go | 66 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/infra/conf/tun.go b/infra/conf/tun.go index 4ba9165c9..0f82363a6 100644 --- a/infra/conf/tun.go +++ b/infra/conf/tun.go @@ -1,6 +1,12 @@ package conf import ( + "crypto/rand" + "fmt" + "math/big" + "net" + "strconv" + "github.com/xtls/xray-core/proxy/tun" "google.golang.org/protobuf/proto" ) @@ -32,10 +38,68 @@ func (v *TunConfig) Build() (proto.Message, error) { } if config.Name == "" { - config.Name = "xray0" + name, err := GetAvailableTunName() + if err != nil { + return nil, err + } + config.Name = name } if config.MTU == 0 { config.MTU = 1500 } return config, nil } + +const ( + tunNamePrefix = "utun" + minTunIndex = 10 + maxTunIndex = 1024 +) + +func GetAvailableTunName() (string, error) { + interfaces, err := net.Interfaces() + if err != nil { + return "", fmt.Errorf("fail to get system interface information: %w", err) + } + + usedNames := make(map[string]struct{}, len(interfaces)) + for _, iface := range interfaces { + usedNames[iface.Name] = struct{}{} + } + + startIndex, err := randomInt(minTunIndex, maxTunIndex) + if err != nil { + return "", fmt.Errorf("fail to generate valid tun name: %w", err) + } + + rangeSize := maxTunIndex - minTunIndex + 1 + + for offset := 0; offset < rangeSize; offset++ { + index := minTunIndex + (startIndex-minTunIndex+offset)%rangeSize + name := tunNamePrefix + strconv.Itoa(index) + + if _, exists := usedNames[name]; !exists { + return name, nil + } + } + + return "", fmt.Errorf( + "no available TUN interface name in range %s%d-%s%d", + tunNamePrefix, + minTunIndex, + tunNamePrefix, + maxTunIndex, + ) +} + +func randomInt(min, max int) (int, error) { + value, err := rand.Int( + rand.Reader, + big.NewInt(int64(max-min+1)), + ) + if err != nil { + return 0, err + } + + return min + int(value.Int64()), nil +} From e78d8ef184707ffe58cb9b0a89e17ef0018bd025 Mon Sep 17 00:00:00 2001 From: RPRX <63339210+RPRX@users.noreply.github.com> Date: Fri, 17 Jul 2026 21:51:29 +0000 Subject: [PATCH 19/59] REALITY server: Refine warnings in parsing configuration (#6508) https://github.com/XTLS/Xray-core/pull/6508#issuecomment-4995787310 https://github.com/XTLS/Xray-core/pull/6507#issuecomment-4995466980 --------- Co-authored-by: Enle <42325154+EnleStars@users.noreply.github.com> Co-authored-by: Aleksey --- infra/conf/transport_security.go | 10 +++++++--- infra/conf/xray.go | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/infra/conf/transport_security.go b/infra/conf/transport_security.go index 21f3702a6..ec45d5555 100644 --- a/infra/conf/transport_security.go +++ b/infra/conf/transport_security.go @@ -113,8 +113,10 @@ func (c *REALITYConfig) Build() (proto.Message, error) { config.MinClientVer[i] = byte(u) } } + errors.LogWarning(context.Background(), `REALITY: Changing "minClientVer" will increase the likelihood of your server's IP being blocked by the GFW`) } else { - config.MinClientVer = []byte{26, 3, 27} // change it at your own risk: https://github.com/XTLS/Xray-core/pull/6181#issuecomment-4567373533 + config.MinClientVer = []byte{26, 3, 27} // change it at your own risk: https://github.com/XTLS/Xray-core/commit/af7eb68028732a8ee3c0e5d6ab2b8a657bb2e770 + errors.LogWarning(context.Background(), `REALITY: The default minimal client version is Xray-core v26.3.27, other clients may be refused to connect`) } if c.MaxClientVer != "" { config.MaxClientVer = make([]byte, 3) @@ -159,8 +161,10 @@ func (c *REALITYConfig) Build() (proto.Message, error) { } for _, sn := range config.ServerNames { - if strings.Contains(sn, "apple") || strings.Contains(sn, "icloud") { - errors.LogWarning(context.Background(), `REALITY: Choosing apple, icloud, etc. as the target may get your IP blocked by the GFW`) + sn = strings.ToLower(sn) + if strings.HasSuffix(sn, ".ru") || strings.HasSuffix(sn, ".ir") || strings.HasSuffix(sn, ".cn") || + strings.Contains(sn, "apple") || strings.Contains(sn, "icloud") || strings.Contains(sn, "microsoft") { + errors.LogWarning(context.Background(), `REALITY: Choosing "`, sn, `" as the target will increase the likelihood of your server's IP being blocked by the GFW`) } } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index a0846a817..bdcabbd50 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -176,7 +176,7 @@ func (c *InboundDetourConfig) Build() (*core.InboundHandlerConfig, error) { receiverSettings.StreamSettings = ss if strings.Contains(ss.SecurityType, "reality") && (receiverSettings.PortList == nil || len(receiverSettings.PortList.Ports()) != 1 || receiverSettings.PortList.Ports()[0] != 443) { - errors.LogWarning(context.Background(), `REALITY: Listening on non-443 ports may get your IP blocked by the GFW`) + errors.LogWarning(context.Background(), `REALITY: Listening on non-443 ports will increase the likelihood of your server's IP being blocked by the GFW`) } } if c.SniffingConfig != nil { From 1d8eb81d7007b8613eaf87b7b9577074447a2c89 Mon Sep 17 00:00:00 2001 From: Jasper344612 <282825138+Jasper344612@users.noreply.github.com> Date: Sat, 18 Jul 2026 06:21:33 +0800 Subject: [PATCH 20/59] TUN inbound: Add `desc` config for Windows ("Wintun" by default) (#6486) https://github.com/XTLS/Xray-docs-next/pull/872#issuecomment-4936055488 --- infra/conf/tun.go | 5 +++++ proxy/tun/README.md | 8 ++++++-- proxy/tun/config.pb.go | 18 ++++++++++++++---- proxy/tun/config.proto | 1 + proxy/tun/tun_windows.go | 6 +++--- 5 files changed, 29 insertions(+), 9 deletions(-) diff --git a/infra/conf/tun.go b/infra/conf/tun.go index 0f82363a6..73e71a991 100644 --- a/infra/conf/tun.go +++ b/infra/conf/tun.go @@ -13,6 +13,7 @@ import ( type TunConfig struct { Name string `json:"name"` + Desc string `json:"desc"` MTU uint32 `json:"mtu"` Gateway []string `json:"gateway"` DNS []string `json:"dns"` @@ -24,6 +25,7 @@ type TunConfig struct { func (v *TunConfig) Build() (proto.Message, error) { config := &tun.Config{ Name: v.Name, + Desc: v.Desc, MTU: v.MTU, Gateway: v.Gateway, DNS: v.DNS, @@ -44,6 +46,9 @@ func (v *TunConfig) Build() (proto.Message, error) { } config.Name = name } + if config.Desc == "" { + config.Desc = "Wintun" + } if config.MTU == 0 { config.MTU = 1500 } diff --git a/proxy/tun/README.md b/proxy/tun/README.md index d1c3aeb77..18c4c3345 100644 --- a/proxy/tun/README.md +++ b/proxy/tun/README.md @@ -31,13 +31,17 @@ Here is simple Xray config snippet to enable the inbound: "port": 0, "protocol": "tun", "settings": { - "name": "xray0", - "MTU": 1492 + "name": "utun10", + "desc": "Wintun", + "mtu": 1500 } } ], ``` +`desc` sets the Windows Wintun adapter tunnel type and defaults to `Wintun`. +It is ignored on other platforms. + ## SUPPORTED FEATURES - IPv4 and IPv6 diff --git a/proxy/tun/config.pb.go b/proxy/tun/config.pb.go index 04ce01d80..33bc2ba6f 100644 --- a/proxy/tun/config.pb.go +++ b/proxy/tun/config.pb.go @@ -7,11 +7,12 @@ package tun import ( - protoreflect "google.golang.org/protobuf/reflect/protoreflect" - protoimpl "google.golang.org/protobuf/runtime/protoimpl" reflect "reflect" sync "sync" unsafe "unsafe" + + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" ) const ( @@ -30,6 +31,7 @@ type Config struct { UserLevel uint32 `protobuf:"varint,5,opt,name=user_level,json=userLevel,proto3" json:"user_level,omitempty"` AutoSystemRoutingTable []string `protobuf:"bytes,6,rep,name=auto_system_routing_table,json=autoSystemRoutingTable,proto3" json:"auto_system_routing_table,omitempty"` AutoOutboundsInterface string `protobuf:"bytes,7,opt,name=auto_outbounds_interface,json=autoOutboundsInterface,proto3" json:"auto_outbounds_interface,omitempty"` + Desc string `protobuf:"bytes,8,opt,name=desc,proto3" json:"desc,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -113,11 +115,18 @@ func (x *Config) GetAutoOutboundsInterface() string { return "" } +func (x *Config) GetDesc() string { + if x != nil { + return x.Desc + } + return "" +} + var File_proxy_tun_config_proto protoreflect.FileDescriptor const file_proxy_tun_config_proto_rawDesc = "" + "\n" + - "\x16proxy/tun/config.proto\x12\x0exray.proxy.tun\"\xee\x01\n" + + "\x16proxy/tun/config.proto\x12\x0exray.proxy.tun\"\x82\x02\n" + "\x06Config\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + "\x03MTU\x18\x02 \x01(\rR\x03MTU\x12\x18\n" + @@ -126,7 +135,8 @@ const file_proxy_tun_config_proto_rawDesc = "" + "\n" + "user_level\x18\x05 \x01(\rR\tuserLevel\x129\n" + "\x19auto_system_routing_table\x18\x06 \x03(\tR\x16autoSystemRoutingTable\x128\n" + - "\x18auto_outbounds_interface\x18\a \x01(\tR\x16autoOutboundsInterfaceBL\n" + + "\x18auto_outbounds_interface\x18\a \x01(\tR\x16autoOutboundsInterface\x12\x12\n" + + "\x04desc\x18\b \x01(\tR\x04descBL\n" + "\x12com.xray.proxy.tunP\x01Z#github.com/xtls/xray-core/proxy/tun\xaa\x02\x0eXray.Proxy.Tunb\x06proto3" var ( diff --git a/proxy/tun/config.proto b/proxy/tun/config.proto index 50d43ce2b..376ac5af4 100644 --- a/proxy/tun/config.proto +++ b/proxy/tun/config.proto @@ -14,4 +14,5 @@ message Config { uint32 user_level = 5; repeated string auto_system_routing_table = 6; string auto_outbounds_interface = 7; + string desc = 8; } diff --git a/proxy/tun/tun_windows.go b/proxy/tun/tun_windows.go index daa44c8ae..4c5a0ed88 100644 --- a/proxy/tun/tun_windows.go +++ b/proxy/tun/tun_windows.go @@ -50,7 +50,7 @@ var _ GVisorDevice = (*WindowsTun)(nil) // interface with the same name exist, it tried to be reused. func NewTun(options *Config) (Tun, error) { // instantiate wintun adapter - adapter, err := open(options.Name) + adapter, err := open(options.Name, options.Desc) if err != nil { return nil, err } @@ -73,12 +73,12 @@ func NewTun(options *Config) (Tun, error) { return tun, nil } -func open(name string) (*wintun.Adapter, error) { +func open(name, desc string) (*wintun.Adapter, error) { // generate a deterministic GUID from the adapter name id := md5.Sum([]byte(name)) guid := (*windows.GUID)(unsafe.Pointer(&id[0])) // try to create adapter anew - adapter, err := wintun.CreateAdapter(name, "Xray", guid) + adapter, err := wintun.CreateAdapter(name, desc, guid) if err == nil { return adapter, nil } From 6e3322d219140a025285ded1114fe17a5edb74d8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:28:51 +0000 Subject: [PATCH 21/59] Bump actions/setup-go from 6 to 7 (#6511) Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6 to 7. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: '7' dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/release-win7.yml | 2 +- .github/workflows/release.yml | 2 +- .github/workflows/test.yml | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release-win7.yml b/.github/workflows/release-win7.yml index f7f997bb6..2a641e77f 100644 --- a/.github/workflows/release-win7.yml +++ b/.github/workflows/release-win7.yml @@ -92,7 +92,7 @@ jobs: echo "ASSET_NAME=$_NAME" >> $GITHUB_ENV - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c459a8837..2bbe698cf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -193,7 +193,7 @@ jobs: echo "ASSET_NAME=$_NAME" >> $GITHUB_ENV - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod check-latest: true diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d7b875d91..e4d790379 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -61,7 +61,7 @@ jobs: - name: Checkout codebase uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod check-latest: true @@ -85,7 +85,7 @@ jobs: - name: Checkout codebase uses: actions/checkout@v7 - name: Set up Go - uses: actions/setup-go@v6 + uses: actions/setup-go@v7 with: go-version-file: go.mod check-latest: true From 5b1b41058e8d125feca3119ad6547292371cf370 Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Sun, 26 Jul 2026 08:13:37 +0800 Subject: [PATCH 22/59] Routing: Exclude iOS from Darwin for `process` (#6524) Fixes https://github.com/XTLS/Xray-core/pull/6434#issuecomment-5016175948 --- common/net/find_process_darwin.go | 2 +- common/net/find_process_darwin.s | 2 +- common/net/find_process_darwin_test.go | 2 +- common/net/find_process_ios.go | 11 +++++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 common/net/find_process_ios.go diff --git a/common/net/find_process_darwin.go b/common/net/find_process_darwin.go index dc0593c26..540e534fa 100644 --- a/common/net/find_process_darwin.go +++ b/common/net/find_process_darwin.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && !ios package net diff --git a/common/net/find_process_darwin.s b/common/net/find_process_darwin.s index 5e915476a..486f61654 100644 --- a/common/net/find_process_darwin.s +++ b/common/net/find_process_darwin.s @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && !ios #include "textflag.h" diff --git a/common/net/find_process_darwin_test.go b/common/net/find_process_darwin_test.go index 7eb8eb34d..88e94277a 100644 --- a/common/net/find_process_darwin_test.go +++ b/common/net/find_process_darwin_test.go @@ -1,4 +1,4 @@ -//go:build darwin +//go:build darwin && !ios package net diff --git a/common/net/find_process_ios.go b/common/net/find_process_ios.go new file mode 100644 index 000000000..226c6b59c --- /dev/null +++ b/common/net/find_process_ios.go @@ -0,0 +1,11 @@ +//go:build ios + +package net + +import ( + "github.com/xtls/xray-core/common/errors" +) + +func FindProcess(network, srcIP string, srcPort uint16, destIP string, destPort uint16) (int, string, string, error) { + return 0, "", "", errors.New("process lookup is not supported on this platform") +} From 4aba687dd3529f47506f40415d0d35da7033d946 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Mon, 27 Jul 2026 21:11:25 +0800 Subject: [PATCH 23/59] XHTTP & gRPC servers: Get accurate localAddr (#6526) Fixes https://github.com/XTLS/Xray-core/pull/6476 --------- Co-authored-by: echoowall --- transport/internet/grpc/encoding/hunkconn.go | 2 ++ transport/internet/grpc/encoding/multiconn.go | 2 ++ transport/internet/grpc/encoding/remoteaddr.go | 14 ++++++++++++++ transport/internet/splithttp/hub.go | 6 +++++- 4 files changed, 23 insertions(+), 1 deletion(-) diff --git a/transport/internet/grpc/encoding/hunkconn.go b/transport/internet/grpc/encoding/hunkconn.go index 92769065e..18262fb95 100644 --- a/transport/internet/grpc/encoding/hunkconn.go +++ b/transport/internet/grpc/encoding/hunkconn.go @@ -38,12 +38,14 @@ func NewHunkReadWriter(hc HunkConn, cancel context.CancelFunc) *HunkReaderWriter func NewHunkConn(hc HunkConn, cancel context.CancelFunc, trustedXForwardedFor []string) net.Conn { rAddr := remoteAddrFromContext(hc.Context(), trustedXForwardedFor) + lAddr := localAddrFromContext(hc.Context()) wrc := NewHunkReadWriter(hc, cancel) return cnc.NewConnection( cnc.ConnectionInput(wrc), cnc.ConnectionOutput(wrc), cnc.ConnectionOnClose(wrc), cnc.ConnectionRemoteAddr(rAddr), + cnc.ConnectionLocalAddr(lAddr), ) } diff --git a/transport/internet/grpc/encoding/multiconn.go b/transport/internet/grpc/encoding/multiconn.go index f27a1c35e..5a51c929b 100644 --- a/transport/internet/grpc/encoding/multiconn.go +++ b/transport/internet/grpc/encoding/multiconn.go @@ -33,12 +33,14 @@ func NewMultiHunkReadWriter(hc MultiHunkConn, cancel context.CancelFunc) *MultiH func NewMultiHunkConn(hc MultiHunkConn, cancel context.CancelFunc, trustedXForwardedFor []string) net.Conn { rAddr := remoteAddrFromContext(hc.Context(), trustedXForwardedFor) + lAddr := localAddrFromContext(hc.Context()) wrc := NewMultiHunkReadWriter(hc, cancel) return cnc.NewConnection( cnc.ConnectionInputMulti(wrc), cnc.ConnectionOutputMulti(wrc), cnc.ConnectionOnClose(wrc), cnc.ConnectionRemoteAddr(rAddr), + cnc.ConnectionLocalAddr(lAddr), ) } diff --git a/transport/internet/grpc/encoding/remoteaddr.go b/transport/internet/grpc/encoding/remoteaddr.go index a28d91933..5c71f7501 100644 --- a/transport/internet/grpc/encoding/remoteaddr.go +++ b/transport/internet/grpc/encoding/remoteaddr.go @@ -56,3 +56,17 @@ func parseTrustedXForwardedFor(md metadata.MD, trusted []string, remoteAddr net. } return nil } + +func localAddrFromContext(ctx context.Context) net.Addr { + var localAddr net.Addr + if pr, ok := peer.FromContext(ctx); ok { + localAddr = pr.LocalAddr + } + if localAddr == nil { + localAddr = &net.TCPAddr{ + IP: []byte{0, 0, 0, 0}, + Port: 0, + } + } + return localAddr +} diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index 557f8a54d..d664c86c4 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -373,11 +373,15 @@ func (h *requestHandler) ServeHTTP(writer http.ResponseWriter, request *http.Req Reader: request.Body, ResponseWriter: writer, } + localAddr := h.localAddr + if la, ok := request.Context().Value(http.LocalAddrContextKey).(net.Addr); ok && la != nil { + localAddr = la + } conn := splitConn{ writer: httpSC, reader: httpSC, remoteAddr: remoteAddr, - localAddr: h.localAddr, + localAddr: localAddr, } if sessionId != "" { // if not stream-one conn.reader = currentSession.uploadQueue From 6ab123bf8fba29a1c9157fc38f421d65c0cbb281 Mon Sep 17 00:00:00 2001 From: Sparrow <248678851+spr334-a@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:45:36 +0900 Subject: [PATCH 24/59] XMC finalmask: Add default directional padding/keep-alive packets, matching Minecraft 26.1.2 login/join/etc traffic shapes (#6487) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6487#issuecomment-5092235757 --------- Co-authored-by: 风扇滑翔翼 --- infra/conf/transport_finalmask.go | 54 +- infra/conf/transport_finalmask_xmc_test.go | 36 + transport/internet/finalmask/xmc/client.go | 133 ++-- transport/internet/finalmask/xmc/config.go | 12 +- transport/internet/finalmask/xmc/config.pb.go | 124 +++- transport/internet/finalmask/xmc/config.proto | 12 +- transport/internet/finalmask/xmc/deadline.go | 85 +++ .../internet/finalmask/xmc/deadline_test.go | 81 +++ .../internet/finalmask/xmc/handshake_test.go | 252 ++++++- .../internet/finalmask/xmc/packet_stream.go | 184 ++++++ .../finalmask/xmc/packet_stream_test.go | 102 +++ transport/internet/finalmask/xmc/padding.go | 432 ++++++++++++ .../internet/finalmask/xmc/padding_preset.go | 282 ++++++++ .../internet/finalmask/xmc/padding_test.go | 618 ++++++++++++++++++ transport/internet/finalmask/xmc/profile.go | 69 ++ .../internet/finalmask/xmc/profile_test.go | 33 + transport/internet/finalmask/xmc/protocol.go | 233 +++++-- .../internet/finalmask/xmc/protocol_test.go | 56 ++ transport/internet/finalmask/xmc/server.go | 125 +++- transport/internet/finalmask/xmc/stream.go | 27 +- .../internet/finalmask/xmc/stream_test.go | 81 +++ 21 files changed, 2853 insertions(+), 178 deletions(-) create mode 100644 infra/conf/transport_finalmask_xmc_test.go create mode 100644 transport/internet/finalmask/xmc/deadline.go create mode 100644 transport/internet/finalmask/xmc/deadline_test.go create mode 100644 transport/internet/finalmask/xmc/packet_stream.go create mode 100644 transport/internet/finalmask/xmc/packet_stream_test.go create mode 100644 transport/internet/finalmask/xmc/padding.go create mode 100644 transport/internet/finalmask/xmc/padding_preset.go create mode 100644 transport/internet/finalmask/xmc/padding_test.go create mode 100644 transport/internet/finalmask/xmc/profile.go create mode 100644 transport/internet/finalmask/xmc/profile_test.go create mode 100644 transport/internet/finalmask/xmc/protocol_test.go create mode 100644 transport/internet/finalmask/xmc/stream_test.go diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index 24c3f0afa..e4c579dda 100644 --- a/infra/conf/transport_finalmask.go +++ b/infra/conf/transport_finalmask.go @@ -11,6 +11,7 @@ import ( "regexp" "strings" + googleuuid "github.com/google/uuid" "github.com/xtls/xray-core/common/errors" "github.com/xtls/xray-core/common/net" "github.com/xtls/xray-core/transport/internet/finalmask/fragment" @@ -720,14 +721,46 @@ func (c *Xdns) Build() (proto.Message, error) { } type XMC struct { - Hostname string `json:"hostname"` - Usernames []string `json:"usernames"` - Password string `json:"password"` + Hostname string `json:"hostname"` + Profiles []XMCProfile `json:"profiles"` + Password string `json:"password"` +} + +type XMCProfile struct { + // Resolve the UUID by username, then request the session profile with + // unsigned=false. Client and server must use the same signed profile. + Username string `json:"username"` + UUID string `json:"uuid"` + TexturesValue string `json:"texturesValue"` + TexturesSignature string `json:"texturesSignature"` +} + +var xmcUsernamePattern = regexp.MustCompile(`^[A-Za-z0-9_]{3,16}$`) + +func (c *XMCProfile) Build() (*xmc.Profile, error) { + if !xmcUsernamePattern.MatchString(c.Username) { + return nil, fmt.Errorf("invalid minecraft profile username: %q", c.Username) + } + + profileUUID, err := googleuuid.Parse(c.UUID) + if err != nil { + return nil, fmt.Errorf("invalid minecraft profile UUID: %w", err) + } + if c.TexturesValue == "" || c.TexturesSignature == "" { + return nil, fmt.Errorf("incomplete minecraft profile textures") + } + + return &xmc.Profile{ + Username: c.Username, + Uuid: append([]byte(nil), profileUUID[:]...), + TexturesValue: c.TexturesValue, + TexturesSignature: c.TexturesSignature, + }, nil } func (c *XMC) Build() (proto.Message, error) { - if len(c.Usernames) == 0 { - c.Usernames = []string{"Dream"} + if len(c.Profiles) == 0 { + return nil, fmt.Errorf("minecraft profiles are required") } if c.Password == "" { @@ -744,12 +777,21 @@ func (c *XMC) Build() (proto.Message, error) { return nil, fmt.Errorf("marshal minecraft rsa public key: %w", err) } + profiles := make([]*xmc.Profile, 0, len(c.Profiles)) + for i := range c.Profiles { + profile, err := c.Profiles[i].Build() + if err != nil { + return nil, fmt.Errorf("build minecraft profile %d: %w", i, err) + } + profiles = append(profiles, profile) + } + return &xmc.Config{ Password: c.Password, - Usernames: c.Usernames, Hostname: c.Hostname, RsaPrivateKey: x509.MarshalPKCS1PrivateKey(rsaPrivateKey), RsaPublicKey: rsaPublicKey, + Profiles: profiles, }, nil } diff --git a/infra/conf/transport_finalmask_xmc_test.go b/infra/conf/transport_finalmask_xmc_test.go new file mode 100644 index 000000000..5d4257456 --- /dev/null +++ b/infra/conf/transport_finalmask_xmc_test.go @@ -0,0 +1,36 @@ +package conf + +import ( + "strings" + "testing" + + "github.com/xtls/xray-core/transport/internet/finalmask/xmc" +) + +func TestXMCBuildProfile(t *testing.T) { + built, err := (&XMC{ + Password: "test-password", + Profiles: []XMCProfile{ + { + Username: "TestUser", + UUID: "00112233-4455-6677-8899-aabbccddeeff", + TexturesValue: "textures-value", + TexturesSignature: "textures-signature", + }, + }, + }).Build() + if err != nil { + t.Fatalf("build XMC config: %v", err) + } + config := built.(*xmc.Config) + if len(config.Profiles) != 1 || len(config.Profiles[0].Uuid) != 16 { + t.Fatalf("unexpected profiles: %+v", config.Profiles) + } +} + +func TestXMCBuildRequiresProfile(t *testing.T) { + _, err := (&XMC{Password: "test-password"}).Build() + if err == nil || !strings.Contains(err.Error(), "profiles are required") { + t.Fatalf("expected required profiles error, got %v", err) + } +} diff --git a/transport/internet/finalmask/xmc/client.go b/transport/internet/finalmask/xmc/client.go index c0d1ee2b4..6159d09a1 100644 --- a/transport/internet/finalmask/xmc/client.go +++ b/transport/internet/finalmask/xmc/client.go @@ -5,7 +5,6 @@ import ( "bytes" "crypto/rand" "crypto/rsa" - "crypto/sha256" "crypto/x509" "fmt" "io" @@ -23,11 +22,16 @@ type clientConn struct { state clientState - handshakeLock sync.Mutex - usernames []string - password string - rsaPublicKey []byte - hostname string + handshakeLock sync.Mutex + lifecycleMu sync.Mutex + closed bool + profiles []loginProfile + password string + rsaPublicKey []byte + hostname string + paddingSchedule []paddingTurn + packet *packetStream + deadlines *connectionDeadlines } type clientState int @@ -37,21 +41,29 @@ var ( clientStateProxy clientState = 2 ) -func newClientConn(c net.Conn, usernames []string, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) { +func newClientConn(c net.Conn, profiles []loginProfile, password string, rsaPublicKey []byte, hostname string) (*clientConn, error) { if len(rsaPublicKey) == 0 { return nil, fmt.Errorf("empty rsa public key") } - + if len(profiles) == 0 { + return nil, fmt.Errorf("empty profiles") + } + paddingSchedule, err := newClientPaddingSchedule2612() + if err != nil { + return nil, fmt.Errorf("select padding profile: %w", err) + } return &clientConn{ - reader: bufio.NewReader(c), - writer: c, - c: c, - state: clientStateHandshake, - handshakeLock: sync.Mutex{}, - usernames: usernames, - password: password, - rsaPublicKey: rsaPublicKey, - hostname: hostname, + reader: bufio.NewReader(c), + writer: c, + c: c, + state: clientStateHandshake, + handshakeLock: sync.Mutex{}, + profiles: profiles, + password: password, + rsaPublicKey: rsaPublicKey, + hostname: hostname, + paddingSchedule: paddingSchedule, + deadlines: newConnectionDeadlines(c), }, nil } @@ -63,12 +75,10 @@ func (c *clientConn) handshake() error { return nil } - // Handshake timeout - err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) - if err != nil { + if err := c.deadlines.beginHandshake(); err != nil { return fmt.Errorf("set deadline: %w", err) } - defer c.c.SetDeadline(time.Time{}) + defer func() { _ = c.deadlines.endHandshake() }() var ( protocolVersion Varint = Varint(775) @@ -95,16 +105,14 @@ func (c *clientConn) handshake() error { } // Login Start - var ( - username string - offlineUUID UUID - ) + randomProfile, err := rand.Int(rand.Reader, big.NewInt(int64(len(c.profiles)))) + if err != nil { + return fmt.Errorf("select profile: %w", err) + } + selectedProfile := c.profiles[randomProfile.Int64()] + username := String(selectedProfile.Username) - randomUsername, _ := rand.Int(rand.Reader, big.NewInt(int64(len(c.usernames)))) - username = c.usernames[randomUsername.Int64()] - generateOfflineUUID(&offlineUUID, string(username)) - - err = writePacket(c.writer, 0x00, new(String(username)), &offlineUUID) + err = writePacket(c.writer, 0x00, &username, &selectedProfile.UUID) if err != nil { return fmt.Errorf("write login start: %w", err) } @@ -145,7 +153,9 @@ func (c *clientConn) handshake() error { } sharedSecret := make([]byte, 16) - rand.Read(sharedSecret) + if _, err = rand.Read(sharedSecret); err != nil { + return fmt.Errorf("generate shared secret: %w", err) + } encryptedSharedSecret, err := rsa.EncryptPKCS1v15(rand.Reader, rsaPublicKey, sharedSecret) if err != nil { @@ -181,7 +191,48 @@ func (c *clientConn) handshake() error { return fmt.Errorf("new crypto writer: %w", err) } + pkt, err = readPacket(c.reader) + if err != nil { + return fmt.Errorf("read login finished: %w", err) + } + if pkt.packetID == 0x00 { + var reason String + if readErr := pkt.readFields(&reason); readErr != nil { + return fmt.Errorf("authentication rejected") + } + return fmt.Errorf("authentication rejected: %s", reason) + } + if pkt.packetID != 0x02 { + return fmt.Errorf("bad login finished packet id: %d", pkt.packetID) + } + + receivedProfile, err := readLoginSuccess(pkt) + if err != nil { + return fmt.Errorf("read login finished fields: %w", err) + } + if receivedProfile != selectedProfile { + return fmt.Errorf("login profile mismatch") + } + loginAcknowledgedLength, err := writePacketWithLength(c.writer, 0x03) + if err != nil { + return fmt.Errorf("write login acknowledged: %w", err) + } + if err = runPaddingSchedule(c.reader, c.writer, true, loginAcknowledgedLength, c.paddingSchedule); err != nil { + return fmt.Errorf("run startup padding: %w", err) + } + + packet := newPacketStream(c.reader, c.writer, true) + c.lifecycleMu.Lock() + if c.closed { + c.lifecycleMu.Unlock() + packet.Stop() + return net.ErrClosed + } + c.packet = packet + c.reader = packet + c.writer = packet c.state = clientStateProxy + c.lifecycleMu.Unlock() return nil } @@ -205,6 +256,13 @@ func (c *clientConn) Write(b []byte) (int, error) { } func (c *clientConn) Close() error { + c.lifecycleMu.Lock() + c.closed = true + packet := c.packet + c.lifecycleMu.Unlock() + if packet != nil { + packet.Stop() + } return c.c.Close() } @@ -217,20 +275,13 @@ func (c *clientConn) RemoteAddr() net.Addr { } func (c *clientConn) SetDeadline(t time.Time) error { - return c.c.SetDeadline(t) + return c.deadlines.setDeadline(t) } func (c *clientConn) SetReadDeadline(t time.Time) error { - return c.c.SetReadDeadline(t) + return c.deadlines.setReadDeadline(t) } func (c *clientConn) SetWriteDeadline(t time.Time) error { - return c.c.SetWriteDeadline(t) -} - -func generateOfflineUUID(uuid *UUID, username string) { - h := sha256.Sum256([]byte("OfflinePlayer:" + username)) - copy(uuid[:], h[:16]) - uuid[6] = (uuid[6] & 0x0f) | 0x30 // UUID version 3 - uuid[8] = (uuid[8] & 0x3f) | 0x80 // UUID variant + return c.deadlines.setWriteDeadline(t) } diff --git a/transport/internet/finalmask/xmc/config.go b/transport/internet/finalmask/xmc/config.go index d0f414a7c..be3a1ef6c 100644 --- a/transport/internet/finalmask/xmc/config.go +++ b/transport/internet/finalmask/xmc/config.go @@ -9,7 +9,11 @@ func (c *Config) TCP() { } func (c *Config) WrapConnClient(conn net.Conn) (net.Conn, error) { - cc, err := newClientConn(conn, c.Usernames, c.Password, c.RsaPublicKey, c.Hostname) + profiles, err := profilesFromConfig(c.Profiles) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + cc, err := newClientConn(conn, profiles, c.Password, c.RsaPublicKey, c.Hostname) if err != nil { return nil, fmt.Errorf("minecraft finalmask: %w", err) } @@ -18,7 +22,11 @@ func (c *Config) WrapConnClient(conn net.Conn) (net.Conn, error) { } func (c *Config) WrapConnServer(conn net.Conn) (net.Conn, error) { - cc, err := wrapConnServer(conn, c.Password, c.RsaPrivateKey, c.RsaPublicKey) + profiles, err := profilesFromConfig(c.Profiles) + if err != nil { + return nil, fmt.Errorf("minecraft finalmask: %w", err) + } + cc, err := wrapConnServer(conn, profiles, c.Password, c.RsaPrivateKey, c.RsaPublicKey) if err != nil { return nil, fmt.Errorf("minecraft finalmask: %w", err) } diff --git a/transport/internet/finalmask/xmc/config.pb.go b/transport/internet/finalmask/xmc/config.pb.go index f4c983b84..e12c6ffe7 100644 --- a/transport/internet/finalmask/xmc/config.pb.go +++ b/transport/internet/finalmask/xmc/config.pb.go @@ -21,20 +21,91 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type Profile struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Resolve the UUID from https://api.mojang.com/users/profiles/minecraft/{username}. + Username string `protobuf:"bytes,1,opt,name=username,proto3" json:"username,omitempty"` + Uuid []byte `protobuf:"bytes,2,opt,name=uuid,proto3" json:"uuid,omitempty"` + // Copy the signed textures property returned by + // https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false. + TexturesValue string `protobuf:"bytes,3,opt,name=textures_value,json=texturesValue,proto3" json:"textures_value,omitempty"` + TexturesSignature string `protobuf:"bytes,4,opt,name=textures_signature,json=texturesSignature,proto3" json:"textures_signature,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Profile) Reset() { + *x = Profile{} + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Profile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Profile) ProtoMessage() {} + +func (x *Profile) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Profile.ProtoReflect.Descriptor instead. +func (*Profile) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0} +} + +func (x *Profile) GetUsername() string { + if x != nil { + return x.Username + } + return "" +} + +func (x *Profile) GetUuid() []byte { + if x != nil { + return x.Uuid + } + return nil +} + +func (x *Profile) GetTexturesValue() string { + if x != nil { + return x.TexturesValue + } + return "" +} + +func (x *Profile) GetTexturesSignature() string { + if x != nil { + return x.TexturesSignature + } + return "" +} + type Config struct { state protoimpl.MessageState `protogen:"open.v1"` Password string `protobuf:"bytes,1,opt,name=password,proto3" json:"password,omitempty"` - Usernames []string `protobuf:"bytes,2,rep,name=usernames,proto3" json:"usernames,omitempty"` RsaPrivateKey []byte `protobuf:"bytes,8,opt,name=rsa_private_key,json=rsaPrivateKey,proto3" json:"rsa_private_key,omitempty"` RsaPublicKey []byte `protobuf:"bytes,9,opt,name=rsa_public_key,json=rsaPublicKey,proto3" json:"rsa_public_key,omitempty"` Hostname string `protobuf:"bytes,10,opt,name=hostname,proto3" json:"hostname,omitempty"` + Profiles []*Profile `protobuf:"bytes,11,rep,name=profiles,proto3" json:"profiles,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Config) Reset() { *x = Config{} - mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -46,7 +117,7 @@ func (x *Config) String() string { func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_xmc_config_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -59,7 +130,7 @@ func (x *Config) ProtoReflect() protoreflect.Message { // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{0} + return file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP(), []int{1} } func (x *Config) GetPassword() string { @@ -69,13 +140,6 @@ func (x *Config) GetPassword() string { return "" } -func (x *Config) GetUsernames() []string { - if x != nil { - return x.Usernames - } - return nil -} - func (x *Config) GetRsaPrivateKey() []byte { if x != nil { return x.RsaPrivateKey @@ -97,18 +161,30 @@ func (x *Config) GetHostname() string { return "" } +func (x *Config) GetProfiles() []*Profile { + if x != nil { + return x.Profiles + } + return nil +} + var File_transport_internet_finalmask_xmc_config_proto protoreflect.FileDescriptor const file_transport_internet_finalmask_xmc_config_proto_rawDesc = "" + "\n" + - "-transport/internet/finalmask/xmc/config.proto\x12%xray.transport.internet.finalmask.xmc\"\xac\x01\n" + + "-transport/internet/finalmask/xmc/config.proto\x12%xray.transport.internet.finalmask.xmc\"\x8f\x01\n" + + "\aProfile\x12\x1a\n" + + "\busername\x18\x01 \x01(\tR\busername\x12\x12\n" + + "\x04uuid\x18\x02 \x01(\fR\x04uuid\x12%\n" + + "\x0etextures_value\x18\x03 \x01(\tR\rtexturesValue\x12-\n" + + "\x12textures_signature\x18\x04 \x01(\tR\x11texturesSignature\"\xe0\x01\n" + "\x06Config\x12\x1a\n" + - "\bpassword\x18\x01 \x01(\tR\bpassword\x12\x1c\n" + - "\tusernames\x18\x02 \x03(\tR\tusernames\x12&\n" + + "\bpassword\x18\x01 \x01(\tR\bpassword\x12&\n" + "\x0frsa_private_key\x18\b \x01(\fR\rrsaPrivateKey\x12$\n" + "\x0ersa_public_key\x18\t \x01(\fR\frsaPublicKey\x12\x1a\n" + "\bhostname\x18\n" + - " \x01(\tR\bhostnameB\x91\x01\n" + + " \x01(\tR\bhostname\x12J\n" + + "\bprofiles\x18\v \x03(\v2..xray.transport.internet.finalmask.xmc.ProfileR\bprofilesJ\x04\b\x02\x10\x03B\x91\x01\n" + ")com.xray.transport.internet.finalmask.xmcP\x01Z:github.com/xtls/xray-core/transport/internet/finalmask/xmc\xaa\x02%Xray.Transport.Internet.Finalmask.XMCb\x06proto3" var ( @@ -123,16 +199,18 @@ func file_transport_internet_finalmask_xmc_config_proto_rawDescGZIP() []byte { return file_transport_internet_finalmask_xmc_config_proto_rawDescData } -var file_transport_internet_finalmask_xmc_config_proto_msgTypes = make([]protoimpl.MessageInfo, 1) +var file_transport_internet_finalmask_xmc_config_proto_msgTypes = make([]protoimpl.MessageInfo, 2) var file_transport_internet_finalmask_xmc_config_proto_goTypes = []any{ - (*Config)(nil), // 0: xray.transport.internet.finalmask.xmc.Config + (*Profile)(nil), // 0: xray.transport.internet.finalmask.xmc.Profile + (*Config)(nil), // 1: xray.transport.internet.finalmask.xmc.Config } var file_transport_internet_finalmask_xmc_config_proto_depIdxs = []int32{ - 0, // [0:0] is the sub-list for method output_type - 0, // [0:0] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 0, // 0: xray.transport.internet.finalmask.xmc.Config.profiles:type_name -> xray.transport.internet.finalmask.xmc.Profile + 1, // [1:1] is the sub-list for method output_type + 1, // [1:1] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_transport_internet_finalmask_xmc_config_proto_init() } @@ -146,7 +224,7 @@ func file_transport_internet_finalmask_xmc_config_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_xmc_config_proto_rawDesc), len(file_transport_internet_finalmask_xmc_config_proto_rawDesc)), NumEnums: 0, - NumMessages: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, diff --git a/transport/internet/finalmask/xmc/config.proto b/transport/internet/finalmask/xmc/config.proto index 25fe29d4b..c880439d0 100644 --- a/transport/internet/finalmask/xmc/config.proto +++ b/transport/internet/finalmask/xmc/config.proto @@ -6,11 +6,21 @@ option go_package = "github.com/xtls/xray-core/transport/internet/finalmask/xmc" option java_package = "com.xray.transport.internet.finalmask.xmc"; option java_multiple_files = true; +message Profile { + // Resolve the UUID from https://api.mojang.com/users/profiles/minecraft/{username}. + string username = 1; + bytes uuid = 2; + // Copy the signed textures property returned by + // https://sessionserver.mojang.com/session/minecraft/profile/{uuid}?unsigned=false. + string textures_value = 3; + string textures_signature = 4; +} message Config { string password = 1; - repeated string usernames = 2; + reserved 2; bytes rsa_private_key = 8; bytes rsa_public_key = 9; string hostname = 10; + repeated Profile profiles = 11; } diff --git a/transport/internet/finalmask/xmc/deadline.go b/transport/internet/finalmask/xmc/deadline.go new file mode 100644 index 000000000..59380183a --- /dev/null +++ b/transport/internet/finalmask/xmc/deadline.go @@ -0,0 +1,85 @@ +package xmc + +import ( + "net" + "sync" + "time" +) + +const handshakeTimeout = 2 * time.Minute + +type connectionDeadlines struct { + mu sync.Mutex + c net.Conn + + read time.Time + write time.Time + handshake time.Time +} + +func newConnectionDeadlines(c net.Conn) *connectionDeadlines { + return &connectionDeadlines{c: c} +} + +func (d *connectionDeadlines) beginHandshake() error { + d.mu.Lock() + defer d.mu.Unlock() + + d.handshake = time.Now().Add(handshakeTimeout) + if err := d.applyLocked(); err != nil { + d.handshake = time.Time{} + _ = d.applyLocked() + return err + } + return nil +} + +func (d *connectionDeadlines) endHandshake() error { + d.mu.Lock() + defer d.mu.Unlock() + + d.handshake = time.Time{} + return d.applyLocked() +} + +func (d *connectionDeadlines) setDeadline(t time.Time) error { + d.mu.Lock() + defer d.mu.Unlock() + + d.read = t + d.write = t + return d.applyLocked() +} + +func (d *connectionDeadlines) setReadDeadline(t time.Time) error { + d.mu.Lock() + defer d.mu.Unlock() + + d.read = t + return d.c.SetReadDeadline(earlierDeadline(d.read, d.handshake)) +} + +func (d *connectionDeadlines) setWriteDeadline(t time.Time) error { + d.mu.Lock() + defer d.mu.Unlock() + + d.write = t + return d.c.SetWriteDeadline(earlierDeadline(d.write, d.handshake)) +} + +func (d *connectionDeadlines) applyLocked() error { + if err := d.c.SetReadDeadline(earlierDeadline(d.read, d.handshake)); err != nil { + return err + } + return d.c.SetWriteDeadline(earlierDeadline(d.write, d.handshake)) +} + +func earlierDeadline(user, internal time.Time) time.Time { + if internal.IsZero() { + return user + } + if user.IsZero() || internal.Before(user) { + return internal + } + return user +} diff --git a/transport/internet/finalmask/xmc/deadline_test.go b/transport/internet/finalmask/xmc/deadline_test.go new file mode 100644 index 000000000..2a56c3e2c --- /dev/null +++ b/transport/internet/finalmask/xmc/deadline_test.go @@ -0,0 +1,81 @@ +package xmc + +import ( + "net" + "sync" + "testing" + "time" +) + +func TestConnectionDeadlinesRestoreCallerValues(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + recording := &deadlineRecordingConn{Conn: client} + deadlines := newConnectionDeadlines(recording) + callerDeadline := time.Now().Add(10 * time.Minute) + if err := deadlines.setDeadline(callerDeadline); err != nil { + t.Fatal(err) + } + if err := deadlines.beginHandshake(); err != nil { + t.Fatal(err) + } + + read, write := recording.currentDeadlines() + if !read.Before(callerDeadline) || !write.Before(callerDeadline) { + t.Fatalf("handshake deadlines = %s/%s, caller = %s", read, write, callerDeadline) + } + + shortReadDeadline := time.Now().Add(time.Second) + if err := deadlines.setReadDeadline(shortReadDeadline); err != nil { + t.Fatal(err) + } + read, _ = recording.currentDeadlines() + if !read.Equal(shortReadDeadline) { + t.Fatalf("read deadline = %s, want %s", read, shortReadDeadline) + } + + if err := deadlines.endHandshake(); err != nil { + t.Fatal(err) + } + read, write = recording.currentDeadlines() + if !read.Equal(shortReadDeadline) || !write.Equal(callerDeadline) { + t.Fatalf("restored deadlines = %s/%s, want %s/%s", read, write, shortReadDeadline, callerDeadline) + } +} + +type deadlineRecordingConn struct { + net.Conn + mu sync.Mutex + read time.Time + write time.Time +} + +func (c *deadlineRecordingConn) SetDeadline(t time.Time) error { + c.mu.Lock() + c.read = t + c.write = t + c.mu.Unlock() + return c.Conn.SetDeadline(t) +} + +func (c *deadlineRecordingConn) SetReadDeadline(t time.Time) error { + c.mu.Lock() + c.read = t + c.mu.Unlock() + return c.Conn.SetReadDeadline(t) +} + +func (c *deadlineRecordingConn) SetWriteDeadline(t time.Time) error { + c.mu.Lock() + c.write = t + c.mu.Unlock() + return c.Conn.SetWriteDeadline(t) +} + +func (c *deadlineRecordingConn) currentDeadlines() (time.Time, time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + return c.read, c.write +} diff --git a/transport/internet/finalmask/xmc/handshake_test.go b/transport/internet/finalmask/xmc/handshake_test.go index 2e48c309c..b2db51ef9 100644 --- a/transport/internet/finalmask/xmc/handshake_test.go +++ b/transport/internet/finalmask/xmc/handshake_test.go @@ -2,10 +2,16 @@ package xmc import ( "bytes" + "crypto/sha256" "crypto/x509" + "errors" + "fmt" + "io" "net" + "strings" "sync" "testing" + "time" ) func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) { @@ -24,6 +30,19 @@ func deriveTestRSAKey(t *testing.T, password string) ([]byte, []byte) { return x509.MarshalPKCS1PrivateKey(key), publicKey } +func testLoginProfile(username string) loginProfile { + profile := loginProfile{ + Username: username, + TexturesValue: strings.Repeat("texture-value-", 40), + TexturesSignature: strings.Repeat("texture-signature-", 24), + } + digest := sha256.Sum256([]byte(username)) + copy(profile.UUID[:], digest[:16]) + profile.UUID[6] = (profile.UUID[6] & 0x0f) | 0x40 + profile.UUID[8] = (profile.UUID[8] & 0x3f) | 0x80 + return profile +} + func TestHandshakeSuccess(t *testing.T) { ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { @@ -32,7 +51,7 @@ func TestHandshakeSuccess(t *testing.T) { defer ln.Close() password := "super-secure-shared-key-12345" - usernames := []string{"test_user"} + profiles := []loginProfile{testLoginProfile("test_user")} privateKey, publicKey := deriveTestRSAKey(t, password) go func() { @@ -42,7 +61,7 @@ func TestHandshakeSuccess(t *testing.T) { } defer rawConn.Close() - server, err := wrapConnServer(rawConn, password, privateKey, publicKey) + server, err := wrapConnServer(rawConn, profiles, password, privateKey, publicKey) if err != nil { t.Errorf("failed to wrap server: %v", err) return @@ -73,7 +92,7 @@ func TestHandshakeSuccess(t *testing.T) { } defer clientRaw.Close() - client, err := newClientConn(clientRaw, usernames, password, publicKey, "localhost") + client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost") if err != nil { t.Fatalf("failed to create client: %v", err) } @@ -103,7 +122,7 @@ func TestHandshakePasswordMismatch(t *testing.T) { clientPassword := "client-secret-123" serverPassword := "server-secret-456" - usernames := []string{"test_user"} + profiles := []loginProfile{testLoginProfile("test_user")} serverPrivateKey, serverPublicKey := deriveTestRSAKey(t, serverPassword) var wg sync.WaitGroup @@ -117,7 +136,7 @@ func TestHandshakePasswordMismatch(t *testing.T) { } defer rawConn.Close() - server, err := wrapConnServer(rawConn, serverPassword, serverPrivateKey, serverPublicKey) + server, err := wrapConnServer(rawConn, profiles, serverPassword, serverPrivateKey, serverPublicKey) if err != nil { // Wrapping is synchronous and shouldn't fail initially simply because key derivation works with any string t.Logf("wrapped server: %v", err) @@ -139,20 +158,233 @@ func TestHandshakePasswordMismatch(t *testing.T) { } defer clientRaw.Close() - client, err := newClientConn(clientRaw, usernames, clientPassword, serverPublicKey, "localhost") + client, err := newClientConn(clientRaw, profiles, clientPassword, serverPublicKey, "localhost") if err != nil { t.Fatalf("failed to create client: %v", err) } err = client.handshake() - if err != nil { - t.Fatalf("client handshake err: %v", err) + if err == nil { + t.Fatal("expected client handshake to fail due to password mismatch") } - _, _ = client.Write([]byte{0x1, 0x2, 0x3, 0x4}) - wg.Wait() // Check if we lost connection or received error t.Log("Handshake mismatch tested") } + +func TestHandshakeNetPipeWithKeepAlive(t *testing.T) { + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + const password = "net-pipe-shared-key" + profiles := []loginProfile{testLoginProfile("pipe_user")} + privateKey, publicKey := deriveTestRSAKey(t, password) + serverDone := make(chan error, 1) + + go func() { + server, err := wrapConnServer(serverRaw, profiles, password, privateKey, publicKey) + if err != nil { + serverDone <- err + return + } + + request := make([]byte, len("hello server")) + if _, err = io.ReadFull(server, request); err != nil { + serverDone <- fmt.Errorf("read request: %w", err) + return + } + if string(request) != "hello server" { + serverDone <- fmt.Errorf("unexpected request: %q", request) + return + } + + followupDone := make(chan error, 1) + go func() { + followup := make([]byte, len("after keepalive")) + _, readErr := io.ReadFull(server, followup) + if readErr == nil && string(followup) != "after keepalive" { + readErr = fmt.Errorf("unexpected followup: %q", followup) + } + followupDone <- readErr + }() + + if err = server.packet.writeKeepAlive(Long(42)); err != nil { + serverDone <- fmt.Errorf("write keep-alive: %w", err) + return + } + if _, err = server.Write([]byte("hello client")); err != nil { + serverDone <- fmt.Errorf("write response: %w", err) + return + } + serverDone <- <-followupDone + }() + + client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost") + if err != nil { + t.Fatal(err) + } + if _, err = client.Write([]byte("hello server")); err != nil { + t.Fatalf("write request: %v", err) + } + response := make([]byte, len("hello client")) + if _, err = io.ReadFull(client, response); err != nil { + t.Fatalf("read response: %v", err) + } + if string(response) != "hello client" { + t.Fatalf("unexpected response: %q", response) + } + if _, err = client.Write([]byte("after keepalive")); err != nil { + t.Fatalf("write followup: %v", err) + } + + select { + case err = <-serverDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("net.Pipe handshake timed out") + } +} + +func TestStatusQueryUnaffected(t *testing.T) { + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + const password = "status-shared-key" + profiles := []loginProfile{testLoginProfile("status_user")} + privateKey, publicKey := deriveTestRSAKey(t, password) + serverDone := make(chan error, 1) + go func() { + server, err := wrapConnServer(serverRaw, profiles, password, privateKey, publicKey) + if err == nil { + err = server.handshake() + } + serverDone <- err + }() + + protocolVersion := Varint(775) + serverAddress := String("localhost") + serverPort := UnsignedShort(25565) + nextState := Varint(1) + if err := writePacket(clientRaw, 0x00, &protocolVersion, &serverAddress, &serverPort, &nextState); err != nil { + t.Fatal(err) + } + if err := writePacket(clientRaw, 0x00); err != nil { + t.Fatal(err) + } + response, err := readPacket(clientRaw) + if err != nil { + t.Fatal(err) + } + if response.packetID != 0x00 { + t.Fatalf("status packet id = %d", response.packetID) + } + var responseJSON String + if err = response.readFields(&responseJSON); err != nil { + t.Fatal(err) + } + if string(responseJSON) != statusResponse { + t.Fatalf("status response = %q", responseJSON) + } + + payload := Long(0x0102030405060708) + if err = writePacket(clientRaw, 0x01, &payload); err != nil { + t.Fatal(err) + } + pong, err := readPacket(clientRaw) + if err != nil { + t.Fatal(err) + } + var receivedPayload Long + if pong.packetID != 0x01 { + t.Fatalf("pong packet id = %d", pong.packetID) + } + if err = pong.readFields(&receivedPayload); err != nil { + t.Fatal(err) + } + if receivedPayload != payload { + t.Fatalf("pong payload = %x", receivedPayload) + } + + select { + case err = <-serverDone: + if err == nil || !strings.Contains(err.Error(), "ping") { + t.Fatalf("server error = %v", err) + } + case <-time.After(time.Second): + t.Fatal("status handshake timed out") + } +} + +func TestClientHandshakeHonorsCallerDeadline(t *testing.T) { + clientRaw, serverRaw := net.Pipe() + defer clientRaw.Close() + defer serverRaw.Close() + + const password = "deadline-shared-key" + profiles := []loginProfile{testLoginProfile("deadline_user")} + _, publicKey := deriveTestRSAKey(t, password) + client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost") + if err != nil { + t.Fatal(err) + } + if err = client.SetDeadline(time.Now().Add(30 * time.Millisecond)); err != nil { + t.Fatal(err) + } + + started := time.Now() + _, err = client.Write([]byte("blocked")) + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("error = %v, want network timeout", err) + } + if elapsed := time.Since(started); elapsed > 500*time.Millisecond { + t.Fatalf("caller deadline took %s", elapsed) + } +} + +func TestClientCloseInterruptsHandshake(t *testing.T) { + clientRaw, serverRaw := net.Pipe() + defer serverRaw.Close() + + const password = "close-shared-key" + profiles := []loginProfile{testLoginProfile("close_user")} + _, publicKey := deriveTestRSAKey(t, password) + client, err := newClientConn(clientRaw, profiles, password, publicKey, "localhost") + if err != nil { + t.Fatal(err) + } + + done := make(chan error, 1) + go func() { + _, writeErr := client.Write([]byte("blocked")) + done <- writeErr + }() + time.Sleep(20 * time.Millisecond) + if err = client.Close(); err != nil { + t.Fatal(err) + } + + select { + case err = <-done: + if err == nil { + t.Fatal("handshake unexpectedly succeeded after close") + } + case <-time.After(time.Second): + t.Fatal("close did not interrupt handshake") + } +} + +func TestValidateLoginAcknowledgedPacketRejectsData(t *testing.T) { + if err := validateLoginAcknowledgedPacket(&mcPacket{packetID: 0x03}); err != nil { + t.Fatalf("valid login acknowledged packet: %v", err) + } + if err := validateLoginAcknowledgedPacket(&mcPacket{packetID: 0x03, data: []byte{0x00}}); err == nil { + t.Fatal("login acknowledged packet with trailing data was accepted") + } +} diff --git a/transport/internet/finalmask/xmc/packet_stream.go b/transport/internet/finalmask/xmc/packet_stream.go new file mode 100644 index 000000000..bb4f937eb --- /dev/null +++ b/transport/internet/finalmask/xmc/packet_stream.go @@ -0,0 +1,184 @@ +package xmc + +import ( + "bytes" + "fmt" + "io" + "sync" + "sync/atomic" + "time" +) + +const ( + configurationClientboundCustomPayload = 0x01 + configurationServerboundCustomPayload = 0x02 + configurationKeepAlive = 0x04 + + packetChannel = "xmc:data" + maxPacketData = 24 * 1024 + keepAlivePeriod = 15 * time.Second +) + +// packetStream carries the raw proxy byte stream in Minecraft configuration +// custom payload packets. The configuration state provides bidirectional +// payload packets and keep-alives without requiring version-specific world data. +type packetStream struct { + reader io.Reader + writer io.Writer + isClient bool + + readMu sync.Mutex + writeMu sync.Mutex + pending []byte + + keepAliveID atomic.Int64 + done chan struct{} + stopOnce sync.Once +} + +func newPacketStream(reader io.Reader, writer io.Writer, isClient bool) *packetStream { + s := &packetStream{ + reader: reader, + writer: writer, + isClient: isClient, + done: make(chan struct{}), + } + if !isClient { + go s.keepAliveLoop() + } + return s +} + +func (s *packetStream) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + s.readMu.Lock() + defer s.readMu.Unlock() + + if len(s.pending) > 0 { + n := copy(p, s.pending) + s.pending = s.pending[n:] + return n, nil + } + + for { + packet, err := readPacket(s.reader) + if err != nil { + return 0, fmt.Errorf("read minecraft packet stream: %w", err) + } + + if packet.packetID == s.remoteCustomPayloadID() { + payload, ok, err := parseCustomPayload(packet) + if err != nil { + return 0, err + } + if !ok || len(payload) == 0 { + continue + } + + n := copy(p, payload) + if n < len(payload) { + s.pending = append(s.pending[:0], payload[n:]...) + } + return n, nil + } + + if packet.packetID == configurationKeepAlive { + var id Long + if err := packet.readFields(&id); err != nil { + return 0, fmt.Errorf("read minecraft keep-alive: %w", err) + } + if s.isClient { + if err := s.writeKeepAlive(id); err != nil { + return 0, err + } + } + } + } +} + +func (s *packetStream) Write(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + + s.writeMu.Lock() + defer s.writeMu.Unlock() + + written := 0 + for written < len(p) { + end := written + maxPacketData + if end > len(p) { + end = len(p) + } + channel := String(packetChannel) + payload := RestBytes(p[written:end]) + if err := writePacket(s.writer, s.localCustomPayloadID(), &channel, &payload); err != nil { + return written, fmt.Errorf("write minecraft custom payload: %w", err) + } + written = end + } + + return written, nil +} + +func (s *packetStream) Stop() { + s.stopOnce.Do(func() { close(s.done) }) +} + +func (s *packetStream) localCustomPayloadID() int { + if s.isClient { + return configurationServerboundCustomPayload + } + return configurationClientboundCustomPayload +} + +func (s *packetStream) remoteCustomPayloadID() int { + if s.isClient { + return configurationClientboundCustomPayload + } + return configurationServerboundCustomPayload +} + +func parseCustomPayload(packet *mcPacket) ([]byte, bool, error) { + r := bytes.NewReader(packet.data) + var channel String + if err := channel.readFrom(r); err != nil { + return nil, false, fmt.Errorf("read minecraft custom payload channel: %w", err) + } + if string(channel) != packetChannel { + return nil, false, nil + } + payload := make([]byte, r.Len()) + if _, err := io.ReadFull(r, payload); err != nil { + return nil, false, fmt.Errorf("read minecraft custom payload data: %w", err) + } + return payload, true, nil +} + +func (s *packetStream) writeKeepAlive(id Long) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + if err := writePacket(s.writer, configurationKeepAlive, &id); err != nil { + return fmt.Errorf("write minecraft keep-alive: %w", err) + } + return nil +} + +func (s *packetStream) keepAliveLoop() { + ticker := time.NewTicker(keepAlivePeriod) + defer ticker.Stop() + for { + select { + case <-ticker.C: + id := Long(s.keepAliveID.Add(1)) + if err := s.writeKeepAlive(id); err != nil { + return + } + case <-s.done: + return + } + } +} diff --git a/transport/internet/finalmask/xmc/packet_stream_test.go b/transport/internet/finalmask/xmc/packet_stream_test.go new file mode 100644 index 000000000..ea605249f --- /dev/null +++ b/transport/internet/finalmask/xmc/packet_stream_test.go @@ -0,0 +1,102 @@ +package xmc + +import ( + "bytes" + "io" + "net" + "testing" +) + +func TestPacketStreamUsesPlainFraming(t *testing.T) { + payload := []byte("hello") + var wire bytes.Buffer + stream := newPacketStream(bytes.NewReader(nil), &wire, true) + + written, err := stream.Write(payload) + if err != nil { + t.Fatalf("write payload: %v", err) + } + if written != len(payload) { + t.Fatalf("written = %d, want %d", written, len(payload)) + } + wantOutbound := []byte{0x0f, 0x02, 0x08, 'x', 'm', 'c', ':', 'd', 'a', 't', 'a', 'h', 'e', 'l', 'l', 'o'} + if !bytes.Equal(wire.Bytes(), wantOutbound) { + t.Fatalf("wire frame = %x, want %x", wire.Bytes(), wantOutbound) + } + + wantInbound := append([]byte(nil), wantOutbound...) + wantInbound[1] = configurationClientboundCustomPayload + reader := newPacketStream(bytes.NewReader(wantInbound), io.Discard, true) + got := make([]byte, len(payload)) + if _, err = io.ReadFull(reader, got); err != nil { + t.Fatalf("read payload: %v", err) + } + if !bytes.Equal(got, payload) { + t.Fatalf("payload = %q, want %q", got, payload) + } +} + +func TestPacketStreamRoundTrip(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() + + const password = "packet-stream-shared-key" + privateKey, publicKey := deriveTestRSAKey(t, password) + profiles := []loginProfile{testLoginProfile("packet_user")} + clientPayload := bytes.Repeat([]byte("client-payload-"), 5000) + serverPayload := bytes.Repeat([]byte("server-payload-"), 5000) + serverDone := make(chan error, 1) + + go func() { + rawConn, acceptErr := ln.Accept() + if acceptErr != nil { + serverDone <- acceptErr + return + } + defer rawConn.Close() + + server, wrapErr := wrapConnServer(rawConn, profiles, password, privateKey, publicKey) + if wrapErr != nil { + serverDone <- wrapErr + return + } + got := make([]byte, len(clientPayload)) + if _, readErr := io.ReadFull(server, got); readErr != nil { + serverDone <- readErr + return + } + if !bytes.Equal(got, clientPayload) { + serverDone <- io.ErrUnexpectedEOF + return + } + _, writeErr := server.Write(serverPayload) + serverDone <- writeErr + }() + + rawClient, err := net.Dial("tcp", ln.Addr().String()) + if err != nil { + t.Fatal(err) + } + defer rawClient.Close() + + client, err := newClientConn(rawClient, profiles, password, publicKey, "localhost") + if err != nil { + t.Fatal(err) + } + if _, err = client.Write(clientPayload); err != nil { + t.Fatalf("write payload: %v", err) + } + got := make([]byte, len(serverPayload)) + if _, err = io.ReadFull(client, got); err != nil { + t.Fatalf("read payload: %v", err) + } + if !bytes.Equal(got, serverPayload) { + t.Fatal("server payload mismatch") + } + if err = <-serverDone; err != nil { + t.Fatalf("server: %v", err) + } +} diff --git a/transport/internet/finalmask/xmc/padding.go b/transport/internet/finalmask/xmc/padding.go new file mode 100644 index 000000000..eb7114a09 --- /dev/null +++ b/transport/internet/finalmask/xmc/padding.go @@ -0,0 +1,432 @@ +package xmc + +import ( + "bytes" + "crypto/rand" + "fmt" + "io" + "math/big" + "time" +) + +type paddingDirection uint8 + +const ( + paddingClientToServer paddingDirection = iota + 1 + paddingServerToClient + + paddingBufferLength = 16 * 1024 + maxPaddingChunkLength = 48 * 1024 + maxPaddingTurnLength = 8 * 1024 * 1024 +) + +type paddingVariant struct { + chunks []int + delays []paddingDelayRange +} + +type paddingDelayRange struct { + min time.Duration + max time.Duration +} + +type paddingTurn struct { + direction paddingDirection + minLength int + maxLength int + variants []paddingVariant + startDelay paddingDelayRange + chunkDelay paddingDelayRange + writeChunkMinLength int + writeChunkLength int + sendMinLength int + sendMaxLength int + sendVariants []int +} + +func runPaddingSchedule(reader io.Reader, writer io.Writer, isClient bool, firstTurnPrefixLength int, schedule []paddingTurn) error { + if err := validatePaddingSchedule(schedule, firstTurnPrefixLength); err != nil { + return err + } + + var writeBuffer []byte + for i, turn := range schedule { + prefixLength := 0 + if i == 0 { + prefixLength = firstTurnPrefixLength + } + + localSends := isClient == (turn.direction == paddingClientToServer) + if localSends { + if err := writePaddingTurnWithBuffer(writer, turn, prefixLength, time.Sleep, &writeBuffer); err != nil { + return fmt.Errorf("write padding turn %d: %w", i, err) + } + continue + } + if err := readPaddingTurn(reader, turn, prefixLength); err != nil { + return fmt.Errorf("read padding turn %d: %w", i, err) + } + } + return nil +} + +func validatePaddingSchedule(schedule []paddingTurn, firstTurnPrefixLength int) error { + if len(schedule) == 0 { + return fmt.Errorf("empty padding schedule") + } + if firstTurnPrefixLength < 0 { + return fmt.Errorf("negative first turn prefix length: %d", firstTurnPrefixLength) + } + if firstTurnPrefixLength > 0 && schedule[0].direction != paddingClientToServer { + return fmt.Errorf("first prefixed padding turn is not client-to-server") + } + + for i, turn := range schedule { + if turn.direction != paddingClientToServer && turn.direction != paddingServerToClient { + return fmt.Errorf("padding turn %d has invalid direction: %d", i, turn.direction) + } + if err := validatePaddingDelayRange(turn.startDelay); err != nil { + return fmt.Errorf("padding turn %d has an invalid start delay: %w", i, err) + } + if err := validatePaddingDelayRange(turn.chunkDelay); err != nil { + return fmt.Errorf("padding turn %d has an invalid chunk delay: %w", i, err) + } + if turn.writeChunkMinLength < 0 || turn.writeChunkLength < turn.writeChunkMinLength || turn.writeChunkLength > maxPaddingChunkLength { + return fmt.Errorf("padding turn %d has an invalid write chunk range: %d-%d", i, turn.writeChunkMinLength, turn.writeChunkLength) + } + if len(turn.variants) > 0 && turn.writeChunkLength != 0 { + return fmt.Errorf("padding turn %d combines variants with generated write chunks", i) + } + + minLength, maxLength, err := paddingTurnBounds(turn) + if err != nil { + return fmt.Errorf("padding turn %d: %w", i, err) + } + hasSendRange := turn.sendMinLength != 0 || turn.sendMaxLength != 0 + if hasSendRange { + if len(turn.variants) > 0 { + return fmt.Errorf("padding turn %d combines variants with a send range", i) + } + if turn.sendMinLength < minLength || turn.sendMaxLength < turn.sendMinLength || turn.sendMaxLength > maxLength { + return fmt.Errorf("padding turn %d has an invalid send range: %d-%d", i, turn.sendMinLength, turn.sendMaxLength) + } + } + if i == 0 && minLength-firstTurnPrefixLength < 1 { + return fmt.Errorf("padding turn 0 is too short for %d prefix bytes", firstTurnPrefixLength) + } + if i == 0 && len(turn.variants) > 0 { + for j, variant := range turn.variants { + if _, _, err = trimPaddingPrefix(variant, firstTurnPrefixLength); err != nil { + return fmt.Errorf("padding turn 0 variant %d: %w", j, err) + } + } + } + if i > 0 && turn.direction == schedule[i-1].direction { + return fmt.Errorf("padding turns %d and %d have the same direction", i-1, i) + } + } + return nil +} + +func writePaddingTurn(w io.Writer, turn paddingTurn, prefixLength int) error { + return writePaddingTurnWithSleep(w, turn, prefixLength, time.Sleep) +} + +func writePaddingTurnWithSleep(w io.Writer, turn paddingTurn, prefixLength int, sleep func(time.Duration)) error { + return writePaddingTurnWithBuffer(w, turn, prefixLength, sleep, nil) +} + +func writePaddingTurnWithBuffer(w io.Writer, turn paddingTurn, prefixLength int, sleep func(time.Duration), reusableBuffer *[]byte) error { + startDelay, err := randomPaddingDelay(turn.startDelay) + if err != nil { + return fmt.Errorf("select padding start delay: %w", err) + } + if startDelay > 0 { + sleep(startDelay) + } + + targetLength, chunks, delays, err := selectPaddingVariant(turn, prefixLength) + if err != nil { + return err + } + recordLength := targetLength - prefixLength + if recordLength < 1 { + return fmt.Errorf("target length %d leaves an invalid record length %d", targetLength, recordLength) + } + + encodedLength := Varint(recordLength) + var header bytes.Buffer + if err = encodedLength.writeTo(&header); err != nil { + return fmt.Errorf("write padding header: %w", err) + } + if len(chunks) == 0 { + writeChunkLength := turn.writeChunkLength + if writeChunkLength == 0 { + writeChunkLength = paddingBufferLength + } else if turn.writeChunkMinLength > 0 { + writeChunkLength, err = randomPaddingTarget(turn.writeChunkMinLength, writeChunkLength) + if err != nil { + return fmt.Errorf("select padding write chunk length: %w", err) + } + } + chunks = defaultPaddingChunks(recordLength, writeChunkLength) + delays = make([]paddingDelayRange, len(chunks)) + for i := 1; i < len(delays); i++ { + delays[i] = turn.chunkDelay + } + } + if chunks[0] < header.Len() { + return fmt.Errorf("first padding chunk %d is shorter than header %d", chunks[0], header.Len()) + } + + maxChunkLength := 0 + for _, chunkLength := range chunks { + if chunkLength < 1 || chunkLength > maxPaddingChunkLength { + return fmt.Errorf("invalid padding chunk length: %d", chunkLength) + } + maxChunkLength = max(maxChunkLength, chunkLength) + } + var buffer []byte + if reusableBuffer == nil { + buffer = make([]byte, maxChunkLength) + } else { + if cap(*reusableBuffer) < maxChunkLength { + *reusableBuffer = make([]byte, maxChunkLength) + } + buffer = (*reusableBuffer)[:maxChunkLength] + clear(buffer) + } + copy(buffer, header.Bytes()) + written := 0 + for i, chunkLength := range chunks { + if i < len(delays) { + delay, delayErr := randomPaddingDelay(delays[i]) + if delayErr != nil { + return fmt.Errorf("select padding chunk %d delay: %w", i, delayErr) + } + if delay > 0 { + sleep(delay) + } + } + if err = writeFull(w, buffer[:chunkLength]); err != nil { + return fmt.Errorf("write padding chunk %d: %w", i, err) + } + written += chunkLength + if i == 0 { + clear(buffer[:header.Len()]) + } + } + if written != recordLength { + return fmt.Errorf("padding chunks total %d, want %d", written, recordLength) + } + return nil +} + +func readPaddingTurn(r io.Reader, turn paddingTurn, prefixLength int) error { + encodedLength, headerLength, err := readVarintWithLength(r) + if err != nil { + return fmt.Errorf("read padding header: %w", err) + } + recordLength := int(encodedLength) + if recordLength < headerLength || recordLength > maxPaddingTurnLength { + return fmt.Errorf("invalid padding record length: %d", recordLength) + } + totalLength := prefixLength + recordLength + if !paddingTurnAcceptsLength(turn, totalLength) { + if len(turn.variants) > 0 { + return fmt.Errorf("padding turn length %d is not an allowed variant", totalLength) + } + return fmt.Errorf("padding turn length %d is outside %d-%d", totalLength, turn.minLength, turn.maxLength) + } + + var buffer [paddingBufferLength]byte + remaining := recordLength - headerLength + for remaining > 0 { + chunkLength := min(remaining, len(buffer)) + if _, err := io.ReadFull(r, buffer[:chunkLength]); err != nil { + return fmt.Errorf("read padding body: %w", err) + } + remaining -= chunkLength + } + return nil +} + +func selectPaddingVariant(turn paddingTurn, prefixLength int) (int, []int, []paddingDelayRange, error) { + if len(turn.variants) == 0 { + minimum, maximum := turn.minLength, turn.maxLength + if turn.sendMinLength != 0 || turn.sendMaxLength != 0 { + minimum, maximum = turn.sendMinLength, turn.sendMaxLength + } + targetLength, err := randomPaddingTarget(minimum, maximum) + return targetLength, nil, nil, err + } + + indices := turn.sendVariants + if len(indices) == 0 { + indices = make([]int, len(turn.variants)) + for i := range indices { + indices[i] = i + } + } + selected, err := randomPaddingIndex(len(indices)) + if err != nil { + return 0, nil, nil, err + } + variantIndex := indices[selected] + if variantIndex < 0 || variantIndex >= len(turn.variants) { + return 0, nil, nil, fmt.Errorf("invalid send variant index: %d", variantIndex) + } + variant := turn.variants[variantIndex] + targetLength := paddingVariantLength(variant) + chunks, delays, err := trimPaddingPrefix(variant, prefixLength) + if err != nil { + return 0, nil, nil, err + } + return targetLength, chunks, delays, nil +} + +func trimPaddingPrefix(variant paddingVariant, prefixLength int) ([]int, []paddingDelayRange, error) { + remainingPrefix := prefixLength + firstChunk := 0 + for firstChunk < len(variant.chunks) && remainingPrefix > 0 { + chunkLength := variant.chunks[firstChunk] + if remainingPrefix < chunkLength { + return nil, nil, fmt.Errorf("prefix length %d splits chunk %d", prefixLength, firstChunk) + } + remainingPrefix -= chunkLength + firstChunk++ + } + if remainingPrefix != 0 || firstChunk == len(variant.chunks) { + return nil, nil, fmt.Errorf("prefix length %d leaves no padding record", prefixLength) + } + + chunks := append([]int(nil), variant.chunks[firstChunk:]...) + delays := make([]paddingDelayRange, len(chunks)) + if len(variant.delays) > 0 { + copy(delays, variant.delays[firstChunk:]) + } + return chunks, delays, nil +} + +func defaultPaddingChunks(recordLength, writeChunkLength int) []int { + chunks := make([]int, 0, (recordLength+writeChunkLength-1)/writeChunkLength) + for remaining := recordLength; remaining > 0; { + chunkLength := min(remaining, writeChunkLength) + chunks = append(chunks, chunkLength) + remaining -= chunkLength + } + return chunks +} + +func paddingTurnBounds(turn paddingTurn) (int, int, error) { + if len(turn.variants) == 0 { + if turn.minLength < 1 || turn.maxLength < turn.minLength || turn.maxLength > maxPaddingTurnLength { + return 0, 0, fmt.Errorf("invalid range: %d-%d", turn.minLength, turn.maxLength) + } + return turn.minLength, turn.maxLength, nil + } + if turn.minLength != 0 || turn.maxLength != 0 { + return 0, 0, fmt.Errorf("variants cannot be combined with a length range") + } + + minLength := maxPaddingTurnLength + 1 + maxLength := 0 + for i, variant := range turn.variants { + if len(variant.chunks) == 0 { + return 0, 0, fmt.Errorf("variant %d has no chunks", i) + } + if len(variant.delays) != 0 && len(variant.delays) != len(variant.chunks) { + return 0, 0, fmt.Errorf("variant %d has %d chunks and %d delays", i, len(variant.chunks), len(variant.delays)) + } + for j, chunkLength := range variant.chunks { + if chunkLength < 1 || chunkLength > maxPaddingChunkLength { + return 0, 0, fmt.Errorf("variant %d chunk %d has invalid length: %d", i, j, chunkLength) + } + if len(variant.delays) > 0 { + if err := validatePaddingDelayRange(variant.delays[j]); err != nil { + return 0, 0, fmt.Errorf("variant %d chunk %d has an invalid delay: %w", i, j, err) + } + } + } + length := paddingVariantLength(variant) + if length > maxPaddingTurnLength { + return 0, 0, fmt.Errorf("variant %d is too long: %d", i, length) + } + minLength = min(minLength, length) + maxLength = max(maxLength, length) + } + for _, index := range turn.sendVariants { + if index < 0 || index >= len(turn.variants) { + return 0, 0, fmt.Errorf("invalid send variant index: %d", index) + } + } + return minLength, maxLength, nil +} + +func paddingTurnAcceptsLength(turn paddingTurn, length int) bool { + if len(turn.variants) == 0 { + return length >= turn.minLength && length <= turn.maxLength + } + for _, variant := range turn.variants { + if paddingVariantLength(variant) == length { + return true + } + } + return false +} + +func paddingVariantLength(variant paddingVariant) int { + total := 0 + for _, chunkLength := range variant.chunks { + total += chunkLength + } + return total +} + +func validatePaddingDelayRange(delay paddingDelayRange) error { + if delay.min < 0 || delay.max < delay.min { + return fmt.Errorf("invalid range: %s-%s", delay.min, delay.max) + } + return nil +} + +func randomPaddingDelay(delay paddingDelayRange) (time.Duration, error) { + if err := validatePaddingDelayRange(delay); err != nil { + return 0, err + } + if delay.min == delay.max { + return delay.min, nil + } + span := int64(delay.max-delay.min) + 1 + offset, err := rand.Int(rand.Reader, big.NewInt(span)) + if err != nil { + return 0, fmt.Errorf("select padding delay: %w", err) + } + return delay.min + time.Duration(offset.Int64()), nil +} + +func randomPaddingIndex(length int) (int, error) { + if length < 1 { + return 0, fmt.Errorf("select from empty padding choices") + } + if length == 1 { + return 0, nil + } + index, err := rand.Int(rand.Reader, big.NewInt(int64(length))) + if err != nil { + return 0, fmt.Errorf("select padding choice: %w", err) + } + return int(index.Int64()), nil +} + +func randomPaddingTarget(minLength, maxLength int) (int, error) { + if minLength == maxLength { + return minLength, nil + } + span := int64(maxLength-minLength) + 1 + offset, err := rand.Int(rand.Reader, big.NewInt(span)) + if err != nil { + return 0, fmt.Errorf("select padding length: %w", err) + } + return minLength + int(offset.Int64()), nil +} diff --git a/transport/internet/finalmask/xmc/padding_preset.go b/transport/internet/finalmask/xmc/padding_preset.go new file mode 100644 index 000000000..b21015545 --- /dev/null +++ b/transport/internet/finalmask/xmc/padding_preset.go @@ -0,0 +1,282 @@ +package xmc + +import ( + "time" +) + +// Length and write-boundary templates come from controlled Minecraft 26.1.2 +// logins. Timing deliberately uses broad random bands that preserve only the +// rough ordering of short and long phases; it does not replay captured delays. +var startupPaddingSchedule2612 = []paddingTurn{ + { + direction: paddingClientToServer, + variants: []paddingVariant{ + paddingVariantFromChunks(2, 26, 16), + }, + }, + { + direction: paddingServerToClient, + variants: []paddingVariant{ + paddingVariantFromChunks(26, 21, 25), + }, + startDelay: millisecondRange(0, 20), + }, + { + direction: paddingClientToServer, + variants: []paddingVariant{ + paddingVariantFromChunks(25), + }, + startDelay: millisecondRange(2, 22), + }, + { + direction: paddingServerToClient, + variants: []paddingVariant{ + registryPaddingVariant(), + }, + startDelay: millisecondRange(20, 50), + }, + { + direction: paddingClientToServer, + variants: []paddingVariant{ + paddingVariantFromChunks(2), + }, + startDelay: millisecondRange(10, 35), + }, + { + direction: paddingServerToClient, + variants: []paddingVariant{ + playStartPaddingVariant(4941, 252, 259, 267, 268, 251, 303, 259, 264, 54, 346), + playStartPaddingVariant(4941, 262, 284, 272, 260, 260, 313, 264, 151, 224, 207, 215, 224, 390), + playStartPaddingVariant(4941, 257, 272, 275, 260, 260, 313, 283, 274, 226, 207, 230, 215, 204, 221, 352), + playStartPaddingVariant(4941, 259, 272, 288, 260, 260, 311, 270, 70, 236, 223, 201, 210, 352), + playStartPaddingVariant(4941, 255, 269, 277, 263, 260, 136, 207, 210, 232, 325), + playStartPaddingVariant(4941, 259, 270, 274, 263, 258, 327, 170, 210, 375), + playStartPaddingVariant(4941, 257, 275, 291, 260, 260, 325, 269, 70, 230, 226, 207, 221, 352), + playStartPaddingVariant(4941, 252, 273, 262, 252, 254, 306, 93), + playStartPaddingVariant(4941, 273, 270, 269, 258, 256, 322, 221, 207, 215, 438), + playStartPaddingVariant(4941, 259, 275, 274, 250, 258, 308, 267, 154, 233, 209, 207, 213, 393), + playStartPaddingVariant(4941, 254, 267, 272, 260, 253, 311, 167, 204, 232, 207, 481, 8), + playStartPaddingVariant(4941, 259, 269, 272, 261, 313, 207, 213, 500, 19), + playStartPaddingVariant(4941, 262, 269, 274, 263, 274, 311, 270, 242, 210, 229, 221, 210, 431), + playStartPaddingVariant(4941, 259, 265, 277, 263, 277, 316, 269, 156, 204, 210, 226, 207, 413), + playStartPaddingVariant(4941, 215, 251, 249, 317, 260, 270, 249, 52), + playStartPaddingVariant(4941, 224, 263, 277, 316, 267, 272, 260, 138, 230, 226, 207, 204, 352), + playStartPaddingVariant(4941, 221, 258, 263, 319, 269, 288, 263, 136, 204, 210, 220, 207, 378), + playStartPaddingVariant(4941, 221, 258, 260, 316, 273, 291, 226, 204, 229, 213, 489, 8), + playStartPaddingVariant(4941, 238, 260, 261, 306, 272, 277, 260, 224, 241, 212, 207, 204, 393), + playStartPaddingVariant(4941, 224, 260, 260, 309, 272, 277, 277, 138, 207, 207, 212, 241, 352), + }, + startDelay: millisecondRange(35, 50), + }, +} + +// These turns cover the finite Play-state tail through the client's +// player_loaded packet. Bounds are the observed per-turn minima and maxima +// across 20 controlled 26.1.2 logins; payload bytes remain opaque padding. +var playJoinPaddingSchedule2612 = []paddingTurn{ + clientPlayPaddingTurn(6, 883), + serverPlayPaddingTurn(346, 58638), + clientPlayPaddingTurn(6, 887), + serverPlayPaddingTurn(388, 61077), + clientPlayPaddingTurn(2, 50), + serverPlayPaddingTurn(575, 65584), + clientPlayPaddingTurn(6, 45), + serverPlayPaddingTurn(86, 63563), + clientPlayPaddingTurn(2, 44), + serverPlayPaddingTurn(42, 51983), + clientPlayPaddingTurn(2, 851), + serverPlayPaddingTurn(309, 25083), + clientPlayPaddingTurn(2, 19), + serverPlayPaddingTurn(74, 63885), + clientPlayPaddingTurn(8, 24), + serverPlayPaddingTurn(30, 66128), + clientPlayPaddingTurn(2, 19), + serverPlayPaddingTurn(26, 35818), + clientPlayPaddingTurn(6, 19), + serverPlayPaddingTurn(35, 59407), + clientPlayPaddingTurn(6, 19), + serverPlayPaddingTurn(37, 65328), + clientPlayPaddingTurn(2, 19), + serverPlayPaddingTurn(26, 60622), + clientPlayPaddingTurn(6, 19), + serverPlayPaddingTurn(11, 60808), + clientPlayPaddingTurn(8, 43), + serverPlayPaddingTurn(55, 62027), + clientPlayPaddingTurn(2, 19), + serverPlayPaddingTurn(427, 65622), + clientPlayPaddingTurn(5, 19), + serverPlayPaddingTurn(35, 59401), + clientPlayPaddingTurn(6, 19), +} + +type paddingLengthRange2612 struct { + minimum int + maximum int +} + +type serverPlayLengthBranches2612 struct { + small paddingLengthRange2612 + large paddingLengthRange2612 +} + +var serverPlayBranches2612 = []serverPlayLengthBranches2612{ + {small: paddingLengthRange2612{346, 18812}, large: paddingLengthRange2612{51702, 58638}}, + {small: paddingLengthRange2612{388, 20689}, large: paddingLengthRange2612{51445, 61077}}, + {small: paddingLengthRange2612{575, 20915}, large: paddingLengthRange2612{41428, 65584}}, + {small: paddingLengthRange2612{86, 2772}, large: paddingLengthRange2612{41428, 63563}}, + {small: paddingLengthRange2612{42, 26813}, large: paddingLengthRange2612{51983, 51983}}, + {small: paddingLengthRange2612{309, 19484}, large: paddingLengthRange2612{24837, 25083}}, + {small: paddingLengthRange2612{74, 40686}, large: paddingLengthRange2612{63885, 63885}}, + {small: paddingLengthRange2612{30, 44114}, large: paddingLengthRange2612{66128, 66128}}, + {small: paddingLengthRange2612{26, 1464}, large: paddingLengthRange2612{9941, 35818}}, + {small: paddingLengthRange2612{35, 42885}, large: paddingLengthRange2612{52194, 59407}}, + {small: paddingLengthRange2612{37, 47553}, large: paddingLengthRange2612{61765, 65328}}, + {small: paddingLengthRange2612{26, 1121}, large: paddingLengthRange2612{16162, 60622}}, + {small: paddingLengthRange2612{11, 45629}, large: paddingLengthRange2612{60808, 60808}}, + {small: paddingLengthRange2612{55, 10035}, large: paddingLengthRange2612{30237, 62027}}, + {small: paddingLengthRange2612{427, 52536}, large: paddingLengthRange2612{64014, 65622}}, + {small: paddingLengthRange2612{35, 22708}, large: paddingLengthRange2612{38987, 59401}}, +} + +// Each mask preserves only the small/large branch order from one baseline +// login. Actual lengths and timing are selected randomly inside each branch. +var serverPlayBranchMasks2612 = []uint32{ + 0x011c, 0x090a, 0x0821, 0xe921, 0x2102, + 0x0844, 0xa101, 0x1106, 0x2e00, 0xab01, + 0xe900, 0xac01, 0xab01, 0x8b80, 0x0808, + 0x2001, 0x0901, 0x000a, 0x2c01, 0x0801, +} + +type clientPlayBurst2612 struct { + playIndex int + regular paddingLengthRange2612 + burst paddingLengthRange2612 +} + +var clientPlayBursts2612 = []clientPlayBurst2612{ + {playIndex: 0, regular: paddingLengthRange2612{6, 44}, burst: paddingLengthRange2612{877, 883}}, + {playIndex: 2, regular: paddingLengthRange2612{6, 45}, burst: paddingLengthRange2612{884, 887}}, + {playIndex: 10, regular: paddingLengthRange2612{2, 19}, burst: paddingLengthRange2612{851, 851}}, +} + +// The 20 samples placed the one client initialization burst in these slots. +var clientPlayBurstChoices2612 = []int{ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, + 2, +} + +var paddingSchedule2612 = buildPaddingSchedule2612() + +func buildPaddingSchedule2612() []paddingTurn { + schedule := make([]paddingTurn, 0, len(startupPaddingSchedule2612)+len(playJoinPaddingSchedule2612)) + schedule = append(schedule, startupPaddingSchedule2612...) + schedule = append(schedule, playJoinPaddingSchedule2612...) + return schedule +} + +func clientPlayPaddingTurn(minimum, maximum int) paddingTurn { + return paddingTurn{ + direction: paddingClientToServer, + minLength: minimum, + maxLength: maximum, + startDelay: millisecondRange(1, 30), + writeChunkLength: 1024, + } +} + +func serverPlayPaddingTurn(minimum, maximum int) paddingTurn { + return paddingTurn{ + direction: paddingServerToClient, + minLength: minimum, + maxLength: maximum, + startDelay: millisecondRange(1, 45), + chunkDelay: millisecondRange(1, 4), + writeChunkMinLength: 32 * 1024, + writeChunkLength: maxPaddingChunkLength, + } +} + +func newClientPaddingSchedule2612() ([]paddingTurn, error) { + choice, err := randomPaddingIndex(len(clientPlayBurstChoices2612)) + if err != nil { + return nil, err + } + selectedBurst := clientPlayBurstChoices2612[choice] + schedule := append([]paddingTurn(nil), paddingSchedule2612...) + for i, burst := range clientPlayBursts2612 { + lengthRange := burst.regular + if i == selectedBurst { + lengthRange = burst.burst + } + turn := &schedule[len(startupPaddingSchedule2612)+burst.playIndex] + turn.sendMinLength = lengthRange.minimum + turn.sendMaxLength = lengthRange.maximum + } + return schedule, nil +} + +type paddingPause struct { + chunk int + delay paddingDelayRange +} + +func paddingVariantFromChunks(chunks ...int) paddingVariant { + return paddingVariant{chunks: chunks} +} + +func pacedPaddingVariant(chunks []int, pauses ...paddingPause) paddingVariant { + delays := make([]paddingDelayRange, len(chunks)) + for _, pause := range pauses { + if pause.chunk < 0 || pause.chunk >= len(delays) { + panic("xmc: padding pause index is outside its chunk template") + } + delays[pause.chunk] = pause.delay + } + return paddingVariant{chunks: chunks, delays: delays} +} + +func registryPaddingVariant() paddingVariant { + return pacedPaddingVariant( + []int{1590, 226, 329, 229, 186, 151, 78, 81, 79, 235, 67, 67, 78, 71, 82, 74, 982, 117, 1118, 1038, 970, 400, 239, 49, 50, 95, 65, 104, 32320, 2}, + paddingPause{28, millisecondRange(1, 4)}, + paddingPause{29, millisecondRange(44, 61)}, + ) +} + +func playStartPaddingVariant(chunks ...int) paddingVariant { + if len(chunks) < 2 { + panic("xmc: play start padding variant needs at least two chunks") + } + return pacedPaddingVariant( + chunks, + paddingPause{len(chunks) / 2, millisecondRange(1, 5)}, + paddingPause{len(chunks) - 1, millisecondRange(9, 20)}, + ) +} + +func millisecondRange(minimum, maximum int) paddingDelayRange { + return paddingDelayRange{ + min: time.Duration(minimum) * time.Millisecond, + max: time.Duration(maximum) * time.Millisecond, + } +} + +func newServerPaddingSchedule2612() ([]paddingTurn, error) { + schedule := append([]paddingTurn(nil), paddingSchedule2612...) + profileIndex, err := randomPaddingIndex(len(serverPlayBranchMasks2612)) + if err != nil { + return nil, err + } + profile := serverPlayBranchMasks2612[profileIndex] + for i, branches := range serverPlayBranches2612 { + lengthRange := branches.small + if profile&(1< turn.maxLength { + t.Fatalf("padding length = %d", encoded.Len()) + } + seen[encoded.Len()] = true + } + if len(seen) < 2 { + t.Fatalf("padding range did not vary: %v", seen) + } +} + +func TestPaddingTurnUsesRestrictedSendRange(t *testing.T) { + turn := paddingTurn{ + direction: paddingServerToClient, + minLength: 3, + maxLength: 100, + sendMinLength: 90, + sendMaxLength: 100, + } + seen := make(map[int]bool) + for range 100 { + var encoded bytes.Buffer + if err := writePaddingTurn(&encoded, turn, 0); err != nil { + t.Fatal(err) + } + if encoded.Len() < turn.sendMinLength || encoded.Len() > turn.sendMaxLength { + t.Fatalf("padding length = %d", encoded.Len()) + } + seen[encoded.Len()] = true + if err := readPaddingTurn(bytes.NewReader(encoded.Bytes()), turn, 0); err != nil { + t.Fatal(err) + } + } + if len(seen) < 2 { + t.Fatalf("restricted send range did not vary: %v", seen) + } +} + +func TestPaddingScheduleSynchronizesDirections(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + schedule := []paddingTurn{ + {direction: paddingClientToServer, minLength: 33, maxLength: 33}, + {direction: paddingServerToClient, minLength: 4097, maxLength: 4097}, + {direction: paddingClientToServer, minLength: 16385, maxLength: 16385}, + } + serverDone := make(chan error, 1) + go func() { + serverDone <- runPaddingSchedule(server, server, false, 3, schedule) + }() + if err := runPaddingSchedule(client, client, true, 3, schedule); err != nil { + t.Fatal(err) + } + select { + case err := <-serverDone: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("server padding schedule did not complete") + } +} + +func TestReadPaddingTurnHandlesFragmentedInput(t *testing.T) { + turn := paddingTurn{direction: paddingClientToServer, minLength: 1024, maxLength: 1024} + var encoded bytes.Buffer + if err := writePaddingTurn(&encoded, turn, 0); err != nil { + t.Fatal(err) + } + if err := readPaddingTurn(&oneByteReader{reader: bytes.NewReader(encoded.Bytes())}, turn, 0); err != nil { + t.Fatal(err) + } +} + +func TestReadPaddingTurnRejectsInvalidLength(t *testing.T) { + turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 96} + data := encodePaddingLength(t, 63) + if err := readPaddingTurn(bytes.NewReader(data), turn, 0); err == nil || !strings.Contains(err.Error(), "outside") { + t.Fatalf("error = %v", err) + } +} + +func TestReadPaddingTurnRejectsNonCanonicalHeader(t *testing.T) { + turn := paddingTurn{direction: paddingClientToServer, minLength: 3, maxLength: 3} + err := readPaddingTurn(bytes.NewReader([]byte{0x83, 0x00, 0x00}), turn, 0) + if err == nil || !strings.Contains(err.Error(), "non-canonical") { + t.Fatalf("error = %v", err) + } +} + +func TestReadPaddingTurnRejectsTruncatedBody(t *testing.T) { + turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 64} + data := encodePaddingLength(t, 64) + if err := readPaddingTurn(bytes.NewReader(data), turn, 0); err == nil || !strings.Contains(err.Error(), "body") { + t.Fatalf("error = %v", err) + } +} + +func TestReadPaddingTurnHonorsConnectionTimeout(t *testing.T) { + client, server := net.Pipe() + defer client.Close() + defer server.Close() + + if err := server.SetReadDeadline(time.Now().Add(20 * time.Millisecond)); err != nil { + t.Fatal(err) + } + turn := paddingTurn{direction: paddingClientToServer, minLength: 64, maxLength: 64} + err := readPaddingTurn(server, turn, 0) + var netErr net.Error + if !errors.As(err, &netErr) || !netErr.Timeout() { + t.Fatalf("error = %v, want network timeout", err) + } +} + +func TestValidatePaddingSchedule(t *testing.T) { + tests := []struct { + name string + schedule []paddingTurn + prefix int + }{ + {name: "empty"}, + {name: "bad direction", schedule: []paddingTurn{{direction: 99, minLength: 4, maxLength: 4}}}, + {name: "too small", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 0, maxLength: 4}}}, + {name: "reversed range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 7}}}, + {name: "wrong first direction", prefix: 3, schedule: []paddingTurn{{direction: paddingServerToClient, minLength: 8, maxLength: 8}}}, + {name: "prefix leaves no header", prefix: 8, schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8}}}, + {name: "same direction", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8}, {direction: paddingClientToServer, minLength: 8, maxLength: 8}}}, + {name: "range with variants", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, variants: []paddingVariant{paddingVariantFromChunks(8)}}}}, + {name: "empty variant", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{{}}}}}, + {name: "bad chunk", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(maxPaddingChunkLength + 1)}}}}, + { + name: "delay mismatch", + schedule: []paddingTurn{{ + direction: paddingClientToServer, + variants: []paddingVariant{{ + chunks: []int{4, 4}, + delays: []paddingDelayRange{{min: time.Millisecond, max: time.Millisecond}}, + }}, + }}, + }, + {name: "reversed start delay", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, startDelay: paddingDelayRange{min: 2 * time.Millisecond, max: time.Millisecond}}}}, + {name: "reversed generated chunk delay", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, chunkDelay: paddingDelayRange{min: 2 * time.Millisecond, max: time.Millisecond}}}}, + {name: "oversized generated chunk", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, writeChunkLength: maxPaddingChunkLength + 1}}}, + {name: "reversed generated chunk range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 8, writeChunkMinLength: 9, writeChunkLength: 8}}}, + {name: "variant with generated chunks", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, writeChunkLength: 8}}}, + {name: "send range outside accepted range", schedule: []paddingTurn{{direction: paddingClientToServer, minLength: 8, maxLength: 16, sendMinLength: 7, sendMaxLength: 12}}}, + {name: "variant with send range", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, sendMinLength: 8, sendMaxLength: 8}}}, + {name: "negative chunk delay", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{{chunks: []int{8}, delays: []paddingDelayRange{{min: -time.Millisecond}}}}}}}, + {name: "bad send variant", schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8)}, sendVariants: []int{1}}}}, + {name: "prefix splits chunk", prefix: 3, schedule: []paddingTurn{{direction: paddingClientToServer, variants: []paddingVariant{paddingVariantFromChunks(8, 4)}}}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := validatePaddingSchedule(test.schedule, test.prefix); err == nil { + t.Fatal("expected invalid padding schedule") + } + }) + } +} + +func TestPaddingSchedule2612MatchesCapturedTemplates(t *testing.T) { + wantDirections := []paddingDirection{ + paddingClientToServer, + paddingServerToClient, + paddingClientToServer, + paddingServerToClient, + paddingClientToServer, + paddingServerToClient, + } + wantLengths := [][]int{ + {44}, + {72}, + {25}, + {41172}, + {2}, + {7464, 8267, 8790, 8153, 7375, 7347, 8184, 6633, 7670, 8241, 7857, 7254, 8407, 8283, 6804, 8177, 8177, 7929, 8296, 8177}, + } + if len(paddingSchedule2612) != len(wantDirections)+33 { + t.Fatalf("padding schedule has %d turns, want %d", len(paddingSchedule2612), len(wantDirections)+33) + } + for i, turn := range paddingSchedule2612[:len(wantDirections)] { + if turn.direction != wantDirections[i] { + t.Fatalf("padding turn %d direction = %d, want %d", i, turn.direction, wantDirections[i]) + } + if len(turn.variants) != len(wantLengths[i]) { + t.Fatalf("padding turn %d has %d variants, want %d", i, len(turn.variants), len(wantLengths[i])) + } + for j, variant := range turn.variants { + if got := paddingVariantLength(variant); got != wantLengths[i][j] { + t.Fatalf("padding turn %d variant %d length = %d, want %d", i, j, got, wantLengths[i][j]) + } + } + } + wantPlayBounds := [][2]int{ + {6, 883}, + {346, 58638}, + {6, 887}, + {388, 61077}, + {2, 50}, + {575, 65584}, + {6, 45}, + {86, 63563}, + {2, 44}, + {42, 51983}, + {2, 851}, + {309, 25083}, + {2, 19}, + {74, 63885}, + {8, 24}, + {30, 66128}, + {2, 19}, + {26, 35818}, + {6, 19}, + {35, 59407}, + {6, 19}, + {37, 65328}, + {2, 19}, + {26, 60622}, + {6, 19}, + {11, 60808}, + {8, 43}, + {55, 62027}, + {2, 19}, + {427, 65622}, + {5, 19}, + {35, 59401}, + {6, 19}, + } + for i, want := range wantPlayBounds { + turn := paddingSchedule2612[len(wantDirections)+i] + wantDirection := paddingClientToServer + if i%2 == 1 { + wantDirection = paddingServerToClient + } + if turn.direction != wantDirection { + t.Fatalf("play turn %d direction = %d, want %d", i, turn.direction, wantDirection) + } + if turn.minLength != want[0] || turn.maxLength != want[1] { + t.Fatalf("play turn %d bounds = %d-%d, want %d-%d", i, turn.minLength, turn.maxLength, want[0], want[1]) + } + if len(turn.variants) != 0 { + t.Fatalf("play turn %d unexpectedly has captured variants", i) + } + } + if got := len(paddingSchedule2612[3].variants[0].chunks); got != 30 { + t.Fatalf("registry turn chunks = %d, want 30", got) + } + minimumPlayStart := maxPaddingTurnLength + maximumPlayStart := 0 + for _, variant := range paddingSchedule2612[5].variants { + length := paddingVariantLength(variant) + minimumPlayStart = min(minimumPlayStart, length) + maximumPlayStart = max(maximumPlayStart, length) + if variant.chunks[0] != 4941 { + t.Fatalf("play start first chunk = %d, want 4941", variant.chunks[0]) + } + } + if minimumPlayStart != 6633 || maximumPlayStart != 8790 { + t.Fatalf("play start bounds = %d-%d, want 6633-8790", minimumPlayStart, maximumPlayStart) + } + if err := validatePaddingSchedule(paddingSchedule2612, 2); err != nil { + t.Fatalf("captured schedule is invalid: %v", err) + } + + serverSchedule, err := newServerPaddingSchedule2612() + if err != nil { + t.Fatal(err) + } + if err = validatePaddingSchedule(serverSchedule, 2); err != nil { + t.Fatalf("server schedule is invalid: %v", err) + } + for i, branches := range serverPlayBranches2612 { + turn := serverSchedule[len(startupPaddingSchedule2612)+1+i*2] + got := paddingLengthRange2612{turn.sendMinLength, turn.sendMaxLength} + if got != branches.small && got != branches.large { + t.Fatalf("server play turn %d send range = %v, want %v or %v", i, got, branches.small, branches.large) + } + } + + for range 20 { + clientSchedule, clientErr := newClientPaddingSchedule2612() + if clientErr != nil { + t.Fatal(clientErr) + } + if clientErr = validatePaddingSchedule(clientSchedule, 2); clientErr != nil { + t.Fatalf("client schedule is invalid: %v", clientErr) + } + burstCount := 0 + for _, burst := range clientPlayBursts2612 { + turn := clientSchedule[len(startupPaddingSchedule2612)+burst.playIndex] + got := paddingLengthRange2612{turn.sendMinLength, turn.sendMaxLength} + switch got { + case burst.regular: + case burst.burst: + burstCount++ + default: + t.Fatalf("client play turn %d send range = %v", burst.playIndex, got) + } + } + if burstCount != 1 { + t.Fatalf("client schedule has %d initialization bursts, want 1", burstCount) + } + } + + for variantIndex := range paddingSchedule2612[3].variants { + turn := paddingSchedule2612[3] + turn.sendVariants = []int{variantIndex} + var encoded bytes.Buffer + if err = writePaddingTurnWithSleep(&encoded, turn, 0, func(time.Duration) {}); err != nil { + t.Fatal(err) + } + if err = readPaddingTurn(bytes.NewReader(encoded.Bytes()), paddingSchedule2612[3], 0); err != nil { + t.Fatalf("registry variant %d was rejected: %v", variantIndex, err) + } + } +} + +func TestPaddingVariantPreservesWriteBoundaries(t *testing.T) { + tests := []struct { + name string + turn paddingTurn + prefix int + want []int + }{ + {name: "login acknowledged turn", turn: paddingSchedule2612[0], prefix: 2, want: []int{26, 16}}, + {name: "server response turn", turn: paddingSchedule2612[1], want: []int{26, 21, 25}}, + {name: "single packet turn", turn: paddingSchedule2612[2], want: []int{25}}, + {name: "fixed registry profile", turn: paddingSchedule2612[3], want: paddingSchedule2612[3].variants[0].chunks}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var writer recordingWriter + if err := writePaddingTurnWithSleep(&writer, test.turn, test.prefix, func(time.Duration) {}); err != nil { + t.Fatal(err) + } + if len(writer.writes) != len(test.want) { + t.Fatalf("writes = %v, want %v", writer.writes, test.want) + } + for i := range test.want { + if writer.writes[i] != test.want[i] { + t.Fatalf("writes = %v, want %v", writer.writes, test.want) + } + } + if err := readPaddingTurn(bytes.NewReader(writer.Bytes()), test.turn, test.prefix); err != nil { + t.Fatal(err) + } + }) + } +} + +func TestPaddingVariantAppliesPacing(t *testing.T) { + turn := paddingTurn{ + direction: paddingClientToServer, + startDelay: paddingDelayRange{min: 3 * time.Millisecond, max: 3 * time.Millisecond}, + variants: []paddingVariant{{ + chunks: []int{3, 5, 7}, + delays: []paddingDelayRange{ + {}, + {min: 2 * time.Millisecond, max: 2 * time.Millisecond}, + {min: 4 * time.Millisecond, max: 4 * time.Millisecond}, + }, + }}, + } + var slept []time.Duration + var writer recordingWriter + if err := writePaddingTurnWithSleep(&writer, turn, 3, func(delay time.Duration) { + slept = append(slept, delay) + }); err != nil { + t.Fatal(err) + } + want := []time.Duration{3 * time.Millisecond, 2 * time.Millisecond, 4 * time.Millisecond} + if len(slept) != len(want) { + t.Fatalf("delays = %v, want %v", slept, want) + } + for i := range want { + if slept[i] != want[i] { + t.Fatalf("delays = %v, want %v", slept, want) + } + } +} + +func TestGeneratedPaddingChunksApplyPacing(t *testing.T) { + turn := paddingTurn{ + direction: paddingServerToClient, + minLength: 100, + maxLength: 100, + writeChunkLength: 32, + chunkDelay: paddingDelayRange{min: 2 * time.Millisecond, max: 2 * time.Millisecond}, + } + var slept []time.Duration + var writer recordingWriter + if err := writePaddingTurnWithSleep(&writer, turn, 0, func(delay time.Duration) { + slept = append(slept, delay) + }); err != nil { + t.Fatal(err) + } + wantWrites := []int{32, 32, 32, 4} + if !slicesEqual(writer.writes, wantWrites) { + t.Fatalf("writes = %v, want %v", writer.writes, wantWrites) + } + wantSleeps := []time.Duration{2 * time.Millisecond, 2 * time.Millisecond, 2 * time.Millisecond} + if !slicesEqual(slept, wantSleeps) { + t.Fatalf("delays = %v, want %v", slept, wantSleeps) + } + if err := readPaddingTurn(bytes.NewReader(writer.Bytes()), turn, 0); err != nil { + t.Fatal(err) + } +} + +func TestGeneratedPaddingChunkLengthIsRandomized(t *testing.T) { + turn := paddingTurn{ + direction: paddingServerToClient, + minLength: 100, + maxLength: 100, + writeChunkMinLength: 16, + writeChunkLength: 32, + } + seen := make(map[int]bool) + for range 100 { + var writer recordingWriter + if err := writePaddingTurnWithSleep(&writer, turn, 0, func(time.Duration) {}); err != nil { + t.Fatal(err) + } + firstWrite := writer.writes[0] + if firstWrite < turn.writeChunkMinLength || firstWrite > turn.writeChunkLength { + t.Fatalf("first write = %d", firstWrite) + } + seen[firstWrite] = true + } + if len(seen) < 2 { + t.Fatalf("generated write chunk length did not vary: %v", seen) + } +} + +func TestPaddingDelayRangeIsRandomized(t *testing.T) { + delayRange := millisecondRange(25, 40) + seen := make(map[time.Duration]bool) + for range 100 { + delay, err := randomPaddingDelay(delayRange) + if err != nil { + t.Fatal(err) + } + if delay < delayRange.min || delay > delayRange.max { + t.Fatalf("delay = %s, want %s-%s", delay, delayRange.min, delayRange.max) + } + seen[delay] = true + } + if len(seen) < 2 { + t.Fatalf("padding delay did not vary: %v", seen) + } +} + +func TestPaddingSchedule2612UsesCoarseTimingBands(t *testing.T) { + assertDelayRange(t, "turn 3 to 4", paddingSchedule2612[3].startDelay, 20*time.Millisecond, 50*time.Millisecond) + assertDelayRange(t, "turn 5 to 6", paddingSchedule2612[5].startDelay, 35*time.Millisecond, 50*time.Millisecond) + assertDelayRange(t, "first play client turn", paddingSchedule2612[6].startDelay, time.Millisecond, 30*time.Millisecond) + assertDelayRange(t, "first play server turn", paddingSchedule2612[7].startDelay, time.Millisecond, 45*time.Millisecond) + assertDelayRange(t, "play server chunk pacing", paddingSchedule2612[7].chunkDelay, time.Millisecond, 4*time.Millisecond) + if paddingSchedule2612[6].writeChunkLength != 1024 { + t.Fatalf("play client write chunk = %d, want 1024", paddingSchedule2612[6].writeChunkLength) + } + if paddingSchedule2612[7].writeChunkLength != maxPaddingChunkLength { + t.Fatalf("play server write chunk = %d, want %d", paddingSchedule2612[7].writeChunkLength, maxPaddingChunkLength) + } + if paddingSchedule2612[7].writeChunkMinLength != 32*1024 { + t.Fatalf("play server minimum write chunk = %d, want %d", paddingSchedule2612[7].writeChunkMinLength, 32*1024) + } + + for i, variant := range paddingSchedule2612[3].variants { + minimum, maximum := paddingVariantDelayBounds(variant) + if minimum != 45*time.Millisecond || maximum != 65*time.Millisecond { + t.Fatalf("turn 4 variant %d duration = %s-%s, want 45ms-65ms", i, minimum, maximum) + } + } + for i, variant := range paddingSchedule2612[5].variants { + minimum, maximum := paddingVariantDelayBounds(variant) + if minimum != 10*time.Millisecond || maximum != 25*time.Millisecond { + t.Fatalf("turn 6 variant %d duration = %s-%s, want 10ms-25ms", i, minimum, maximum) + } + } +} + +func assertDelayRange(t *testing.T, name string, got paddingDelayRange, minimum, maximum time.Duration) { + t.Helper() + if got.min != minimum || got.max != maximum { + t.Fatalf("%s delay = %s-%s, want %s-%s", name, got.min, got.max, minimum, maximum) + } +} + +func paddingVariantDelayBounds(variant paddingVariant) (time.Duration, time.Duration) { + var minimum time.Duration + var maximum time.Duration + for _, delay := range variant.delays { + minimum += delay.min + maximum += delay.max + } + return minimum, maximum +} + +func slicesEqual[T comparable](left, right []T) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} + +func encodePaddingLength(t *testing.T, length int) []byte { + t.Helper() + var encoded bytes.Buffer + value := Varint(length) + if err := value.writeTo(&encoded); err != nil { + t.Fatal(err) + } + return encoded.Bytes() +} + +type oneByteReader struct { + reader io.Reader +} + +func (r *oneByteReader) Read(p []byte) (int, error) { + if len(p) > 1 { + p = p[:1] + } + return r.reader.Read(p) +} + +type recordingWriter struct { + bytes.Buffer + writes []int +} + +func (w *recordingWriter) Write(p []byte) (int, error) { + w.writes = append(w.writes, len(p)) + return w.Buffer.Write(p) +} diff --git a/transport/internet/finalmask/xmc/profile.go b/transport/internet/finalmask/xmc/profile.go new file mode 100644 index 000000000..6ef55349b --- /dev/null +++ b/transport/internet/finalmask/xmc/profile.go @@ -0,0 +1,69 @@ +package xmc + +import "fmt" + +type loginProfile struct { + Username string + UUID UUID + TexturesValue string + TexturesSignature string +} + +func profilesFromConfig(configured []*Profile) ([]loginProfile, error) { + if len(configured) == 0 { + return nil, fmt.Errorf("empty profiles") + } + + profiles := make([]loginProfile, 0, len(configured)) + for _, configuredProfile := range configured { + if configuredProfile == nil || configuredProfile.Username == "" { + return nil, fmt.Errorf("invalid profile") + } + if len(configuredProfile.Uuid) != len(UUID{}) { + return nil, fmt.Errorf("bad profile UUID length: %d", len(configuredProfile.Uuid)) + } + if configuredProfile.TexturesValue == "" || configuredProfile.TexturesSignature == "" { + return nil, fmt.Errorf("incomplete profile textures") + } + + profile := loginProfile{ + Username: configuredProfile.Username, + TexturesValue: configuredProfile.TexturesValue, + TexturesSignature: configuredProfile.TexturesSignature, + } + copy(profile.UUID[:], configuredProfile.Uuid) + profiles = append(profiles, profile) + } + return profiles, nil +} + +func findProfile(profiles []loginProfile, username string, uuid UUID) (loginProfile, bool) { + for _, profile := range profiles { + if profile.Username == username && profile.UUID == uuid { + return profile, true + } + } + return loginProfile{}, false +} + +func readLoginSuccess(packet *mcPacket) (loginProfile, error) { + var ( + profile loginProfile + username String + propertyCount Varint + propertyName String + value String + signed Boolean + signature String + ) + if err := packet.readFields(&profile.UUID, &username, &propertyCount, &propertyName, &value, &signed, &signature); err != nil { + return loginProfile{}, err + } + if propertyCount != 1 || propertyName != "textures" || !signed { + return loginProfile{}, fmt.Errorf("invalid login profile properties") + } + profile.Username = string(username) + profile.TexturesValue = string(value) + profile.TexturesSignature = string(signature) + return profile, nil +} diff --git a/transport/internet/finalmask/xmc/profile_test.go b/transport/internet/finalmask/xmc/profile_test.go new file mode 100644 index 000000000..35dd41286 --- /dev/null +++ b/transport/internet/finalmask/xmc/profile_test.go @@ -0,0 +1,33 @@ +package xmc + +import ( + "bytes" + "testing" +) + +func TestProfilesFromConfigRejectsEmpty(t *testing.T) { + if _, err := profilesFromConfig(nil); err == nil { + t.Fatal("expected empty profiles error") + } +} + +func TestProfilesFromConfig(t *testing.T) { + uuid := bytes.Repeat([]byte{0x2a}, 16) + profiles, err := profilesFromConfig([]*Profile{ + { + Username: "SignedUser", + Uuid: uuid, + TexturesValue: "textures-value", + TexturesSignature: "textures-signature", + }, + }) + if err != nil { + t.Fatalf("build explicit profile: %v", err) + } + if len(profiles) != 1 || profiles[0].Username != "SignedUser" { + t.Fatalf("unexpected profile: %+v", profiles) + } + if profiles[0].TexturesValue != "textures-value" || profiles[0].TexturesSignature != "textures-signature" { + t.Fatalf("textures were not preserved: %+v", profiles[0]) + } +} diff --git a/transport/internet/finalmask/xmc/protocol.go b/transport/internet/finalmask/xmc/protocol.go index 68a4fb74d..a636d413d 100644 --- a/transport/internet/finalmask/xmc/protocol.go +++ b/transport/internet/finalmask/xmc/protocol.go @@ -7,6 +7,11 @@ import ( "io" ) +const ( + maxPacketDataLength = 32 * 1024 + maxPacketBodyLength = maxPacketDataLength + 5 +) + type field interface { readFrom(r io.Reader) error writeTo(w io.Writer) error @@ -18,25 +23,38 @@ type mcPacket struct { } func readPacket(b io.Reader) (*mcPacket, error) { - var packetLength Varint - err := packetLength.readFrom(b) + packet, _, err := readPacketWithLength(b) + return packet, err +} + +func readPacketWithLength(b io.Reader) (*mcPacket, int, error) { + packetData, wireLength, err := readFrame(b, maxPacketBodyLength) if err != nil { - return nil, fmt.Errorf("read packet length: %w", err) + return nil, 0, err + } + packet, err := decodePacketBody(packetData) + return packet, wireLength, err +} + +func decodePacketBody(packetData []byte) (*mcPacket, error) { + if len(packetData) < 1 || len(packetData) > maxPacketBodyLength { + return nil, fmt.Errorf("read packet: bad length: %d", len(packetData)) } + body := bytes.NewReader(packetData) var packetID Varint - err = packetID.readFrom(b) + err := packetID.readFrom(body) if err != nil { return nil, fmt.Errorf("read packet ID: %w", err) } - dataLength := int(packetLength) - varintSize(packetID) - if dataLength < 0 || dataLength > 1024*32 { + dataLength := body.Len() + if dataLength > maxPacketDataLength { return nil, fmt.Errorf("read packet: bad length: %d", dataLength) } data := make([]byte, dataLength) - _, err = io.ReadFull(b, data) + _, err = io.ReadFull(body, data) if err != nil { return nil, fmt.Errorf("read packet data: %w", err) } @@ -47,9 +65,24 @@ func readPacket(b io.Reader) (*mcPacket, error) { }, nil } +func readFrame(r io.Reader, maxLength int) ([]byte, int, error) { + frameLength, prefixLength, err := readVarintWithLength(r) + if err != nil { + return nil, 0, fmt.Errorf("read packet length: %w", err) + } + if frameLength < 1 || int(frameLength) > maxLength { + return nil, 0, fmt.Errorf("read packet: bad length: %d", frameLength) + } + + frame := make([]byte, int(frameLength)) + if _, err := io.ReadFull(r, frame); err != nil { + return nil, 0, fmt.Errorf("read packet data: %w", err) + } + return frame, prefixLength + len(frame), nil +} + func (p *mcPacket) readFields(fields ...field) error { r := bytes.NewReader(p.data) - for _, field := range fields { err := field.readFrom(r) if err != nil { @@ -62,47 +95,49 @@ func (p *mcPacket) readFields(fields ...field) error { type Varint int32 +const ( + SEGMENT_BITS = 0x7F + CONTINUE_BIT = 0x80 +) + func (v *Varint) readFrom(r io.Reader) error { - SEGMENT_BITS := byte(0x7F) - CONTINUE_BIT := byte(0x80) - - var err error - - var value int32 = 0 - var position int32 = 0 - var currentByte byte - - for true { - currentByte, err = readByte(r) - if err != nil { - return fmt.Errorf("read varint: %w", err) - } - value |= int32(currentByte&SEGMENT_BITS) << position - - if (currentByte & CONTINUE_BIT) == 0 { - break - } - - position += 7 - - if position >= 32 { - return fmt.Errorf("read varint: too large") - } + value, _, err := readVarintWithLength(r) + if err != nil { + return err } - - *v = Varint(value) - + *v = value return nil } -func (v *Varint) writeTo(w io.Writer) error { - SEGMENT_BITS := byte(0x7F) - CONTINUE_BIT := byte(0x80) +func readVarintWithLength(r io.Reader) (Varint, int, error) { + var value int32 + for index := 0; index < 5; index++ { + currentByte, err := readByte(r) + if err != nil { + return 0, 0, fmt.Errorf("read varint: %w", err) + } + if index == 4 && currentByte&0xf0 != 0 { + return 0, 0, fmt.Errorf("read varint: too large") + } + value |= int32(currentByte&SEGMENT_BITS) << (7 * index) - value := int32(*v) + if currentByte&CONTINUE_BIT == 0 { + parsed := Varint(value) + length := index + 1 + if length != varintSize(parsed) { + return 0, 0, fmt.Errorf("read varint: non-canonical encoding") + } + return parsed, length, nil + } + } + return 0, 0, fmt.Errorf("read varint: too large") +} + +func (v *Varint) writeTo(w io.Writer) error { + value := uint32(*v) for { - currentByte := byte(value & int32(SEGMENT_BITS)) + currentByte := byte(value & SEGMENT_BITS) value >>= 7 if value != 0 { currentByte |= CONTINUE_BIT @@ -122,11 +157,12 @@ func (v *Varint) writeTo(w io.Writer) error { } func varintSize(value Varint) int { + uintValue := uint32(value) size := 0 - for { + for range 5 { size++ - value >>= 7 - if value == 0 { + uintValue >>= 7 + if uintValue == 0 { break } } @@ -238,6 +274,31 @@ func (v *UUID) readFrom(r io.Reader) error { return nil } +type Boolean bool + +func (v *Boolean) readFrom(r io.Reader) error { + b, err := readByte(r) + if err != nil { + return fmt.Errorf("read boolean: %w", err) + } + if b > 1 { + return fmt.Errorf("read boolean: invalid value: %d", b) + } + *v = b == 1 + return nil +} + +func (v *Boolean) writeTo(w io.Writer) error { + value := byte(0) + if *v { + value = 1 + } + if _, err := w.Write([]byte{value}); err != nil { + return fmt.Errorf("write boolean: %w", err) + } + return nil +} + func (v *UUID) writeTo(w io.Writer) error { _, err := w.Write(v[:]) if err != nil { @@ -256,7 +317,7 @@ func (v *Bytes) readFrom(r io.Reader) error { } if length < 0 || length >= 1024 { - return fmt.Errorf("read bytes: invalid size: %d", err) + return fmt.Errorf("read bytes: invalid size: %d", length) } buf := make([]byte, length) @@ -271,6 +332,24 @@ func (v *Bytes) readFrom(r io.Reader) error { return nil } +type RestBytes []byte + +func (v *RestBytes) readFrom(r io.Reader) error { + buf, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("read remaining bytes: %w", err) + } + *v = append((*v)[:0], buf...) + return nil +} + +func (v *RestBytes) writeTo(w io.Writer) error { + if _, err := w.Write(*v); err != nil { + return fmt.Errorf("write remaining bytes: %w", err) + } + return nil +} + func (v *Bytes) writeTo(w io.Writer) error { length := Varint(len(*v)) err := length.writeTo(w) @@ -297,36 +376,66 @@ func readByte(r io.Reader) (byte, error) { } func writePacket(w io.Writer, packetID int, fields ...field) error { + _, err := writePacketWithLength(w, packetID, fields...) + return err +} + +func writePacketWithLength(w io.Writer, packetID int, fields ...field) (int, error) { + frame, err := encodePacket(packetID, fields...) + if err != nil { + return 0, err + } + if err = writeFull(w, frame); err != nil { + return 0, fmt.Errorf("write packet data: %w", err) + } + return len(frame), nil +} + +func encodePacket(packetID int, fields ...field) ([]byte, error) { var dataBuf bytes.Buffer for _, field := range fields { err := field.writeTo(&dataBuf) if err != nil { - return fmt.Errorf("write packet field: %w", err) + return nil, fmt.Errorf("write packet field: %w", err) } } - - var buf bytes.Buffer - - var packetLength Varint = Varint(varintSize(Varint(packetID)) + dataBuf.Len()) - err := packetLength.writeTo(&buf) - if err != nil { - return fmt.Errorf("write packet length: %w", err) + if dataBuf.Len() > maxPacketDataLength { + return nil, fmt.Errorf("write packet: bad length: %d", dataBuf.Len()) } - var packetIDVarint Varint = Varint(packetID) - err = packetIDVarint.writeTo(&buf) - if err != nil { - return fmt.Errorf("write packet ID: %w", err) + packetIDVarint := Varint(packetID) + bodyLength := varintSize(packetIDVarint) + dataBuf.Len() + if bodyLength > maxPacketBodyLength { + return nil, fmt.Errorf("write packet: bad length: %d", bodyLength) } - buf.Write(dataBuf.Bytes()) - - _, err = w.Write(buf.Bytes()) - if err != nil { - return fmt.Errorf("write packet data: %w", err) + var frame bytes.Buffer + frame.Grow(varintSize(Varint(bodyLength)) + bodyLength) + frameLength := Varint(bodyLength) + if err := frameLength.writeTo(&frame); err != nil { + return nil, fmt.Errorf("write packet length: %w", err) } + if err := packetIDVarint.writeTo(&frame); err != nil { + return nil, fmt.Errorf("write packet ID: %w", err) + } + frame.Write(dataBuf.Bytes()) + return frame.Bytes(), nil +} +func writeFull(w io.Writer, p []byte) error { + for len(p) > 0 { + n, err := w.Write(p) + if n > 0 { + p = p[n:] + } + if err != nil { + return err + } + if n == 0 { + return io.ErrShortWrite + } + } return nil } diff --git a/transport/internet/finalmask/xmc/protocol_test.go b/transport/internet/finalmask/xmc/protocol_test.go new file mode 100644 index 000000000..3aa68d488 --- /dev/null +++ b/transport/internet/finalmask/xmc/protocol_test.go @@ -0,0 +1,56 @@ +package xmc + +import ( + "bytes" + "strings" + "testing" +) + +func TestReadPacketDoesNotConsumeFollowingPacket(t *testing.T) { + data := []byte{0x01, 0x80, 0x01, 0x00} + r := bytes.NewReader(data) + if _, err := readPacket(r); err == nil { + t.Fatal("expected truncated packet ID to fail") + } + pkt, err := readPacket(r) + if err != nil { + t.Fatalf("read following packet: %v", err) + } + if pkt.packetID != 0 { + t.Fatalf("packet ID = %d", pkt.packetID) + } +} + +func TestPacketWithLengthReportsWireBytes(t *testing.T) { + var wire bytes.Buffer + written, err := writePacketWithLength(&wire, 0x03) + if err != nil { + t.Fatal(err) + } + if written != 2 || !bytes.Equal(wire.Bytes(), []byte{0x01, 0x03}) { + t.Fatalf("wire = %x, length = %d", wire.Bytes(), written) + } + + packet, read, err := readPacketWithLength(bytes.NewReader(wire.Bytes())) + if err != nil { + t.Fatal(err) + } + if packet.packetID != 0x03 || read != written { + t.Fatalf("packet ID = %d, read = %d, written = %d", packet.packetID, read, written) + } +} + +func TestReadPacketRejectsNonCanonicalLengthVarint(t *testing.T) { + _, _, err := readPacketWithLength(bytes.NewReader([]byte{0x81, 0x00, 0x03})) + if err == nil || !strings.Contains(err.Error(), "non-canonical") { + t.Fatalf("error = %v", err) + } +} + +func TestVarintRejectsOversizedFifthByte(t *testing.T) { + var value Varint + err := value.readFrom(bytes.NewReader([]byte{0xff, 0xff, 0xff, 0xff, 0x1f})) + if err == nil || !strings.Contains(err.Error(), "too large") { + t.Fatalf("error = %v", err) + } +} diff --git a/transport/internet/finalmask/xmc/server.go b/transport/internet/finalmask/xmc/server.go index c79be767d..43fba9593 100644 --- a/transport/internet/finalmask/xmc/server.go +++ b/transport/internet/finalmask/xmc/server.go @@ -31,10 +31,16 @@ type serverConn struct { state serverState - handshakeLock sync.Mutex - password string - rsaPrivateKey *rsa.PrivateKey - rsaPublicKey []byte + handshakeLock sync.Mutex + lifecycleMu sync.Mutex + closed bool + profiles []loginProfile + password string + rsaPrivateKey *rsa.PrivateKey + rsaPublicKey []byte + paddingSchedule []paddingTurn + packet *packetStream + deadlines *connectionDeadlines } func (c *serverConn) handshake() error { @@ -45,12 +51,10 @@ func (c *serverConn) handshake() error { return nil } - // handshake timeout - err := c.c.SetDeadline(time.Now().Add(time.Second * 30)) - if err != nil { + if err := c.deadlines.beginHandshake(); err != nil { return fmt.Errorf("set deadline: %w", err) } - defer c.c.SetDeadline(time.Time{}) + defer func() { _ = c.deadlines.endHandshake() }() var ( protocolVersion Varint @@ -138,17 +142,20 @@ func (c *serverConn) handshake() error { if err != nil { return fmt.Errorf("read login start packet: %w", err) } + profile, found := findProfile(c.profiles, string(username), uuid) // encrypt request var ( - serverId String = String("") - publicKey Bytes = Bytes(c.rsaPublicKey) - verifyToken Bytes = Bytes(make([]byte, 4)) - shouldAuthenticate Varint = Varint(1) + serverId String = String("") + publicKey Bytes = Bytes(c.rsaPublicKey) + verifyToken Bytes = Bytes(make([]byte, 4)) + shouldAuthenticate Boolean = true ) - rand.Read(verifyToken) + if _, err = rand.Read(verifyToken); err != nil { + return fmt.Errorf("generate verify token: %w", err) + } err = writePacket(c.writer, 0x01, &serverId, &publicKey, &verifyToken, &shouldAuthenticate) if err != nil { @@ -183,6 +190,9 @@ func (c *serverConn) handshake() error { if err != nil { return fmt.Errorf("decrypt shared secret: %w", err) } + if len(sharedSecret) != 16 { + return fmt.Errorf("bad shared secret length: %d", len(sharedSecret)) + } decryptedVerifyToken, err = rsa.DecryptPKCS1v15(rand.Reader, c.rsaPrivateKey, encryptedVerifyToken) if err != nil { @@ -210,8 +220,47 @@ func (c *serverConn) handshake() error { writeDisconnectPacket(c.writer, `{"type":"translatable","translate":"multiplayer.disconnect.authservers_down"}`) return fmt.Errorf("bad password") } + if !found { + if err = writeDisconnectPacket(c.writer, `{"text":"You are not white-listed on this server!"}`); err != nil { + return fmt.Errorf("write unknown login profile disconnect: %w", err) + } + return fmt.Errorf("unknown login profile") + } + loginName := String(profile.Username) + propertyCount := Varint(1) + propertyName := String("textures") + texturesValue := String(profile.TexturesValue) + signed := Boolean(true) + texturesSignature := String(profile.TexturesSignature) + if err = writePacket(c.writer, 0x02, &profile.UUID, &loginName, &propertyCount, &propertyName, &texturesValue, &signed, &texturesSignature); err != nil { + return fmt.Errorf("write login finished: %w", err) + } + + var loginAcknowledgedLength int + pkt, loginAcknowledgedLength, err = readPacketWithLength(c.reader) + if err != nil { + return fmt.Errorf("read login acknowledged: %w", err) + } + if err = validateLoginAcknowledgedPacket(pkt); err != nil { + return err + } + if err = runPaddingSchedule(c.reader, c.writer, false, loginAcknowledgedLength, c.paddingSchedule); err != nil { + return fmt.Errorf("run startup padding: %w", err) + } + + packet := newPacketStream(c.reader, c.writer, false) + c.lifecycleMu.Lock() + if c.closed { + c.lifecycleMu.Unlock() + packet.Stop() + return net.ErrClosed + } + c.packet = packet + c.reader = packet + c.writer = packet c.state = serverStateProxy + c.lifecycleMu.Unlock() return nil @@ -220,6 +269,16 @@ func (c *serverConn) handshake() error { } } +func validateLoginAcknowledgedPacket(pkt *mcPacket) error { + if pkt.packetID != 0x03 { + return fmt.Errorf("bad login acknowledged packet id: %d", pkt.packetID) + } + if len(pkt.data) != 0 { + return fmt.Errorf("bad login acknowledged packet data length: %d", len(pkt.data)) + } + return nil +} + func (c *serverConn) Read(b []byte) (int, error) { err := c.handshake() if err != nil { @@ -239,6 +298,13 @@ func (c *serverConn) Write(b []byte) (int, error) { } func (c *serverConn) Close() error { + c.lifecycleMu.Lock() + c.closed = true + packet := c.packet + c.lifecycleMu.Unlock() + if packet != nil { + packet.Stop() + } return c.c.Close() } @@ -251,38 +317,47 @@ func (c *serverConn) RemoteAddr() net.Addr { } func (c *serverConn) SetDeadline(t time.Time) error { - return c.c.SetDeadline(t) + return c.deadlines.setDeadline(t) } func (c *serverConn) SetReadDeadline(t time.Time) error { - return c.c.SetReadDeadline(t) + return c.deadlines.setReadDeadline(t) } func (c *serverConn) SetWriteDeadline(t time.Time) error { - return c.c.SetWriteDeadline(t) + return c.deadlines.setWriteDeadline(t) } -func wrapConnServer(c net.Conn, password string, rsaPrivateKeyDER []byte, rsaPublicKey []byte) (*serverConn, error) { +func wrapConnServer(c net.Conn, profiles []loginProfile, password string, rsaPrivateKeyDER []byte, rsaPublicKey []byte) (*serverConn, error) { + if len(profiles) == 0 { + return nil, fmt.Errorf("empty profiles") + } if len(rsaPrivateKeyDER) == 0 { return nil, fmt.Errorf("empty rsa private key") } if len(rsaPublicKey) == 0 { return nil, fmt.Errorf("empty rsa public key") } - rsaPrivateKey, err := x509.ParsePKCS1PrivateKey(rsaPrivateKeyDER) if err != nil { return nil, fmt.Errorf("parse rsa private key: %w", err) } + paddingSchedule, err := newServerPaddingSchedule2612() + if err != nil { + return nil, fmt.Errorf("select padding profile: %w", err) + } s := &serverConn{ - reader: bufio.NewReader(c), - writer: c, - c: c, - state: serverStateHandshake, - password: password, - rsaPrivateKey: rsaPrivateKey, - rsaPublicKey: rsaPublicKey, + reader: bufio.NewReader(c), + writer: c, + c: c, + state: serverStateHandshake, + profiles: profiles, + password: password, + rsaPrivateKey: rsaPrivateKey, + rsaPublicKey: rsaPublicKey, + paddingSchedule: paddingSchedule, + deadlines: newConnectionDeadlines(c), } return s, nil diff --git a/transport/internet/finalmask/xmc/stream.go b/transport/internet/finalmask/xmc/stream.go index 2849a55ea..e44746949 100644 --- a/transport/internet/finalmask/xmc/stream.go +++ b/transport/internet/finalmask/xmc/stream.go @@ -6,12 +6,14 @@ import ( "crypto/cipher" "fmt" "io" + "sync" ) type cryptoStream struct { stream cipher.Stream r io.Reader w io.Writer + mu sync.Mutex } func newCryptoReader(r io.Reader, sharedSecret []byte) (*cryptoStream, error) { @@ -30,12 +32,19 @@ func (c *cryptoStream) Read(b []byte) (int, error) { panic("read on a write-only crypto stream") } - n, err := c.r.Read(b) - if err != nil { - return 0, fmt.Errorf("crypto reader: read: %w", err) - } + c.mu.Lock() + defer c.mu.Unlock() - c.stream.XORKeyStream(b[:n], b[:n]) + n, err := c.r.Read(b) + if n > 0 { + c.stream.XORKeyStream(b[:n], b[:n]) + } + if err != nil { + if err == io.EOF { + return n, io.EOF + } + return n, fmt.Errorf("crypto reader: read: %w", err) + } return n, nil } @@ -56,13 +65,15 @@ func (c *cryptoStream) Write(b []byte) (int, error) { panic("write on a read-only crypto stream") } + c.mu.Lock() + defer c.mu.Unlock() + encrypted := make([]byte, len(b)) c.stream.XORKeyStream(encrypted, b) - n, err := c.w.Write(encrypted) - if err != nil { + if err := writeFull(c.w, encrypted); err != nil { return 0, fmt.Errorf("crypto writer: write: %w", err) } - return n, nil + return len(b), nil } diff --git a/transport/internet/finalmask/xmc/stream_test.go b/transport/internet/finalmask/xmc/stream_test.go new file mode 100644 index 000000000..bd75d7b86 --- /dev/null +++ b/transport/internet/finalmask/xmc/stream_test.go @@ -0,0 +1,81 @@ +package xmc + +import ( + "bytes" + "io" + "testing" +) + +type dataAndEOFReader struct { + data []byte +} + +func (r *dataAndEOFReader) Read(p []byte) (int, error) { + if len(r.data) == 0 { + return 0, io.EOF + } + n := copy(p, r.data) + r.data = r.data[n:] + return n, io.EOF +} + +type shortWriter struct { + bytes.Buffer +} + +func (w *shortWriter) Write(p []byte) (int, error) { + if len(p) > 1 { + p = p[:len(p)/2] + } + return w.Buffer.Write(p) +} + +func TestCryptoReaderPreservesDataReturnedWithEOF(t *testing.T) { + secret := []byte("0123456789abcdef") + plaintext := []byte("payload returned with EOF") + var encrypted bytes.Buffer + writer, err := newCryptoWriter(&encrypted, secret) + if err != nil { + t.Fatal(err) + } + if _, err = writer.Write(plaintext); err != nil { + t.Fatal(err) + } + + reader, err := newCryptoReader(&dataAndEOFReader{data: encrypted.Bytes()}, secret) + if err != nil { + t.Fatal(err) + } + got := make([]byte, len(plaintext)) + n, err := reader.Read(got) + if err == nil || n != len(plaintext) { + t.Fatalf("Read = %d, %v", n, err) + } + if !bytes.Equal(got[:n], plaintext) { + t.Fatalf("plaintext = %q", got[:n]) + } +} + +func TestCryptoWriterHandlesShortWrites(t *testing.T) { + secret := []byte("0123456789abcdef") + plaintext := bytes.Repeat([]byte("short-write"), 100) + var dst shortWriter + writer, err := newCryptoWriter(&dst, secret) + if err != nil { + t.Fatal(err) + } + if n, err := writer.Write(plaintext); err != nil || n != len(plaintext) { + t.Fatalf("Write = %d, %v", n, err) + } + reader, err := newCryptoReader(bytes.NewReader(dst.Bytes()), secret) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(reader) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, plaintext) { + t.Fatal("decrypted payload mismatch") + } +} From 18e283909ca253d4220136eccca0a27006ea709f Mon Sep 17 00:00:00 2001 From: RPRX <63339210+RPRX@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:42:44 +0000 Subject: [PATCH 25/59] XHTTP client: Reduce default `maxConnections` from 6 to 3 for anti-TSPU https://github.com/XTLS/Xray-core/issues/6376#issuecomment-5101210849 Replaces https://github.com/XTLS/Xray-core/commit/18b85adb4e288f49a7894351c6e0f2428c0beef6 --- infra/conf/transport_method.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infra/conf/transport_method.go b/infra/conf/transport_method.go index 2b3a238d3..19cb496e3 100644 --- a/infra/conf/transport_method.go +++ b/infra/conf/transport_method.go @@ -450,8 +450,8 @@ func (c *SplitHTTPConfig) Build() (proto.Message, error) { return nil, errors.New("maxConnections cannot be specified together with maxConcurrency") } if c.Xmux == (XmuxConfig{}) { - c.Xmux.MaxConnections.From = 6 - c.Xmux.MaxConnections.To = 6 + c.Xmux.MaxConnections.From = 3 + c.Xmux.MaxConnections.To = 3 c.Xmux.HMaxRequestTimes.From = 600 c.Xmux.HMaxRequestTimes.To = 900 c.Xmux.HMaxReusableSecs.From = 1800 From 5ca6f4b7d4dc20a881d4330e498892697627ec0c Mon Sep 17 00:00:00 2001 From: RPRX <63339210+RPRX@users.noreply.github.com> Date: Tue, 28 Jul 2026 07:59:48 +0000 Subject: [PATCH 26/59] Xray-core v26.7.28 Sponsor & Donation & NFTs: https://github.com/XTLS/Xray-core/issues/3668 Project X Channel: https://t.me/projectXtls Announcement of NFTs by Project X: https://github.com/XTLS/Xray-core/discussions/3633 Project X NFT: https://opensea.io/assets/ethereum/0x5ee362866001613093361eb8569d59c4141b76d1/1 VLESS Post-Quantum Encryption: https://github.com/XTLS/Xray-core/pull/5067 VLESS NFT: https://opensea.io/collection/vless XHTTP: Beyond REALITY: https://github.com/XTLS/Xray-core/discussions/4113 REALITY NFT: https://opensea.io/assets/ethereum/0x5ee362866001613093361eb8569d59c4141b76d1/2 --- core/core.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/core.go b/core/core.go index 737079272..db5d4afbb 100644 --- a/core/core.go +++ b/core/core.go @@ -20,7 +20,7 @@ import ( var ( Version_x byte = 26 Version_y byte = 7 - Version_z byte = 11 + Version_z byte = 28 ) var ( From 2b329b3675538dafe38e67cc92f811e4bbdb1179 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:27:08 +0000 Subject: [PATCH 27/59] Bump docker/login-action from 4 to 4.5.2 (#6556) Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.5.2 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 57a2bc65c..17ead5ddb 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -82,7 +82,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v4 + uses: docker/login-action@v4.5.2 with: registry: ghcr.io username: ${{ github.repository_owner }} From b02bdcf4ccfffdc911a212388ae1d03bbee00f30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 08:27:32 +0000 Subject: [PATCH 28/59] Bump google.golang.org/grpc from 1.82.1 to 1.83.0 (#6585) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.82.1 to 1.83.0. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.82.1...v1.83.0) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.83.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 28 ++++++++++++++-------------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 259e29a1c..9799355a0 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/windows v1.0.1 - google.golang.org/grpc v1.82.1 + google.golang.org/grpc v1.83.0 google.golang.org/protobuf v1.36.11 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h12.io/socks v1.0.3 @@ -56,7 +56,7 @@ require ( golang.org/x/text v0.40.0 // indirect golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.47.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 25dfa4179..6bbc8f6f5 100644 --- a/go.sum +++ b/go.sum @@ -82,16 +82,16 @@ github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt7 github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= @@ -147,10 +147,10 @@ golang.zx2c4.com/wireguard/windows v1.0.1 h1:eOxiDVbywPC+ZQqvdCK7x+ZwWXKbYv50TtH golang.zx2c4.com/wireguard/windows v1.0.1/go.mod h1:+fbT3FFdX4zzYDLwJh5+HPEcNN/3HyNdzhNSVsQM+zs= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE= -google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= +google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From fc5620de986ea4598807a36dea787653127cf345 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:44:12 +0000 Subject: [PATCH 29/59] Bump docker/login-action from 4.5.2 to 4.6.0 (#6610) Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/docker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 17ead5ddb..e25afcd44 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -82,7 +82,7 @@ jobs: uses: docker/setup-buildx-action@v4 - name: Login to GitHub Container Registry - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From bc6e966af890d0ef481501ec171321ec802c6857 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Aug 2026 05:44:15 +0000 Subject: [PATCH 30/59] Bump github.com/cloudflare/circl from 1.6.4 to 1.6.5 (#6609) Bumps [github.com/cloudflare/circl](https://github.com/cloudflare/circl) from 1.6.4 to 1.6.5. - [Release notes](https://github.com/cloudflare/circl/releases) - [Commits](https://github.com/cloudflare/circl/compare/v1.6.4...v1.6.5) --- updated-dependencies: - dependency-name: github.com/cloudflare/circl dependency-version: 1.6.5 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9799355a0..4bc380a82 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.26 require ( github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 - github.com/cloudflare/circl v1.6.4 + github.com/cloudflare/circl v1.6.5 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 github.com/golang/mock v1.7.0-rc.1 github.com/google/go-cmp v0.7.0 diff --git a/go.sum b/go.sum index 6bbc8f6f5..0f73d11d5 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkd github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= -github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= +github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= +github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= From a000371b2aba07140ed56a94bc2e5675fa55f356 Mon Sep 17 00:00:00 2001 From: Maksim Varentsov Date: Wed, 12 Aug 2026 06:51:38 +0200 Subject: [PATCH 31/59] Tunnel inbound: Support TPROXY on OpenBSD as well (#6546) https://github.com/XTLS/Xray-core/pull/6546#issuecomment-5100711574 --- proxy/dokodemo/fakeudp_openbsd.go | 58 ++++++++++++++++++++++ proxy/dokodemo/fakeudp_other.go | 4 +- transport/internet/sockopt_openbsd.go | 33 ++++++++++++ transport/internet/sockopt_other.go | 4 +- transport/internet/udp/hub_openbsd.go | 57 +++++++++++++++++++++ transport/internet/udp/hub_openbsd_test.go | 57 +++++++++++++++++++++ transport/internet/udp/hub_other.go | 4 +- 7 files changed, 211 insertions(+), 6 deletions(-) create mode 100644 proxy/dokodemo/fakeudp_openbsd.go create mode 100644 transport/internet/sockopt_openbsd.go create mode 100644 transport/internet/udp/hub_openbsd.go create mode 100644 transport/internet/udp/hub_openbsd_test.go diff --git a/proxy/dokodemo/fakeudp_openbsd.go b/proxy/dokodemo/fakeudp_openbsd.go new file mode 100644 index 000000000..8fcbcbea9 --- /dev/null +++ b/proxy/dokodemo/fakeudp_openbsd.go @@ -0,0 +1,58 @@ +//go:build openbsd +// +build openbsd + +package dokodemo + +import ( + "fmt" + "net" + "os" + + "golang.org/x/sys/unix" +) + +func FakeUDP(addr *net.UDPAddr, mark int) (net.PacketConn, error) { + ip4 := addr.IP.To4() + if ip4 == nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("IPv6 is not supported by the OpenBSD transparent UDP patch")} + } + + fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + if err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("socket open: %w", err)} + } + + closeFD := true + defer func() { + if closeFD { + unix.Close(fd) + } + }() + + if err = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_BINDANY, 1); err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("set socket option SO_BINDANY: %w", err)} + } + if err = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("set socket option SO_REUSEADDR: %w", err)} + } + + sockaddr := &unix.SockaddrInet4{Port: addr.Port} + copy(sockaddr.Addr[:], ip4) + if err = unix.Bind(fd, sockaddr); err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("bind %s: %w", addr.String(), err)} + } + + fdFile := os.NewFile(uintptr(fd), fmt.Sprintf("net-udp-bindany-%s", addr.String())) + if fdFile == nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("convert descriptor to file")} + } + defer fdFile.Close() + + packetConn, err := net.FilePacketConn(fdFile) + if err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("convert descriptor to packet connection: %w", err)} + } + + closeFD = false + return packetConn, nil +} diff --git a/proxy/dokodemo/fakeudp_other.go b/proxy/dokodemo/fakeudp_other.go index 490f09124..353617937 100644 --- a/proxy/dokodemo/fakeudp_other.go +++ b/proxy/dokodemo/fakeudp_other.go @@ -1,5 +1,5 @@ -//go:build !linux -// +build !linux +//go:build !linux && !openbsd +// +build !linux,!openbsd package dokodemo diff --git a/transport/internet/sockopt_openbsd.go b/transport/internet/sockopt_openbsd.go new file mode 100644 index 000000000..888a73cb0 --- /dev/null +++ b/transport/internet/sockopt_openbsd.go @@ -0,0 +1,33 @@ +//go:build openbsd +// +build openbsd + +package internet + +import ( + "github.com/xtls/xray-core/common/errors" + "golang.org/x/sys/unix" +) + +func applyOutboundSocketOptions(network string, address string, fd uintptr, config *SocketConfig) error { + return nil +} + +func applyInboundSocketOptions(network string, fd uintptr, config *SocketConfig) error { + if config.ReceiveOriginalDestAddress && isUDPSocket(network) { + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTADDR, 1); err != nil { + return errors.New("failed to set IP_RECVDSTADDR").Base(err) + } + if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTPORT, 1); err != nil { + return errors.New("failed to set IP_RECVDSTPORT").Base(err) + } + } + return nil +} + +func setReuseAddr(fd uintptr) error { + return nil +} + +func setReusePort(fd uintptr) error { + return nil +} diff --git a/transport/internet/sockopt_other.go b/transport/internet/sockopt_other.go index 9cce3df26..3bcdf51d5 100644 --- a/transport/internet/sockopt_other.go +++ b/transport/internet/sockopt_other.go @@ -1,5 +1,5 @@ -//go:build js || netbsd || openbsd || solaris -// +build js netbsd openbsd solaris +//go:build js || netbsd || solaris +// +build js netbsd solaris package internet diff --git a/transport/internet/udp/hub_openbsd.go b/transport/internet/udp/hub_openbsd.go new file mode 100644 index 000000000..a036a7458 --- /dev/null +++ b/transport/internet/udp/hub_openbsd.go @@ -0,0 +1,57 @@ +//go:build openbsd +// +build openbsd + +package udp + +import ( + "encoding/binary" + + "github.com/xtls/xray-core/common/net" + "golang.org/x/sys/unix" +) + +func retrieveOriginalDestFromControlMessages(msgs []unix.SocketControlMessage) net.Destination { + var ip []byte + var port uint16 + var haveAddress bool + var havePort bool + + for _, msg := range msgs { + if msg.Header.Level != unix.IPPROTO_IP { + continue + } + + switch msg.Header.Type { + case unix.IP_RECVDSTADDR: + if len(msg.Data) < 4 { + continue + } + ip = append(ip[:0], msg.Data[:4]...) + haveAddress = true + case unix.IP_RECVDSTPORT: + if len(msg.Data) < 2 { + continue + } + port = binary.BigEndian.Uint16(msg.Data[:2]) + havePort = true + } + } + + if !haveAddress || !havePort || port == 0 { + return net.Destination{} + } + + return net.UDPDestination(net.IPAddress(ip), net.Port(port)) +} + +func RetrieveOriginalDest(oob []byte) net.Destination { + msgs, err := unix.ParseSocketControlMessage(oob) + if err != nil { + return net.Destination{} + } + return retrieveOriginalDestFromControlMessages(msgs) +} + +func ReadUDPMsg(conn *net.UDPConn, payload []byte, oob []byte) (int, int, int, *net.UDPAddr, error) { + return conn.ReadMsgUDP(payload, oob) +} diff --git a/transport/internet/udp/hub_openbsd_test.go b/transport/internet/udp/hub_openbsd_test.go new file mode 100644 index 000000000..084a117bd --- /dev/null +++ b/transport/internet/udp/hub_openbsd_test.go @@ -0,0 +1,57 @@ +//go:build openbsd +// +build openbsd + +package udp + +import ( + "testing" + + "golang.org/x/sys/unix" +) + +func TestRetrieveOriginalDestFromControlMessages(t *testing.T) { + msgs := []unix.SocketControlMessage{ + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IP, Type: unix.IP_RECVDSTPORT}, + Data: []byte{0x30, 0x39}, + }, + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IP, Type: unix.IP_RECVDSTADDR}, + Data: []byte{203, 0, 113, 7}, + }, + } + + dest := retrieveOriginalDestFromControlMessages(msgs) + if !dest.IsValid() { + t.Fatal("destination is invalid") + } + if got, want := dest.Address.String(), "203.0.113.7"; got != want { + t.Fatalf("address = %q, want %q", got, want) + } + if got, want := dest.Port.Value(), uint16(12345); got != want { + t.Fatalf("port = %d, want %d", got, want) + } +} + +func TestRetrieveOriginalDestRequiresAddressAndPort(t *testing.T) { + tests := [][]unix.SocketControlMessage{ + { + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IP, Type: unix.IP_RECVDSTADDR}, + Data: []byte{203, 0, 113, 7}, + }, + }, + { + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IP, Type: unix.IP_RECVDSTPORT}, + Data: []byte{0x30, 0x39}, + }, + }, + } + + for _, msgs := range tests { + if dest := retrieveOriginalDestFromControlMessages(msgs); dest.IsValid() { + t.Fatalf("unexpected destination: %v", dest) + } + } +} diff --git a/transport/internet/udp/hub_other.go b/transport/internet/udp/hub_other.go index 3a7841832..f85da08b5 100644 --- a/transport/internet/udp/hub_other.go +++ b/transport/internet/udp/hub_other.go @@ -1,5 +1,5 @@ -//go:build !linux && !freebsd && !darwin -// +build !linux,!freebsd,!darwin +//go:build !linux && !freebsd && !darwin && !openbsd +// +build !linux,!freebsd,!darwin,!openbsd package udp From a12801c13b0a72efd2058be6d97763921b1236e2 Mon Sep 17 00:00:00 2001 From: fanymagnet Date: Wed, 12 Aug 2026 08:29:32 +0300 Subject: [PATCH 32/59] Routing: Add `localOS` that directly matches `runtime.GOOS` (#6553) https://github.com/XTLS/Xray-core/pull/6553#issuecomment-5262686006 --- app/router/condition.go | 20 ++++++++++++++++++++ app/router/condition_test.go | 27 +++++++++++++++++++++++++++ app/router/config.go | 4 ++++ app/router/config.pb.go | 18 ++++++++++++++---- app/router/config.proto | 3 +++ infra/conf/router.go | 5 +++++ 6 files changed, 73 insertions(+), 4 deletions(-) diff --git a/app/router/condition.go b/app/router/condition.go index b8a482593..4375a8fee 100644 --- a/app/router/condition.go +++ b/app/router/condition.go @@ -5,6 +5,7 @@ import ( "os" "path/filepath" "regexp" + "runtime" "slices" "strings" @@ -393,3 +394,22 @@ func (m *ProcessNameMatcher) Apply(ctx routing.Context) bool { } return false } + +// LocalOSMatcher matches the operating system Xray itself is running on. That never +// changes while Xray is running, so the result is resolved when the rule is built. +type LocalOSMatcher struct { + matched bool +} + +func NewLocalOSMatcher(names []string) *LocalOSMatcher { + return &LocalOSMatcher{ + matched: slices.ContainsFunc(names, func(name string) bool { + return strings.EqualFold(name, runtime.GOOS) + }), + } +} + +// Apply implements Condition. +func (m *LocalOSMatcher) Apply(_ routing.Context) bool { + return m.matched +} diff --git a/app/router/condition_test.go b/app/router/condition_test.go index 1b94bb8ef..510be8b47 100644 --- a/app/router/condition_test.go +++ b/app/router/condition_test.go @@ -2,7 +2,9 @@ package router_test import ( "path/filepath" + "runtime" "strconv" + "strings" "testing" . "github.com/xtls/xray-core/app/router" @@ -343,6 +345,31 @@ func TestChinaSites(t *testing.T) { } } +func TestLocalOSRule(t *testing.T) { + otherOS := "plan9" + if runtime.GOOS == otherOS { + otherOS = "linux" + } + + cases := []struct { + localOS []string + output bool + }{ + {localOS: []string{runtime.GOOS}, output: true}, + {localOS: []string{otherOS}, output: false}, + {localOS: []string{otherOS, runtime.GOOS}, output: true}, + {localOS: []string{strings.ToUpper(runtime.GOOS)}, output: true}, + } + + for _, test := range cases { + cond, err := (&RoutingRule{LocalOs: test.localOS}).BuildCondition() + common.Must(err) + if got := cond.Apply(withBackground()); got != test.output { + t.Errorf("for localOS %v on %s: expected %v, got %v", test.localOS, runtime.GOOS, test.output, got) + } + } +} + func BenchmarkMphDomainMatcher(b *testing.B) { b.Setenv("xray.location.asset", filepath.Join("..", "..", "resources")) rules, err := geodata.ParseDomainRules([]string{"geosite:cn"}, geodata.Domain_Substr) diff --git a/app/router/config.go b/app/router/config.go index 0a76905c1..036b24ef5 100644 --- a/app/router/config.go +++ b/app/router/config.go @@ -33,6 +33,10 @@ func (r *Rule) Apply(ctx routing.Context) bool { func (rr *RoutingRule) BuildCondition() (Condition, error) { conds := NewConditionChan() + if len(rr.LocalOs) > 0 { + conds.Add(NewLocalOSMatcher(rr.LocalOs)) + } + if len(rr.InboundTag) > 0 { conds.Add(NewInboundTagMatcher(rr.InboundTag)) } diff --git a/app/router/config.pb.go b/app/router/config.pb.go index 6c1e27504..636096a7f 100644 --- a/app/router/config.pb.go +++ b/app/router/config.pb.go @@ -107,8 +107,10 @@ type RoutingRule struct { VlessRouteList *net.PortList `protobuf:"bytes,20,opt,name=vless_route_list,json=vlessRouteList,proto3" json:"vless_route_list,omitempty"` Process []string `protobuf:"bytes,21,rep,name=process,proto3" json:"process,omitempty"` Webhook *WebhookConfig `protobuf:"bytes,22,opt,name=webhook,proto3" json:"webhook,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // List of operating systems for matching the one Xray itself is running on. + LocalOs []string `protobuf:"bytes,23,rep,name=local_os,json=localOs,proto3" json:"local_os,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *RoutingRule) Reset() { @@ -278,6 +280,13 @@ func (x *RoutingRule) GetWebhook() *WebhookConfig { return nil } +func (x *RoutingRule) GetLocalOs() []string { + if x != nil { + return x.LocalOs + } + return nil +} + type isRoutingRule_TargetTag interface { isRoutingRule_TargetTag() } @@ -637,7 +646,7 @@ var File_app_router_config_proto protoreflect.FileDescriptor const file_app_router_config_proto_rawDesc = "" + "\n" + - "\x17app/router/config.proto\x12\x0fxray.app.router\x1a!common/serial/typed_message.proto\x1a\x15common/net/port.proto\x1a\x18common/net/network.proto\x1a\x1bcommon/geodata/geodat.proto\"\xc1\a\n" + + "\x17app/router/config.proto\x12\x0fxray.app.router\x1a!common/serial/typed_message.proto\x1a\x15common/net/port.proto\x1a\x18common/net/network.proto\x1a\x1bcommon/geodata/geodat.proto\"\xdc\a\n" + "\vRoutingRule\x12\x12\n" + "\x03tag\x18\x01 \x01(\tH\x00R\x03tag\x12%\n" + "\rbalancing_tag\x18\f \x01(\tH\x00R\fbalancingTag\x12\x19\n" + @@ -661,7 +670,8 @@ const file_app_router_config_proto_rawDesc = "" + "\x0flocal_port_list\x18\x12 \x01(\v2\x19.xray.common.net.PortListR\rlocalPortList\x12C\n" + "\x10vless_route_list\x18\x14 \x01(\v2\x19.xray.common.net.PortListR\x0evlessRouteList\x12\x18\n" + "\aprocess\x18\x15 \x03(\tR\aprocess\x128\n" + - "\awebhook\x18\x16 \x01(\v2\x1e.xray.app.router.WebhookConfigR\awebhook\x1a=\n" + + "\awebhook\x18\x16 \x01(\v2\x1e.xray.app.router.WebhookConfigR\awebhook\x12\x19\n" + + "\blocal_os\x18\x17 \x03(\tR\alocalOs\x1a=\n" + "\x0fAttributesEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01B\f\n" + diff --git a/app/router/config.proto b/app/router/config.proto index 60f565c36..67e4e47f1 100644 --- a/app/router/config.proto +++ b/app/router/config.proto @@ -56,6 +56,9 @@ message RoutingRule { repeated string process = 21; WebhookConfig webhook = 22; + + // List of operating systems for matching the one Xray itself is running on. + repeated string local_os = 23; } message WebhookConfig { diff --git a/infra/conf/router.go b/infra/conf/router.go index af83c7435..3f85ce8fa 100644 --- a/infra/conf/router.go +++ b/infra/conf/router.go @@ -148,6 +148,7 @@ func parseFieldRule(msg json.RawMessage) (*router.RoutingRule, error) { LocalIP *StringList `json:"localIP"` LocalPort *PortList `json:"localPort"` Process *StringList `json:"process"` + LocalOS *StringList `json:"localOS"` Webhook *WebhookRuleConfig `json:"webhook"` } rawFieldRule := new(RawFieldRule) @@ -261,6 +262,10 @@ func parseFieldRule(msg json.RawMessage) (*router.RoutingRule, error) { rule.Process = *rawFieldRule.Process } + if rawFieldRule.LocalOS != nil && len(*rawFieldRule.LocalOS) > 0 { + rule.LocalOs = *rawFieldRule.LocalOS + } + if rawFieldRule.Webhook != nil && rawFieldRule.Webhook.URL != "" { rule.Webhook = &router.WebhookConfig{ Url: rawFieldRule.Webhook.URL, From 8b419d833df0e25935667c2b510178137d3b18ad Mon Sep 17 00:00:00 2001 From: yiguodev <147401898+yiguodev@users.noreply.github.com> Date: Wed, 12 Aug 2026 13:40:07 +0800 Subject: [PATCH 33/59] Routing: Fix `process` for macOS IPv4-mapped sockets (#6557) Fixes https://github.com/XTLS/Xray-core/issues/6533 --- common/net/find_process_darwin.go | 4 +- common/net/find_process_darwin_test.go | 63 ++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 1 deletion(-) diff --git a/common/net/find_process_darwin.go b/common/net/find_process_darwin.go index 540e534fa..ec8672c4b 100644 --- a/common/net/find_process_darwin.go +++ b/common/net/find_process_darwin.go @@ -198,7 +198,9 @@ func darwinSocketInfoMatchLevel(info []byte, network string, srcAddr netip.Addr, vflag := info[darwinInSockInfoVFlagOff] if srcAddr.Is4() { - if family != unix.AF_INET || vflag&darwinInSockInfoIPv4 == 0 { + // Dual-stack sockets expose IPv4-mapped connections as AF_INET6 + // while marking the endpoint as IPv4 in ini_vflag. + if (family != unix.AF_INET && family != unix.AF_INET6) || vflag&darwinInSockInfoIPv4 == 0 { return darwinSocketNoMatch } } else { diff --git a/common/net/find_process_darwin_test.go b/common/net/find_process_darwin_test.go index 88e94277a..28cfc8b51 100644 --- a/common/net/find_process_darwin_test.go +++ b/common/net/find_process_darwin_test.go @@ -52,6 +52,57 @@ func TestFindProcessDarwinTCP(t *testing.T) { assertCurrentProcess(t, pid, name, path) } +func TestFindProcessDarwinTCPIPv4Mapped(t *testing.T) { + listener, err := stdnet.Listen("tcp4", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + + accepted := make(chan stdnet.Conn, 1) + go func() { + conn, err := listener.Accept() + if err == nil { + accepted <- conn + return + } + close(accepted) + }() + + listenerAddr := listener.Addr().(*stdnet.TCPAddr) + fd, err := unix.Socket(unix.AF_INET6, unix.SOCK_STREAM, unix.IPPROTO_TCP) + if err != nil { + t.Fatal(err) + } + defer unix.Close(fd) + + mappedAddr := [16]byte{10: 0xff, 11: 0xff, 12: 127, 15: 1} + if err := unix.Connect(fd, &unix.SockaddrInet6{ + Port: listenerAddr.Port, + Addr: mappedAddr, + }); err != nil { + t.Fatal(err) + } + + serverConn := <-accepted + if serverConn == nil { + t.Fatal("server did not accept tcp connection") + } + defer serverConn.Close() + + local, err := unix.Getsockname(fd) + if err != nil { + t.Fatal(err) + } + localPort := local.(*unix.SockaddrInet6).Port + + pid, name, path, err := FindProcess("tcp", "127.0.0.1", uint16(localPort), "127.0.0.1", uint16(listenerAddr.Port)) + if err != nil { + t.Fatal(err) + } + assertCurrentProcess(t, pid, name, path) +} + func TestFindProcessDarwinUDP(t *testing.T) { conn, err := stdnet.ListenUDP("udp", &stdnet.UDPAddr{IP: stdnet.ParseIP("127.0.0.1")}) if err != nil { @@ -264,6 +315,18 @@ func TestDarwinSocketInfoMatchLevelFallbacks(t *testing.T) { } } +func TestDarwinSocketInfoMatchLevelIPv4Mapped(t *testing.T) { + src := netip.MustParseAddr("127.0.0.1") + dst := netip.MustParseAddr("203.0.113.10") + info := newDarwinSocketInfo("tcp", src, 12345, dst, 443) + writeDarwinNativeUint32(info, darwinSocketInfoFamilyOff, uint32(unix.AF_INET6)) + + level := darwinSocketInfoMatchLevel(info, "tcp", src, 12345, dst, 443, true) + if level != darwinSocketExactMatch { + t.Fatalf("unexpected match level: got %d, want %d", level, darwinSocketExactMatch) + } +} + func newDarwinSocketInfo(network string, local netip.Addr, localPort uint16, remote netip.Addr, remotePort uint16) []byte { info := make([]byte, darwinSocketFDInfoSize) switch network { From 7d214f8b094f75322fa3990f8aadad1c912f24f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Wed, 12 Aug 2026 16:38:01 +0800 Subject: [PATCH 34/59] WireGuard outbound: Fix `sendThrough` support (#6570) Fixes https://github.com/XTLS/Xray-core/issues/6559 --- app/proxyman/outbound/handler.go | 1 - proxy/wireguard/client.go | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/app/proxyman/outbound/handler.go b/app/proxyman/outbound/handler.go index 5f4833e9b..8cf2a783a 100644 --- a/app/proxyman/outbound/handler.go +++ b/app/proxyman/outbound/handler.go @@ -347,7 +347,6 @@ func (h *Handler) SetOutboundGateway(ctx context.Context, ob *session.Outbound) // case addr.Family().IsDomain(): default: ob.Gateway = addr - } } diff --git a/proxy/wireguard/client.go b/proxy/wireguard/client.go index cae846077..59709b4bf 100644 --- a/proxy/wireguard/client.go +++ b/proxy/wireguard/client.go @@ -138,6 +138,7 @@ func (h *Handler) Process(ctx context.Context, link *transport.Link, dialer inte } ob.Name = "wireguard" ob.CanSpliceCopy = 3 + dialer.SetOutboundGateway(ctx, ob) if err := h.init(ctx); err != nil { return err From 7021606ad39622255ace8a3ff976b4b2550b3f87 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:31:30 +0000 Subject: [PATCH 35/59] Bump google.golang.org/protobuf from 1.36.11 to 1.36.12 (#6616) Bumps google.golang.org/protobuf from 1.36.11 to 1.36.12. --- updated-dependencies: - dependency-name: google.golang.org/protobuf dependency-version: 1.36.12 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 4bc380a82..8f1751f6a 100644 --- a/go.mod +++ b/go.mod @@ -32,7 +32,7 @@ require ( golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/windows v1.0.1 google.golang.org/grpc v1.83.0 - google.golang.org/protobuf v1.36.11 + google.golang.org/protobuf v1.36.12 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h12.io/socks v1.0.3 lukechampine.com/blake3 v1.4.1 diff --git a/go.sum b/go.sum index 0f73d11d5..d36866e6c 100644 --- a/go.sum +++ b/go.sum @@ -151,8 +151,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= -google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= -google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= +google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= From 09107b71dc70a3a3c6259a06dde3b7e8605bbf96 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:31:41 +0000 Subject: [PATCH 36/59] Bump golang.org/x/net from 0.57.0 to 0.58.0 (#6633) Bumps [golang.org/x/net](https://github.com/golang/net) from 0.57.0 to 0.58.0. - [Commits](https://github.com/golang/net/compare/v0.57.0...v0.58.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.58.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 10 +++++----- go.sum | 20 ++++++++++---------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index 8f1751f6a..fbd8ba0c2 100644 --- a/go.mod +++ b/go.mod @@ -23,9 +23,9 @@ require ( github.com/vishvananda/netlink v1.3.1 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f go4.org/netipx v0.0.0-20231129151722-fdeea329fbba - golang.org/x/crypto v0.54.0 + golang.org/x/crypto v0.55.0 golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 - golang.org/x/net v0.57.0 + golang.org/x/net v0.58.0 golang.org/x/sync v0.22.0 golang.org/x/sys v0.47.0 golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 @@ -52,10 +52,10 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.37.0 // indirect - golang.org/x/text v0.40.0 // indirect + golang.org/x/mod v0.38.0 // indirect + golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.47.0 // indirect + golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index d36866e6c..241142f29 100644 --- a/go.sum +++ b/go.sum @@ -98,18 +98,18 @@ go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBs go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= -golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= +golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= +golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= -golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -127,15 +127,15 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= -golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= -golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 2323273e373f06b829b8c1cc0b4da149153e7358 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Aug 2026 05:31:44 +0000 Subject: [PATCH 37/59] Bump github.com/stretchr/testify from 1.11.1 to 1.12.0 (#6641) Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.11.1 to 1.12.0. - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.11.1...v1.12.0) --- updated-dependencies: - dependency-name: github.com/stretchr/testify dependency-version: 1.12.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 +--- go.sum | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index fbd8ba0c2..ec285e794 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sagernet/sing v0.5.1 github.com/sagernet/sing-shadowsocks v0.2.7 - github.com/stretchr/testify v1.11.1 + github.com/stretchr/testify v1.12.0 github.com/vishvananda/netlink v1.3.1 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -40,7 +40,6 @@ require ( require ( github.com/andybalholm/brotli v1.0.6 // indirect - github.com/davecgh/go-spew v1.1.1 // indirect github.com/google/btree v1.1.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect @@ -48,7 +47,6 @@ require ( github.com/pion/dtls/v3 v3.1.4 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/transport/v4 v4.0.2 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect diff --git a/go.sum b/go.sum index 241142f29..d10af03f6 100644 --- a/go.sum +++ b/go.sum @@ -7,8 +7,6 @@ github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= github.com/cloudflare/circl v1.6.5/go.mod h1:h5LNyxAc5nTue9DS5jT+48en2PSDYt3zdGnz5OstK6c= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -55,8 +53,6 @@ github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6 github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= @@ -69,8 +65,8 @@ github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= -github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= -github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= +github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= From d3f1a2428556b22ed542f1caf3026d899bc87dd7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:28:35 +0000 Subject: [PATCH 38/59] Bump github.com/pion/stun/v3 from 3.1.6 to 3.1.7 (#6674) Bumps [github.com/pion/stun/v3](https://github.com/pion/stun) from 3.1.6 to 3.1.7. - [Release notes](https://github.com/pion/stun/releases) - [Commits](https://github.com/pion/stun/compare/v3.1.6...v3.1.7) --- updated-dependencies: - dependency-name: github.com/pion/stun/v3 dependency-version: 3.1.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 6 +++--- go.sum | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index ec285e794..b27ea98b3 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/klauspost/cpuid/v2 v2.4.0 github.com/miekg/dns v1.1.72 github.com/pelletier/go-toml v1.9.5 - github.com/pion/stun/v3 v3.1.6 + github.com/pion/stun/v3 v3.1.7 github.com/pires/go-proxyproto v0.15.0 github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af github.com/robfig/cron/v3 v3.0.1 @@ -44,9 +44,9 @@ require ( github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/kr/text v0.2.0 // indirect - github.com/pion/dtls/v3 v3.1.4 // indirect + github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect - github.com/pion/transport/v4 v4.0.2 // indirect + github.com/pion/transport/v4 v4.1.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect diff --git a/go.sum b/go.sum index d10af03f6..63eb811d0 100644 --- a/go.sum +++ b/go.sum @@ -43,14 +43,14 @@ github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3v github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoUmOs6V/G4D5nPVUW73rKvXxP4XUJc= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= -github.com/pion/dtls/v3 v3.1.4 h1:QhvtMflMfu9Kf0RcDC5BJBle4caPskByrKQR6uuYqpY= -github.com/pion/dtls/v3 v3.1.4/go.mod h1:cr/qotLISUw/9C1m83ZPNZtj9WnXkYLpfCptPqbkInc= +github.com/pion/dtls/v3 v3.1.5 h1:9xJtVsHwMYeSjPp5Hh1FTis4DchnQWtnOa5o+6ygqfc= +github.com/pion/dtls/v3 v3.1.5/go.mod h1:gz1K4jg6c+fq86oQMH4pilpCEOEPwmEr2jY+VcF/mkU= github.com/pion/logging v0.2.4 h1:tTew+7cmQ+Mc1pTBLKH2puKsOvhm32dROumOZ655zB8= github.com/pion/logging v0.2.4/go.mod h1:DffhXTKYdNZU+KtJ5pyQDjvOAh/GsNSyv1lbkFbe3so= -github.com/pion/stun/v3 v3.1.6 h1:WnhsD0eHCiwCfKNkVx0VJJwr2Y3eV4Ueih3KJ+dfZy8= -github.com/pion/stun/v3 v3.1.6/go.mod h1:zRUghXSQU32Lx5orJsz3uYMkIihweXb3mu5gIns02fs= -github.com/pion/transport/v4 v4.0.2 h1:ifYlPqNwsy6aKQ9y8yzxXlHae5431ZrH2avkD/Rn6Tk= -github.com/pion/transport/v4 v4.0.2/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= +github.com/pion/stun/v3 v3.1.7 h1:uRXMTlGLf89WgItGNyZ6aR5jMTX0NBbybXADpQCzn+E= +github.com/pion/stun/v3 v3.1.7/go.mod h1:Nq77RW4aRrSNrltf2ksUJLjxWeipj4lnlgdsYIxC8g8= +github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97BvQ= +github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= From 0604ffa9577555cc69286ed317ea48cdca0dbc3d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:29:12 +0000 Subject: [PATCH 39/59] Bump github.com/miekg/dns from 1.1.72 to 1.1.73 (#6675) Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.72 to 1.1.73. - [Commits](https://github.com/miekg/dns/compare/v1.1.72...v1.1.73) --- updated-dependencies: - dependency-name: github.com/miekg/dns dependency-version: 1.1.73 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 +--- go.sum | 8 ++------ 2 files changed, 3 insertions(+), 9 deletions(-) diff --git a/go.mod b/go.mod index b27ea98b3..e84489883 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/klauspost/cpuid/v2 v2.4.0 - github.com/miekg/dns v1.1.72 + github.com/miekg/dns v1.1.73 github.com/pelletier/go-toml v1.9.5 github.com/pion/stun/v3 v3.1.7 github.com/pires/go-proxyproto v0.15.0 @@ -50,10 +50,8 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect - golang.org/x/mod v0.38.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.48.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 63eb811d0..fbd6dc689 100644 --- a/go.sum +++ b/go.sum @@ -37,8 +37,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= -github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= +github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= +github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/phayes/freeport v0.0.0-20180830031419-95f893ade6f2 h1:JhzVVoYvbOACxoUmOs6V/G4D5nPVUW73rKvXxP4XUJc= @@ -99,8 +99,6 @@ golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xi golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= @@ -130,8 +128,6 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= From 5fe6d6217a1dfd6efdaeb937aa84b3daee095371 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:29:35 +0000 Subject: [PATCH 40/59] Bump google.golang.org/grpc from 1.83.0 to 1.83.1 (#6676) Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.83.0 to 1.83.1. - [Release notes](https://github.com/grpc/grpc-go/releases) - [Commits](https://github.com/grpc/grpc-go/compare/v1.83.0...v1.83.1) --- updated-dependencies: - dependency-name: google.golang.org/grpc dependency-version: 1.83.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index e84489883..2cf43597c 100644 --- a/go.mod +++ b/go.mod @@ -31,7 +31,7 @@ require ( golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb golang.zx2c4.com/wireguard/windows v1.0.1 - google.golang.org/grpc v1.83.0 + google.golang.org/grpc v1.83.1 google.golang.org/protobuf v1.36.12 gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h12.io/socks v1.0.3 diff --git a/go.sum b/go.sum index fbd6dc689..b2347754f 100644 --- a/go.sum +++ b/go.sum @@ -141,8 +141,8 @@ gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.83.0 h1:JeNZEKJFbQxArAMl+hiytHauacDNqJUllNfmIMmpqnQ= -google.golang.org/grpc v1.83.0/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= +google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y= +google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ= google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc= google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From f02a35786124a6ad046727f2408e32317cc19a41 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 24 Aug 2026 02:38:00 +0000 Subject: [PATCH 41/59] Bump github.com/stretchr/testify from 1.12.0 to 1.12.1 (#6657) Bumps [github.com/stretchr/testify](https://github.com/stretchr/testify) from 1.12.0 to 1.12.1. - [Release notes](https://github.com/stretchr/testify/releases) - [Commits](https://github.com/stretchr/testify/compare/v1.12.0...v1.12.1) --- updated-dependencies: - dependency-name: github.com/stretchr/testify dependency-version: 1.12.1 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 2cf43597c..b56e9c4bf 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,7 @@ require ( github.com/robfig/cron/v3 v3.0.1 github.com/sagernet/sing v0.5.1 github.com/sagernet/sing-shadowsocks v0.2.7 - github.com/stretchr/testify v1.12.0 + github.com/stretchr/testify v1.12.1 github.com/vishvananda/netlink v1.3.1 github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f go4.org/netipx v0.0.0-20231129151722-fdeea329fbba @@ -50,9 +50,9 @@ require ( github.com/quic-go/qpack v0.6.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect + go.yaml.in/yaml/v3 v3.0.5 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index b2347754f..29504f710 100644 --- a/go.sum +++ b/go.sum @@ -65,8 +65,8 @@ github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= -github.com/stretchr/testify v1.12.0 h1:K6Mr6jO9JICuend/5xzTM03ydSV3vdNRYAdPSukj8uI= -github.com/stretchr/testify v1.12.0/go.mod h1:bOYBZb5qJ00vPzWfIqBUZPaxK8jWiXc6d3ErP4Ca9Gw= +github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= +github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -90,6 +90,8 @@ go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/ go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw= +go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -151,8 +153,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EV gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= h12.io/socks v1.0.3 h1:Ka3qaQewws4j4/eDQnOdpr4wXsC//dXtWvftlIcCQUo= From c7e569b0377724600af1ea2a05eb8f4c7c3e0609 Mon Sep 17 00:00:00 2001 From: LjhAUMEM Date: Tue, 25 Aug 2026 22:28:36 +0800 Subject: [PATCH 42/59] WireGuard outbound: Add `remoteDNS` & honor TTL (#6620) Closes https://github.com/XTLS/Xray-core/pull/6569#issuecomment-5263789755 Fixes https://github.com/XTLS/Xray-core/issues/6567#issuecomment-5150957597 --------- Co-authored-by: LagPixelLOL <2282688304@qq.com> --- infra/conf/wireguard.go | 2 + proxy/wireguard/client.go | 72 +++++++++++++++++++++++++++--------- proxy/wireguard/config.pb.go | 14 ++++++- proxy/wireguard/config.proto | 1 + proxy/wireguard/netstack.go | 21 ++++++----- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/infra/conf/wireguard.go b/infra/conf/wireguard.go index 8131be28a..0e41c9c61 100644 --- a/infra/conf/wireguard.go +++ b/infra/conf/wireguard.go @@ -66,6 +66,7 @@ type WireGuardConfig struct { MTU int32 `json:"mtu"` Reserved []byte `json:"reserved"` DomainStrategy string `json:"domainStrategy"` + DNS []string `json:"remoteDNS"` } func (c *WireGuardConfig) Build() (proto.Message, error) { @@ -141,6 +142,7 @@ func (c *WireGuardConfig) Build() (proto.Message, error) { config.IsClient = c.IsClient config.NoKernelTun = c.NoKernelTun + config.DNS = c.DNS return config, nil } diff --git a/proxy/wireguard/client.go b/proxy/wireguard/client.go index 59709b4bf..ef0cc1e2a 100644 --- a/proxy/wireguard/client.go +++ b/proxy/wireguard/client.go @@ -5,9 +5,10 @@ import ( "fmt" gonet "net" "net/netip" - reflect "reflect" + "reflect" "strings" "sync" + "time" "golang.zx2c4.com/wireguard/tun" @@ -30,6 +31,11 @@ import ( "golang.zx2c4.com/wireguard/device" ) +type entry struct { + got []net.IP + time time.Time +} + type Handler struct { conf *DeviceConfig policyManager policy.Manager @@ -43,6 +49,11 @@ type Handler struct { tnet *Net dev *device.Device mu sync.Mutex + + // TODO: cache cleanup loop + local bool + cache map[string]entry + cacheMu sync.Mutex } func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { @@ -98,6 +109,20 @@ func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { return nil, err } + local := false + dns := conf.DNS + if len(dns) == 0 { + dns = []string{"1.1.1.1", "1.0.0.1", "2606:4700:4700::1111", "2606:4700:4700::1001"} + } + if len(dns) == 1 && dns[0] == "local" { + local = true + dns = nil + } + dnses := make([]netip.Addr, 0, len(dns)) + for _, dns := range dns { + dnses = append(dnses, netip.MustParseAddr(dns)) + } + kernelTunSupported, err := KernelTunSupported() if err != nil { errors.LogWarningInner(context.Background(), err, "Failed to check kernel TUN support") @@ -106,10 +131,10 @@ func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { var tnet *Net if !conf.NoKernelTun && kernelTunSupported { errors.LogWarning(context.Background(), "Using kernel TUN") - tun, tnet, err = createKernelTun(localAddresses, []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1"), netip.MustParseAddr("2606:4700:4700::1111"), netip.MustParseAddr("2606:4700:4700::1001")}, int(conf.Mtu)) + tun, tnet, err = createKernelTun(localAddresses, dnses, int(conf.Mtu)) } else { errors.LogWarning(context.Background(), "Using gVisor TUN") - tun, tnet, _, err = CreateNetTUN(localAddresses, []netip.Addr{netip.MustParseAddr("1.1.1.1"), netip.MustParseAddr("1.0.0.1"), netip.MustParseAddr("2606:4700:4700::1111"), netip.MustParseAddr("2606:4700:4700::1001")}, int(conf.Mtu), true) + tun, tnet, _, err = CreateNetTUN(localAddresses, dnses, int(conf.Mtu), true) } if err != nil { return nil, err @@ -126,6 +151,9 @@ func NewClient(ctx context.Context, conf *DeviceConfig) (*Handler, error) { tun: tun, tnet: tnet, + + local: local, + cache: make(map[string]entry), }, nil } @@ -343,31 +371,34 @@ func (h *Handler) init(ctx context.Context) error { } func (h *Handler) resolveLocal(host string) (net.IP, error) { - return resolveDomain(host, h.conf.DomainStrategy, func(host string) ([]net.IP, error) { - ips, _, err := h.dns.LookupIP(host, dns.IPOption{IPv4Enable: true, IPv6Enable: true}) - return ips, err + return h.resolveDomain(host, h.conf.DomainStrategy, func(host string) ([]net.IP, uint32, error) { + return h.dns.LookupIP(host, dns.IPOption{IPv4Enable: true, IPv6Enable: true}) }) } func (h *Handler) resolveRemote(host string) (net.IP, error) { - return resolveDomain(host, h.conf.DomainStrategy, func(host string) ([]net.IP, error) { - addrs, err := h.tnet.LookupHost(host) - if err != nil { - return nil, err + return h.resolveDomain(host, h.conf.DomainStrategy, func(host string) ([]net.IP, uint32, error) { + if h.local { + return h.dns.LookupIP(host, dns.IPOption{IPv4Enable: true, IPv6Enable: true}) } - ips := make([]net.IP, 0, len(addrs)) - for _, addr := range addrs { - ips = append(ips, net.ParseIP(addr)) - } - return ips, nil + return h.tnet.LookupHost(host) }) } -func resolveDomain(host string, strategy DeviceConfig_DomainStrategy, lookupIP func(host string) ([]net.IP, error)) (net.IP, error) { +func (h *Handler) resolveDomain(host string, strategy DeviceConfig_DomainStrategy, lookupIP func(host string) ([]net.IP, uint32, error)) (net.IP, error) { if ip := net.ParseIP(host); ip != nil { return ip, nil } - ips, err := lookupIP(host) + h.cacheMu.Lock() + if entry, ok := h.cache[host]; ok { + if time.Now().Before(entry.time) { + h.cacheMu.Unlock() + return entry.got[dice.Roll(len(entry.got))], nil + } + delete(h.cache, host) + } + h.cacheMu.Unlock() + ips, ttl, err := lookupIP(host) if err != nil { return nil, err } @@ -407,6 +438,13 @@ func resolveDomain(host string, strategy DeviceConfig_DomainStrategy, lookupIP f if len(got) == 0 { return nil, dns.ErrEmptyResponse } + entry := entry{ + got: got, + time: time.Now().Add(time.Duration(ttl) * time.Second), + } + h.cacheMu.Lock() + h.cache[host] = entry + h.cacheMu.Unlock() return got[dice.Roll(len(got))], nil } diff --git a/proxy/wireguard/config.pb.go b/proxy/wireguard/config.pb.go index 59db6564f..4f434d1fc 100644 --- a/proxy/wireguard/config.pb.go +++ b/proxy/wireguard/config.pb.go @@ -164,6 +164,7 @@ type DeviceConfig struct { DomainStrategy DeviceConfig_DomainStrategy `protobuf:"varint,7,opt,name=domain_strategy,json=domainStrategy,proto3,enum=xray.proxy.wireguard.DeviceConfig_DomainStrategy" json:"domain_strategy,omitempty"` IsClient bool `protobuf:"varint,8,opt,name=is_client,json=isClient,proto3" json:"is_client,omitempty"` NoKernelTun bool `protobuf:"varint,9,opt,name=no_kernel_tun,json=noKernelTun,proto3" json:"no_kernel_tun,omitempty"` + DNS []string `protobuf:"bytes,10,rep,name=DNS,proto3" json:"DNS,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -261,6 +262,13 @@ func (x *DeviceConfig) GetNoKernelTun() bool { return false } +func (x *DeviceConfig) GetDNS() []string { + if x != nil { + return x.DNS + } + return nil +} + var File_proxy_wireguard_config_proto protoreflect.FileDescriptor const file_proxy_wireguard_config_proto_rawDesc = "" + @@ -275,7 +283,7 @@ const file_proxy_wireguard_config_proto_rawDesc = "" + "\n" + "keep_alive\x18\x04 \x01(\tR\tkeepAlive\x12\x1f\n" + "\vallowed_ips\x18\x05 \x03(\tR\n" + - "allowedIps\"\xdc\x03\n" + + "allowedIps\"\xee\x03\n" + "\fDeviceConfig\x12\x1d\n" + "\n" + "secret_key\x18\x01 \x01(\tR\tsecretKey\x12\x1a\n" + @@ -286,7 +294,9 @@ const file_proxy_wireguard_config_proto_rawDesc = "" + "\breserved\x18\x06 \x01(\fR\breserved\x12Z\n" + "\x0fdomain_strategy\x18\a \x01(\x0e21.xray.proxy.wireguard.DeviceConfig.DomainStrategyR\x0edomainStrategy\x12\x1b\n" + "\tis_client\x18\b \x01(\bR\bisClient\x12\"\n" + - "\rno_kernel_tun\x18\t \x01(\bR\vnoKernelTun\"\\\n" + + "\rno_kernel_tun\x18\t \x01(\bR\vnoKernelTun\x12\x10\n" + + "\x03DNS\x18\n" + + " \x03(\tR\x03DNS\"\\\n" + "\x0eDomainStrategy\x12\f\n" + "\bFORCE_IP\x10\x00\x12\r\n" + "\tFORCE_IP4\x10\x01\x12\r\n" + diff --git a/proxy/wireguard/config.proto b/proxy/wireguard/config.proto index 171d5b21a..628059827 100644 --- a/proxy/wireguard/config.proto +++ b/proxy/wireguard/config.proto @@ -34,4 +34,5 @@ message DeviceConfig { DomainStrategy domain_strategy = 7; bool is_client = 8; bool no_kernel_tun = 9; + repeated string DNS = 10; } \ No newline at end of file diff --git a/proxy/wireguard/netstack.go b/proxy/wireguard/netstack.go index 22c42e3f9..813f46790 100644 --- a/proxy/wireguard/netstack.go +++ b/proxy/wireguard/netstack.go @@ -248,7 +248,7 @@ var ( errTimeout = errors.New("i/o timeout") ) -func (net *Net) LookupHost(host string) (addrs []string, err error) { +func (net *Net) LookupHost(host string) (addrs []net.IP, ttl uint32, err error) { return net.LookupContextHost(context.Background(), host) } @@ -567,9 +567,9 @@ func (tnet *Net) tryOneName(ctx context.Context, name string, qtype dnsmessage.T return dnsmessage.Parser{}, "", lastErr } -func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, error) { +func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]net.IP, uint32, error) { if host == "" || (!tnet.hasV6 && !tnet.hasV4) { - return nil, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} + return nil, 0, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} } zlen := len(host) if strings.IndexByte(host, ':') != -1 { @@ -578,11 +578,11 @@ func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, } } if ip, err := netip.ParseAddr(host[:zlen]); err == nil { - return []string{ip.String()}, nil + return []net.IP{ip.AsSlice()}, 0, nil } if !isDomainName(host) { - return nil, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} + return nil, 0, &net.DNSError{Err: errNoSuchHost.Error(), Name: host, IsNotFound: true} } type result struct { p dnsmessage.Parser @@ -611,6 +611,7 @@ func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, lane <- result{p, server, err} }() } + ttl := uint32(300) for l := 0; l < lanes; l++ { result := <-lane if result.error != nil { @@ -644,6 +645,7 @@ func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, } break loop } + ttl = min(ttl, h.TTL) addrsV4 = append(addrsV4, netip.AddrFrom4(a.A)) case dnsmessage.TypeAAAA: @@ -656,6 +658,7 @@ func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, } break loop } + ttl = min(ttl, h.TTL) addrsV6 = append(addrsV6, netip.AddrFrom16(aaaa.AAAA)) default: @@ -680,11 +683,11 @@ func (tnet *Net) LookupContextHost(ctx context.Context, host string) ([]string, } if len(addrs) == 0 && lastErr != nil { - return nil, lastErr + return nil, 0, lastErr } - saddrs := make([]string, 0, len(addrs)) + ips := make([]net.IP, 0, len(addrs)) for _, ip := range addrs { - saddrs = append(saddrs, ip.String()) + ips = append(ips, ip.AsSlice()) } - return saddrs, nil + return ips, ttl, nil } From 9b373e39ca1aeb2068a0618baed7dc54caa4e338 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Wed, 26 Aug 2026 00:49:14 +0800 Subject: [PATCH 43/59] Sniffer: Fix SniffUTP() (#6667) Fixes https://github.com/XTLS/Xray-core/pull/6664 --------- Co-authored-by: n0ctal <4c866w5fn9@privaterelay.appleid.com> --- common/protocol/bittorrent/bittorrent.go | 59 +++++++--------- common/protocol/bittorrent/bittorrent_test.go | 67 +++++++++++++++++++ 2 files changed, 92 insertions(+), 34 deletions(-) create mode 100644 common/protocol/bittorrent/bittorrent_test.go diff --git a/common/protocol/bittorrent/bittorrent.go b/common/protocol/bittorrent/bittorrent.go index 708307645..4c89b2c69 100644 --- a/common/protocol/bittorrent/bittorrent.go +++ b/common/protocol/bittorrent/bittorrent.go @@ -3,11 +3,8 @@ package bittorrent import ( "encoding/binary" "errors" - "math" - "time" "github.com/xtls/xray-core/common" - "github.com/xtls/xray-core/common/buf" ) type SniffHeader struct{} @@ -39,50 +36,44 @@ func SniffUTP(b []byte) (*SniffHeader, error) { return nil, common.ErrNoClue } - buffer := buf.FromBytes(b) - - var typeAndVersion uint8 - - if binary.Read(buffer, binary.BigEndian, &typeAndVersion) != nil { - return nil, common.ErrNoClue - } else if b[0]>>4&0xF > 4 || b[0]&0xF != 1 { + // type 4 (ST_SYN), version 1 + if b[0] != 0x41 { return nil, errNotBittorrent } - var extension uint8 - - if binary.Read(buffer, binary.BigEndian, &extension) != nil { - return nil, common.ErrNoClue - } else if extension != 0 && extension != 1 { + // timestamp_difference is always 0 in new connections + if binary.BigEndian.Uint32(b[8:12]) != 0 { return nil, errNotBittorrent } + // Walk the extension chain. Selective ack (1) and extension bits (2) + extension, offset := b[1], 20 for extension != 0 { - if extension != 1 { + if len(b) < offset+2 { return nil, errNotBittorrent } - if binary.Read(buffer, binary.BigEndian, &extension) != nil { - return nil, common.ErrNoClue + length := int(b[offset+1]) + switch extension { + case 1: // selective ack + if length < 4 || length%4 != 0 { + return nil, errNotBittorrent + } + case 2: // extension bits: fixed 8 bytes, sent in ST_SYN by µTorrent + if length != 8 { + return nil, errNotBittorrent + } + default: + return nil, errNotBittorrent } - - var length uint8 - if err := binary.Read(buffer, binary.BigEndian, &length); err != nil { - return nil, common.ErrNoClue - } - if common.Error2(buffer.ReadBytes(int32(length))) != nil { - return nil, common.ErrNoClue + if len(b) < offset+2+length { + return nil, errNotBittorrent } + extension = b[offset] + offset += 2 + length } - if common.Error2(buffer.ReadBytes(2)) != nil { - return nil, common.ErrNoClue - } - - var timestamp uint32 - if err := binary.Read(buffer, binary.BigEndian, ×tamp); err != nil { - return nil, common.ErrNoClue - } - if math.Abs(float64(time.Now().UnixMicro()-int64(timestamp))) > float64(24*time.Hour) { + // extensions should consume all ST_SYN payload + if len(b) != offset { return nil, errNotBittorrent } diff --git a/common/protocol/bittorrent/bittorrent_test.go b/common/protocol/bittorrent/bittorrent_test.go new file mode 100644 index 000000000..7fae197ce --- /dev/null +++ b/common/protocol/bittorrent/bittorrent_test.go @@ -0,0 +1,67 @@ +package bittorrent + +import ( + "encoding/binary" + "testing" + + "github.com/xtls/xray-core/common" +) + +// utpPacket builds the fixed 20-byte header defined by BEP 29. +func utpPacket(packetType, extension byte, tsDiff uint32, payload ...byte) []byte { + b := make([]byte, 20) + b[0] = packetType<<4 | 1 + b[1] = extension + binary.BigEndian.PutUint16(b[2:4], 0x4a3f) // connection_id, random + binary.BigEndian.PutUint32(b[4:8], 0x8c3a91d2) // timestamp_microseconds, sender's clock + binary.BigEndian.PutUint32(b[8:12], tsDiff) + binary.BigEndian.PutUint32(b[12:16], 0x00100000) // wnd_size + binary.BigEndian.PutUint16(b[16:18], 0x71ee) // seq_nr, random in libutp/libtorrent + binary.BigEndian.PutUint16(b[18:20], 0x0000) // ack_nr + return append(b, payload...) +} + +func TestSniffUTP(t *testing.T) { + selectiveAck := []byte{0, 4, 0xff, 0x00, 0xff, 0x00} + wrongVersion := utpPacket(4, 0, 0) + wrongVersion[0] = 4<<4 | 2 + + cases := []struct { + name string + payload []byte + err error + }{ + {"syn", utpPacket(4, 0, 0), nil}, + {"syn with selective ack", append(utpPacket(4, 1, 0), selectiveAck...), nil}, + {"syn with extension bits", append(utpPacket(4, 2, 0), 0, 8, 1, 2, 3, 4, 5, 6, 7, 8), nil}, + {"extension bits with wrong length", append(utpPacket(4, 2, 0), 0, 4, 1, 2, 3, 4), errNotBittorrent}, + {"syn with nonzero timestamp_difference", utpPacket(4, 0, 0x1234), errNotBittorrent}, + {"syn with trailing payload", utpPacket(4, 0, 0, 'x'), errNotBittorrent}, + // txid 0x4100, no EDNS0: the worst case colliding with the uTP header + {"dns query", []byte{ + 0x41, 0x00, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 'a', 0x02, 'c', 'o', 0x00, 0x00, 0x01, 0x00, 0x01, + }, errNotBittorrent}, + {"established connection packets", utpPacket(0, 0, 0x5678, 'x', 'y', 'z'), errNotBittorrent}, + {"state", utpPacket(2, 0, 0x5678), errNotBittorrent}, + {"fin", utpPacket(1, 0, 0x5678), errNotBittorrent}, + {"wrong version", wrongVersion, errNotBittorrent}, + {"unknown packet type", utpPacket(5, 0, 0), errNotBittorrent}, + {"unknown extension", utpPacket(4, 2, 0), errNotBittorrent}, + {"extension chain past the datagram", utpPacket(4, 1, 0, 0, 8, 0xff), errNotBittorrent}, + {"selective ack not in multiples of 4", append(utpPacket(4, 1, 0), 0, 3, 0xff, 0x00, 0xff), errNotBittorrent}, + {"shorter than the header", utpPacket(4, 0, 0)[:19], common.ErrNoClue}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + h, err := SniffUTP(c.payload) + if err != c.err { + t.Fatalf("expected error %v, got %v", c.err, err) + } + if err == nil && h == nil { + t.Fatal("expected a sniff header, got nil") + } + }) + } +} From 598bde74122f5f0207c7c6a56ccb74fbbbe33fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Wed, 26 Aug 2026 02:00:14 +0800 Subject: [PATCH 44/59] Router: Refactor to fix API data race (#6678) Fixes https://github.com/XTLS/Xray-core/pull/6673 --------- Co-authored-by: Kosta --- app/router/balancing.go | 6 +- app/router/balancing_override.go | 17 --- app/router/router.go | 173 +++++++++++-------------------- app/router/webhook.go | 40 +++---- 4 files changed, 79 insertions(+), 157 deletions(-) diff --git a/app/router/balancing.go b/app/router/balancing.go index 5f8cb1c21..5b7ef60a6 100644 --- a/app/router/balancing.go +++ b/app/router/balancing.go @@ -136,7 +136,7 @@ func (b *Balancer) SelectOutbounds() ([]string, error) { // GetPrincipleTarget implements routing.BalancerPrincipleTarget func (r *Router) GetPrincipleTarget(tag string) ([]string, error) { - if b, ok := r.balancers[tag]; ok { + if b, ok := (*r.balancers.Load())[tag]; ok { if s, ok := b.strategy.(BalancingPrincipleTarget); ok { candidates, err := b.SelectOutbounds() if err != nil { @@ -151,7 +151,7 @@ func (r *Router) GetPrincipleTarget(tag string) ([]string, error) { // SetOverrideTarget implements routing.BalancerOverrider func (r *Router) SetOverrideTarget(tag, target string) error { - if b, ok := r.balancers[tag]; ok { + if b, ok := (*r.balancers.Load())[tag]; ok { b.override.Put(target) return nil } @@ -160,7 +160,7 @@ func (r *Router) SetOverrideTarget(tag, target string) error { // GetOverrideTarget implements routing.BalancerOverrider func (r *Router) GetOverrideTarget(tag string) (string, error) { - if b, ok := r.balancers[tag]; ok { + if b, ok := (*r.balancers.Load())[tag]; ok { return b.override.Get(), nil } return "", errors.New("cannot find tag") diff --git a/app/router/balancing_override.go b/app/router/balancing_override.go index 96c0aea16..e3a34b75d 100644 --- a/app/router/balancing_override.go +++ b/app/router/balancing_override.go @@ -2,25 +2,8 @@ package router import ( sync "sync" - - "github.com/xtls/xray-core/common/errors" ) -func (r *Router) OverrideBalancer(balancer string, target string) error { - var b *Balancer - for tag, bl := range r.balancers { - if tag == balancer { - b = bl - break - } - } - if b == nil { - return errors.New("balancer '", balancer, "' not found") - } - b.override.Put(target) - return nil -} - type overrideSettings struct { target string } diff --git a/app/router/router.go b/app/router/router.go index 97a1d0d57..406f932fb 100644 --- a/app/router/router.go +++ b/app/router/router.go @@ -2,7 +2,9 @@ package router import ( "context" + "maps" "sync" + "sync/atomic" "github.com/xtls/xray-core/common" "github.com/xtls/xray-core/common/errors" @@ -17,8 +19,8 @@ import ( // Router is an implementation of routing.Router. type Router struct { domainStrategy Config_DomainStrategy - rules []*Rule - balancers map[string]*Balancer + rules atomic.Pointer[[]*Rule] + balancers atomic.Pointer[map[string]*Balancer] dns dns.Client ctx context.Context @@ -43,52 +45,9 @@ func (r *Router) Init(ctx context.Context, config *Config, d dns.Client, ohm out r.ohm = ohm r.dispatcher = dispatcher - r.balancers = make(map[string]*Balancer, len(config.BalancingRule)) - for _, rule := range config.BalancingRule { - balancer, err := rule.Build(ohm, dispatcher) - if err != nil { - return err - } - balancer.InjectContext(ctx) - r.balancers[rule.Tag] = balancer - } - - r.rules = make([]*Rule, 0, len(config.Rule)) - for _, rule := range config.Rule { - cond, err := rule.BuildCondition() - if err != nil { - r.closeWebhooks() - return err - } - rr := &Rule{ - Condition: cond, - Tag: rule.GetTag(), - RuleTag: rule.GetRuleTag(), - } - if wh := rule.GetWebhook(); wh != nil { - notifier, err := NewWebhookNotifier(wh) - if err != nil { - r.closeWebhooks() - return err - } - rr.Webhook = notifier - } - btag := rule.GetBalancingTag() - if len(btag) > 0 { - brule, found := r.balancers[btag] - if !found { - if rr.Webhook != nil { - rr.Webhook.Close() - } - r.closeWebhooks() - return errors.New("balancer ", btag, " not found") - } - rr.Balancer = brule - } - r.rules = append(r.rules, rr) - } - - return nil + r.rules.Store(new([]*Rule)) + r.balancers.Store(&map[string]*Balancer{}) + return r.ReloadRules(config, false) } // PickRoute implements routing.Router. @@ -124,18 +83,22 @@ func (r *Router) ReloadRules(config *Config, shouldAppend bool) error { r.mu.Lock() defer r.mu.Unlock() - if !shouldAppend { - for _, rule := range r.rules { - if rule.Webhook != nil { - rule.Webhook.Close() - } + oldRules := *r.rules.Load() + oldBalancers := *r.balancers.Load() + + var newRules []*Rule + newBalancers := make(map[string]*Balancer) + existTags := make(map[string]bool, len(oldRules)+len(config.Rule)) + if shouldAppend { + newRules = append(newRules, oldRules...) + maps.Copy(newBalancers, oldBalancers) + for _, rule := range oldRules { + existTags[rule.RuleTag] = true } - r.balancers = make(map[string]*Balancer, len(config.BalancingRule)) - r.rules = make([]*Rule, 0, len(config.Rule)) } + for _, rule := range config.BalancingRule { - _, found := r.balancers[rule.Tag] - if found { + if _, found := newBalancers[rule.Tag]; found { return errors.New("duplicate balancer tag") } balancer, err := rule.Build(r.ohm, r.dispatcher) @@ -143,27 +106,12 @@ func (r *Router) ReloadRules(config *Config, shouldAppend bool) error { return err } balancer.InjectContext(r.ctx) - r.balancers[rule.Tag] = balancer - } - - startIdx := len(r.rules) - closeNewWebhooks := func() { - for i := startIdx; i < len(r.rules); i++ { - if r.rules[i].Webhook != nil { - r.rules[i].Webhook.Close() - } - } - r.rules = r.rules[:startIdx] + newBalancers[rule.Tag] = balancer } for _, rule := range config.Rule { - if r.RuleExists(rule.GetRuleTag()) { - closeNewWebhooks() - return errors.New("duplicate ruleTag ", rule.GetRuleTag()) - } cond, err := rule.BuildCondition() if err != nil { - closeNewWebhooks() return err } rr := &Rule{ @@ -171,69 +119,64 @@ func (r *Router) ReloadRules(config *Config, shouldAppend bool) error { Tag: rule.GetTag(), RuleTag: rule.GetRuleTag(), } + if rr.RuleTag != "" && existTags[rr.RuleTag] { + return errors.New("duplicate ruleTag ", rr.RuleTag) + } + existTags[rr.RuleTag] = true if wh := rule.GetWebhook(); wh != nil { notifier, err := NewWebhookNotifier(wh) if err != nil { - closeNewWebhooks() return err } rr.Webhook = notifier } - btag := rule.GetBalancingTag() - if len(btag) > 0 { - brule, found := r.balancers[btag] + if btag := rule.GetBalancingTag(); len(btag) > 0 { + brule, found := newBalancers[btag] if !found { - if rr.Webhook != nil { - rr.Webhook.Close() - } - closeNewWebhooks() return errors.New("balancer ", btag, " not found") } rr.Balancer = brule } - r.rules = append(r.rules, rr) + newRules = append(newRules, rr) } + r.balancers.Store(&newBalancers) + r.rules.Store(&newRules) + if !shouldAppend { + closeWebhooks(oldRules) + } return nil } -func (r *Router) RuleExists(tag string) bool { - if tag != "" { - for _, rule := range r.rules { - if rule.RuleTag == tag { - return true - } - } - } - return false -} - // RemoveRule implements routing.Router. func (r *Router) RemoveRule(tag string) error { + if tag == "" { + return errors.New("empty tag name!") + } + r.mu.Lock() defer r.mu.Unlock() - newRules := []*Rule{} - if tag != "" { - for _, rule := range r.rules { - if rule.RuleTag != tag { - newRules = append(newRules, rule) - } else if rule.Webhook != nil { - rule.Webhook.Close() - } + oldRules := *r.rules.Load() + newRules := make([]*Rule, 0, len(oldRules)) + var removed []*Rule + for _, rule := range oldRules { + if rule.RuleTag != tag { + newRules = append(newRules, rule) + } else { + removed = append(removed, rule) } - r.rules = newRules - return nil } - return errors.New("empty tag name!") + r.rules.Store(&newRules) + closeWebhooks(removed) + return nil } // ListRule implements routing.Router func (r *Router) ListRule() []routing.Route { - r.mu.Lock() - defer r.mu.Unlock() - ruleList := make([]routing.Route, 0) - for _, rule := range r.rules { + rules := *r.rules.Load() + ruleList := make([]routing.Route, 0, len(rules)) + for _, rule := range rules { ruleList = append(ruleList, &Route{ outboundTag: rule.Tag, ruleTag: rule.RuleTag, @@ -252,7 +195,9 @@ func (r *Router) pickRouteInternal(ctx routing.Context) (*Rule, routing.Context, ctx = routing_dns.ContextWithDNSClient(ctx, r.dns) } - for _, rule := range r.rules { + rules := *r.rules.Load() + + for _, rule := range rules { if rule.Apply(ctx) { return rule, ctx, nil } @@ -265,7 +210,7 @@ func (r *Router) pickRouteInternal(ctx routing.Context) (*Rule, routing.Context, ctx = routing_dns.ContextWithDNSClient(ctx, r.dns) // Try applying rules again if we have IPs. - for _, rule := range r.rules { + for _, rule := range rules { if rule.Apply(ctx) { return rule, ctx, nil } @@ -279,9 +224,9 @@ func (r *Router) Start() error { return nil } -// closeWebhooks closes all webhook notifiers in the current rule set. -func (r *Router) closeWebhooks() { - for _, rule := range r.rules { +// closeWebhooks closes all webhook notifiers in the given rule set. +func closeWebhooks(rules []*Rule) { + for _, rule := range rules { if rule.Webhook != nil { rule.Webhook.Close() } @@ -292,7 +237,7 @@ func (r *Router) closeWebhooks() { func (r *Router) Close() error { r.mu.Lock() defer r.mu.Unlock() - r.closeWebhooks() + closeWebhooks(*r.rules.Load()) return nil } diff --git a/app/router/webhook.go b/app/router/webhook.go index 0c7a3e4d6..cac82ee5f 100644 --- a/app/router/webhook.go +++ b/app/router/webhook.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "sync" + "sync/atomic" "time" "github.com/xtls/xray-core/common/errors" @@ -40,6 +41,7 @@ type WebhookNotifier struct { deduplication uint32 client *http.Client seen sync.Map + lastSweep atomic.Int64 done chan struct{} wg sync.WaitGroup closeOnce sync.Once @@ -77,11 +79,6 @@ func NewWebhookNotifier(cfg *WebhookConfig) (*WebhookNotifier, error) { } } - if h.deduplication > 0 { - h.wg.Add(1) - go h.cleanupLoop() - } - return h, nil } @@ -201,6 +198,7 @@ func (h *WebhookNotifier) isDuplicate(email string) bool { } ttl := time.Duration(h.deduplication) * time.Second now := time.Now() + h.maybeSweep(now, ttl) if v, loaded := h.seen.LoadOrStore(email, now); loaded { if now.Sub(v.(time.Time)) < ttl { return true @@ -210,27 +208,23 @@ func (h *WebhookNotifier) isDuplicate(email string) bool { return false } -func (h *WebhookNotifier) cleanupLoop() { - defer h.wg.Done() - ttl := time.Duration(h.deduplication) * time.Second - ticker := time.NewTicker(ttl) - defer ticker.Stop() - for { - select { - case <-h.done: - return - case <-ticker.C: - now := time.Now() - h.seen.Range(func(key, value any) bool { - if now.Sub(value.(time.Time)) >= ttl { - h.seen.Delete(key) - } - return true - }) - } +func (h *WebhookNotifier) maybeSweep(now time.Time, ttl time.Duration) { + last := h.lastSweep.Load() + if now.UnixNano()-last < int64(ttl) { + return } + if !h.lastSweep.CompareAndSwap(last, now.UnixNano()) { + return // another goroutine did the sweep + } + h.seen.Range(func(key, value any) bool { + if now.Sub(value.(time.Time)) >= ttl { + h.seen.Delete(key) + } + return true + }) } +// Only need to call if the Notifier is really used, otherwise GC can clean it func (h *WebhookNotifier) Close() error { h.closeOnce.Do(func() { close(h.done) From f124daf5a37c3b968a618f92ca42396f3c001de5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Wed, 26 Aug 2026 02:30:33 +0800 Subject: [PATCH 45/59] Observatory: Fix consuming 100% CPU when no outbound matches `subjectSelector` (#6669) Fixes https://github.com/XTLS/Xray-core/issues/6666 --- app/observatory/observer.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/observatory/observer.go b/app/observatory/observer.go index 80d81e227..29cc86f63 100644 --- a/app/observatory/observer.go +++ b/app/observatory/observer.go @@ -78,6 +78,12 @@ func (o *Observer) background() { sleepTime = time.Duration(o.config.ProbeInterval) } + if len(outbounds) == 0 { + errors.LogWarning(o.ctx, "no outbound matches subjectSelector ", o.config.SubjectSelector) + time.Sleep(sleepTime) + continue + } + if !o.config.EnableConcurrency { sort.Strings(outbounds) for _, v := range outbounds { From 77f98eba0978cbcb425e2f8ec3cec86bd5aa8444 Mon Sep 17 00:00:00 2001 From: ivolfram <45515661+dudkin-2005@users.noreply.github.com> Date: Wed, 26 Aug 2026 22:19:41 +0300 Subject: [PATCH 46/59] XHTTP client: Fix a race condition and a data race (#6665) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6665#issuecomment-5429028477 --------- Co-authored-by: 风扇滑翔翼 --- transport/internet/splithttp/client.go | 11 ++++++----- transport/internet/splithttp/dialer.go | 3 ++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/transport/internet/splithttp/client.go b/transport/internet/splithttp/client.go index 0e3a73f2e..a46c2b8ca 100644 --- a/transport/internet/splithttp/client.go +++ b/transport/internet/splithttp/client.go @@ -8,6 +8,7 @@ import ( "net/http" "net/http/httptrace" "sync" + "sync/atomic" "github.com/apernet/quic-go/http3" "github.com/xtls/xray-core/common" @@ -32,7 +33,7 @@ type DialerClient interface { type DefaultDialerClient struct { transportConfig *Config client *http.Client - closed bool + closed atomic.Bool httpVersion string // pool of net.Conn, created using dialUploadConn uploadRawPool *sync.Pool @@ -40,7 +41,7 @@ type DefaultDialerClient struct { } func (c *DefaultDialerClient) IsClosed() bool { - return c.closed + return c.closed.Load() } func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessionId string, body io.Reader, uploadOnly bool) (wrc io.ReadCloser, remoteAddr, localAddr net.Addr, err error) { @@ -72,7 +73,7 @@ func (c *DefaultDialerClient) OpenStream(ctx context.Context, url string, sessio resp, err := c.client.Do(req) if err != nil { if !uploadOnly { // stream-down is enough - c.closed = true + c.closed.Store(true) errors.LogInfoInner(ctx, err, "failed to "+method+" "+url) } gotConn.Close() @@ -108,7 +109,7 @@ func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessio if c.httpVersion != "1.1" { resp, err := c.client.Do(req) if err != nil { - c.closed = true + c.closed.Store(true) return err } @@ -148,7 +149,7 @@ func (c *DefaultDialerClient) PostPacket(ctx context.Context, url string, sessio if h1UploadConn.UnreadedResponsesCount > 0 { resp, err := http.ReadResponse(h1UploadConn.RespBufReader, req) if err != nil { - c.closed = true + c.closed.Store(true) return fmt.Errorf("error while reading response: %s", err.Error()) } io.Copy(io.Discard, resp.Body) diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index 817d93552..d2908aa1b 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -595,11 +595,12 @@ func (w uploadWriter) Write(b []byte) (int, error) { var writed int for _, buff := range buffer.MultiBuffer { + n := int(buff.Len()) err := w.WriteMultiBuffer(buf.MultiBuffer{buff}) if err != nil { return writed, err } - writed += int(buff.Len()) + writed += n } return writed, nil } From dffc7ada5eef8a8b3df7da8928536ce57135a119 Mon Sep 17 00:00:00 2001 From: FunLay123 <137938315+FunLay123@users.noreply.github.com> Date: Thu, 27 Aug 2026 03:41:43 +0700 Subject: [PATCH 47/59] XHTTP client: Define Request.GetBody() for packet-up so h2 can replay after GOAWAY (#6632) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6632#issuecomment-5430735467 --------- Co-authored-by: 风扇滑翔翼 --- transport/internet/splithttp/config.go | 15 ++++++--- transport/internet/splithttp/config_test.go | 36 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/transport/internet/splithttp/config.go b/transport/internet/splithttp/config.go index c2441386c..e06281b1b 100644 --- a/transport/internet/splithttp/config.go +++ b/transport/internet/splithttp/config.go @@ -1,6 +1,7 @@ package splithttp import ( + "bytes" "encoding/base64" "fmt" "io" @@ -330,14 +331,18 @@ func (c *Config) FillStreamRequest(request *http.Request, sessionId string, seqS func (c *Config) FillPacketRequest(request *http.Request, sessionId string, seqStr string, payload buf.MultiBuffer) error { dataPlacement := c.GetNormalizedUplinkDataPlacement() + data := make([]byte, payload.Len()) + payload.Copy(data) + buf.ReleaseMulti(payload) + if dataPlacement == PlacementBody || dataPlacement == PlacementAuto { request.Header = c.GetRequestHeader() - request.Body = io.NopCloser(&buf.MultiBufferContainer{MultiBuffer: payload}) - request.ContentLength = int64(payload.Len()) + request.Body = io.NopCloser(bytes.NewReader(data)) + request.ContentLength = int64(len(data)) + request.GetBody = func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(data)), nil + } } else { - data := make([]byte, payload.Len()) - payload.Copy(data) - buf.ReleaseMulti(payload) switch dataPlacement { case PlacementHeader: request.Header = c.GetRequestHeaderWithPayload(data) diff --git a/transport/internet/splithttp/config_test.go b/transport/internet/splithttp/config_test.go index 2cb1e5e9d..2ee7470aa 100644 --- a/transport/internet/splithttp/config_test.go +++ b/transport/internet/splithttp/config_test.go @@ -1,9 +1,13 @@ package splithttp_test import ( + "io" + "net/http" "testing" "github.com/stretchr/testify/assert" + "github.com/xtls/xray-core/common" + "github.com/xtls/xray-core/common/buf" . "github.com/xtls/xray-core/transport/internet/splithttp" ) @@ -77,3 +81,35 @@ func Test_GetNormalizedPath(t *testing.T) { }) } } + +func Test_FillPacketRequest_GetBody(t *testing.T) { + data := []byte("hello xray") + payload := buf.MergeBytes(nil, data) + + req, err := http.NewRequest("POST", "https://example.com/", nil) + common.Must(err) + + config := &Config{} + config.FillPacketRequest(req, "sess", "0", payload) + + if req.GetBody == nil { + t.Fatalf("Expected GetBody to be set") + } + + first, err := io.ReadAll(req.Body) + common.Must(err) + + if string(data) != string(first) { + t.Fatalf("Body mismatch. Format %q and %q are not equal", data, first) + } + + body2, err := req.GetBody() + common.Must(err) + + second, err := io.ReadAll(body2) + common.Must(err) + + if string(data) != string(second) { + t.Fatalf("Replayed body mismatch. Format %q and %q are not equal", data, second) + } +} From 25c11e2d2b808ff62ec0766b4345f1966ff2fd96 Mon Sep 17 00:00:00 2001 From: Maksim Varentsov Date: Wed, 26 Aug 2026 23:05:05 +0200 Subject: [PATCH 48/59] Tunnel inbound: SO_REUSEPORT fix and IPv6 support for the OpenBSD transparent proxy (#6624) Completes https://github.com/XTLS/Xray-core/pull/6546 --- proxy/dokodemo/fakeudp_openbsd.go | 32 ++++++++++++--- transport/internet/sockopt_openbsd.go | 15 +++++-- transport/internet/udp/hub_openbsd.go | 46 +++++++++++++++------- transport/internet/udp/hub_openbsd_test.go | 42 ++++++++++++++++++++ 4 files changed, 110 insertions(+), 25 deletions(-) diff --git a/proxy/dokodemo/fakeudp_openbsd.go b/proxy/dokodemo/fakeudp_openbsd.go index 8fcbcbea9..7f714e4b6 100644 --- a/proxy/dokodemo/fakeudp_openbsd.go +++ b/proxy/dokodemo/fakeudp_openbsd.go @@ -12,12 +12,29 @@ import ( ) func FakeUDP(addr *net.UDPAddr, mark int) (net.PacketConn, error) { - ip4 := addr.IP.To4() - if ip4 == nil { - return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("IPv6 is not supported by the OpenBSD transparent UDP patch")} + domain := unix.AF_INET6 + var sockaddr unix.Sockaddr + if ip4 := addr.IP.To4(); ip4 != nil { + domain = unix.AF_INET + sa := &unix.SockaddrInet4{Port: addr.Port} + copy(sa.Addr[:], ip4) + sockaddr = sa + } else if ip6 := addr.IP.To16(); ip6 != nil { + sa := &unix.SockaddrInet6{Port: addr.Port} + copy(sa.Addr[:], ip6) + if addr.Zone != "" { + iface, err := net.InterfaceByName(addr.Zone) + if err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("resolve zone %s: %w", addr.Zone, err)} + } + sa.ZoneId = uint32(iface.Index) + } + sockaddr = sa + } else { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("unsupported address %v", addr.IP)} } - fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + fd, err := unix.Socket(domain, unix.SOCK_DGRAM, 0) if err != nil { return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("socket open: %w", err)} } @@ -35,9 +52,12 @@ func FakeUDP(addr *net.UDPAddr, mark int) (net.PacketConn, error) { if err = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil { return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("set socket option SO_REUSEADDR: %w", err)} } + // Several client sessions can be answered from the same original + // destination at the same time, so the address has to be shareable. + if err = unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_REUSEPORT, 1); err != nil { + return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("set socket option SO_REUSEPORT: %w", err)} + } - sockaddr := &unix.SockaddrInet4{Port: addr.Port} - copy(sockaddr.Addr[:], ip4) if err = unix.Bind(fd, sockaddr); err != nil { return nil, &net.OpError{Op: "fake", Err: fmt.Errorf("bind %s: %w", addr.String(), err)} } diff --git a/transport/internet/sockopt_openbsd.go b/transport/internet/sockopt_openbsd.go index 888a73cb0..1efa56674 100644 --- a/transport/internet/sockopt_openbsd.go +++ b/transport/internet/sockopt_openbsd.go @@ -14,11 +14,18 @@ func applyOutboundSocketOptions(network string, address string, fd uintptr, conf func applyInboundSocketOptions(network string, fd uintptr, config *SocketConfig) error { if config.ReceiveOriginalDestAddress && isUDPSocket(network) { - if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTADDR, 1); err != nil { - return errors.New("failed to set IP_RECVDSTADDR").Base(err) + // Only the options matching the socket's address family are accepted, + // so one of the two pairs succeeding is enough. + err6 := unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_RECVPKTINFO, 1) + if err6 == nil { + err6 = unix.SetsockoptInt(int(fd), unix.IPPROTO_IPV6, unix.IPV6_RECVDSTPORT, 1) } - if err := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTPORT, 1); err != nil { - return errors.New("failed to set IP_RECVDSTPORT").Base(err) + err4 := unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTADDR, 1) + if err4 == nil { + err4 = unix.SetsockoptInt(int(fd), unix.IPPROTO_IP, unix.IP_RECVDSTPORT, 1) + } + if err4 != nil && err6 != nil { + return errors.New("failed to enable receiving the original destination").Base(err4) } } return nil diff --git a/transport/internet/udp/hub_openbsd.go b/transport/internet/udp/hub_openbsd.go index a036a7458..04c465a9d 100644 --- a/transport/internet/udp/hub_openbsd.go +++ b/transport/internet/udp/hub_openbsd.go @@ -17,23 +17,39 @@ func retrieveOriginalDestFromControlMessages(msgs []unix.SocketControlMessage) n var havePort bool for _, msg := range msgs { - if msg.Header.Level != unix.IPPROTO_IP { - continue - } - - switch msg.Header.Type { - case unix.IP_RECVDSTADDR: - if len(msg.Data) < 4 { - continue + switch msg.Header.Level { + case unix.IPPROTO_IP: + switch msg.Header.Type { + case unix.IP_RECVDSTADDR: + if len(msg.Data) < 4 { + continue + } + ip = append(ip[:0], msg.Data[:4]...) + haveAddress = true + case unix.IP_RECVDSTPORT: + if len(msg.Data) < 2 { + continue + } + port = binary.BigEndian.Uint16(msg.Data[:2]) + havePort = true } - ip = append(ip[:0], msg.Data[:4]...) - haveAddress = true - case unix.IP_RECVDSTPORT: - if len(msg.Data) < 2 { - continue + case unix.IPPROTO_IPV6: + switch msg.Header.Type { + case unix.IPV6_PKTINFO: + // struct in6_pktinfo: the destination address is followed by + // the interface index. + if len(msg.Data) < 16 { + continue + } + ip = append(ip[:0], msg.Data[:16]...) + haveAddress = true + case unix.IPV6_RECVDSTPORT: + if len(msg.Data) < 2 { + continue + } + port = binary.BigEndian.Uint16(msg.Data[:2]) + havePort = true } - port = binary.BigEndian.Uint16(msg.Data[:2]) - havePort = true } } diff --git a/transport/internet/udp/hub_openbsd_test.go b/transport/internet/udp/hub_openbsd_test.go index 084a117bd..a5c3e250e 100644 --- a/transport/internet/udp/hub_openbsd_test.go +++ b/transport/internet/udp/hub_openbsd_test.go @@ -33,6 +33,36 @@ func TestRetrieveOriginalDestFromControlMessages(t *testing.T) { } } +func TestRetrieveOriginalDestFromControlMessagesIPv6(t *testing.T) { + // 2001:db8::7 followed by the interface index, as in struct in6_pktinfo. + pktinfo := []byte{ + 0x20, 0x01, 0x0d, 0xb8, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0x07, + 0x02, 0, 0, 0, + } + msgs := []unix.SocketControlMessage{ + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IPV6, Type: unix.IPV6_RECVDSTPORT}, + Data: []byte{0x30, 0x39}, + }, + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IPV6, Type: unix.IPV6_PKTINFO}, + Data: pktinfo, + }, + } + + dest := retrieveOriginalDestFromControlMessages(msgs) + if !dest.IsValid() { + t.Fatal("destination is invalid") + } + if got, want := dest.Address.IP().String(), "2001:db8::7"; got != want { + t.Fatalf("address = %q, want %q", got, want) + } + if got, want := dest.Port.Value(), uint16(12345); got != want { + t.Fatalf("port = %d, want %d", got, want) + } +} + func TestRetrieveOriginalDestRequiresAddressAndPort(t *testing.T) { tests := [][]unix.SocketControlMessage{ { @@ -47,6 +77,18 @@ func TestRetrieveOriginalDestRequiresAddressAndPort(t *testing.T) { Data: []byte{0x30, 0x39}, }, }, + { + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IPV6, Type: unix.IPV6_PKTINFO}, + Data: make([]byte, 20), + }, + }, + { + { + Header: unix.Cmsghdr{Level: unix.IPPROTO_IPV6, Type: unix.IPV6_RECVDSTPORT}, + Data: []byte{0x30, 0x39}, + }, + }, } for _, msgs := range tests { From aa3d6589da5e28fc3b0303572e4330dfeb7a383c Mon Sep 17 00:00:00 2001 From: Jidos <104550372+Jidos86@users.noreply.github.com> Date: Thu, 27 Aug 2026 02:29:58 +0500 Subject: [PATCH 49/59] TUN inbound: Wait() blocks via kqueue instead of busy-spinning on Darwin (#6580) Fixes https://github.com/XTLS/Xray-core/issues/6579 --- proxy/tun/tun_darwin.go | 141 ++++++++++++++++++++++++++- proxy/tun/tun_darwin_test.go | 184 +++++++++++++++++++++++++++++++++++ 2 files changed, 320 insertions(+), 5 deletions(-) diff --git a/proxy/tun/tun_darwin.go b/proxy/tun/tun_darwin.go index 0bb73b5e7..1ae4d451f 100644 --- a/proxy/tun/tun_darwin.go +++ b/proxy/tun/tun_darwin.go @@ -11,6 +11,8 @@ import ( "os" "strconv" "sync" + "sync/atomic" + "time" "unsafe" "github.com/xtls/xray-core/common/buf" @@ -38,21 +40,111 @@ const ( ND6_INFINITE_LIFETIME = 0xFFFFFFFF // netinet6/nd6.h ) -//go:linkname procyield runtime.procyield -func procyield(cycles uint32) - type DarwinTun struct { tunFile *os.File options *Config tunFd int ownsFd bool // true for macOS (we created the fd), false for iOS (fd from system) + // Genuinely blocks Wait() until tunFd is readable, instead of the + // previous procyield-only busy-spin (dispatchLoop in + // stack_gvisor_endpoint.go calls ReadPacket() then Wait() in a tight + // loop with no other throttling whenever the queue is empty -- with + // only procyield(1), that pins a full CPU core for as long as the + // tunnel is up, observed causing severe device heating/thermal + // shutdown). nil if kqueue setup failed, in which case Wait() falls + // back to a bounded time.Sleep instead. See waitKqueue's own doc + // comment for why this is a dedicated type rather than a bare fd. + waitKq *waitKqueue + routeMonitor *os.File routeMonitorOnce sync.Once systemRoutes []netip.Prefix gateway netip.Prefix } +// waitKqueue owns a kqueue fd used by DarwinTun.Wait() to block on +// read-readiness. Closing and waiting can race from different goroutines +// (Close() from the caller that tears down the tunnel, Wait() from +// dispatchLoop's own goroutine) -- reviewer feedback on XTLS/Xray-core#6580 +// found that a bare `int` fd field let Close() race Wait()'s use of the +// same fd number, and on Darwin a closed fd number can be reused by an +// unrelated concurrent open() before Wait() gets to call Kevent on it, +// so Wait() could end up polling (or Close() could end up closing) a +// completely unrelated file descriptor. This type makes closing +// idempotent (sync.Once) and gates every Kevent call behind an atomic +// "closed" flag checked immediately before the syscall, so Wait() never +// issues a kevent syscall against a fd number that Close() has already +// (or is concurrently) invalidated -- there's still a narrow window where +// Wait() checks-then-uses the fd, but Close() only actually closes it +// after Wait() cannot start a new syscall on it (the flag is set first, +// synchronized with acquire/release semantics), which is sufficient since +// Wait()'s Kevent call itself is what's being raced, not a fd read/write. +type waitKqueue struct { + fd int + closed atomic.Bool + once sync.Once +} + +// newWaitKqueue creates a kqueue registered for read-readiness on fd, for +// Wait() to block on. Returns nil if anything fails, so callers can fall +// back to a bounded sleep rather than error out of NewTun over what is +// purely a CPU-efficiency concern. +func newWaitKqueue(fd int) *waitKqueue { + kq, err := unix.Kqueue() + if err != nil { + return nil + } + _, err = unix.Kevent(kq, []unix.Kevent_t{{ + Ident: uint64(fd), + Filter: unix.EVFILT_READ, + Flags: unix.EV_ADD | unix.EV_ENABLE, + }}, nil, nil) + if err != nil { + _ = unix.Close(kq) + return nil + } + return &waitKqueue{fd: kq} +} + +// wait blocks until the registered fd is readable, timeout elapses, or a +// benign interrupt occurs -- all three are "this kqueue is still healthy, +// the caller should just try again" and return true; the caller +// (DarwinTun.Wait) doesn't need to distinguish them since it always calls +// ReadPacket() right after anyway, and that already handles "nothing was +// actually there" via ErrQueueEmpty. Returns false only when the kqueue +// itself is no longer usable -- already closed, or the kevent syscall +// failed for a reason other than EINTR -- see its own call site in +// DarwinTun.Wait for why a persistent failure must not be silently +// retried forever (reviewer feedback, XTLS/Xray-core#6580 P2). +func (w *waitKqueue) wait(timeout time.Duration) (ok bool) { + if w.closed.Load() { + return false + } + events := make([]unix.Kevent_t, 1) + ts := unix.NsecToTimespec(timeout.Nanoseconds()) + _, err := unix.Kevent(w.fd, nil, events, &ts) + if err != nil { + return errors.Is(err, unix.EINTR) + } + return true +} + +// close marks the kqueue as unusable (so any Wait() call that hasn't yet +// entered the kevent syscall bails out instead) and closes the underlying +// fd exactly once, regardless of how many times close is called or +// whether it races a Wait() already inside its kevent syscall (that call +// either completes against the still-open fd or returns an error safely +// -- either way, no other goroutine can be handed this fd number in +// between the atomic flag flip and the actual close, since nothing else +// in this type ever creates a new kqueue with the same field). +func (w *waitKqueue) close() { + w.once.Do(func() { + w.closed.Store(true) + _ = unix.Close(w.fd) + }) +} + var ( _ Tun = (*DarwinTun)(nil) _ GVisorDevice = (*DarwinTun)(nil) @@ -77,6 +169,7 @@ func NewTun(options *Config) (Tun, error) { options: options, tunFd: fd, ownsFd: false, + waitKq: newWaitKqueue(fd), }, nil } @@ -103,6 +196,7 @@ func NewTun(options *Config) (Tun, error) { options: options, tunFd: int(tunFile.Fd()), ownsFd: true, + waitKq: newWaitKqueue(int(tunFile.Fd())), gateway: gateway, }, nil } @@ -134,6 +228,9 @@ func (t *DarwinTun) Close() error { _ = t.routeMonitor.Close() } }) + if t.waitKq != nil { + t.waitKq.close() + } routeErr := t.unsetSystemRoutes() if t.ownsFd { return xerrors.Combine(routeErr, t.tunFile.Close()) @@ -242,9 +339,43 @@ func (t *DarwinTun) ReadPacket() (byte, *stack.PacketBuffer, error) { }), nil } -// Wait some cpu cycles +// Wait blocks until tunFd is readable (or a short timeout elapses), rather +// than spinning the CPU -- see the waitKq field's own doc comment. A bounded +// timeout (not an indefinite wait) keeps this responsive to a Close() that +// happens to race a call already parked here. +// +// Reviewer feedback (XTLS/Xray-core#6580, P2): the original version +// discarded every error from the underlying kevent syscall. dispatchLoop +// (stack_gvisor_endpoint.go) calls ReadPacket() then Wait() in an +// unconditional tight loop -- if kevent started failing at runtime for a +// persistent reason (not just a benign EINTR), Wait() returning +// immediately every time reintroduces exactly the busy-spin this whole +// change exists to remove, just routed through a failing syscall instead +// of procyield. waitKq.wait's own bool return distinguishes "genuinely +// interrupted, try again" from "this kqueue is unusable now" -- Wait() +// permanently falls back to the sleep path once that happens, rather than +// retrying the same broken kqueue forever. func (t *DarwinTun) Wait() { - procyield(1) + if t.waitKq != nil && t.waitKq.wait(time.Second) { + return + } + if t.waitKq != nil { + // Persistent kevent failure (not a benign EINTR, and not just + // "the 1s timeout elapsed with nothing to read" -- wait() already + // returned true for both of those cases above). Stop trusting + // this kqueue for the rest of this DarwinTun's lifetime instead of + // re-attempting a syscall that's already shown it won't succeed. + t.waitKq.close() + t.waitKq = nil + } + // Reviewer feedback (XTLS/Xray-core#6580): procyield here is the same + // busy-spin this whole change exists to remove, just gated behind an + // edge case (kqueue setup failing, which practically never happens on + // real Darwin systems, or having just failed permanently above) + // instead of always -- a genuine bounded sleep actually yields the CPU + // instead of being a near-instant scheduler hint that lets the tight + // dispatchLoop caller spin just as hot as before. + time.Sleep(time.Millisecond) } func (t *DarwinTun) newEndpoint() (stack.LinkEndpoint, error) { diff --git a/proxy/tun/tun_darwin_test.go b/proxy/tun/tun_darwin_test.go index 0d8fe7ad4..2c299e378 100644 --- a/proxy/tun/tun_darwin_test.go +++ b/proxy/tun/tun_darwin_test.go @@ -3,7 +3,11 @@ package tun import ( + "sync" "testing" + "time" + + "golang.org/x/sys/unix" ) func TestSelectDarwinGatewayDefault(t *testing.T) { @@ -47,3 +51,183 @@ func TestSelectDarwinGatewayRequiresUsableLocalAddress(t *testing.T) { t.Fatal("expected error") } } + +// newTestSocketpair returns a connected AF_UNIX/SOCK_DGRAM pair -- a real +// fd DarwinTun.Wait's kqueue can register EVFILT_READ against, without +// needing an actual utun interface (which requires root/network +// entitlements this test environment doesn't have). Datagram sockets +// (unlike pipes) support both "write makes readable" and "close makes +// readable" the same way a tun fd's read-readiness behaves. +func newTestSocketpair(t *testing.T) (a, b int) { + t.Helper() + fds, err := unix.Socketpair(unix.AF_UNIX, unix.SOCK_DGRAM, 0) + if err != nil { + t.Fatalf("socketpair: %v", err) + } + t.Cleanup(func() { + _ = unix.Close(fds[0]) + _ = unix.Close(fds[1]) + }) + return fds[0], fds[1] +} + +// Reviewer feedback, XTLS/Xray-core#6580: "blocking with no data" case -- +// wait() must not return before the timeout when nothing is written. +func TestWaitKqueueBlocksWithNoData(t *testing.T) { + a, _ := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + defer kq.close() + + start := time.Now() + ok := kq.wait(150 * time.Millisecond) + elapsed := time.Since(start) + + if !ok { + t.Fatal("wait() returned false on a healthy kqueue with a plain timeout") + } + if elapsed < 100*time.Millisecond { + t.Fatalf("wait() returned after only %v, expected it to block close to the 150ms timeout", elapsed) + } +} + +// Reviewer feedback: "wake up with a readable fd" case. +func TestWaitKqueueWakesOnReadable(t *testing.T) { + a, b := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + defer kq.close() + + done := make(chan bool, 1) + go func() { + done <- kq.wait(5 * time.Second) + }() + + time.Sleep(20 * time.Millisecond) // let wait() actually enter the syscall first + if _, err := unix.Write(b, []byte{0x1}); err != nil { + t.Fatalf("write: %v", err) + } + + select { + case ok := <-done: + if !ok { + t.Fatal("wait() returned false after the fd became readable") + } + case <-time.After(2 * time.Second): + t.Fatal("wait() did not wake up within 2s of the fd becoming readable") + } +} + +// Reviewer feedback: "timeout" case, explicitly (distinct from the +// no-data test above, which also checks blocking duration -- this one +// only checks the return value). +func TestWaitKqueueTimesOut(t *testing.T) { + a, _ := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + defer kq.close() + + if !kq.wait(50 * time.Millisecond) { + t.Fatal("wait() returned false on a plain timeout with no error condition") + } +} + +// Reviewer feedback: "Close() wakes a blocked wait" case, and the +// no-double-close/no-fd-reuse concern (P1) -- close() while wait() is +// parked in its syscall must not panic, must not leave wait() hung, and a +// second close() call (from a caller that, say, calls Close() twice on +// the same DarwinTun) must be safe. +func TestWaitKqueueCloseDuringWaitIsSafe(t *testing.T) { + a, _ := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + + started := make(chan struct{}) + done := make(chan bool, 1) + go func() { + close(started) + done <- kq.wait(5 * time.Second) + }() + + <-started + time.Sleep(20 * time.Millisecond) // let wait() actually enter the syscall first + kq.close() + kq.close() // double-close must be idempotent, not panic or double-free the fd + + select { + case <-done: + // Either true (the close-of-the-underlying-fd unblocked kevent, a + // real kqueue behavior) or false (wait() observed the closed flag + // first) is acceptable -- what matters is that it returned at all, + // promptly, without hanging or crashing. + case <-time.After(2 * time.Second): + t.Fatal("wait() did not return within 2s of close() being called") + } + + // A wait() call *after* close() must return false immediately (the + // closed-flag fast path), not attempt a syscall against the + // already-closed (and potentially since-reused, on a real system) fd + // number. + if kq.wait(time.Second) { + t.Fatal("wait() returned true after close() -- should short-circuit via the closed flag") + } +} + +// Reviewer feedback: "multiple/concurrent close guard" case -- many +// goroutines calling close() concurrently must close the underlying fd +// exactly once. +func TestWaitKqueueConcurrentCloseIsSafe(t *testing.T) { + a, _ := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + + var wg sync.WaitGroup + for range 20 { + wg.Add(1) + go func() { + defer wg.Done() + kq.close() + }() + } + wg.Wait() + + if !kq.closed.Load() { + t.Fatal("closed flag not set after concurrent close() calls") + } +} + +// Reviewer feedback: "kevent runtime failure without spinning" case (P2). +// Simulates a kqueue that has gone bad (closed out from under it, as if a +// concurrent/erroneous close happened) and confirms wait() reports it as +// unusable (false) rather than silently returning true forever, which is +// what DarwinTun.Wait relies on to permanently fall back to the sleep +// path instead of re-entering a failing syscall on every dispatchLoop +// iteration. +func TestWaitKqueueReportsPersistentFailure(t *testing.T) { + a, _ := newTestSocketpair(t) + kq := newWaitKqueue(a) + if kq == nil { + t.Fatal("newWaitKqueue returned nil") + } + // Close the underlying kqueue fd directly (bypassing kq.close(), which + // would also set the closed flag) to simulate the fd going bad for a + // reason other than this type's own close() -- e.g. some other code + // path in the process closing it, or the kernel invalidating it. + _ = unix.Close(kq.fd) + + for i := 0; i < 5; i++ { + if kq.wait(50 * time.Millisecond) { + t.Fatalf("wait() call %d returned true against a closed underlying fd -- should report failure, not spin", i) + } + } +} From 65458e919fcb3548d44481ea7929031a14bf117e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=A3=8E=E6=89=87=E6=BB=91=E7=BF=94=E7=BF=BC?= Date: Thu, 27 Aug 2026 19:57:15 +0800 Subject: [PATCH 50/59] Config: Fix some issues (#6640) https://github.com/XTLS/Xray-core/pull/6640#issuecomment-5420106315 Fixes https://github.com/XTLS/Xray-core/issues/6636 Fixes https://github.com/XTLS/Xray-core/issues/6600 ... --- common/reflect/marshal_test.go | 1 + infra/conf/common.go | 3 +++ infra/conf/xray.go | 12 ++++++------ proxy/http/client.go | 9 ++++++--- transport/internet/tcp/hub.go | 3 --- transport/internet/tcp_hub.go | 3 +++ 6 files changed, 19 insertions(+), 12 deletions(-) diff --git a/common/reflect/marshal_test.go b/common/reflect/marshal_test.go index d560f1b4e..748bc69e0 100644 --- a/common/reflect/marshal_test.go +++ b/common/reflect/marshal_test.go @@ -207,6 +207,7 @@ func getConfig() string { "tag": "XHTTP_IN", "streamSettings": { "network": "xhttp", + "security": "tls", "xhttpSettings": { "host": "bing.com", "path": "/xhttp_client_upload", diff --git a/infra/conf/common.go b/infra/conf/common.go index 0de3568cd..3ddd01574 100644 --- a/infra/conf/common.go +++ b/infra/conf/common.go @@ -65,6 +65,9 @@ func (v *Address) UnmarshalJSON(data []byte) error { } func (v *Address) Build() *net.IPOrDomain { + if v == nil { + return nil + } return net.NewIPOrDomain(v.Address) } diff --git a/infra/conf/xray.go b/infra/conf/xray.go index bdcabbd50..c029a7bf7 100644 --- a/infra/conf/xray.go +++ b/infra/conf/xray.go @@ -140,7 +140,7 @@ func (c *InboundDetourConfig) Build() (*core.InboundHandlerConfig, error) { // TUN inbound doesn't need port configuration as it uses network interface instead if strings.ToLower(c.Protocol) == "tun" { // Skip port validation for TUN - } else if c.ListenOn == nil { + } else if c.ListenOn == nil || len(c.ListenOn.String()) == 0 { // Listen on anyip, must set PortList if c.PortList == nil { return nil, errors.New("Listen on AnyIP but no Port(s) set in InboundDetour.") @@ -251,13 +251,13 @@ func validateOutboundTransportSecurity(rawConfig interface{}, senderSettings *pr if vlessCfg.Encryption != "" && vlessCfg.Encryption != "none" { return nil } - if requiresTransportSecurity(vlessCfg.Address) { + if requiresTransportSecurity(vlessCfg.Vnext[0].Address) { return errors.New("vless without TLS or other encryption is prohibited unless the server address is a private IP or domain") } } if tjCfg, ok := rawConfig.(*TrojanClientConfig); ok { - if requiresTransportSecurity(tjCfg.Address) { + if requiresTransportSecurity(tjCfg.Servers[0].Address) { return errors.New("trojan without TLS is prohibited unless the server address is a private IP or domain") } } @@ -358,13 +358,13 @@ func (c *OutboundDetourConfig) Build() (*core.OutboundHandlerConfig, error) { if err != nil { return nil, errors.New("failed to load outbound detour config for protocol ", c.Protocol).Base(err) } - if err := validateOutboundTransportSecurity(rawConfig, senderSettings); err != nil { - return nil, err - } ts, err := rawConfig.(Buildable).Build() if err != nil { return nil, errors.New("failed to build outbound handler for protocol ", c.Protocol).Base(err) } + if err := validateOutboundTransportSecurity(rawConfig, senderSettings); err != nil { + return nil, err + } return &core.OutboundHandlerConfig{ SenderSettings: serial.ToTypedMessage(senderSettings), diff --git a/proxy/http/client.go b/proxy/http/client.go index 0e50edba3..e82a747fe 100644 --- a/proxy/http/client.go +++ b/proxy/http/client.go @@ -173,15 +173,18 @@ func fillRequestHeader(ctx context.Context, header []*Header) ([]*Header, error) outbounds := session.OutboundsFromContext(ctx) ob := outbounds[len(outbounds)-1] - if inbound == nil || ob == nil { - return nil, errors.New("missing inbound or outbound metadata from context") + var src net.Destination + if inbound != nil { + src = inbound.Source + } else { + src = net.TCPDestination(net.AnyIP, 0) } data := struct { Source net.Destination Target net.Destination }{ - Source: inbound.Source, + Source: src, Target: ob.Target, } diff --git a/transport/internet/tcp/hub.go b/transport/internet/tcp/hub.go index ede97499d..c68d55dd9 100644 --- a/transport/internet/tcp/hub.go +++ b/transport/internet/tcp/hub.go @@ -42,9 +42,6 @@ func ListenTCP(ctx context.Context, address net.Address, port net.Port, streamSe var listener net.Listener var err error if port == net.Port(0) { // unix - if !address.Family().IsDomain() { - return nil, errors.New("invalid unix listen: ", address).AtError() - } listener, err = internet.ListenSystem(ctx, &net.UnixAddr{ Name: address.Domain(), Net: "unix", diff --git a/transport/internet/tcp_hub.go b/transport/internet/tcp_hub.go index cf0ad80d2..31183368a 100644 --- a/transport/internet/tcp_hub.go +++ b/transport/internet/tcp_hub.go @@ -65,6 +65,9 @@ func ListenTCP(ctx context.Context, address net.Address, port net.Port, settings if address.Family().IsDomain() { return nil, errors.New("domain address is not allowed for listening: ", address.Domain()) } + if port == 0 { + return nil, errors.New("port 0 is not allowed for listening on TCP") + } protocol := settings.ProtocolName listenFunc := transportListenerCache[protocol] From ada99a4eb00f169b0e2d650990d77fb7930967bb Mon Sep 17 00:00:00 2001 From: LjhAUMEM Date: Fri, 28 Aug 2026 01:25:56 +0800 Subject: [PATCH 51/59] Hysteria: Upgrade to official v2.12.2 (#6565) https://github.com/XTLS/Xray-core/pull/6565#issuecomment-5202554275 Fixes https://github.com/XTLS/Xray-core/issues/6408 Fixes https://github.com/XTLS/Xray-core/issues/6598 ... --- go.mod | 8 +- go.sum | 21 ++- infra/conf/transport_finalmask.go | 42 +++-- infra/conf/transport_internet.go | 12 +- infra/conf/transport_method.go | 2 + transport/internet/config.pb.go | 94 +++++++--- transport/internet/config.proto | 22 ++- transport/internet/finalmask/realm/client.go | 93 +++++++++- .../internet/finalmask/realm/config.pb.go | 172 ++++++++++++++++-- .../internet/finalmask/realm/config.proto | 14 ++ transport/internet/finalmask/realm/portmap.go | 139 ++++++++++++++ transport/internet/finalmask/realm/server.go | 54 +++++- transport/internet/finalmask/realm/stun.go | 43 ++++- transport/internet/hysteria/config.pb.go | 31 ++-- transport/internet/hysteria/config.proto | 13 +- .../hysteria/congestion/brutal/brutal.go | 17 +- .../hysteria/congestion/brutal/brutal_test.go | 45 +++++ .../internet/hysteria/congestion/utils.go | 23 ++- transport/internet/hysteria/dialer.go | 13 +- transport/internet/hysteria/hub.go | 85 +++++++-- transport/internet/splithttp/dialer.go | 14 +- transport/internet/splithttp/hub.go | 15 +- 22 files changed, 832 insertions(+), 140 deletions(-) create mode 100644 transport/internet/finalmask/realm/portmap.go create mode 100644 transport/internet/hysteria/congestion/brutal/brutal_test.go diff --git a/go.mod b/go.mod index b56e9c4bf..bc2c23844 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module github.com/xtls/xray-core go 1.26 require ( - github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 + github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e github.com/cloudflare/circl v1.6.5 github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 github.com/golang/mock v1.7.0-rc.1 @@ -11,6 +11,7 @@ require ( github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/klauspost/cpuid/v2 v2.4.0 + github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 github.com/miekg/dns v1.1.73 github.com/pelletier/go-toml v1.9.5 github.com/pion/stun/v3 v3.1.7 @@ -41,9 +42,14 @@ require ( require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/google/btree v1.1.2 // indirect + github.com/google/gopacket v1.1.19 // indirect + github.com/huin/goupnp v1.2.0 // indirect + github.com/jackpal/go-nat-pmp v1.0.2 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect + github.com/koron/go-ssdp v0.0.4 // indirect github.com/kr/text v0.2.0 // indirect + github.com/libp2p/go-netroute v0.2.1 // indirect github.com/pion/dtls/v3 v3.1.5 // indirect github.com/pion/logging v0.2.4 // indirect github.com/pion/transport/v4 v4.1.0 // indirect diff --git a/go.sum b/go.sum index 29504f710..a7ac551bf 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716 h1:J1O+xpLuJWkdYbw5JPGwBqIHs2J8tiEP7Py9lPqkN2I= -github.com/apernet/quic-go v0.59.1-0.20260425001925-6c6cc9bcb716/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= +github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e h1:5mgtR5gwIgBKMiGI1QdXldZZ+SNor06Nbu1wCBulQBg= +github.com/apernet/quic-go v0.61.1-0.20260806010916-184d081eef3e/go.mod h1:x7qxEvX6MCVtDuBKHj3E+88+BtrbEMuAL5qGUKItjW8= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.5 h1:O64F26HEqNhznd/hrC5KZXVKYuKM2rx4deZDTc4ihQA= @@ -21,22 +21,34 @@ github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= +github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/h12w/go-socks5 v0.0.0-20200522160539-76189e178364 h1:5XxdakFhqd9dnXoAZy1Mb2R/DZ6D1e+0bGC/JhucGYI= github.com/h12w/go-socks5 v0.0.0-20200522160539-76189e178364/go.mod h1:eDJQioIyy4Yn3MVivT7rv/39gAJTrA7lgmYr8EW950c= +github.com/huin/goupnp v1.2.0 h1:uOKW26NG1hsSSbXIZ1IR7XP9Gjd1U8pnLaCMgntmkmY= +github.com/huin/goupnp v1.2.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= +github.com/jackpal/go-nat-pmp v1.0.2 h1:KzKSgb7qkJvOUTqYl9/Hg/me3pWgBmERKrTGD7BdWus= +github.com/jackpal/go-nat-pmp v1.0.2/go.mod h1:QPH045xvCAeXUZOxsnwmrtiCoxIr9eob+4orBN1SBKc= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= +github.com/koron/go-ssdp v0.0.4 h1:1IDwrghSKYM7yLf7XCzbByg2sJ/JcNOZRXS2jczTwz0= +github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoKtbmZk= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138 h1:YohuNPT/1k3VcThCQlBZ43PCPWPfMRS1zcxWBF2SLK8= +github.com/libp2p/go-nat v1.0.1-0.20250821073202-01afc089f138/go.mod h1:TXQg5tfSy+bUjnhT5728j5j/MBj7keIYqqZ1+8k/ui8= +github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU= +github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ= github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE= github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= @@ -53,6 +65,8 @@ github.com/pion/transport/v4 v4.1.0 h1:8S+nF2reM2cJuqC6g78OVy2BBgmbdns+acx3jA97B github.com/pion/transport/v4 v4.1.0/go.mod h1:06hFI+jCFcok2X2MekVufNZ/uzNZXivGBPfviSVcjgM= github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI= github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= @@ -100,6 +114,8 @@ golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -129,6 +145,7 @@ golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/infra/conf/transport_finalmask.go b/infra/conf/transport_finalmask.go index e4c579dda..db57fdbbd 100644 --- a/infra/conf/transport_finalmask.go +++ b/infra/conf/transport_finalmask.go @@ -816,9 +816,11 @@ func (c *Xicmp) Build() (proto.Message, error) { } type Realm struct { - Url string `json:"url"` - StunServers []string `json:"stunServers"` - TlsConfig *TLSConfig `json:"tlsConfig"` + Url string `json:"url"` + StunServers []string `json:"stunServers"` + TlsConfig *TLSConfig `json:"tlsConfig"` + IPMode string `json:"ipMode"` + PortMapping *realm.PortMapping `json:"portMapping"` } func (c *Realm) Build() (proto.Message, error) { @@ -898,6 +900,8 @@ func (c *Realm) Build() (proto.Message, error) { ID: id, StunServers: stunServers, TlsConfig: tlsConfig, + IPMode: strings.ToLower(c.IPMode), + PortMapping: c.PortMapping, }, nil } @@ -928,20 +932,24 @@ func (c *Mask) Build(tcp bool) (proto.Message, error) { } type QuicParamsConfig struct { - Congestion string `json:"congestion"` - Debug bool `json:"debug"` - BbrProfile string `json:"bbrProfile"` - BrutalUp Bandwidth `json:"brutalUp"` - BrutalDown Bandwidth `json:"brutalDown"` - UdpHop UdpHop `json:"udpHop"` - InitStreamReceiveWindow uint64 `json:"initStreamReceiveWindow"` - MaxStreamReceiveWindow uint64 `json:"maxStreamReceiveWindow"` - InitConnectionReceiveWindow uint64 `json:"initConnectionReceiveWindow"` - MaxConnectionReceiveWindow uint64 `json:"maxConnectionReceiveWindow"` - MaxIdleTimeout int64 `json:"maxIdleTimeout"` - KeepAlivePeriod int64 `json:"keepAlivePeriod"` - DisablePathMTUDiscovery bool `json:"disablePathMTUDiscovery"` - MaxIncomingStreams int64 `json:"maxIncomingStreams"` + Congestion string `json:"congestion"` + Debug bool `json:"debug"` + BbrProfile string `json:"bbrProfile"` + BrutalUp Bandwidth `json:"brutalUp"` + BrutalDown Bandwidth `json:"brutalDown"` + BrutalDisableLossCompensation bool `json:"brutalDisableLossCompensation"` + UdpHop UdpHop `json:"udpHop"` + InitStreamReceiveWindow uint64 `json:"initStreamReceiveWindow"` + MaxStreamReceiveWindow uint64 `json:"maxStreamReceiveWindow"` + InitConnectionReceiveWindow uint64 `json:"initConnectionReceiveWindow"` + MaxConnectionReceiveWindow uint64 `json:"maxConnectionReceiveWindow"` + MaxIdleTimeout int64 `json:"maxIdleTimeout"` + KeepAlivePeriod int64 `json:"keepAlivePeriod"` + DisablePathMTUDiscovery bool `json:"disablePathMTUDiscovery"` + DisableChromeParrot bool `json:"disableChromeParrot"` + DisableGSO bool `json:"disableGSO"` + MaxIncomingStreams int64 `json:"maxIncomingStreams"` + DisableStatelessReset bool `json:"disableStatelessReset"` } type FinalMask struct { diff --git a/infra/conf/transport_internet.go b/infra/conf/transport_internet.go index 24b5fba7c..1ef986950 100644 --- a/infra/conf/transport_internet.go +++ b/infra/conf/transport_internet.go @@ -285,10 +285,11 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { } config.QuicParams = &internet.QuicParams{ - Congestion: c.FinalMask.QuicParams.Congestion, - BbrProfile: profile, - BrutalUp: up, - BrutalDown: down, + Congestion: c.FinalMask.QuicParams.Congestion, + BbrProfile: profile, + BrutalUp: up, + BrutalDown: down, + BrutalDisableLossCompensation: c.FinalMask.QuicParams.BrutalDisableLossCompensation, UdpHop: &internet.UdpHop{ Ports: c.FinalMask.QuicParams.UdpHop.PortList.Build().Ports(), IntervalMin: int64(c.FinalMask.QuicParams.UdpHop.Interval.From), @@ -301,7 +302,10 @@ func (c *StreamConfig) Build() (*internet.StreamConfig, error) { MaxIdleTimeout: c.FinalMask.QuicParams.MaxIdleTimeout, KeepAlivePeriod: c.FinalMask.QuicParams.KeepAlivePeriod, DisablePathMtuDiscovery: c.FinalMask.QuicParams.DisablePathMTUDiscovery, + DisableChromeParrot: c.FinalMask.QuicParams.DisableChromeParrot, + DisableGSO: c.FinalMask.QuicParams.DisableGSO, MaxIncomingStreams: c.FinalMask.QuicParams.MaxIncomingStreams, + DisableStatelessReset: c.FinalMask.QuicParams.DisableStatelessReset, } } } diff --git a/infra/conf/transport_method.go b/infra/conf/transport_method.go index 19cb496e3..cff77da83 100644 --- a/infra/conf/transport_method.go +++ b/infra/conf/transport_method.go @@ -751,6 +751,7 @@ type Masquerade struct { Url string `json:"url"` RewriteHost bool `json:"rewriteHost"` + XForwarded bool `json:"xForwarded"` Insecure bool `json:"insecure"` Content string `json:"content"` @@ -791,6 +792,7 @@ func (c *HysteriaConfig) Build() (proto.Message, error) { config.MasqFile = c.Masquerade.Dir config.MasqUrl = c.Masquerade.Url config.MasqUrlRewriteHost = c.Masquerade.RewriteHost + config.MasqUrlXForwarded = c.Masquerade.XForwarded config.MasqUrlInsecure = c.Masquerade.Insecure config.MasqString = c.Masquerade.Content config.MasqStringHeaders = c.Masquerade.Headers diff --git a/transport/internet/config.pb.go b/transport/internet/config.pb.go index ee696b43b..f747998e1 100644 --- a/transport/internet/config.pb.go +++ b/transport/internet/config.pb.go @@ -443,22 +443,26 @@ func (x *UdpHop) GetIntervalMax() int64 { } type QuicParams struct { - state protoimpl.MessageState `protogen:"open.v1"` - Congestion string `protobuf:"bytes,1,opt,name=congestion,proto3" json:"congestion,omitempty"` - BbrProfile string `protobuf:"bytes,2,opt,name=bbr_profile,json=bbrProfile,proto3" json:"bbr_profile,omitempty"` - BrutalUp uint64 `protobuf:"varint,3,opt,name=brutal_up,json=brutalUp,proto3" json:"brutal_up,omitempty"` - BrutalDown uint64 `protobuf:"varint,4,opt,name=brutal_down,json=brutalDown,proto3" json:"brutal_down,omitempty"` - UdpHop *UdpHop `protobuf:"bytes,5,opt,name=udp_hop,json=udpHop,proto3" json:"udp_hop,omitempty"` - InitStreamReceiveWindow uint64 `protobuf:"varint,6,opt,name=init_stream_receive_window,json=initStreamReceiveWindow,proto3" json:"init_stream_receive_window,omitempty"` - MaxStreamReceiveWindow uint64 `protobuf:"varint,7,opt,name=max_stream_receive_window,json=maxStreamReceiveWindow,proto3" json:"max_stream_receive_window,omitempty"` - InitConnReceiveWindow uint64 `protobuf:"varint,8,opt,name=init_conn_receive_window,json=initConnReceiveWindow,proto3" json:"init_conn_receive_window,omitempty"` - MaxConnReceiveWindow uint64 `protobuf:"varint,9,opt,name=max_conn_receive_window,json=maxConnReceiveWindow,proto3" json:"max_conn_receive_window,omitempty"` - MaxIdleTimeout int64 `protobuf:"varint,10,opt,name=max_idle_timeout,json=maxIdleTimeout,proto3" json:"max_idle_timeout,omitempty"` - KeepAlivePeriod int64 `protobuf:"varint,11,opt,name=keep_alive_period,json=keepAlivePeriod,proto3" json:"keep_alive_period,omitempty"` - DisablePathMtuDiscovery bool `protobuf:"varint,12,opt,name=disable_path_mtu_discovery,json=disablePathMtuDiscovery,proto3" json:"disable_path_mtu_discovery,omitempty"` - MaxIncomingStreams int64 `protobuf:"varint,13,opt,name=max_incoming_streams,json=maxIncomingStreams,proto3" json:"max_incoming_streams,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + state protoimpl.MessageState `protogen:"open.v1"` + Congestion string `protobuf:"bytes,1,opt,name=congestion,proto3" json:"congestion,omitempty"` + BbrProfile string `protobuf:"bytes,2,opt,name=bbr_profile,json=bbrProfile,proto3" json:"bbr_profile,omitempty"` + BrutalUp uint64 `protobuf:"varint,3,opt,name=brutal_up,json=brutalUp,proto3" json:"brutal_up,omitempty"` + BrutalDown uint64 `protobuf:"varint,4,opt,name=brutal_down,json=brutalDown,proto3" json:"brutal_down,omitempty"` + BrutalDisableLossCompensation bool `protobuf:"varint,5,opt,name=brutal_disable_loss_compensation,json=brutalDisableLossCompensation,proto3" json:"brutal_disable_loss_compensation,omitempty"` + UdpHop *UdpHop `protobuf:"bytes,6,opt,name=udp_hop,json=udpHop,proto3" json:"udp_hop,omitempty"` + InitStreamReceiveWindow uint64 `protobuf:"varint,7,opt,name=init_stream_receive_window,json=initStreamReceiveWindow,proto3" json:"init_stream_receive_window,omitempty"` + MaxStreamReceiveWindow uint64 `protobuf:"varint,8,opt,name=max_stream_receive_window,json=maxStreamReceiveWindow,proto3" json:"max_stream_receive_window,omitempty"` + InitConnReceiveWindow uint64 `protobuf:"varint,9,opt,name=init_conn_receive_window,json=initConnReceiveWindow,proto3" json:"init_conn_receive_window,omitempty"` + MaxConnReceiveWindow uint64 `protobuf:"varint,10,opt,name=max_conn_receive_window,json=maxConnReceiveWindow,proto3" json:"max_conn_receive_window,omitempty"` + MaxIdleTimeout int64 `protobuf:"varint,11,opt,name=max_idle_timeout,json=maxIdleTimeout,proto3" json:"max_idle_timeout,omitempty"` + KeepAlivePeriod int64 `protobuf:"varint,12,opt,name=keep_alive_period,json=keepAlivePeriod,proto3" json:"keep_alive_period,omitempty"` + DisablePathMtuDiscovery bool `protobuf:"varint,13,opt,name=disable_path_mtu_discovery,json=disablePathMtuDiscovery,proto3" json:"disable_path_mtu_discovery,omitempty"` + DisableChromeParrot bool `protobuf:"varint,14,opt,name=disable_chrome_parrot,json=disableChromeParrot,proto3" json:"disable_chrome_parrot,omitempty"` + DisableGSO bool `protobuf:"varint,15,opt,name=disableGSO,proto3" json:"disableGSO,omitempty"` + MaxIncomingStreams int64 `protobuf:"varint,16,opt,name=max_incoming_streams,json=maxIncomingStreams,proto3" json:"max_incoming_streams,omitempty"` + DisableStatelessReset bool `protobuf:"varint,17,opt,name=disable_stateless_reset,json=disableStatelessReset,proto3" json:"disable_stateless_reset,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *QuicParams) Reset() { @@ -519,6 +523,13 @@ func (x *QuicParams) GetBrutalDown() uint64 { return 0 } +func (x *QuicParams) GetBrutalDisableLossCompensation() bool { + if x != nil { + return x.BrutalDisableLossCompensation + } + return false +} + func (x *QuicParams) GetUdpHop() *UdpHop { if x != nil { return x.UdpHop @@ -575,6 +586,20 @@ func (x *QuicParams) GetDisablePathMtuDiscovery() bool { return false } +func (x *QuicParams) GetDisableChromeParrot() bool { + if x != nil { + return x.DisableChromeParrot + } + return false +} + +func (x *QuicParams) GetDisableGSO() bool { + if x != nil { + return x.DisableGSO + } + return false +} + func (x *QuicParams) GetMaxIncomingStreams() int64 { if x != nil { return x.MaxIncomingStreams @@ -582,6 +607,13 @@ func (x *QuicParams) GetMaxIncomingStreams() int64 { return 0 } +func (x *QuicParams) GetDisableStatelessReset() bool { + if x != nil { + return x.DisableStatelessReset + } + return false +} + type ProxyConfig struct { state protoimpl.MessageState `protogen:"open.v1"` Tag string `protobuf:"bytes,1,opt,name=tag,proto3" json:"tag,omitempty"` @@ -1020,7 +1052,7 @@ const file_transport_internet_config_proto_rawDesc = "" + "\x06UdpHop\x12\x14\n" + "\x05ports\x18\x01 \x03(\rR\x05ports\x12!\n" + "\finterval_min\x18\x02 \x01(\x03R\vintervalMin\x12!\n" + - "\finterval_max\x18\x03 \x01(\x03R\vintervalMax\"\xf2\x04\n" + + "\finterval_max\x18\x03 \x01(\x03R\vintervalMax\"\xc7\x06\n" + "\n" + "QuicParams\x12\x1e\n" + "\n" + @@ -1030,17 +1062,23 @@ const file_transport_internet_config_proto_rawDesc = "" + "bbrProfile\x12\x1b\n" + "\tbrutal_up\x18\x03 \x01(\x04R\bbrutalUp\x12\x1f\n" + "\vbrutal_down\x18\x04 \x01(\x04R\n" + - "brutalDown\x128\n" + - "\audp_hop\x18\x05 \x01(\v2\x1f.xray.transport.internet.UdpHopR\x06udpHop\x12;\n" + - "\x1ainit_stream_receive_window\x18\x06 \x01(\x04R\x17initStreamReceiveWindow\x129\n" + - "\x19max_stream_receive_window\x18\a \x01(\x04R\x16maxStreamReceiveWindow\x127\n" + - "\x18init_conn_receive_window\x18\b \x01(\x04R\x15initConnReceiveWindow\x125\n" + - "\x17max_conn_receive_window\x18\t \x01(\x04R\x14maxConnReceiveWindow\x12(\n" + - "\x10max_idle_timeout\x18\n" + - " \x01(\x03R\x0emaxIdleTimeout\x12*\n" + - "\x11keep_alive_period\x18\v \x01(\x03R\x0fkeepAlivePeriod\x12;\n" + - "\x1adisable_path_mtu_discovery\x18\f \x01(\bR\x17disablePathMtuDiscovery\x120\n" + - "\x14max_incoming_streams\x18\r \x01(\x03R\x12maxIncomingStreams\"Q\n" + + "brutalDown\x12G\n" + + " brutal_disable_loss_compensation\x18\x05 \x01(\bR\x1dbrutalDisableLossCompensation\x128\n" + + "\audp_hop\x18\x06 \x01(\v2\x1f.xray.transport.internet.UdpHopR\x06udpHop\x12;\n" + + "\x1ainit_stream_receive_window\x18\a \x01(\x04R\x17initStreamReceiveWindow\x129\n" + + "\x19max_stream_receive_window\x18\b \x01(\x04R\x16maxStreamReceiveWindow\x127\n" + + "\x18init_conn_receive_window\x18\t \x01(\x04R\x15initConnReceiveWindow\x125\n" + + "\x17max_conn_receive_window\x18\n" + + " \x01(\x04R\x14maxConnReceiveWindow\x12(\n" + + "\x10max_idle_timeout\x18\v \x01(\x03R\x0emaxIdleTimeout\x12*\n" + + "\x11keep_alive_period\x18\f \x01(\x03R\x0fkeepAlivePeriod\x12;\n" + + "\x1adisable_path_mtu_discovery\x18\r \x01(\bR\x17disablePathMtuDiscovery\x122\n" + + "\x15disable_chrome_parrot\x18\x0e \x01(\bR\x13disableChromeParrot\x12\x1e\n" + + "\n" + + "disableGSO\x18\x0f \x01(\bR\n" + + "disableGSO\x120\n" + + "\x14max_incoming_streams\x18\x10 \x01(\x03R\x12maxIncomingStreams\x126\n" + + "\x17disable_stateless_reset\x18\x11 \x01(\bR\x15disableStatelessReset\"Q\n" + "\vProxyConfig\x12\x10\n" + "\x03tag\x18\x01 \x01(\tR\x03tag\x120\n" + "\x13transportLayerProxy\x18\x02 \x01(\bR\x13transportLayerProxy\"\x93\x01\n" + diff --git a/transport/internet/config.proto b/transport/internet/config.proto index c139111aa..58b4f21b2 100644 --- a/transport/internet/config.proto +++ b/transport/internet/config.proto @@ -75,15 +75,19 @@ message QuicParams { string bbr_profile = 2; uint64 brutal_up = 3; uint64 brutal_down = 4; - UdpHop udp_hop = 5; - uint64 init_stream_receive_window = 6; - uint64 max_stream_receive_window = 7; - uint64 init_conn_receive_window = 8; - uint64 max_conn_receive_window = 9; - int64 max_idle_timeout = 10; - int64 keep_alive_period = 11; - bool disable_path_mtu_discovery = 12; - int64 max_incoming_streams = 13; + bool brutal_disable_loss_compensation = 5; + UdpHop udp_hop = 6; + uint64 init_stream_receive_window = 7; + uint64 max_stream_receive_window = 8; + uint64 init_conn_receive_window = 9; + uint64 max_conn_receive_window = 10; + int64 max_idle_timeout = 11; + int64 keep_alive_period = 12; + bool disable_path_mtu_discovery = 13; + bool disable_chrome_parrot = 14; + bool disableGSO = 15; + int64 max_incoming_streams = 16; + bool disable_stateless_reset = 17; } message ProxyConfig { diff --git a/transport/internet/finalmask/realm/client.go b/transport/internet/finalmask/realm/client.go index 5b2354a58..b47b9d57e 100644 --- a/transport/internet/finalmask/realm/client.go +++ b/transport/internet/finalmask/realm/client.go @@ -7,6 +7,7 @@ import ( "net/netip" "slices" "strings" + "sync" "time" "github.com/pion/stun/v3" @@ -15,35 +16,67 @@ import ( ) type realmConnClient struct { + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc net.PacketConn peer *net.UDPAddr realmClient *Client realmID string stunServers []string + family Family + mapper *PortMapper stunTimeout time.Duration punchTimeout time.Duration punchInterval time.Duration } func NewConnClient(config *Config, raw net.PacketConn) (net.PacketConn, error) { + ctx, cancel := context.WithCancel(context.Background()) + + family := Family_Dual + switch config.IPMode { + case "dual": + case "v4": + family = Family_V4 + case "v6": + family = Family_V6 + } + + var mapper *PortMapper + if config.PortMapping != nil && config.PortMapping.Enabled { + var err error + start := time.Now() + mapper, err = NewPortMapper(context.Background(), raw.LocalAddr().(*net.UDPAddr).Port, PortMapConfig{Timeout: time.Duration(config.PortMapping.Timeout) * time.Second, Lifetime: time.Duration(config.PortMapping.Lifetime) * time.Second}) + if err != nil { + errors.LogErrorInner(context.Background(), err, "[realm] [port mapping] [", raw.LocalAddr().(*net.UDPAddr).Port, "] init failed after ", time.Since(start)) + } else { + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] gateway ", mapper.GatewayType(), ", external ", mapper.ExternalAddr()) + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] init success with ", time.Since(start)) + } + } + conn := &realmConnClient{ + ctx: ctx, + cancel: cancel, PacketConn: raw, realmClient: NewClient(config.Scheme, config.Host, config.Port, config.Token, config.TlsConfig), realmID: config.ID, stunServers: config.StunServers, + family: family, + mapper: mapper, stunTimeout: defaultSTUNTimeout, punchTimeout: defaultPunchTimeout, punchInterval: defaultPunchInterval, } - return conn.getpeer() } func (c *realmConnClient) getpeer() (net.PacketConn, error) { start := time.Now() - servers := resolveSTUNServers(c.PacketConn.LocalAddr().(*net.UDPAddr).IP, c.stunServers) + servers := resolveSTUNServers(c.PacketConn.LocalAddr().(*net.UDPAddr).IP, c.stunServers, c.family) errors.LogDebug(context.Background(), "[realm] update stun servers ", servers, " with ", time.Since(start)) if len(servers) == 0 { return nil, errors.New("empty locals") @@ -70,7 +103,7 @@ func (c *realmConnClient) getpeer() (net.PacketConn, error) { peers, _ := parseAddrPorts(resp.Addresses) errors.LogDebug(context.Background(), "[realm] update peers ", peers) - filteredPeers, seen := candidatePunchAddrs(locals, peers) + filteredPeers, seen := candidatePunchAddrs(locals, peers, c.family) errors.LogDebug(context.Background(), "[realm] filtered peers ", filteredPeers) expandedPeers := expandSymmetricNATCandidates(filteredPeers, seen) errors.LogDebug(context.Background(), "[realm] expanded peers ", expandedPeers) @@ -86,6 +119,11 @@ func (c *realmConnClient) getpeer() (net.PacketConn, error) { } errors.LogDebug(context.Background(), "[realm] punch peer ", peer, " with ", time.Since(start)) + if c.mapper != nil { + c.wg.Add(1) + go portMapLoop(c.ctx, c.mapper, c.wg.Done) + } + c.peer = peer return c, nil } @@ -116,10 +154,12 @@ func (c *realmConnClient) discover(servers []*net.UDPAddr) []netip.AddrPort { } } c.PacketConn.SetReadDeadline(time.Time{}) + if c.mapper != nil { + results = insertAddr(results, c.mapper.ExternalAddr()) + } slices.SortFunc(results, func(a, b netip.AddrPort) int { return strings.Compare(a.String(), b.String()) }) - return results } @@ -169,3 +209,48 @@ func (c *realmConnClient) punch(meta PunchMetadata, peers []netip.AddrPort) (*ne func (c *realmConnClient) WriteTo(p []byte, addr net.Addr) (n int, err error) { return c.PacketConn.WriteTo(p, c.peer) } + +func (c *realmConnClient) Close() error { + // Sadly, closing the core does not first close the sockets created for outbound connections + c.cancel() + c.wg.Wait() + return nil +} + +func portMapLoop(ctx context.Context, mapper *PortMapper, done func()) { + defer func() { + err := mapper.Close() + done() + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] removed with ", err) + }() + interval := mapper.Lifetime() / 2 + if interval <= 0 { + interval = time.Minute + } + t := time.NewTicker(interval) + defer t.Stop() + failing := false + for { + select { + case <-ctx.Done(): + return + case <-t.C: + changed, err := mapper.Renew(ctx) + if err != nil { + if ctx.Err() != nil { + return + } + if !failing { + errors.LogError(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] renewal failed") + failing = true + } + continue + } + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] external ", mapper.ExternalAddr(), ", changed ", changed) + if failing { + errors.LogError(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] recovered") + failing = false + } + } + } +} diff --git a/transport/internet/finalmask/realm/config.pb.go b/transport/internet/finalmask/realm/config.pb.go index ee273e732..35c94d551 100644 --- a/transport/internet/finalmask/realm/config.pb.go +++ b/transport/internet/finalmask/realm/config.pb.go @@ -22,6 +22,115 @@ const ( _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) ) +type Family int32 + +const ( + Family_Dual Family = 0 + Family_V4 Family = 1 + Family_V6 Family = 2 +) + +// Enum value maps for Family. +var ( + Family_name = map[int32]string{ + 0: "Dual", + 1: "V4", + 2: "V6", + } + Family_value = map[string]int32{ + "Dual": 0, + "V4": 1, + "V6": 2, + } +) + +func (x Family) Enum() *Family { + p := new(Family) + *p = x + return p +} + +func (x Family) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Family) Descriptor() protoreflect.EnumDescriptor { + return file_transport_internet_finalmask_realm_config_proto_enumTypes[0].Descriptor() +} + +func (Family) Type() protoreflect.EnumType { + return &file_transport_internet_finalmask_realm_config_proto_enumTypes[0] +} + +func (x Family) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Family.Descriptor instead. +func (Family) EnumDescriptor() ([]byte, []int) { + return file_transport_internet_finalmask_realm_config_proto_rawDescGZIP(), []int{0} +} + +type PortMapping struct { + state protoimpl.MessageState `protogen:"open.v1"` + Enabled bool `protobuf:"varint,1,opt,name=enabled,proto3" json:"enabled,omitempty"` + Timeout int64 `protobuf:"varint,2,opt,name=timeout,proto3" json:"timeout,omitempty"` + Lifetime int64 `protobuf:"varint,3,opt,name=lifetime,proto3" json:"lifetime,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PortMapping) Reset() { + *x = PortMapping{} + mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PortMapping) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PortMapping) ProtoMessage() {} + +func (x *PortMapping) ProtoReflect() protoreflect.Message { + mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PortMapping.ProtoReflect.Descriptor instead. +func (*PortMapping) Descriptor() ([]byte, []int) { + return file_transport_internet_finalmask_realm_config_proto_rawDescGZIP(), []int{0} +} + +func (x *PortMapping) GetEnabled() bool { + if x != nil { + return x.Enabled + } + return false +} + +func (x *PortMapping) GetTimeout() int64 { + if x != nil { + return x.Timeout + } + return 0 +} + +func (x *PortMapping) GetLifetime() int64 { + if x != nil { + return x.Lifetime + } + return 0 +} + type Config struct { state protoimpl.MessageState `protogen:"open.v1"` Scheme string `protobuf:"bytes,1,opt,name=scheme,proto3" json:"scheme,omitempty"` @@ -31,13 +140,15 @@ type Config struct { ID string `protobuf:"bytes,5,opt,name=ID,proto3" json:"ID,omitempty"` StunServers []string `protobuf:"bytes,6,rep,name=stun_servers,json=stunServers,proto3" json:"stun_servers,omitempty"` TlsConfig *tls.Config `protobuf:"bytes,7,opt,name=tls_config,json=tlsConfig,proto3" json:"tls_config,omitempty"` + IPMode string `protobuf:"bytes,8,opt,name=IPMode,proto3" json:"IPMode,omitempty"` + PortMapping *PortMapping `protobuf:"bytes,9,opt,name=port_mapping,json=portMapping,proto3" json:"port_mapping,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } func (x *Config) Reset() { *x = Config{} - mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[1] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -49,7 +160,7 @@ func (x *Config) String() string { func (*Config) ProtoMessage() {} func (x *Config) ProtoReflect() protoreflect.Message { - mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[0] + mi := &file_transport_internet_finalmask_realm_config_proto_msgTypes[1] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -62,7 +173,7 @@ func (x *Config) ProtoReflect() protoreflect.Message { // Deprecated: Use Config.ProtoReflect.Descriptor instead. func (*Config) Descriptor() ([]byte, []int) { - return file_transport_internet_finalmask_realm_config_proto_rawDescGZIP(), []int{0} + return file_transport_internet_finalmask_realm_config_proto_rawDescGZIP(), []int{1} } func (x *Config) GetScheme() string { @@ -114,11 +225,29 @@ func (x *Config) GetTlsConfig() *tls.Config { return nil } +func (x *Config) GetIPMode() string { + if x != nil { + return x.IPMode + } + return "" +} + +func (x *Config) GetPortMapping() *PortMapping { + if x != nil { + return x.PortMapping + } + return nil +} + var File_transport_internet_finalmask_realm_config_proto protoreflect.FileDescriptor const file_transport_internet_finalmask_realm_config_proto_rawDesc = "" + "\n" + - "/transport/internet/finalmask/realm/config.proto\x12'xray.transport.internet.finalmask.realm\x1a#transport/internet/tls/config.proto\"\xd5\x01\n" + + "/transport/internet/finalmask/realm/config.proto\x12'xray.transport.internet.finalmask.realm\x1a#transport/internet/tls/config.proto\"]\n" + + "\vPortMapping\x12\x18\n" + + "\aenabled\x18\x01 \x01(\bR\aenabled\x12\x18\n" + + "\atimeout\x18\x02 \x01(\x03R\atimeout\x12\x1a\n" + + "\blifetime\x18\x03 \x01(\x03R\blifetime\"\xc6\x02\n" + "\x06Config\x12\x16\n" + "\x06scheme\x18\x01 \x01(\tR\x06scheme\x12\x12\n" + "\x04host\x18\x02 \x01(\tR\x04host\x12\x12\n" + @@ -127,7 +256,13 @@ const file_transport_internet_finalmask_realm_config_proto_rawDesc = "" + "\x02ID\x18\x05 \x01(\tR\x02ID\x12!\n" + "\fstun_servers\x18\x06 \x03(\tR\vstunServers\x12B\n" + "\n" + - "tls_config\x18\a \x01(\v2#.xray.transport.internet.tls.ConfigR\ttlsConfigB\x97\x01\n" + + "tls_config\x18\a \x01(\v2#.xray.transport.internet.tls.ConfigR\ttlsConfig\x12\x16\n" + + "\x06IPMode\x18\b \x01(\tR\x06IPMode\x12W\n" + + "\fport_mapping\x18\t \x01(\v24.xray.transport.internet.finalmask.realm.PortMappingR\vportMapping*\"\n" + + "\x06Family\x12\b\n" + + "\x04Dual\x10\x00\x12\x06\n" + + "\x02V4\x10\x01\x12\x06\n" + + "\x02V6\x10\x02B\x97\x01\n" + "+com.xray.transport.internet.finalmask.realmP\x01Z xray.transport.internet.tls.Config - 1, // [1:1] is the sub-list for method output_type - 1, // [1:1] is the sub-list for method input_type - 1, // [1:1] is the sub-list for extension type_name - 1, // [1:1] is the sub-list for extension extendee - 0, // [0:1] is the sub-list for field type_name + 3, // 0: xray.transport.internet.finalmask.realm.Config.tls_config:type_name -> xray.transport.internet.tls.Config + 1, // 1: xray.transport.internet.finalmask.realm.Config.port_mapping:type_name -> xray.transport.internet.finalmask.realm.PortMapping + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_transport_internet_finalmask_realm_config_proto_init() } @@ -166,13 +305,14 @@ func file_transport_internet_finalmask_realm_config_proto_init() { File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_transport_internet_finalmask_realm_config_proto_rawDesc), len(file_transport_internet_finalmask_realm_config_proto_rawDesc)), - NumEnums: 0, - NumMessages: 1, + NumEnums: 1, + NumMessages: 2, NumExtensions: 0, NumServices: 0, }, GoTypes: file_transport_internet_finalmask_realm_config_proto_goTypes, DependencyIndexes: file_transport_internet_finalmask_realm_config_proto_depIdxs, + EnumInfos: file_transport_internet_finalmask_realm_config_proto_enumTypes, MessageInfos: file_transport_internet_finalmask_realm_config_proto_msgTypes, }.Build() File_transport_internet_finalmask_realm_config_proto = out.File diff --git a/transport/internet/finalmask/realm/config.proto b/transport/internet/finalmask/realm/config.proto index 62dce86e3..75bf0f43e 100644 --- a/transport/internet/finalmask/realm/config.proto +++ b/transport/internet/finalmask/realm/config.proto @@ -8,6 +8,18 @@ option java_multiple_files = true; import "transport/internet/tls/config.proto"; +enum Family { + Dual = 0; + V4 = 1; + V6 = 2; +} + +message PortMapping { + bool enabled = 1; + int64 timeout = 2; + int64 lifetime = 3; +} + message Config { string scheme = 1; string host = 2; @@ -16,4 +28,6 @@ message Config { string ID = 5; repeated string stun_servers = 6; xray.transport.internet.tls.Config tls_config = 7; + string IPMode = 8; + PortMapping port_mapping = 9; } \ No newline at end of file diff --git a/transport/internet/finalmask/realm/portmap.go b/transport/internet/finalmask/realm/portmap.go new file mode 100644 index 000000000..edd4d172d --- /dev/null +++ b/transport/internet/finalmask/realm/portmap.go @@ -0,0 +1,139 @@ +package realm + +import ( + "context" + "errors" + "fmt" + "net/netip" + "sync" + "time" + + "github.com/libp2p/go-nat" +) + +const ( + defaultPortMapTimeout = 10 * time.Second + defaultPortMapLifetime = 10 * time.Minute + + portMapDescription = "hysteria-realm" + portMapProtocol = "udp" +) + +var ErrInvalidPortMapConfig = errors.New("invalid port mapping config") + +type PortMapConfig struct { + Timeout time.Duration + Lifetime time.Duration +} + +func (c PortMapConfig) withDefaults() (PortMapConfig, error) { + if c.Timeout == 0 { + c.Timeout = defaultPortMapTimeout + } + if c.Timeout < 0 { + return c, fmt.Errorf("%w: timeout must not be negative", ErrInvalidPortMapConfig) + } + if c.Lifetime == 0 { + c.Lifetime = defaultPortMapLifetime + } + if c.Lifetime < 0 { + return c, fmt.Errorf("%w: lifetime must not be negative", ErrInvalidPortMapConfig) + } + return c, nil +} + +// PortMapper maintains a UDP port mapping on the local gateway via UPnP or +// NAT-PMP. It does not renew the mapping by itself; the caller is expected +// to call Renew periodically (typically every Lifetime/2). +type PortMapper struct { + gateway nat.NAT + internalPort int + config PortMapConfig + + mu sync.Mutex + externalAddr netip.AddrPort +} + +// NewPortMapper discovers the local gateway and maps internalPort for UDP. +// It blocks for up to 2x config.Timeout (discovery + mapping). +func NewPortMapper(ctx context.Context, internalPort int, config PortMapConfig) (*PortMapper, error) { + if internalPort <= 0 || internalPort > 65535 { + return nil, fmt.Errorf("%w: invalid internal port %d", ErrInvalidPortMapConfig, internalPort) + } + config, err := config.withDefaults() + if err != nil { + return nil, err + } + + discoverCtx, cancel := context.WithTimeout(ctx, config.Timeout) + gateway, err := nat.DiscoverGateway(discoverCtx) + cancel() + if err != nil { + return nil, fmt.Errorf("gateway discovery failed: %w", err) + } + + m := &PortMapper{ + gateway: gateway, + internalPort: internalPort, + config: config, + } + if _, err := m.Renew(ctx); err != nil { + return nil, err + } + return m, nil +} + +// Renew (re-)requests the port mapping and refreshes the external address. +// It reports whether the external address changed since the last call. +func (m *PortMapper) Renew(ctx context.Context) (bool, error) { + opCtx, cancel := context.WithTimeout(ctx, m.config.Timeout) + defer cancel() + externalPort, err := m.gateway.AddPortMapping(opCtx, portMapProtocol, m.internalPort, portMapDescription, m.config.Lifetime) + if err != nil { + return false, fmt.Errorf("add port mapping failed: %w", err) + } + externalIP, err := m.gateway.GetExternalAddress() + if err != nil { + return false, fmt.Errorf("get external address failed: %w", err) + } + addr, ok := netip.AddrFromSlice(externalIP) + if !ok || addr.IsUnspecified() || addr.IsLoopback() { + return false, fmt.Errorf("gateway returned unusable external address: %s", externalIP) + } + externalAddr := netip.AddrPortFrom(addr.Unmap(), uint16(externalPort)) + + m.mu.Lock() + changed := externalAddr != m.externalAddr + m.externalAddr = externalAddr + m.mu.Unlock() + return changed, nil +} + +// ExternalAddr returns the gateway's external IP and the mapped external port. +func (m *PortMapper) ExternalAddr() netip.AddrPort { + m.mu.Lock() + defer m.mu.Unlock() + return m.externalAddr +} + +// InternalPort returns the mapped local UDP port. +func (m *PortMapper) InternalPort() int { + return m.internalPort +} + +// Lifetime returns the effective mapping lease duration. +func (m *PortMapper) Lifetime() time.Duration { + return m.config.Lifetime +} + +// GatewayType returns the protocol used to talk to the gateway ("UPnP" or "NAT-PMP"). +func (m *PortMapper) GatewayType() string { + return m.gateway.Type() +} + +// Close removes the port mapping from the gateway. Best-effort. +func (m *PortMapper) Close() error { + ctx, cancel := context.WithTimeout(context.Background(), m.config.Timeout) + defer cancel() + return m.gateway.DeletePortMapping(ctx, portMapProtocol, m.internalPort) +} diff --git a/transport/internet/finalmask/realm/server.go b/transport/internet/finalmask/realm/server.go index aacd4d0f5..1cca66586 100644 --- a/transport/internet/finalmask/realm/server.go +++ b/transport/internet/finalmask/realm/server.go @@ -33,14 +33,16 @@ type STUNPacketEvent struct { } type realmConnServer struct { - cleaned chan struct{} - ctx context.Context - cancel context.CancelFunc + wg sync.WaitGroup + ctx context.Context + cancel context.CancelFunc net.PacketConn realmClient *Client realmID string stunServers []string + family Family + mapper *PortMapper stunTimeout time.Duration punchTimeout time.Duration punchInterval time.Duration @@ -57,8 +59,29 @@ type realmConnServer struct { func NewConnServer(config *Config, raw net.PacketConn) (net.PacketConn, error) { ctx, cancel := context.WithCancel(context.Background()) + family := Family_Dual + switch config.IPMode { + case "dual": + case "v4": + family = Family_V4 + case "v6": + family = Family_V6 + } + + var mapper *PortMapper + if config.PortMapping != nil && config.PortMapping.Enabled { + var err error + start := time.Now() + mapper, err = NewPortMapper(context.Background(), raw.LocalAddr().(*net.UDPAddr).Port, PortMapConfig{Timeout: time.Duration(config.PortMapping.Timeout) * time.Second, Lifetime: time.Duration(config.PortMapping.Lifetime) * time.Second}) + if err != nil { + errors.LogErrorInner(context.Background(), err, "[realm] [port mapping] [", raw.LocalAddr().(*net.UDPAddr).Port, "] init failed after ", time.Since(start)) + } else { + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] gateway ", mapper.GatewayType(), ", external ", mapper.ExternalAddr()) + errors.LogDebug(context.Background(), "[realm] [port mapping] [", mapper.InternalPort(), "] init success with ", time.Since(start)) + } + } + conn := &realmConnServer{ - cleaned: make(chan struct{}), ctx: ctx, cancel: cancel, PacketConn: raw, @@ -66,6 +89,8 @@ func NewConnServer(config *Config, raw net.PacketConn) (net.PacketConn, error) { realmClient: NewClient(config.Scheme, config.Host, config.Port, config.Token, config.TlsConfig), realmID: config.ID, stunServers: config.StunServers, + family: family, + mapper: mapper, stunTimeout: defaultSTUNTimeout, punchTimeout: defaultPunchTimeout, punchInterval: defaultPunchInterval, @@ -74,6 +99,12 @@ func NewConnServer(config *Config, raw net.PacketConn) (net.PacketConn, error) { stun: make(chan STUNPacketEvent, defaultEventBuffer), } + if mapper != nil { + conn.wg.Add(1) + go portMapLoop(ctx, mapper, conn.wg.Done) + } + + conn.wg.Add(1) go conn.run() return conn, nil @@ -137,6 +168,8 @@ func (c *realmConnServer) discover(servers []*net.UDPAddr) []netip.AddrPort { results := make([]netip.AddrPort, 0, len(servers)) for len(transactionIDs) > 0 { select { + case <-c.ctx.Done(): + goto end case <-deadline.C: goto end case ev := <-c.stun: @@ -148,6 +181,9 @@ func (c *realmConnServer) discover(servers []*net.UDPAddr) []netip.AddrPort { } end: deadline.Stop() + if c.mapper != nil { + results = insertAddr(results, c.mapper.ExternalAddr()) + } slices.SortFunc(results, func(a, b netip.AddrPort) int { return strings.Compare(a.String(), b.String()) }) @@ -159,7 +195,7 @@ func (c *realmConnServer) getlocals(force bool) []netip.AddrPort { c.localsMu.Lock() if force || time.Since(c.localsLast) > defaultStunCacheTTL { start := time.Now() - servers := resolveSTUNServers(c.PacketConn.LocalAddr().(*net.UDPAddr).IP, c.stunServers) + servers := resolveSTUNServers(c.PacketConn.LocalAddr().(*net.UDPAddr).IP, c.stunServers, c.family) errors.LogDebug(context.Background(), "[realm] update stun servers ", servers, " with ", time.Since(start)) if len(servers) > 0 { start = time.Now() @@ -232,7 +268,7 @@ retry: if err != nil { errors.LogErrorInner(context.Background(), err, "[realm] ", c.realmID, " register session err retry in ", backoff) if c.waitctx(c.ctx, backoff) { - close(c.cleaned) + c.wg.Done() return } backoff *= 2 @@ -259,7 +295,7 @@ retry: case <-c.ctx.Done(): _ = c.realmClient.Deregister(context.Background(), c.realmID, resp.SessionID) errors.LogDebug(context.Background(), "[realm] ", c.realmID, " ", resp.SessionID, " deregistered") - close(c.cleaned) + c.wg.Done() return default: goto retry @@ -360,7 +396,7 @@ func (c *realmConnServer) punchEvent(ctx context.Context, sid string, ev *PunchE peers, _ := parseAddrPorts(ev.Addresses) errors.LogDebug(context.Background(), "[realm] ", ev.Nonce, " update peers ", peers) - filteredPeers, seen := candidatePunchAddrs(locals, peers) + filteredPeers, seen := candidatePunchAddrs(locals, peers, c.family) errors.LogDebug(context.Background(), "[realm] ", ev.Nonce, " filtered peers ", filteredPeers) expandedPeers := expandSymmetricNATCandidates(filteredPeers, seen) errors.LogDebug(context.Background(), "[realm] ", ev.Nonce, " expanded peers ", expandedPeers) @@ -398,6 +434,6 @@ func (c *realmConnServer) ReadFrom(p []byte) (int, net.Addr, error) { func (c *realmConnServer) Close() error { c.cancel() - <-c.cleaned + c.wg.Wait() return c.PacketConn.Close() } diff --git a/transport/internet/finalmask/realm/stun.go b/transport/internet/finalmask/realm/stun.go index f490cbe03..d7dbe3f61 100644 --- a/transport/internet/finalmask/realm/stun.go +++ b/transport/internet/finalmask/realm/stun.go @@ -23,14 +23,23 @@ const ( symmetricNATMaxPortsPerHost = 32 ) -func resolveSTUNServers(local net.IP, servers []string) []*net.UDPAddr { +func resolveSTUNServers(local net.IP, servers []string, family Family) []*net.UDPAddr { var network string - if local.IsUnspecified() { - network = "ip" - } else { - if local.To4() != nil { - network = "ip4" + if family == Family_Dual { + if local.IsUnspecified() { + network = "ip" } else { + if local.To4() != nil { + network = "ip4" + } else { + network = "ip6" + } + } + } else { + if family == Family_V4 { + network = "ip4" + } + if family == Family_V6 { network = "ip6" } } @@ -104,7 +113,7 @@ func netIPPortToAddrPort(ip net.IP, port int) (netip.AddrPort, error) { return netip.AddrPortFrom(netip.AddrFrom16(addr), uint16(port)), nil } -func candidatePunchAddrs(locals, peers []netip.AddrPort) ([]netip.AddrPort, map[netip.AddrPort]struct{}) { +func candidatePunchAddrs(locals, peers []netip.AddrPort, family Family) ([]netip.AddrPort, map[netip.AddrPort]struct{}) { var allow4, allow6 bool for _, local := range locals { if local.Addr().Is4() { @@ -116,6 +125,12 @@ func candidatePunchAddrs(locals, peers []netip.AddrPort) ([]netip.AddrPort, map[ break } } + if family == Family_V4 { + allow6 = false + } + if family == Family_V6 { + allow4 = false + } seen := make(map[netip.AddrPort]struct{}, len(peers)) candidates := make([]netip.AddrPort, 0, len(peers)) for _, peer := range peers { @@ -218,3 +233,17 @@ func parseAddrPorts(addrs []string) ([]netip.AddrPort, error) { } return out, nil } + +func insertAddr(addrs []netip.AddrPort, addr netip.AddrPort) []netip.AddrPort { + if !addr.IsValid() { + return addrs + } + out := append([]netip.AddrPort(nil), addrs...) + i, found := slices.BinarySearchFunc(out, addr, func(a, b netip.AddrPort) int { + return strings.Compare(a.String(), b.String()) + }) + if found { + return out + } + return slices.Insert(out, i, addr) +} diff --git a/transport/internet/hysteria/config.pb.go b/transport/internet/hysteria/config.pb.go index 2a2cf1a2f..8e32b8fc5 100644 --- a/transport/internet/hysteria/config.pb.go +++ b/transport/internet/hysteria/config.pb.go @@ -29,10 +29,11 @@ type Config struct { MasqFile string `protobuf:"bytes,5,opt,name=masq_file,json=masqFile,proto3" json:"masq_file,omitempty"` MasqUrl string `protobuf:"bytes,6,opt,name=masq_url,json=masqUrl,proto3" json:"masq_url,omitempty"` MasqUrlRewriteHost bool `protobuf:"varint,7,opt,name=masq_url_rewrite_host,json=masqUrlRewriteHost,proto3" json:"masq_url_rewrite_host,omitempty"` - MasqUrlInsecure bool `protobuf:"varint,8,opt,name=masq_url_insecure,json=masqUrlInsecure,proto3" json:"masq_url_insecure,omitempty"` - MasqString string `protobuf:"bytes,9,opt,name=masq_string,json=masqString,proto3" json:"masq_string,omitempty"` - MasqStringHeaders map[string]string `protobuf:"bytes,10,rep,name=masq_string_headers,json=masqStringHeaders,proto3" json:"masq_string_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` - MasqStringStatusCode int32 `protobuf:"varint,11,opt,name=masq_string_status_code,json=masqStringStatusCode,proto3" json:"masq_string_status_code,omitempty"` + MasqUrlXForwarded bool `protobuf:"varint,8,opt,name=masq_url_x_forwarded,json=masqUrlXForwarded,proto3" json:"masq_url_x_forwarded,omitempty"` + MasqUrlInsecure bool `protobuf:"varint,9,opt,name=masq_url_insecure,json=masqUrlInsecure,proto3" json:"masq_url_insecure,omitempty"` + MasqString string `protobuf:"bytes,10,opt,name=masq_string,json=masqString,proto3" json:"masq_string,omitempty"` + MasqStringHeaders map[string]string `protobuf:"bytes,11,rep,name=masq_string_headers,json=masqStringHeaders,proto3" json:"masq_string_headers,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"bytes,2,opt,name=value"` + MasqStringStatusCode int32 `protobuf:"varint,12,opt,name=masq_string_status_code,json=masqStringStatusCode,proto3" json:"masq_string_status_code,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -109,6 +110,13 @@ func (x *Config) GetMasqUrlRewriteHost() bool { return false } +func (x *Config) GetMasqUrlXForwarded() bool { + if x != nil { + return x.MasqUrlXForwarded + } + return false +} + func (x *Config) GetMasqUrlInsecure() bool { if x != nil { return x.MasqUrlInsecure @@ -141,20 +149,21 @@ var File_transport_internet_hysteria_config_proto protoreflect.FileDescriptor const file_transport_internet_hysteria_config_proto_rawDesc = "" + "\n" + - "(transport/internet/hysteria/config.proto\x12 xray.transport.internet.hysteria\"\x8f\x04\n" + + "(transport/internet/hysteria/config.proto\x12 xray.transport.internet.hysteria\"\xc0\x04\n" + "\x06Config\x12\x12\n" + "\x04auth\x18\x02 \x01(\tR\x04auth\x12(\n" + "\x10udp_idle_timeout\x18\x03 \x01(\x03R\x0eudpIdleTimeout\x12\x1b\n" + "\tmasq_type\x18\x04 \x01(\tR\bmasqType\x12\x1b\n" + "\tmasq_file\x18\x05 \x01(\tR\bmasqFile\x12\x19\n" + "\bmasq_url\x18\x06 \x01(\tR\amasqUrl\x121\n" + - "\x15masq_url_rewrite_host\x18\a \x01(\bR\x12masqUrlRewriteHost\x12*\n" + - "\x11masq_url_insecure\x18\b \x01(\bR\x0fmasqUrlInsecure\x12\x1f\n" + - "\vmasq_string\x18\t \x01(\tR\n" + + "\x15masq_url_rewrite_host\x18\a \x01(\bR\x12masqUrlRewriteHost\x12/\n" + + "\x14masq_url_x_forwarded\x18\b \x01(\bR\x11masqUrlXForwarded\x12*\n" + + "\x11masq_url_insecure\x18\t \x01(\bR\x0fmasqUrlInsecure\x12\x1f\n" + + "\vmasq_string\x18\n" + + " \x01(\tR\n" + "masqString\x12o\n" + - "\x13masq_string_headers\x18\n" + - " \x03(\v2?.xray.transport.internet.hysteria.Config.MasqStringHeadersEntryR\x11masqStringHeaders\x125\n" + - "\x17masq_string_status_code\x18\v \x01(\x05R\x14masqStringStatusCode\x1aD\n" + + "\x13masq_string_headers\x18\v \x03(\v2?.xray.transport.internet.hysteria.Config.MasqStringHeadersEntryR\x11masqStringHeaders\x125\n" + + "\x17masq_string_status_code\x18\f \x01(\x05R\x14masqStringStatusCode\x1aD\n" + "\x16MasqStringHeadersEntry\x12\x10\n" + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + "\x05value\x18\x02 \x01(\tR\x05value:\x028\x01J\x04\b\x01\x10\x02B\x82\x01\n" + diff --git a/transport/internet/hysteria/config.proto b/transport/internet/hysteria/config.proto index 8eaac87c1..279784acd 100644 --- a/transport/internet/hysteria/config.proto +++ b/transport/internet/hysteria/config.proto @@ -11,12 +11,17 @@ message Config { string auth = 2; int64 udp_idle_timeout = 3; + string masq_type = 4; + string masq_file = 5; + string masq_url = 6; bool masq_url_rewrite_host = 7; - bool masq_url_insecure = 8; - string masq_string = 9; - map masq_string_headers = 10; - int32 masq_string_status_code = 11; + bool masq_url_x_forwarded = 8; + bool masq_url_insecure = 9; + + string masq_string = 10; + map masq_string_headers = 11; + int32 masq_string_status_code = 12; } \ No newline at end of file diff --git a/transport/internet/hysteria/congestion/brutal/brutal.go b/transport/internet/hysteria/congestion/brutal/brutal.go index 969923a51..c6b1fcbf4 100644 --- a/transport/internet/hysteria/congestion/brutal/brutal.go +++ b/transport/internet/hysteria/congestion/brutal/brutal.go @@ -33,6 +33,8 @@ type BrutalSender struct { pktInfoSlots [pktInfoSlotCount]pktInfo ackRate float64 + disableLossCompensation bool + debug bool lastAckPrintTimestamp int64 } @@ -43,13 +45,14 @@ type pktInfo struct { LossCount uint64 } -func NewBrutalSender(bps uint64) *BrutalSender { +func NewBrutalSender(bps uint64, disableLossCompensation bool) *BrutalSender { debug, _ := strconv.ParseBool(os.Getenv(debugEnv)) bs := &BrutalSender{ - bps: congestion.ByteCount(bps), - maxDatagramSize: congestion.InitialPacketSize, - ackRate: 1, - debug: debug, + bps: congestion.ByteCount(bps), + maxDatagramSize: congestion.InitialPacketSize, + ackRate: 1, + disableLossCompensation: disableLossCompensation, + debug: debug, } bs.pacer = common.NewPacer(func() congestion.ByteCount { return congestion.ByteCount(float64(bs.bps) / bs.ackRate) @@ -127,6 +130,10 @@ func (b *BrutalSender) SetMaxDatagramSize(size congestion.ByteCount) { } func (b *BrutalSender) updateAckRate(currentTimestamp int64) { + if b.disableLossCompensation { + b.ackRate = 1 + return + } minTimestamp := currentTimestamp - pktInfoSlotCount var ackCount, lossCount uint64 for _, info := range b.pktInfoSlots { diff --git a/transport/internet/hysteria/congestion/brutal/brutal_test.go b/transport/internet/hysteria/congestion/brutal/brutal_test.go new file mode 100644 index 000000000..2565beb49 --- /dev/null +++ b/transport/internet/hysteria/congestion/brutal/brutal_test.go @@ -0,0 +1,45 @@ +package brutal + +import ( + "testing" + "time" + + "github.com/apernet/quic-go/congestion" + "github.com/apernet/quic-go/monotime" +) + +// feedAckRate drives a single sampling slot with the given number of acked and +// lost packets and returns the resulting ackRate. +func feedAckRate(disableLossCompensation bool, ackCount, lossCount int) float64 { + b := NewBrutalSender(1000000, disableLossCompensation) + acked := make([]congestion.AckedPacketInfo, ackCount) + lost := make([]congestion.LostPacketInfo, lossCount) + // eventTime lands in a fixed slot; a single event carries enough samples. + b.OnCongestionEventEx(0, monotime.Time(5*time.Second), acked, lost) + return b.ackRate +} + +func TestBrutalLossCompensation(t *testing.T) { + tests := []struct { + name string + ack, loss int + want float64 // expected ackRate when compensation is ENABLED + }{ + {"no loss", 100, 0, 1.0}, + {"20% loss", 80, 20, 0.8}, + {"50% loss clamps to floor", 50, 50, minAckRate}, // 0.5 clamped up to 0.8 + {"few samples stays 1", 10, 5, 1.0}, // below minSampleCount + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Compensation enabled (default behavior): ackRate reacts to loss. + if got := feedAckRate(false, tt.ack, tt.loss); got != tt.want { + t.Errorf("compensation on: ackRate = %v, want %v", got, tt.want) + } + // Compensation disabled: ackRate must stay pinned at 1 regardless. + if got := feedAckRate(true, tt.ack, tt.loss); got != 1.0 { + t.Errorf("compensation off: ackRate = %v, want 1.0", got) + } + }) + } +} diff --git a/transport/internet/hysteria/congestion/utils.go b/transport/internet/hysteria/congestion/utils.go index 0f04318db..87293722d 100644 --- a/transport/internet/hysteria/congestion/utils.go +++ b/transport/internet/hysteria/congestion/utils.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/apernet/quic-go" + "github.com/apernet/quic-go/congestion" "github.com/xtls/xray-core/transport/internet/hysteria/congestion/bbr" "github.com/xtls/xray-core/transport/internet/hysteria/congestion/brutal" ) @@ -36,13 +37,29 @@ func NormalizeBBRProfile(profile string) (string, error) { func UseBBR(conn *quic.Conn, profile bbr.Profile) { conn.SetCongestionControl(bbr.NewBbrSender( bbr.DefaultClock{}, - bbr.GetInitialPacketSize(conn.RemoteAddr()), + seedPacketSize(conn.InitialPacketSize(), bbr.GetInitialPacketSize(conn.RemoteAddr())), profile, )) } -func UseBrutal(conn *quic.Conn, tx uint64) { - conn.SetCongestionControl(brutal.NewBrutalSender(tx)) +// seedPacketSize picks the datagram size to seed a replacement congestion +// controller with, given the size QUIC itself starts at and the guess derived +// from the remote address. +// +// The seed must not exceed what QUIC actually starts at. If it does, the first +// path MTU probe can land between the two: QUIC sees an increase and reports +// it, but the controller sees a decrease, which it cannot represent. Taking the +// smaller of the two keeps the address-based guess as a floor for connections +// whose path we can't reason about, while never seeding above QUIC. +func seedPacketSize(quicSize, byAddr congestion.ByteCount) congestion.ByteCount { + if quicSize <= 0 { + return byAddr + } + return min(quicSize, byAddr) +} + +func UseBrutal(conn *quic.Conn, tx uint64, disableLossCompensation bool) { + conn.SetCongestionControl(brutal.NewBrutalSender(tx, disableLossCompensation)) } func UseConfigured(conn *quic.Conn, congestionType, bbrProfile string) { diff --git a/transport/internet/hysteria/dialer.go b/transport/internet/hysteria/dialer.go index ed7dd4b2c..0307df576 100644 --- a/transport/internet/hysteria/dialer.go +++ b/transport/internet/hysteria/dialer.go @@ -90,6 +90,7 @@ func (c *client) dial(ctx context.Context) error { MaxIdleTimeout: time.Duration(quicParams.MaxIdleTimeout) * time.Second, KeepAlivePeriod: time.Duration(quicParams.KeepAlivePeriod) * time.Second, DisablePathMTUDiscovery: quicParams.DisablePathMtuDiscovery || (runtime.GOOS != "linux" && runtime.GOOS != "windows" && runtime.GOOS != "darwin"), + ChromeParrot: !quicParams.DisableChromeParrot, EnableDatagrams: true, MaxDatagramFrameSize: MaxDatagramFrameSize, OmitMaxDatagramFrameSize: time.Now().After(time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)), @@ -172,7 +173,12 @@ func (c *client) dial(ctx context.Context) error { pktConn = newConn } - tr := &quic.Transport{Conn: pktConn} + tr := &quic.Transport{Conn: pktConn, DisableGSO: quicParams.DisableGSO} + + if !quicParams.DisableChromeParrot { + tr.ConnectionIDGenerator = quic.ZeroLengthConnectionIDGenerator{} + c.tlsConfig.GetCertificate = nil + } var conn *quic.Conn rt := &http3.Transport{ @@ -219,6 +225,7 @@ func (c *client) dial(ctx context.Context) error { // udp, _ := strconv.ParseBool(resp.Header.Get(ResponseHeaderUDPEnabled)) down, _ := strconv.ParseUint(resp.Header.Get(CommonHeaderCCRX), 10, 64) + errors.LogDebug(context.Background(), "ECHAccepted ", conn.ConnectionState().TLS.ECHAccepted) switch quicParams.Congestion { case "reno": @@ -228,10 +235,10 @@ func (c *client) dial(ctx context.Context) error { if quicParams.BrutalUp == 0 || down == 0 { congestion.UseBBR(conn, bbr.Profile(quicParams.BbrProfile)) } else { - congestion.UseBrutal(conn, min(quicParams.BrutalUp, down)) + congestion.UseBrutal(conn, min(quicParams.BrutalUp, down), quicParams.BrutalDisableLossCompensation) } case "force-brutal": - congestion.UseBrutal(conn, quicParams.BrutalUp) + congestion.UseBrutal(conn, quicParams.BrutalUp, quicParams.BrutalDisableLossCompensation) default: panic(quicParams.Congestion) } diff --git a/transport/internet/hysteria/hub.go b/transport/internet/hysteria/hub.go index 59bcd7d42..c0cc641ba 100644 --- a/transport/internet/hysteria/hub.go +++ b/transport/internet/hysteria/hub.go @@ -2,6 +2,7 @@ package hysteria import ( "context" + "crypto/rand" gotls "crypto/tls" "net/http" "net/http/httputil" @@ -78,10 +79,10 @@ func (h *httpHandler) AuthHTTP(w http.ResponseWriter, r *http.Request) bool { if quicParams.BrutalUp == 0 || down == 0 { congestion.UseBBR(conn, bbr.Profile(quicParams.BbrProfile)) } else { - congestion.UseBrutal(conn, min(quicParams.BrutalUp, down)) + congestion.UseBrutal(conn, min(quicParams.BrutalUp, down), quicParams.BrutalDisableLossCompensation) } case "force-brutal": - congestion.UseBrutal(conn, quicParams.BrutalUp) + congestion.UseBrutal(conn, quicParams.BrutalUp, quicParams.BrutalDisableLossCompensation) default: panic(quicParams.Congestion) } @@ -165,8 +166,9 @@ func (l *Listener) handleClient(conn *quic.Conn) { Handler: handler, StreamDispatcher: handler.StreamDispatcher, } - _ = h3s.ServeQUICConn(conn) + err := h3s.ServeQUICConn(conn) _ = conn.CloseWithError(closeErrCodeOK, "") + errors.LogDebug(context.Background(), conn.RemoteAddr(), " ServeQUICConn exited with ", err) } func (l *Listener) keepAccepting() { @@ -219,21 +221,43 @@ func Listen(ctx context.Context, address net.Address, port net.Port, streamSetti return nil, err } transport := http.DefaultTransport.(*http.Transport) - if config.MasqUrlInsecure { - transport = transport.Clone() - transport.TLSClientConfig = &gotls.Config{ - InsecureSkipVerify: true, + switch u.Scheme { + case "http", "https": + if config.MasqUrlInsecure { + transport = transport.Clone() + if transport.TLSClientConfig == nil { + transport.TLSClientConfig = &gotls.Config{} + } + transport.TLSClientConfig.InsecureSkipVerify = true } + case "", "unix": + u = &url.URL{Scheme: "http", Host: "localhost"} + path := u.Path + dialer := &net.Dialer{Timeout: 30 * time.Second} + transport = transport.Clone() + transport.Proxy = nil + transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) { + return dialer.DialContext(ctx, "unix", path) + } + transport.MaxIdleConns = masqueradeProxyMaxIdleConnections + transport.MaxIdleConnsPerHost = masqueradeProxyMaxIdleConnsPerHost + default: + return nil, errors.New("unknown scheme") } masqHandler = &httputil.ReverseProxy{ - Rewrite: func(pr *httputil.ProxyRequest) { - pr.SetURL(u) + Rewrite: func(r *httputil.ProxyRequest) { + r.SetURL(u) if !config.MasqUrlRewriteHost { - pr.Out.Host = pr.In.Host + r.Out.Host = r.In.Host + } + if config.MasqUrlXForwarded { + r.SetXForwarded() } }, - Transport: transport, + Transport: transport, + BufferPool: newMasqueradeProxyBufferPool(), ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) { + errors.LogErrorInner(context.Background(), err, "HTTP reverse proxy error") w.WriteHeader(http.StatusBadGateway) }, } @@ -307,7 +331,13 @@ func Listen(ctx context.Context, address net.Address, port net.Port, streamSetti pktConn = newConn } - tr := &quic.Transport{Conn: pktConn} + var k *quic.StatelessResetKey + if !quicParams.DisableStatelessReset { + k = &quic.StatelessResetKey{} + common.Must2(rand.Read((*k)[:])) + } + + tr := &quic.Transport{Conn: pktConn, DisableGSO: quicParams.DisableGSO, StatelessResetKey: k} listener, err := tr.Listen(tlsConfig.GetTLSConfig(tls.WithNextProto("h3")), quicConfig) if err != nil { @@ -336,3 +366,34 @@ func Listen(ctx context.Context, address net.Address, port net.Port, streamSetti func init() { common.Must(internet.RegisterTransportListener(protocolName, Listen)) } + +const ( + masqueradeProxyBufferSize = 32 * 1024 + masqueradeProxyMaxIdleConnections = 100 + masqueradeProxyMaxIdleConnsPerHost = 32 +) + +type masqueradeProxyBufferPool struct { + pool sync.Pool +} + +func newMasqueradeProxyBufferPool() *masqueradeProxyBufferPool { + return &masqueradeProxyBufferPool{ + pool: sync.Pool{ + New: func() any { + return make([]byte, masqueradeProxyBufferSize) + }, + }, + } +} + +func (p *masqueradeProxyBufferPool) Get() []byte { + return p.pool.Get().([]byte) +} + +func (p *masqueradeProxyBufferPool) Put(buf []byte) { + if cap(buf) < masqueradeProxyBufferSize { + return + } + p.pool.Put(buf[:masqueradeProxyBufferSize]) +} diff --git a/transport/internet/splithttp/dialer.go b/transport/internet/splithttp/dialer.go index d2908aa1b..3c0f1a573 100644 --- a/transport/internet/splithttp/dialer.go +++ b/transport/internet/splithttp/dialer.go @@ -175,6 +175,7 @@ func createHTTPClient(dest net.Destination, streamSettings *internet.MemoryStrea KeepAlivePeriod: time.Duration(quicParams.KeepAlivePeriod) * time.Second, MaxIncomingStreams: quicParams.MaxIncomingStreams, DisablePathMTUDiscovery: quicParams.DisablePathMtuDiscovery || (runtime.GOOS != "linux" && runtime.GOOS != "windows" && runtime.GOOS != "darwin"), + ChromeParrot: !quicParams.DisableChromeParrot, } if quicParams.MaxIdleTimeout == 0 { quicConfig.MaxIdleTimeout = net.ConnIdleTimeout @@ -255,18 +256,25 @@ func createHTTPClient(dest net.Destination, streamSettings *internet.MemoryStrea pktConn = newConn } - conn, err := quic.DialEarly(ctx, pktConn, udpAddr, tlsCfg, cfg) + tr := &quic.Transport{Conn: pktConn, DisableGSO: quicParams.DisableGSO} + + if !quicParams.DisableChromeParrot { + tr.ConnectionIDGenerator = quic.ZeroLengthConnectionIDGenerator{} + tlsCfg.GetCertificate = nil + } + + conn, err := tr.DialEarly(ctx, udpAddr, tlsCfg, cfg) if err != nil { return nil, err } - context.AfterFunc(conn.Context(), func() { pktConn.Close() }) + context.AfterFunc(conn.Context(), func() { tr.Close(); pktConn.Close() }) switch quicParams.Congestion { case "reno": case "", "bbr": congestion.UseBBR(conn, bbr.Profile(quicParams.BbrProfile)) case "force-brutal": - congestion.UseBrutal(conn, quicParams.BrutalUp) + congestion.UseBrutal(conn, quicParams.BrutalUp, quicParams.BrutalDisableLossCompensation) default: panic(quicParams.Congestion) } diff --git a/transport/internet/splithttp/hub.go b/transport/internet/splithttp/hub.go index d664c86c4..c542a325b 100644 --- a/transport/internet/splithttp/hub.go +++ b/transport/internet/splithttp/hub.go @@ -3,6 +3,7 @@ package splithttp import ( "bytes" "context" + "crypto/rand" gotls "crypto/tls" "encoding/base64" "fmt" @@ -506,7 +507,15 @@ func ListenXH(ctx context.Context, address net.Address, port net.Port, streamSet DisablePathMTUDiscovery: quicParams.DisablePathMtuDiscovery || (runtime.GOOS != "linux" && runtime.GOOS != "windows" && runtime.GOOS != "darwin"), } - l.h3listener, err = quic.ListenEarly(Conn, tlsConfig, quicConfig) + var k *quic.StatelessResetKey + if !quicParams.DisableStatelessReset { + k = &quic.StatelessResetKey{} + common.Must2(rand.Read((*k)[:])) + } + + tr := &quic.Transport{Conn: Conn, DisableGSO: quicParams.DisableGSO, StatelessResetKey: k} + + l.h3listener, err = tr.ListenEarly(tlsConfig, quicConfig) if err != nil { return nil, errors.New("failed to listen QUIC for XHTTP/3 on ", address, ":", port).Base(err) } @@ -525,6 +534,8 @@ func ListenXH(ctx context.Context, address net.Address, port net.Port, streamSet if err := l.h3server.ServeListener(l.h3listener); err != nil { errors.LogErrorInner(ctx, err, "failed to serve HTTP/3 for XHTTP/3") } + _ = tr.Close() + _ = Conn.Close() }() } else { // tcp l.listener, err = internet.ListenSystem(ctx, &net.TCPAddr{ @@ -622,7 +633,7 @@ func (l *QListener) Accept(ctx context.Context) (*quic.Conn, error) { case "", "bbr": congestion.UseBBR(conn, bbr.Profile(l.quicParams.BbrProfile)) case "force-brutal": - congestion.UseBrutal(conn, l.quicParams.BrutalUp) + congestion.UseBrutal(conn, l.quicParams.BrutalUp, l.quicParams.BrutalDisableLossCompensation) default: panic(l.quicParams.Congestion) } From 540b9070f5bc0e67a04341f11bde598153957b91 Mon Sep 17 00:00:00 2001 From: Maksim Varentsov Date: Thu, 27 Aug 2026 23:46:26 +0200 Subject: [PATCH 52/59] Transport: Bind the UDP outbound socket in the destination family (#6688) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit https://github.com/XTLS/Xray-core/pull/6688#issuecomment-5445559197 Completes https://github.com/XTLS/Xray-core/pull/6624#issuecomment-5430488664 --------- Co-authored-by: 风扇滑翔翼 --- transport/internet/system_dialer.go | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/transport/internet/system_dialer.go b/transport/internet/system_dialer.go index 05adc778a..b80e9a295 100644 --- a/transport/internet/system_dialer.go +++ b/transport/internet/system_dialer.go @@ -50,18 +50,24 @@ func (d *DefaultSystemDialer) Dial(ctx context.Context, src net.Address, dest ne errors.LogDebug(ctx, "dialing to "+dest.String()) if dest.Network == net.Network_UDP { - srcAddr := resolveSrcAddr(net.Network_UDP, src) - if srcAddr == nil { - srcAddr = &net.UDPAddr{ - IP: []byte{0, 0, 0, 0}, - Port: 0, - } - } - var lc net.ListenConfig destAddr, err := net.ResolveUDPAddr("udp", dest.NetAddr()) if err != nil { return nil, err } + srcAddr := resolveSrcAddr(net.Network_UDP, src) + if srcAddr == nil { + // some OS don't support mapped IPv4 dual stack + // and need to select 0.0.0.0 or [::] manually based on the destination + wildcard := net.AnyIP.IP() + if destAddr.IP.To4() == nil { + wildcard = net.AnyIPv6.IP() + } + srcAddr = &net.UDPAddr{ + IP: wildcard, + Port: 0, + } + } + var lc net.ListenConfig lc.Control = func(network, address string, c syscall.RawConn) error { for _, ctl := range Controllers { if err := ctl(network, address, c); err != nil { From c1958dba04ba065cd82a05b65bfe877e2323f0cc Mon Sep 17 00:00:00 2001 From: brookwko <57313549+brookwko@users.noreply.github.com> Date: Thu, 27 Aug 2026 15:32:45 -0700 Subject: [PATCH 53/59] TUN inbound: Support `autoSystemRoutingTable` and `autoOutboundsInterface` on FreeBSD as well (#6691) https://github.com/XTLS/Xray-core/pull/6691#issuecomment-5445818753 Closes https://github.com/XTLS/Xray-core/issues/5594#issuecomment-4789101025 --- proxy/tun/tun_freebsd.go | 754 ++++++++++++++++++++++++++++++++-- proxy/tun/tun_freebsd_test.go | 150 +++++++ 2 files changed, 879 insertions(+), 25 deletions(-) create mode 100644 proxy/tun/tun_freebsd_test.go diff --git a/proxy/tun/tun_freebsd.go b/proxy/tun/tun_freebsd.go index 655f5ed2e..078475031 100644 --- a/proxy/tun/tun_freebsd.go +++ b/proxy/tun/tun_freebsd.go @@ -3,28 +3,60 @@ package tun import ( + "context" "errors" "net" - _ "unsafe" + "net/netip" + "os" + "slices" + "sync" + "unsafe" "golang.zx2c4.com/wireguard/tun" "gvisor.dev/gvisor/pkg/buffer" "gvisor.dev/gvisor/pkg/tcpip" "gvisor.dev/gvisor/pkg/tcpip/stack" + "golang.org/x/net/route" "golang.org/x/sys/unix" "github.com/xtls/xray-core/common/buf" + xerrors "github.com/xtls/xray-core/common/errors" ) -const tunHeaderSize = 4 +const ( + tunHeaderSize = 4 + defaultFreeBSDGateway = "169.254.10.1/30" + + // escapeFib is the routing table outbound sockets are switched to so + // their traffic bypasses the TUN routes installed in the default FIB + // (FreeBSD's substitute for the per-socket interface binding other + // platforms use). Requires the boot tunable net.fibs >= 2. + escapeFib = 1 +) //go:linkname procyield runtime.procyield func procyield(cycles uint32) type FreeBSDTun struct { - device tun.Device - mtu uint32 + device tun.Device + options *Config + tunIndex int + autoInterface bool + + systemRoutes []netip.Prefix + escapeMu sync.Mutex + escapeRoutes []escapeRoute + routeMonitor *os.File + routeMonitorOnce sync.Once +} + +// escapeRoute remembers one route written into the escape FIB, in the exact +// shape needed to delete it again. A zero gateway means an interface route. +type escapeRoute struct { + prefix netip.Prefix + ifIndex int + gateway netip.Addr } var ( @@ -34,20 +66,172 @@ var ( // NewTun builds new tun interface handler func NewTun(options *Config) (Tun, error) { + gateway, local, err := selectFreeBSDGateway(options.Gateway) + if err != nil { + return nil, err + } + + // net.fibs is a boot-time constant, so validate the escape routing table + // before the shared handler registers a dialer controller that would + // otherwise steer every outbound socket into a table that was never set up. + if options.AutoOutboundsInterface != "" { + if err := checkEscapeFib(); err != nil { + return nil, err + } + } + tunDev, err := tun.CreateTUN(options.Name, int(options.MTU)) if err != nil { return nil, err } - return &FreeBSDTun{device: tunDev, mtu: options.MTU}, nil + name, err := tunDev.Name() + if err != nil { + _ = tunDev.Close() + return nil, err + } + // From here the interface exists in the kernel; the wireguard library does + // not remove it on Close, so every failure path must destroy it too or the + // next start fails with "interface already exists". + iface, err := net.InterfaceByName(name) + if err != nil { + _ = tunDev.Close() + destroyInterface(name) + return nil, err + } + if err := setIPAddress(name, gateway, local, iface.Index); err != nil { + _ = tunDev.Close() + destroyInterface(name) + return nil, err + } + + return &FreeBSDTun{ + device: tunDev, + options: options, + tunIndex: iface.Index, + autoInterface: options.AutoOutboundsInterface != "", + }, nil +} + +// selectFreeBSDGateway picks the first IPv4 prefix from the configured gateway +// list and the local address derived from it (the darwin semantics: the +// gateway is the remote side of the point-to-point pair, the local address is +// the next one after it), falling back to the same link-local default. +func selectFreeBSDGateway(configured []string) (netip.Prefix, netip.Addr, error) { + gateway := netip.MustParsePrefix(defaultFreeBSDGateway) + if len(configured) > 0 { + found := false + for _, value := range configured { + prefix, err := netip.ParsePrefix(value) + if err != nil { + return netip.Prefix{}, netip.Addr{}, xerrors.New("invalid FreeBSD gateway ", value).Base(err) + } + if prefix.Addr().Is4() { + gateway, found = prefix, true + break + } + } + if !found { + return netip.Prefix{}, netip.Addr{}, xerrors.New("FreeBSD gateway requires at least one IPv4 prefix") + } + } + + local, ok := nextLocalIPv4(gateway) + if !ok || !gateway.Contains(local) { + return netip.Prefix{}, netip.Addr{}, xerrors.New("FreeBSD gateway ", gateway.String(), " must contain at least one usable local IPv4 address after the gateway address") + } + return gateway, local, nil +} + +func nextLocalIPv4(gateway netip.Prefix) (netip.Addr, bool) { + local4 := gateway.Addr().As4() + for i := len(local4) - 1; i >= 0; i-- { + local4[i]++ + if local4[i] != 0 { + return netip.AddrFrom4(local4), true + } + } + return netip.Addr{}, false } func (t *FreeBSDTun) Start() error { + if err := t.setSystemRoutes(); err != nil { + return err + } + + // Gate on this instance's own option, not the package-global updater, + // which a previously-removed inbound may have left set. checkEscapeFib + // already ran in NewTun, before the dialer controller was registered. + if t.autoInterface { + if err := t.syncEscapeFib(); err != nil { + _ = t.unsetSystemRoutes() + return err + } + fd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + t.unsetEscapeFib() + _ = t.unsetSystemRoutes() + return err + } + t.routeMonitor = os.NewFile(uintptr(fd), "xray-route-monitor") + go t.monitorRouteChanges() + } return nil } +// monitorRouteChanges refreshes the outbound interface and the escape FIB +// mirror whenever the system routing table changes. +func (t *FreeBSDTun) monitorRouteChanges() { + buffer := make([]byte, 64*1024) + for { + if _, err := t.routeMonitor.Read(buffer); err != nil { + if !errors.Is(err, os.ErrClosed) { + xerrors.LogInfoInner(context.Background(), err, "[tun] failed to monitor route changes") + } + return + } + if updater != nil { + updater.Update() + } + if err := t.syncEscapeFib(); err != nil { + xerrors.LogInfoInner(context.Background(), err, "[tun] failed to refresh escape routes") + } + } +} + func (t *FreeBSDTun) Close() error { - return t.device.Close() + t.routeMonitorOnce.Do(func() { + if t.routeMonitor != nil { + _ = t.routeMonitor.Close() + } + }) + t.unsetEscapeFib() + routeErr := t.unsetSystemRoutes() + name, nameErr := t.Name() + closeErr := t.device.Close() + // The wireguard tun device does not tear the interface down on FreeBSD, + // so an unclean shutdown would leave utun behind and the next start + // would fail with "interface already exists"; destroy it explicitly. + if nameErr == nil { + destroyInterface(name) + } + return xerrors.Combine(routeErr, closeErr) +} + +func destroyInterface(name string) { + fd, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + if err != nil { + return + } + defer unix.Close(fd) + // struct ifreq: 16-byte name + a 16-byte union (SIOCIFDESTROY's encoded + // length is 32 bytes on amd64, and the kernel copies in all of it). + var req struct { + Name [unix.IFNAMSIZ]byte + _ [16]byte + } + copy(req.Name[:], name) + _ = ioctlPtr(fd, unix.SIOCIFDESTROY, unsafe.Pointer(&req)) } func (t *FreeBSDTun) Name() (string, error) { @@ -55,21 +239,13 @@ func (t *FreeBSDTun) Name() (string, error) { } func (t *FreeBSDTun) Index() (int, error) { - name, err := t.Name() - if err != nil { - return 0, err - } - iface, err := net.InterfaceByName(name) - if err != nil { - return 0, err - } - return iface.Index, nil + return t.tunIndex, nil } // WritePacket implements GVisorDevice method to write one packet to the tun device func (t *FreeBSDTun) WritePacket(packet *stack.PacketBuffer) tcpip.Error { // request memory to write from reusable buffer pool - b := buf.NewWithSize(int32(t.mtu) + tunHeaderSize) + b := buf.NewWithSize(int32(t.options.MTU) + tunHeaderSize) defer b.Release() // prepare Unix specific packet header @@ -104,7 +280,7 @@ func (t *FreeBSDTun) WritePacket(packet *stack.PacketBuffer) tcpip.Error { // which will make the stack call Wait which should implement desired push-back func (t *FreeBSDTun) ReadPacket() (byte, *stack.PacketBuffer, error) { // request memory to write from reusable buffer pool - b := buf.NewWithSize(int32(t.mtu) + tunHeaderSize) + b := buf.NewWithSize(int32(t.options.MTU) + tunHeaderSize) // read the bytes to the interface file n, err := b.ReadFrom(t.device.File()) @@ -141,23 +317,551 @@ func (t *FreeBSDTun) Wait() { } func (t *FreeBSDTun) newEndpoint() (stack.LinkEndpoint, error) { - return &LinkEndpoint{deviceMTU: t.mtu, device: t}, nil + return &LinkEndpoint{deviceMTU: t.options.MTU, device: t}, nil } -func setinterface(network, address string, fd uintptr, iface *net.Interface) error { +const ( + IN6_IFF_NODAD = 0x0020 // netinet6/in6_var.h + ND6_INFINITE_LIFETIME = 0xFFFFFFFF // netinet6/nd6.h +) + +// ifAliasReq4 is struct in_aliasreq from netinet/in_var.h in the 64-byte +// layout unix.SIOCAIFADDR encodes (name + addr/dstaddr/mask sockaddrs). +type ifAliasReq4 struct { + Name [unix.IFNAMSIZ]byte + Addr unix.RawSockaddrInet4 + Dstaddr unix.RawSockaddrInet4 + Mask unix.RawSockaddrInet4 +} + +// ifAliasReq6 is struct in6_aliasreq from netinet6/in6_var.h. The trailing +// Vhid field matters: unix.SIOCAIFADDR_IN6 is not in x/sys/unix, so +// siocaifaddrIn6 is derived from this struct's size, and the kernel only +// accepts the ioctl whose encoded length matches the real struct. +type ifAliasReq6 struct { + Name [unix.IFNAMSIZ]byte + Addr unix.RawSockaddrInet6 + Dstaddr unix.RawSockaddrInet6 + Prefixmask unix.RawSockaddrInet6 + Flags int32 + Lifetime addrLifetime6 + Vhid int32 +} + +// addrLifetime6 is struct in6_addrlifetime (time_t is int64 on freebsd/amd64). +type addrLifetime6 struct { + Expire int64 + Preferred int64 + Vltime uint32 + Pltime uint32 +} + +// SIOCAIFADDR_IN6 = _IOW('i', 27, struct in6_aliasreq); x/sys/unix does not +// carry the netinet6 ioctls, so encode it from the struct size like the +// header macro does. +const siocaifaddrIn6 = 0x80000000 | (uintptr(unsafe.Sizeof(ifAliasReq6{})) << 16) | ('i' << 8) | 27 + +// setIPAddress assigns the local/remote point-to-point IPv4 pair and a +// link-local IPv6 address to the interface, required for the routing to work +// (same scheme as the darwin implementation: local address is the one right +// after the gateway address). +func setIPAddress(name string, gateway netip.Prefix, local netip.Addr, ifIndex int) error { + socket4, err := unix.Socket(unix.AF_INET, unix.SOCK_DGRAM, 0) + if err != nil { + return err + } + defer unix.Close(socket4) + + local4 := local.As4() + + ifReq4 := ifAliasReq4{ + Addr: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: local4, + }, + Dstaddr: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: gateway.Addr().As4(), + }, + Mask: unix.RawSockaddrInet4{ + Len: unix.SizeofSockaddrInet4, + Family: unix.AF_INET, + Addr: prefixMask4(gateway.Bits()), + }, + } + copy(ifReq4.Name[:], name) + if err = ioctlPtr(socket4, unix.SIOCAIFADDR, unsafe.Pointer(&ifReq4)); err != nil { + return os.NewSyscallError("SIOCAIFADDR", err) + } + + socket6, err := unix.Socket(unix.AF_INET6, unix.SOCK_DGRAM, 0) + if err != nil { + return err + } + defer unix.Close(socket6) + + // link-local ipv6 address with suffix from ipv4, enough for v6 interface + // routes to be attachable (darwin parity); a link-local address needs its + // scope, which for FreeBSD ioctls is the interface index + local6 := netip.AddrFrom16([16]byte{0: 0xfe, 1: 0x80, 12: local4[0], 13: local4[1], 14: local4[2], 15: local4[3]}) + + ifReq6 := ifAliasReq6{ + Addr: unix.RawSockaddrInet6{ + Len: unix.SizeofSockaddrInet6, + Family: unix.AF_INET6, + Addr: local6.As16(), + Scope_id: uint32(ifIndex), + }, + Prefixmask: unix.RawSockaddrInet6{ + Len: unix.SizeofSockaddrInet6, + Family: unix.AF_INET6, + Addr: prefixMask6(64), + }, + Flags: IN6_IFF_NODAD, + Lifetime: addrLifetime6{ + Vltime: ND6_INFINITE_LIFETIME, + Pltime: ND6_INFINITE_LIFETIME, + }, + } + copy(ifReq6.Name[:], name) + if err = ioctlPtr(socket6, uint(siocaifaddrIn6), unsafe.Pointer(&ifReq6)); err != nil { + // non-fatal: FreeBSD auto-configures a link-local address on UP + // interfaces, which is all the v6 interface routes need + xerrors.LogInfoInner(context.Background(), os.NewSyscallError("SIOCAIFADDR_IN6", err), "[tun] failed to assign the IPv6 link-local address") + } + return nil } -func findOutboundInterface(tunIndex int, fixedName string) (*net.Interface, error) { - if fixedName == "" { - return nil, errors.New("automatic outbound interface selection is not supported on this platform") +func ioctlPtr(fd int, req uint, arg unsafe.Pointer) error { + _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uintptr(req), uintptr(arg)) + if errno != 0 { + return errno } - iface, err := net.InterfaceByName(fixedName) + return nil +} + +func prefixMask4(bits int) [4]byte { + var mask [4]byte + copy(mask[:], net.CIDRMask(bits, 32)) + return mask +} + +func prefixMask6(bits int) [16]byte { + var mask [16]byte + copy(mask[:], net.CIDRMask(bits, 128)) + return mask +} + +// setinterface is the per-socket half of autoOutboundsInterface. FreeBSD has +// no SO_BINDTODEVICE/IP_BOUND_IF equivalent, so the socket is pointed at the +// escape FIB instead, where Start() mirrors the physical default route; the +// iface argument is resolved by the shared updater but unused here (the escape +// is table-based, not a per-socket interface bind). checkEscapeFib in NewTun +// guarantees the FIB exists before this can run. +func setinterface(network, address string, fd uintptr, iface *net.Interface) error { + return unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_SETFIB, escapeFib) +} + +func (t *FreeBSDTun) setSystemRoutes() error { + routes, err := buildSystemRoutes(t.options.AutoSystemRoutingTable) + if err != nil { + return err + } + // Route through the interface, not a gateway: the tun(4) device is a + // broadcast interface here, so its point-to-point peer address doubles as + // the subnet broadcast and the kernel refuses to route to it (EACCES). + // Interface routes sidestep the gateway entirely (what wg-quick does on + // FreeBSD). + for _, destination := range routes { + if err := execRoute(-1, unix.RTM_ADD, t.tunIndex, destination, netip.Addr{}); err != nil { + _ = t.unsetSystemRoutes() + return xerrors.New("failed to add system route ", destination).Base(err) + } + t.systemRoutes = append(t.systemRoutes, destination) + } + return nil +} + +func (t *FreeBSDTun) unsetSystemRoutes() error { + var errs []error + for i := len(t.systemRoutes) - 1; i >= 0; i-- { + destination := t.systemRoutes[i] + if err := execRoute(-1, unix.RTM_DELETE, t.tunIndex, destination, netip.Addr{}); err != nil && !errors.Is(err, unix.ESRCH) { + errs = append(errs, xerrors.New("failed to delete system route ", destination).Base(err)) + } + } + t.systemRoutes = nil + return xerrors.Combine(errs...) +} + +func buildSystemRoutes(configured []string) ([]netip.Prefix, error) { + routes := make([]netip.Prefix, 0, len(configured)) + seen := make(map[netip.Prefix]struct{}) + + appendRoute := func(prefix netip.Prefix) { + prefix = prefix.Masked() + if _, found := seen[prefix]; found { + return + } + seen[prefix] = struct{}{} + routes = append(routes, prefix) + } + + for _, value := range configured { + prefix, err := netip.ParsePrefix(value) + if err != nil { + return nil, xerrors.New("invalid system route ", value).Base(err) + } + if prefix.Bits() == 0 { + for _, protected := range protectedDefaultRoutes(prefix.Addr().Is4()) { + appendRoute(protected) + } + continue + } + appendRoute(prefix) + } + + return routes, nil +} + +// protectedDefaultRoutes splits a full default route into eight more-specific +// prefixes covering everything but the zero /8, so the system's real default +// route stays in place for outbound interface discovery (darwin parity). +func protectedDefaultRoutes(ipv4 bool) []netip.Prefix { + routes := make([]netip.Prefix, 0, 8) + for i := 0; i < 8; i++ { + if ipv4 { + var address [4]byte + address[0] = 1 << i + routes = append(routes, netip.PrefixFrom(netip.AddrFrom4(address), 8-i)) + } else { + var address [16]byte + address[0] = 1 << i + routes = append(routes, netip.PrefixFrom(netip.AddrFrom16(address), 8-i)) + } + } + return routes +} + +// execRoute writes one RTM message to a routing socket. fib >= 0 targets that +// routing table via SO_SETFIB on the routing socket (what route(8) -fib +// does); fib -1 leaves the process default table. An invalid gateway produces +// an interface route pinned to interfaceIndex instead of a gateway route. +func execRoute(fib int, messageType int, interfaceIndex int, destination netip.Prefix, gateway netip.Addr) error { + message := route.RouteMessage{ + Type: messageType, + Version: unix.RTM_VERSION, + Flags: unix.RTF_STATIC | unix.RTF_GATEWAY, + Seq: 1, + } + if messageType == unix.RTM_ADD { + message.Flags |= unix.RTF_UP + } + + var gatewayAddr route.Addr + switch { + case !gateway.IsValid(): + message.Flags &^= unix.RTF_GATEWAY + message.Index = interfaceIndex + gatewayAddr = &route.LinkAddr{Index: interfaceIndex} + case gateway.Is4(): + gatewayAddr = &route.Inet4Addr{IP: gateway.As4()} + default: + gatewayAddr = &route.Inet6Addr{IP: gateway.As16()} + } + + if destination.Addr().Is4() { + message.Addrs = []route.Addr{ + unix.RTAX_DST: &route.Inet4Addr{IP: destination.Addr().As4()}, + unix.RTAX_NETMASK: &route.Inet4Addr{IP: prefixMask4(destination.Bits())}, + unix.RTAX_GATEWAY: gatewayAddr, + } + } else { + message.Addrs = []route.Addr{ + unix.RTAX_DST: &route.Inet6Addr{IP: destination.Addr().As16()}, + unix.RTAX_NETMASK: &route.Inet6Addr{IP: prefixMask6(destination.Bits())}, + unix.RTAX_GATEWAY: gatewayAddr, + } + } + + request, err := message.Marshal() + if err != nil { + return err + } + fd, err := unix.Socket(unix.AF_ROUTE, unix.SOCK_RAW, 0) + if err != nil { + return err + } + defer unix.Close(fd) + if fib >= 0 { + if err := unix.SetsockoptInt(fd, unix.SOL_SOCKET, unix.SO_SETFIB, fib); err != nil { + return err + } + } + _, err = unix.Write(fd, request) + return err +} + +func findOutboundInterface(tunIndex int, fixedName string) (*net.Interface, error) { + if fixedName != "" { + iface, err := net.InterfaceByName(fixedName) + if err != nil { + return nil, err + } + if iface.Index == tunIndex { + return nil, errors.New("outbound interface cannot be the TUN interface") + } + return iface, nil + } + + physical, err := physicalDefaultRoutes(tunIndex, 0) if err != nil { return nil, err } - if iface.Index == tunIndex { - return nil, errors.New("outbound interface cannot be the TUN interface") + for _, family := range []int{unix.AF_INET, unix.AF_INET6} { + for _, route := range physical { + if route.family == family { + return route.iface, nil + } + } + } + return nil, errors.New("default route not found") +} + +// physicalRoute describes one physical default route: the interface it +// leaves through, its gateway, and the connected prefix that makes the +// gateway resolvable. +type physicalRoute struct { + family int + iface *net.Interface + gateway netip.Addr + connected netip.Prefix +} + +// physicalDefaultRoutes scans the default routing table for default routes +// that do not go through the TUN interface, at most one per address family +// (the first usable one wins, matching the darwin implementation's +// preference order). A non-zero onlyIndex restricts the scan to that +// interface, for the fixed-name mode of autoOutboundsInterface. +func physicalDefaultRoutes(tunIndex int, onlyIndex int) ([]physicalRoute, error) { + rib, err := route.FetchRIB(unix.AF_UNSPEC, route.RIBTypeRoute, 0) + if err != nil { + return nil, err + } + messages, err := route.ParseRIB(route.RIBTypeRoute, rib) + if err != nil { + return nil, err + } + + found := make([]physicalRoute, 0, 2) + seen := make(map[int]bool) + for _, message := range messages { + routeMessage, ok := message.(*route.RouteMessage) + if !ok || routeMessage.Index == tunIndex { + continue + } + if onlyIndex != 0 && routeMessage.Index != onlyIndex { + continue + } + if routeMessage.Flags&unix.RTF_UP == 0 || routeMessage.Flags&unix.RTF_GATEWAY == 0 { + continue + } + + family, ok := defaultRouteFamily(routeMessage) + if !ok || seen[family] { + continue + } + iface, err := usableInterface(routeMessage.Index) + if err != nil { + continue + } + gatewayAddr, ok := routeAddrToNetip(routeMessage.Addrs[unix.RTAX_GATEWAY]) + if !ok { + continue + } + connected, err := connectedPrefix(iface, gatewayAddr) + if err != nil { + continue + } + seen[family] = true + found = append(found, physicalRoute{ + family: family, + iface: iface, + gateway: gatewayAddr, + connected: connected, + }) + } + + if len(found) == 0 { + return nil, errors.New("default route not found") + } + return found, nil +} + +// defaultRouteFamily reports the address family of a RIB message that +// represents a true default route (unspecified destination, zero mask). +func defaultRouteFamily(message *route.RouteMessage) (int, bool) { + if len(message.Addrs) <= unix.RTAX_NETMASK { + return 0, false + } + + switch destination := message.Addrs[unix.RTAX_DST].(type) { + case *route.Inet4Addr: + mask, ok := message.Addrs[unix.RTAX_NETMASK].(*route.Inet4Addr) + if !ok || destination.IP != netip.IPv4Unspecified().As4() { + return 0, false + } + ones, bits := net.IPMask(mask.IP[:]).Size() + return unix.AF_INET, ones == 0 && bits == 32 + case *route.Inet6Addr: + mask, ok := message.Addrs[unix.RTAX_NETMASK].(*route.Inet6Addr) + if !ok || destination.IP != netip.IPv6Unspecified().As16() { + return 0, false + } + ones, bits := net.IPMask(mask.IP[:]).Size() + return unix.AF_INET6, ones == 0 && bits == 128 + default: + return 0, false + } +} + +func usableInterface(index int) (*net.Interface, error) { + iface, err := net.InterfaceByIndex(index) + if err != nil { + return nil, err + } + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + return nil, errors.New("default route interface is not usable") } return iface, nil } + +func routeAddrToNetip(addr route.Addr) (netip.Addr, bool) { + switch typed := addr.(type) { + case *route.Inet4Addr: + return netip.AddrFrom4(typed.IP), true + case *route.Inet6Addr: + return netip.AddrFrom16(typed.IP), true + default: + return netip.Addr{}, false + } +} + +// checkEscapeFib verifies the system can host the escape routing table at +// all: FIBs are a boot-time resource on FreeBSD. +func checkEscapeFib() error { + fibs, err := unix.SysctlUint32("net.fibs") + if err != nil { + return xerrors.New("failed to read net.fibs").Base(err) + } + if fibs < 2 { + return errors.New("automatic outbound interface on FreeBSD needs a second routing table: add net.fibs=2 to /boot/loader.conf and reboot") + } + current, err := unix.SysctlUint32("net.my_fibnum") + if err != nil { + return xerrors.New("failed to read net.my_fibnum").Base(err) + } + if current == escapeFib { + return errors.New("xray runs inside routing table 1, which is reserved as the escape table; start it in another FIB") + } + return nil +} + +// syncEscapeFib mirrors the physical default routes (and the connected +// prefixes their gateways resolve through) into the escape FIB, replacing +// whatever mirror a previous call installed. On discovery failure the old +// mirror is kept, since a stale escape route beats none during a transient +// route flap. +func (t *FreeBSDTun) syncEscapeFib() error { + var onlyIndex int + if t.options.AutoOutboundsInterface != "" && updater != nil { + if iface := updater.Get(); iface != nil { + onlyIndex = iface.Index + } + } + physical, err := physicalDefaultRoutes(t.tunIndex, onlyIndex) + if err != nil { + return err + } + + desired := make([]escapeRoute, 0, 2*len(physical)) + for _, p := range physical { + desired = append(desired, + escapeRoute{prefix: p.connected, ifIndex: p.iface.Index}, + escapeRoute{prefix: defaultPrefix(p.family), ifIndex: p.iface.Index, gateway: p.gateway}, + ) + } + + t.escapeMu.Lock() + defer t.escapeMu.Unlock() + + // The route monitor hears our own escape FIB writes too; rewriting an + // unchanged mirror on every wake-up would ping-pong forever. + if slices.Equal(t.escapeRoutes, desired) { + return nil + } + + t.unsetEscapeFibLocked() + for _, entry := range desired { + err := execRoute(escapeFib, unix.RTM_ADD, entry.ifIndex, entry.prefix, entry.gateway) + if err != nil && !errors.Is(err, unix.EEXIST) { + return xerrors.New("failed to add escape route ", entry.prefix).Base(err) + } + t.escapeRoutes = append(t.escapeRoutes, entry) + } + return nil +} + +func (t *FreeBSDTun) unsetEscapeFib() { + t.escapeMu.Lock() + defer t.escapeMu.Unlock() + t.unsetEscapeFibLocked() +} + +func (t *FreeBSDTun) unsetEscapeFibLocked() { + for i := len(t.escapeRoutes) - 1; i >= 0; i-- { + entry := t.escapeRoutes[i] + err := execRoute(escapeFib, unix.RTM_DELETE, entry.ifIndex, entry.prefix, entry.gateway) + if err != nil && !errors.Is(err, unix.ESRCH) { + xerrors.LogInfoInner(context.Background(), err, "[tun] failed to delete escape route ", entry.prefix) + } + } + t.escapeRoutes = nil +} + +func defaultPrefix(family int) netip.Prefix { + if family == unix.AF_INET { + return netip.PrefixFrom(netip.IPv4Unspecified(), 0) + } + return netip.PrefixFrom(netip.IPv6Unspecified(), 0) +} + +// connectedPrefix finds the interface's address prefix containing the +// gateway, which the escape FIB needs as an interface route so the mirrored +// default route's gateway is resolvable there. +func connectedPrefix(iface *net.Interface, gateway netip.Addr) (netip.Prefix, error) { + addrs, err := iface.Addrs() + if err != nil { + return netip.Prefix{}, err + } + for _, addr := range addrs { + ipNet, ok := addr.(*net.IPNet) + if !ok { + continue + } + ip, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + continue + } + ip = ip.Unmap() + ones, _ := ipNet.Mask.Size() + prefix := netip.PrefixFrom(ip, ones).Masked() + if prefix.Contains(gateway.WithZone("").Unmap()) { + return prefix, nil + } + } + return netip.Prefix{}, errors.New("no connected prefix contains the gateway") +} diff --git a/proxy/tun/tun_freebsd_test.go b/proxy/tun/tun_freebsd_test.go new file mode 100644 index 000000000..7f4e8ad5a --- /dev/null +++ b/proxy/tun/tun_freebsd_test.go @@ -0,0 +1,150 @@ +//go:build freebsd + +package tun + +import ( + "net/netip" + "testing" + + "golang.org/x/net/route" + "golang.org/x/sys/unix" +) + +func TestSelectFreeBSDGatewayDefault(t *testing.T) { + gateway, local, err := selectFreeBSDGateway(nil) + if err != nil { + t.Fatal(err) + } + if gateway != netip.MustParsePrefix(defaultFreeBSDGateway) { + t.Fatal("expected default gateway, got ", gateway) + } + if local != netip.MustParseAddr("169.254.10.2") { + t.Fatal("wrong local address: ", local) + } +} + +func TestSelectFreeBSDGatewayPicksFirstIPv4(t *testing.T) { + gateway, local, err := selectFreeBSDGateway([]string{"fd00::1/64", "10.0.0.1/30", "10.9.9.9/24"}) + if err != nil { + t.Fatal(err) + } + if gateway != netip.MustParsePrefix("10.0.0.1/30") { + t.Fatal("wrong gateway: ", gateway) + } + if local != netip.MustParseAddr("10.0.0.2") { + t.Fatal("wrong local address: ", local) + } +} + +func TestSelectFreeBSDGatewayRequiresIPv4(t *testing.T) { + if _, _, err := selectFreeBSDGateway([]string{"fd00::1/64"}); err == nil { + t.Fatal("expected error when no IPv4 gateway is configured") + } +} + +func TestSelectFreeBSDGatewayRejectsGarbage(t *testing.T) { + if _, _, err := selectFreeBSDGateway([]string{"not-a-prefix"}); err == nil { + t.Fatal("expected error for invalid gateway") + } +} + +func TestSelectFreeBSDGatewayRejectsFullPrefix(t *testing.T) { + // 10.0.0.255/30: the "next" local address falls outside the prefix + if _, _, err := selectFreeBSDGateway([]string{"10.0.0.255/30"}); err == nil { + t.Fatal("expected error when no usable local address follows the gateway") + } +} + +func TestNextLocalIPv4(t *testing.T) { + local, ok := nextLocalIPv4(netip.MustParsePrefix("169.254.10.1/30")) + if !ok || local != netip.MustParseAddr("169.254.10.2") { + t.Fatal("wrong local address: ", local) + } +} + +func TestBuildSystemRoutesSplitsDefault(t *testing.T) { + routes, err := buildSystemRoutes([]string{"0.0.0.0/0"}) + if err != nil { + t.Fatal(err) + } + expected := []string{ + "1.0.0.0/8", "2.0.0.0/7", "4.0.0.0/6", "8.0.0.0/5", + "16.0.0.0/4", "32.0.0.0/3", "64.0.0.0/2", "128.0.0.0/1", + } + if len(routes) != len(expected) { + t.Fatal("expected ", len(expected), " routes, got ", routes) + } + for i, want := range expected { + if routes[i] != netip.MustParsePrefix(want) { + t.Fatal("route ", i, ": expected ", want, ", got ", routes[i]) + } + } +} + +func TestBuildSystemRoutesSplitsDefaultIPv6(t *testing.T) { + routes, err := buildSystemRoutes([]string{"::/0"}) + if err != nil { + t.Fatal(err) + } + if len(routes) != 8 || routes[7] != netip.MustParsePrefix("8000::/1") { + t.Fatal("unexpected v6 split: ", routes) + } +} + +func TestBuildSystemRoutesDeduplicates(t *testing.T) { + routes, err := buildSystemRoutes([]string{"10.0.0.0/8", "10.1.2.3/8", "0.0.0.0/0", "0.0.0.0/0"}) + if err != nil { + t.Fatal(err) + } + if len(routes) != 9 { // 10.0.0.0/8 once + 8 splits once + t.Fatal("expected 9 routes, got ", routes) + } +} + +func TestBuildSystemRoutesRejectsGarbage(t *testing.T) { + if _, err := buildSystemRoutes([]string{"10.0.0.0/33"}); err == nil { + t.Fatal("expected error for invalid route") + } +} + +func routeMessage(dst, mask route.Addr) *route.RouteMessage { + addrs := make([]route.Addr, unix.RTAX_NETMASK+1) + addrs[unix.RTAX_DST] = dst + addrs[unix.RTAX_NETMASK] = mask + return &route.RouteMessage{Addrs: addrs} +} + +func TestDefaultRouteFamilyMatchesIPv4Default(t *testing.T) { + family, ok := defaultRouteFamily(routeMessage(&route.Inet4Addr{}, &route.Inet4Addr{})) + if !ok || family != unix.AF_INET { + t.Fatal("expected IPv4 default route match") + } +} + +func TestDefaultRouteFamilyMatchesIPv6Default(t *testing.T) { + family, ok := defaultRouteFamily(routeMessage(&route.Inet6Addr{}, &route.Inet6Addr{})) + if !ok || family != unix.AF_INET6 { + t.Fatal("expected IPv6 default route match") + } +} + +func TestDefaultRouteFamilyRejectsNonDefault(t *testing.T) { + if _, ok := defaultRouteFamily(routeMessage( + &route.Inet4Addr{IP: [4]byte{10, 0, 0, 0}}, + &route.Inet4Addr{IP: [4]byte{255, 0, 0, 0}}, + )); ok { + t.Fatal("non-default destination must not match") + } + if _, ok := defaultRouteFamily(routeMessage( + &route.Inet4Addr{}, + &route.Inet4Addr{IP: [4]byte{255, 0, 0, 0}}, + )); ok { + t.Fatal("non-zero mask must not match") + } +} + +func TestDefaultRouteFamilyRejectsShortAddrs(t *testing.T) { + if _, ok := defaultRouteFamily(&route.RouteMessage{}); ok { + t.Fatal("message without addresses must not match") + } +} From d9c54026c5ff1652d8fc2e58ffd06706dd27a6a6 Mon Sep 17 00:00:00 2001 From: Kosta <181651143+MakostaDev@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:15:25 +0400 Subject: [PATCH 54/59] Sniffing: Support QUICv2 (#6695) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 风扇滑翔翼 --- common/protocol/quic/qtls_go118.go | 8 -- common/protocol/quic/sniff.go | 163 ++++++++++++++++------------- common/protocol/quic/sniff_test.go | 9 ++ 3 files changed, 98 insertions(+), 82 deletions(-) diff --git a/common/protocol/quic/qtls_go118.go b/common/protocol/quic/qtls_go118.go index bfa5e2457..4983b71f7 100644 --- a/common/protocol/quic/qtls_go118.go +++ b/common/protocol/quic/qtls_go118.go @@ -1,18 +1,10 @@ package quic import ( - "crypto" "crypto/cipher" _ "crypto/tls" _ "unsafe" ) -type CipherSuiteTLS13 struct { - ID uint16 - KeyLen int - AEAD func(key, fixedNonce []byte) cipher.AEAD - Hash crypto.Hash -} - //go:linkname AEADAESGCMTLS13 crypto/tls.aeadAESGCMTLS13 func AEADAESGCMTLS13(key, nonceMask []byte) cipher.AEAD diff --git a/common/protocol/quic/sniff.go b/common/protocol/quic/sniff.go index eee3640f9..14c165c32 100644 --- a/common/protocol/quic/sniff.go +++ b/common/protocol/quic/sniff.go @@ -3,7 +3,6 @@ package quic import ( "crypto" "crypto/aes" - "crypto/tls" "encoding/binary" "io" @@ -28,22 +27,43 @@ func (s SniffHeader) Domain() string { return s.domain } -const ( - versionDraft29 uint32 = 0xff00001d - version1 uint32 = 0x1 +var ( + errNotQUIC = errors.New("not quic") + errNotQUICInitial = errors.New("not initial packet") ) +type quicVersionSpec struct { + ver uint32 + typeInitial byte + initialSalt []byte + labelPrefix string +} + var ( - quicSaltOld = []byte{0xaf, 0xbf, 0xec, 0x28, 0x99, 0x93, 0xd2, 0x4c, 0x9e, 0x97, 0x86, 0xf1, 0x9c, 0x61, 0x11, 0xe0, 0x43, 0x90, 0xa8, 0x99} - quicSalt = []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a} - initialSuite = &CipherSuiteTLS13{ - ID: tls.TLS_AES_128_GCM_SHA256, - KeyLen: 16, - AEAD: AEADAESGCMTLS13, - Hash: crypto.SHA256, + quicDraft29 = quicVersionSpec{ + ver: 0xff00001d, + typeInitial: 0b00, + initialSalt: []byte{0xaf, 0xbf, 0xec, 0x28, 0x99, 0x93, 0xd2, 0x4c, 0x9e, 0x97, 0x86, 0xf1, 0x9c, 0x61, 0x11, 0xe0, 0x43, 0x90, 0xa8, 0x99}, + labelPrefix: "quic", + } + quicV1 = quicVersionSpec{ + ver: 0x1, + typeInitial: 0b00, + initialSalt: []byte{0x38, 0x76, 0x2c, 0xf7, 0xf5, 0x59, 0x34, 0xb3, 0x4d, 0x17, 0x9a, 0xe6, 0xa4, 0xc8, 0x0c, 0xad, 0xcc, 0xbb, 0x7f, 0x0a}, + labelPrefix: "quic", + } + quicV2 = quicVersionSpec{ + ver: 0x6b3343cf, + typeInitial: 0b01, + initialSalt: []byte{0x0d, 0xed, 0xe3, 0xde, 0xf7, 0x00, 0xa6, 0xdb, 0x81, 0x93, 0x81, 0xbe, 0x6e, 0x26, 0x9d, 0xcb, 0xf9, 0xbd, 0x2e, 0xd9}, + labelPrefix: "quicv2", + } + + quicVersionSpecMap = map[uint32]*quicVersionSpec{ + quicDraft29.ver: &quicDraft29, + quicV1.ver: &quicV1, + quicV2.ver: &quicV2, } - errNotQuic = errors.New("not quic") - errNotQuicInitial = errors.New("not initial packet") ) func SniffQUIC(b []byte) (*SniffHeader, error) { @@ -63,60 +83,61 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { buffer := buf.FromBytes(b) typeByte, err := buffer.ReadByte() if err != nil { - return nil, errNotQuic + return nil, errNotQUIC } isLongHeader := typeByte&0x80 > 0 if !isLongHeader || typeByte&0x40 == 0 { - return nil, errNotQuicInitial + return nil, errNotQUICInitial } vb, err := buffer.ReadBytes(4) if err != nil { - return nil, errNotQuic + return nil, errNotQUIC } versionNumber := binary.BigEndian.Uint32(vb) - if versionNumber != 0 && typeByte&0x40 == 0 { - return nil, errNotQuic - } else if versionNumber != versionDraft29 && versionNumber != version1 { - return nil, errNotQuic + var s *quicVersionSpec + if v, ok := quicVersionSpecMap[versionNumber]; ok { + s = v + } else { + return nil, errNotQUIC } - packetType := (typeByte & 0x30) >> 4 - isQuicInitial := packetType == 0x0 - var destConnID []byte if l, err := buffer.ReadByte(); err != nil { - return nil, errNotQuic + return nil, errNotQUIC } else if destConnID, err = buffer.ReadBytes(int32(l)); err != nil { - return nil, errNotQuic + return nil, errNotQUIC } if l, err := buffer.ReadByte(); err != nil { - return nil, errNotQuic + return nil, errNotQUIC } else if common.Error2(buffer.ReadBytes(int32(l))) != nil { - return nil, errNotQuic + return nil, errNotQUIC } - if isQuicInitial { // Only initial packets have token, see https://datatracker.ietf.org/doc/html/rfc9000#section-17.2.2 - tokenLen, err := readShortQuicVarint(buffer) + packetType := (typeByte & 0x30) >> 4 + isQUICInitial := packetType == s.typeInitial + + if isQUICInitial { // Only initial packets have token, see https://datatracker.ietf.org/doc/html/rfc9000#section-17.2.2 + tokenLen, err := readShortQUICVarint(buffer) if err != nil || tokenLen > int32(len(b)) { - return nil, errNotQuic + return nil, errNotQUIC } if _, err = buffer.ReadBytes(tokenLen); err != nil { - return nil, errNotQuic + return nil, errNotQUIC } } - packetLen, err := readShortQuicVarint(buffer) + packetLen, err := readShortQUICVarint(buffer) if err != nil { - return nil, errNotQuic + return nil, errNotQUIC } // packetLen is impossible to be shorter than this if packetLen < 4 { - return nil, errNotQuic + return nil, errNotQUIC } hdrLen := len(b) - int(buffer.Len()) @@ -125,26 +146,22 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { } restPayload := b[hdrLen+int(packetLen):] - if !isQuicInitial { // Skip this packet if it's not initial packet + if !isQUICInitial { // Skip this packet if it's not initial packet b = restPayload continue } - var salt []byte - if versionNumber == version1 { - salt = quicSalt - } else { - salt = quicSaltOld - } + salt := s.initialSalt + label := s.labelPrefix initialSecret := hkdf.Extract(crypto.SHA256.New, destConnID, salt) - secret := hkdfExpandLabel(crypto.SHA256, initialSecret, []byte{}, "client in", crypto.SHA256.Size()) - hpKey := hkdfExpandLabel(initialSuite.Hash, secret, []byte{}, "quic hp", initialSuite.KeyLen) + secret := hkdfExpandLabel(initialSecret, "client in", crypto.SHA256.Size()) + hpKey := hkdfExpandLabel(secret, label+" hp", 16) block, err := aes.NewCipher(hpKey) if err != nil { return nil, err } if len(b) < hdrLen+4+block.BlockSize() { - return nil, errNotQuic + return nil, errNotQUIC } cache.Clear() mask := cache.Extend(int32(block.BlockSize())) @@ -155,8 +172,8 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { b[hdrLen+i] ^= mask[i+1] } - key := hkdfExpandLabel(crypto.SHA256, secret, []byte{}, "quic key", 16) - iv := hkdfExpandLabel(crypto.SHA256, secret, []byte{}, "quic iv", 12) + key := hkdfExpandLabel(secret, label+" key", 16) + iv := hkdfExpandLabel(secret, label+" iv", 12) cipher := AEADAESGCMTLS13(key, iv) nonce := cache.Extend(int32(cipher.NonceSize())) @@ -181,44 +198,44 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { case 0x00: // PADDING frame case 0x01: // PING frame case 0x02, 0x03: // ACK frame - if _, err = readShortQuicVarint(buffer); err != nil { // Field: Largest Acknowledged + if _, err = readShortQUICVarint(buffer); err != nil { // Field: Largest Acknowledged return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { // Field: ACK Delay + if _, err = readShortQUICVarint(buffer); err != nil { // Field: ACK Delay return nil, io.ErrUnexpectedEOF } - ackRangeCount, err := readShortQuicVarint(buffer) // Field: ACK Range Count + ackRangeCount, err := readShortQUICVarint(buffer) // Field: ACK Range Count if err != nil { return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { // Field: First ACK Range + if _, err = readShortQUICVarint(buffer); err != nil { // Field: First ACK Range return nil, io.ErrUnexpectedEOF } for i := 0; i < int(ackRangeCount); i++ { // Field: ACK Range - if _, err = readShortQuicVarint(buffer); err != nil { // Field: ACK Range -> Gap + if _, err = readShortQUICVarint(buffer); err != nil { // Field: ACK Range -> Gap return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { // Field: ACK Range -> ACK Range Length + if _, err = readShortQUICVarint(buffer); err != nil { // Field: ACK Range -> ACK Range Length return nil, io.ErrUnexpectedEOF } } if frameType == 0x03 { - if _, err = readShortQuicVarint(buffer); err != nil { // Field: ECN Counts -> ECT0 Count + if _, err = readShortQUICVarint(buffer); err != nil { // Field: ECN Counts -> ECT0 Count return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { // Field: ECN Counts -> ECT1 Count + if _, err = readShortQUICVarint(buffer); err != nil { // Field: ECN Counts -> ECT1 Count return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { //nolint:misspell // Field: ECN Counts -> ECT-CE Count + if _, err = readShortQUICVarint(buffer); err != nil { //nolint:misspell // Field: ECN Counts -> ECT-CE Count return nil, io.ErrUnexpectedEOF } } case 0x06: // CRYPTO frame, we will use this frame - offset, err := readShortQuicVarint(buffer) // Field: Offset + offset, err := readShortQUICVarint(buffer) // Field: Offset if err != nil { return nil, io.ErrUnexpectedEOF } - length, err := readShortQuicVarint(buffer) // Field: Length + length, err := readShortQUICVarint(buffer) // Field: Length if err != nil || length > buffer.Len() { return nil, io.ErrUnexpectedEOF } @@ -234,13 +251,13 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { return nil, io.ErrUnexpectedEOF } case 0x1c: // CONNECTION_CLOSE frame, only 0x1c is permitted in initial packet - if _, err = readShortQuicVarint(buffer); err != nil { // Field: Error Code + if _, err = readShortQUICVarint(buffer); err != nil { // Field: Error Code return nil, io.ErrUnexpectedEOF } - if _, err = readShortQuicVarint(buffer); err != nil { // Field: Frame Type + if _, err = readShortQUICVarint(buffer); err != nil { // Field: Frame Type return nil, io.ErrUnexpectedEOF } - length, err := readShortQuicVarint(buffer) // Field: Reason Phrase Length + length, err := readShortQUICVarint(buffer) // Field: Reason Phrase Length if err != nil { return nil, io.ErrUnexpectedEOF } @@ -250,7 +267,7 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { default: // Only above frame types are permitted in initial packet. // See https://www.rfc-editor.org/rfc/rfc9000.html#section-17.2.2-8 - return nil, errNotQuicInitial + return nil, errNotQUICInitial } } @@ -268,35 +285,33 @@ func SniffQUIC(b []byte) (*SniffHeader, error) { return nil, protocol.ErrProtoNeedMoreData } -func hkdfExpandLabel(hash crypto.Hash, secret, context []byte, label string, length int) []byte { - b := make([]byte, 3, 3+6+len(label)+1+len(context)) - binary.BigEndian.PutUint16(b, uint16(length)) - b[2] = uint8(6 + len(label)) - b = append(b, []byte("tls13 ")...) - b = append(b, []byte(label)...) - b = b[:3+6+len(label)+1] - b[3+6+len(label)] = uint8(len(context)) - b = append(b, context...) +func hkdfExpandLabel(secret []byte, label string, length int) []byte { + b := make([]byte, 0, 2+1+6+len(label)+1) + b = binary.BigEndian.AppendUint16(b, uint16(length)) + b = append(b, byte(6+len(label))) + b = append(b, "tls13 "...) + b = append(b, label...) + b = append(b, 0) // context out := make([]byte, length) - n, err := hkdf.Expand(hash.New, secret, b).Read(out) + n, err := hkdf.Expand(crypto.SHA256.New, secret, b).Read(out) if err != nil || n != length { panic("quic: HKDF-Expand-Label invocation failed unexpectedly") } return out } -// readShortQuicVarint wraps quicvarint.Read with a max limit for length related fields. +// readShortQUICVarint wraps quicvarint.Read with a max limit for length related fields. // we only handle QUIC Initial so these numbers should not exceed 65535 // returns int32 to reduce type conversion -func readShortQuicVarint(reader io.ByteReader) (int32, error) { +func readShortQUICVarint(reader io.ByteReader) (int32, error) { v, err := quicvarint.Read(reader) if err != nil { return 0, err } if v > 65535 { // not used( - return 0, errNotQuicInitial + return 0, errNotQUICInitial } return int32(v), nil } diff --git a/common/protocol/quic/sniff_test.go b/common/protocol/quic/sniff_test.go index 8c6fbef42..3674d9212 100644 --- a/common/protocol/quic/sniff_test.go +++ b/common/protocol/quic/sniff_test.go @@ -268,6 +268,15 @@ func TestSniffQUICPacketNumberLength4(t *testing.T) { } } +func TestSniffQUICv2(t *testing.T) { + pkt, err := hex.DecodeString("d26b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e2ae9c783da28244f30c6af9a8aeff6db7c5ae6c595b2e7d367a5e6578bbb9f7859202ac5a5cdaaf32429e7c94e4c6b9494ba1a83e3adab4c716a08e7fe38907888196905c660db7d72f461d2835e81e02f8cf976dc2f08ab3faa18939d73a3da2faca3e6be9df6ce51da00b00dcd5c64f53c6347531e509d7678aba83d8818026f8f6b955cfa95ea8ae9ba0ad0b7f01a11f424abcf490b704663dbb7e700bcecd9725974eb07d2eb67484aa9c8f361d12bb83ff88de06854c45576ff0141461164f999828407ff7ce54abb514ad75e8743c27b23801d330a8b58f75952fe8e4ea696056fc29676351c7e1d6a2d7860ab8ab2582d952c05720a39a551d24b1ce58edb6b14039e905feaa51cac9f712ee22798c6f62d186193c4b9abe7c339d528e0d20c7106a85575fe3535703e0e3bfc33ccdc4de1372466db56519c692e021e5976c0ef841222f1cad2884a6cf78f3973ce8b795a191397cea39d72080cb18f7da7da579758b7923f29c756ac89fa253302c403222bc1888bc7bc0e3f3cfd43000129d3b670b28a2f3b938f6c6c67b27a10fa5d5f92d845a78d063cfe83aa30386e8cba9d152999e56e46ade53940f228671573ca76ea36a910c01a269cf698681b7b462a9b0f467ac8083b2b1568dbb56f657f6ea4e52954520370ffa1b5e24375bfbc53a58aa2d1883e6712cdd6c10b88d69196357f93fa3856d253e01f2ce8237fbd71f27c897ef8378d6f5823af1c69334f97900da32d64359ae6a20231a8ddb56fdc694cdc7a4d2719df0494b34831f10d46f768af082ad6acb491c937ec0e9ad9c9c2aa9f5c8fab93538b4dbeead9c1ecf274fa894377482565aa827a65e6eac57c6e313a95bbc4cb597359f29a2d7ebfb528310c01ba5048a85f3611feafa239ab3ce24a5f8876771b72467b5b2a96bdda475250da97886361db71c001d5e324633246f60a19985c682ea8027aeb5885cbe1ee4f3249d273b111227ef7ba267545feaa4981c3e40cfa82f6f22e550eae2df67678d9591df1f9ffb9cd199dba82b270c0ca42f2977e12031e0ec6a61c1c635012c3f3887e9284394695a8be3ee7249600bb6828ab2e65e1193dc42b3411de0e42bd9abb4d4df7cab5fe2d0000874c08e2e3ebb5dd42129e4ed28327cb531b66e9a9cee4be3ceae38f0a57a4b333cca1a5e6807318d7c6a4b1c07bfd6787e5e73b8dbaddb959df91f41c8afeaaa2349f5d0c6f6a953a49375f72311a45d1beb536fffb032cd8b968c069688ac95dedbd65cf3c82c412e6dc8d1a3ef07c608cd9798a88b0652fdf8f6b42488d7a2aab024746b50c1e6628704e022e1fe29d6407988c78dc572f9d422789aaa9cb6c4b3dff3f9def75e29ed37f777c8d7dcfc235562f2e27736eafbedc0d42c15a2a65711e09ed21bfe69a4e6a6aceae1ae4f1f6af94715d5174c618700fcb15d65aca5b5bc2381f95c2dc1cf684add0fd2984257ae39787077d6ae4ee7b44f938d54fba7ccb3f0bf7336c4a20480b44364fc17956372dea9b182602408371a07b014b848f44f50d18e6a596d9f2ea73b89fd182c3cca573590899dc5aa4bb439925d64fbe56da8fd44c2e785db349b41e11737b1f0fae2fbf3692815b483bb492961d426f991cf0b615b12d3185ccf110f9f2ded4f611bc87ad8d167eb2e7638b1388d0c68dbfec9b56619a10c62f1a4146a617ab0802a0940234b4a3fdb1bdd0d67416ccba9ad21dd37bfd8320efe43bef743de305740f23764ad3e208b55e7de6b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e2423d7e3778747532056c364b468ea574954ea0d66a8e67bd93d6896837814c76d24cccfcbece575590c5c022eb13598146aee3a164f133329c278e83daab535f8a595edfccafca9fa08bf5d729d55ff3fb810cfcd075726bd3f181466e2d0ab59e26f7fe9c2a220457c31b1cd25e98a1cf2de53232049950716c2b87fe96a3be76b34ec0d8806579260f727d209c6e3a54584ceb3a2d464c666fe12778a0a1cfe3fc2a7e52cd304031cdb900194c82d781418f518a2bacfc61fdcdd9070fb569fb60fdfd927cd40e1ce5524c485524c6a19a73126f0d1b8950ec83b6f2a5601c793440eea7ec53803411bc7462d026ca4acb6bc0889304fedaad4f74888e19b4dbc9a8d628b3a33da47dab660aa032a62d6de64de1ab25010d4623fd4a64ffacc2e5e990944384df899d5f4801371222cbc96895352d31caf1c1b6928d29df14b07d4f89ef766009dfc403139988b7d5718d0779ee9d7f08f0f6da313b73c2018f8724bd92ef9b69f56fa6d7a3158e580561eb31fe3c9ac8569bcc1cdc3b4deb3b7106f147acd1c7662dfd61c3b0836bc52dcf06d6905f36c189b5fba90c90d9e06d52fe4707e97808c4115d6f9c9aac9e11dc524b20b48a73913c00a330e028fb0a69976703515c858ce5c633fcf615531f7041c536dddb24617d3153bb9d7294bef7b1e1f4d0ae05f4aae0fb7e8f8be5884ecb12d2fd45ff58cd9031b121a521271270d6660d9d3dc358f65ea3ccf4a362f21a5f62b7d96fd5ef05a0e129f435944bbc7fcb3b40134e4842af013f526a0881216fdc1b2d64829360277d10b304f4316795ba0ce90cb7f994019b4869dfafffb8952cec74f266cac503fc59d00da5c94dd120a18097d7aebe2ef7879e27e6f31b6c73935050a64432696787af7afa69c119205c5afdfde63c0f42602ccbe85035bd33e7abd0d56acc44ead9508c8d5adc2463cf2caeb16a64c7108d99364e7781a7cabd84fa24820eb6f3910a05a351d68884cd9b73b8c0c1fb57a8cda4caa1bc8126ed8a8e351a5cd2dbea787be50ca52733bc77baa13eda498ec59850745531294a7a7b80c5fe18e860b3c85193af4c733972677a369717e1ae31ea4b660bfdf0355673643b75f23d8c5cfe8e5b6167b256eb37e3661e74f477926ad9defb0b1e48eeebc8426a3e7aa920ab85df106cabbead4117b9ad81cc40974950f97b51e299881aa52045c178febc2dca090666138677402191d1fb6541d4132c2994603b5ef90f15c0c2660b84b7211afad02eec9f956196bbb655c1754b3bb6551f76f36c448dcaff9f24cc65695b91d4449424744cd8a4b6ed55ac3cc489c94efb81b9c22fbce758be2626210484c305345baab7ca070b39d806c3546f0ba098ced2636d6c25d36fdefd0ff1d78591d223cd4d1c853acccf27578f7ad3c8bc6d124fc4e02c7c0946ce31dc442e05f3752a3ab6121fa087cd1fb276aa52d900ec7c79ea48568530ae40cf4fc7d7ed6ca520b809cfaf62189ea6a262831d598f1c373e8e8d0d5f3128bf76e0c61b82bb30b685cfa359979a38f412568642d9177be7bb2c9ae51438fa4d9610f2608266c374f5892c4802f672adfc42ee9ce83d415516b68e5ae06b3a1991628290c10369729fd2f213a0347ec13d21841766e964ffe5780f3f5adba0ed0b8cc4d222f41cb8797cd8b6f3044985c5c80601ff51da73d1e95483713cc2dffcbf6a4b0c239c3ced06f168842c8f231d2c45a4ad02d21ed37181d0960d42b02d7643b9baf1f6da6b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e2614c156521c4569f1261c3fe04e09dfd9a1baa7f56530844007d051fea3c6cd8268e6e98a152a942294662a0e1a022a8a05c57f0b52f7c4dba202153dd25d009273e27dd003031cfa69854d15cd3499f74d766e94454a488d898eea2372ed520a9875ee38a1398253299bccc64bcc5487405be0833c42c46eabe7c1e85d5c8adefc59064183522cb494aa4c0e0454b2641b76ab460516017bbb784a16539c71ae598813e20f33efcda9596207678b5c275211d483a3cc0e6fe338e21903e68976661d678d62f399a7ec0f34884d21d9c8d093cc9c3f3f0bd0452c65afda505a7f9dcaaa6d263f83b5c29b469858c5202a97ae6128416719a924171009c3859635e20ec9eeaad4d153bfb7735aebd3f598c62b479d7fa733411ce77ca745327356ea4545749de9e9322fc094c1b4d6fe65da28a0f4ccec8396fa0b24fb975adc1129dbf5edb70b6d0ad70f9673a76e7f9fc08709032192de9e70e065b828a63757a294aff90c4ad9ae33f6c6184c7a954852575972e1a1c3b9944e0942d3a40a5d10c4d5fae8a5b79ef18772c2cb5db3973ff24af913caae03c756192edd60daa373b84c7c1a8936f2438c88051501421e80bb1abcea182e281da560114fc919c1aa1e131046068f662359a8cf7430977725c6a3d6f8f8b6fa70e32424ecc10bb7f39ac9f19b0b3875449696ec803cd32d294a78abbf4e166b04c455cf52b8229a29a3bb0bf98ec4461e483e470e4359c0e4191e9f9023f63408eeb67507096e1e6290da895a1fdf9bd670599a8dccda085e79de8ca6b01b650f1c6511e29f9b7879f706083d5ca31e152b656b614d444be23a124a618c94554e7ec2f9913959620ec149b269180f71a95e35c6d4d05e6fc779fe2ab4755dbff04465cbf4c5df1808870cea4f4357d5887538a2b1b1dffae2614ed301549f6ae0d623eb879a5d203c6ae32109a5d81e059b6719baab203ee4a5a4ecba4aca7a4a44f6042c7494804fcbad59e8e57cf8f3af4c1cc2359d45cc9134e2f410b42aca884845c261188d5a63b49214b3409af64c55a4b8b01b505423e6d571544b55d9d82411b5852481eb48d30446b217efc6817d3c322acf08b32f8ac6f282baeb6bea9b445e1aa98755d24e18966b89f1d4bb4e3b25585a8d2d42d2c688a74918b968b974fa476e5cb98ce0ff889e2839ac800b27b9d44cdcb23aa089e4b4b1ba3695b4b5e4bed2da40e5994f0545c72e7acc8ea8be1266bf4560e8f8fa8a8f800f1a23a5c466e1d5836ca4bf9331a1417af217bfe32c853595c14ca6d0af1ac7da0f54d6862cf0742978516fe072e88d04f61593ed9f5c316403464a13e960383e1db71efa440a439f0dbe2c429b8c4722470a0dc0d249f0de30e4012322133402c6238c5f9d3fa260b4f49d35f75f19097d4a5d6923c13546ff5fdd37a0780b93426d104ba4a9ce2c3fc57510b878c3e315567828e226fd5ec3eab8a364c119dd86857022e579d14b014a962901224a75115853b191a6127e81901fd033621da203edc2479adfef60a07f8bad9a704f2dbb7f5f25e9d1464ea3ae849fc1c4adb10d8db58df9ee60763f3f379ef8615acf96db7c4879dff9fce314f7814855cc9b5147fe9b684e3136ed11152b3326e8c9d09cf10e3c5ae2b11e79e656dad4ccf820f4b930b75e59bef74ad6e43e43da623195ba0ce25ef59c1e6b0c8171783868cb06df16a73a0ff4f9f1788b3209c4d12d4b59409a4acecff24f4f12e55cc1e0e50517427ebb30cc9d56b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e20a4b52b678d6c69e62366bcabf6b3dde5ef15a489e455c35c464988b9456651328b5018c2291306ed86e0352a20f1313ef89267028a04364a2e5c09bed8660e8642decfe270641b71682ddb22f605c2c9b7da14245ec51d1ce9d8126db7ca049d7d1e61544521f8e10b70f9c7b5e3bc9f41969e42de239dc532985ba19d9c18e54b277510a79f7a20a4675b8752b07d6d77721da2313981f7b2c9f2e712e2751b9b776a992cf7621a0c3d77049a4ec445d866207cc29bf97f7a75b645a725de4e1a21f64c9f1911dd21b2c12e0e26a620805cbcd9725b4643b76dba92a2568404f44cf5b26b5a5c27484c12a76cc74fcaf8406b021805c769beeaff196a2af69cc327c3a37cb53a6e9c92a51f3455e54823f20786cb4e3accf07b12b2cbddc30874353409a2da6bba6929ceaeb1552ebf1cb92c3cfbe90e19223a83027381d4906a19ed24111b733e390e25b08beaa14fa6dfd66429659fcb28ec7cb54e5dd4a33e626e3abbd979dc71b8e291ca3127a5fa637b395c0eff42f11f146d3bc94ea8433de2c4680aa3810423e5c97ed6617116c260da97ed5b0ab84d51aae3638ef93f037f032c3e074bd8fd9f01a7a1764a11d3e51f53636f29b6749913db895f9cbbe6e8b2b3b8adc7c27bb41c786e81b78a55f6fdde4189b027d91490e795c308f5b58d2dff0e01d4c57916213ae950063f624521e797240a0ceba216fd1351ee4c8becf1da891a98f88cc89694800d21c4562eaad49dd8fdd4940a927ec2b277f004f0f881d255ca27cc7293d62a7662f8b5cb6d611f50d4fdddda19873858aa5ee1d873bebcd0b3509a498129174c2df574c393ca081dc6165bd298170c8896f788ca599b23e0b19acb69d2293541c004d4b8cbf6fdadec455092e1f7226f9a6c2149d0df4eee3746ab9676c59a16cd8f09781975df9b826309c6ea3e9e19f9e095402c53101f6def94379e1f6c5457700d4ffb09b32b54dc0b4182611737aa9df5ef81ec15f108ccecfcd672b52599154d3c670864d721af24f242aa7104fddd089f5d6859cdf397e28731624a40d60ff85185843b1422263eba7431087b7a0b4f164d5950f3b927c200a266a5f88488c333f51dc888a05c75e8a7c619ae87df50d457ab79b8be03d1aa3a97800b638403803578a47b5fa0a58c64cf3cf1285f20d12ae44219fe5cd7427e905fb29718dd3120910e78a1ca46a1bfbb7d3fe4e99577ed8b4de9a0493dd83bae2fa2fb3e9afc1ef5a3613b9b3fcfe74a430b53b0528de4a1a2bfba6fb822a93a8c98a2335dfb4ad85075ecfe8ebededc7ce87e4da0a1d30b9d986e8f2a8280297b821ac909ddf0df91e990db315369cb95002f0a1d3eba9e8e6b0b9fad9d9638447fa55fb4ea4cd5fd381387bef0d41bcbd515a7f7f37cc0bb81c67ab73bf4829530ede3553731e4013c85b8a34179073f665286a4d5b44aba72bc76caa31c47a614ba708ccd80ef2fd6308c1174c63bda3dd31785391c5b2e7334f398952ec0b3e1a845298ab9bf5f22ad97696f4bfd434c673c1bdc4e68d588ae6b24d30333b8bd5a186a96d75b3ccd13f951f1cb65db8ac74d80a9ebdaf80890a3db645072c7ed441e6dc9f74a28a8b7681bf9c40e1fc4d201f8802ded71bc34b2d50f315ff3e8fd5e76a10c831c338f5cbdb80924d5621b281df8e81c71b61bb64fd456b9a03200aeb7931711b28fb581f616861e77f27b792e6cd10c6a1a99439036beb586a193224752c456fc97adfbf3665aa036406f8c9de6b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e28586520ef48c6fe89b7565bc5946f6abd6ca7845180d63c7b0e6e84d4955532ff8e99a1a8c93b4335179703c8a89d7d90068034f9981ddd9cb89cf6d298ec4df54b72806b7ca767cc40b752c4490780b6b725ac9b0d087b54f5d3892085cdfff5ba054afc9d0235abbb41d5234f758d640439270523719fa93d350dd66f333bc9f0c548adefe682f95986cc4eecdd7541a3faaa999a8a7bda8ebe4f1e297c4f0c88e4123257d3fe900637c7023f17b7f94e0d2791b5bd5328403f35024cf08c1708ae3af0529e3f67bf7cb0f363e9d8d508960b905793cd713e6f30f7338671d10c60f51cf5468696a510c77c2bfce3da768da3c0cb2281e00a711c6c43b861bad5dc642b7292a9022385953474d3e3c116176729dada5aacf879e708163ade724824768d978c294f6cceb5f0b4ecd85e38ba721025dbe72da8e29018aa12449ea5e4b9e065c50e54e05bdcbc28674fa57aec824feb1f745c400d828a94844ffcb1d32f12553967d78db776671bc7a14c747cd7dfb64bcc74e4a76aa7ea9560f6851443ac63953cc6398ed941728d38a8e14d033591787f5c5986cc8e74baefe7addce116aef35132e15bc6fd384baea77de47352c410c225d736dafecce217dda91c8171b076f6918bba00bd685947c7bf393ede7cef4dbaf5ed24dbbcc7055fc40a1828113b47ac9bde59fb0f5e8cd9b940f390d7b17463a820b9e6dfa45d0913e0dc5fa2e0a3e491b2ab16f2703348c797455de31722394d9d29956904339364ac25f330461242ad61bb58635b3788040e3537d9116b35148e4f907325b5bcae2532ac22691ee60e01dbd70c50122ec404646d6897bf4fc7bdf563e2233af67196952cad29feeb00dc70d72138711ebc5f3fe2d47341cc110a91c52afcf7bf07d44037ca08b1b5e19967d4d60a7faa33c87b59abd57da743509b083331bed0fecbb85d935566eb82ab6f78ebcf938ac47b30b23b0eed1cf939b16f252978a908e14781ff5a022d23baf3c74b1dbbea687eaf95e38404557e8d846b5763bf7cf6689b8d5a75c2c74a3f6bb8917e343557430912890fe76b207da019ffc45ade8d17fb6fc8277cdb037e6f541c67aef38e6fc4716dbea781dc8bdb29433536eac06f9eef1e95811e68144d2f53a7694e9bd750cc5c1b1136667d0bf0c6ea8b20e80bcd9d4f47eb6ed28ef8b20bb0e007a7a4aa6d4e24597968dd8f0bbfbc50049a1d6298395fb130a1edbc0792a0b4524d57c310e392b1609ebf4f1f639540900b80c10ba986927dc2b6447af93c214f9ac5c9b0fbbb4a650b2c5d67dfacf7e7a9b38dd26c5d3989c927217d50a74bf170fb871782c397090e89344d62e984f77257bf87ebc55557da8e1e72900adeac39b295430302a3bbffde69304534ba9f4fe75299d9707e5033ea01b4c4f14ece65e0feea8f27ef66ec06d9002205f0cfc8c2189b6999810f936a4e30d4f863578f6b7e92aa01037d641a029780fbd8dbbf1a0de7e46e1f7a2e6f2e4b45655d4139c3974c92ed2a59f6ab5d9d53021d2a9cd187c9b3ec92a979b6c4164697ea2377b6cdcb3fb25ed159db59a76031981388c9b49da88640b25b22f225ea4c4142383f26dd4912c169f9e2340bac781ca64588b6c786b0914ddc2ea3691fafc739d76a2d31f981db592171a2605667b58c5465dadc5dc4a66511d726d50d856e524885eb2ba4fd856f262328b9ba895f8dfa098aada2fda08d731acfb939ecb5d208f391054d3461f7eea2019b5fc261e8b95d86b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e26be96eb2df1b4d663f8f1a2e38467e2df1fcabfcc4ea0242afad0a76d0e3c4555d2f722d0b808e84f18e218875335c7f533fa81889402e3ed49895c1d751d8b40fc00dc20132522d9daf0355d7ef99497f44d22def2a885049bf705f9d66c9b31bd766a162e4fe454e478f3108454f7c7485c8544cfaeaed8bf42f7c834014cc8c14b1318b72cd49304f8f91d29709a90491bba44725df6fad1dfefc4e1b0462a6d0b0719ad53fb79379e224bb6e6fae24690d8edf3bb415967023f456411806f734edd0ddcf5d287e025b6eedf8856f2d1a46064aa80fd58ac1cb251e3c55d46c685d4aef763a27f4efb93089137fe84f51fff32bbd610b508e58e66ac334b00a0b8728a372dcea832a76db320dbd91d64d526d66270e0ccfd851ecb640c95e1aefc6ec3a8e42b2b35c75a4f284e8f149eca999f1bdc65fa961742d17edacd2d4b2d33465c44ccd7cf82906d6a6e6d615e2507e0eae9c2eef01777105b54d3b11a96ddf9a4104f9505886d072944f8cd5a557da7b771003cbfe8eff63f28877b4cd3269718d65016c524c9b9106055fd05977d7aec5733f6fcea089fbf11cd513ba7edc297db629577dad435cc859b441468ce4de8b5fead8c8fed170eb86d2d670dfb474811d83b1ea4cdf5d4bfd3c53d6c80a46b38568816836eac288b4150961285a6c59ad264e435cfb795ae51e20bd7d692127962c322ad79554f4590a09157ff2859473b5d9021400cc1c3e3da407b66d17f8fc303d7bd747e8c427d9469829a02c51480313a6a05f3c44aed32e3974b7a6f3c803e5dcdabb6c878581b60aa5e8801967c52ffdc9cac7db6c820e5006b286fb13d06a7ec13e9986589d54dfdd74aae5ecb050a40931896a5a0c6a63c7835df7e6045dc36ba7898de03a44ecbbb51cbc00e71aefb081f7db3adc0977b8213e9f551d37218bb96ceffd725b5a1df0fa0030366ebea0cd35de5f5023d838f3c92fb2ad2f36c4c3c82fbbdeda6eb7ab4e2473d034c78eafef0f1bbf1f14dd18a9ffae34a36b28090f22a3ca60c52dd209778cebe64bfba963503208589e1005799af36d778945c8211bc4d83945638f7b4aa993592df7ef7c6f9773d93fcf430459d0459c8a49a359659174556c7d844fe8a8e4e5085f4f0dc2aefeaa37d8ef5363ad00a5e12efb3fec5bbfa78f669f4f228b4f629108cc6abaab664e5623392da9f33fcea6e27cd5f34e040e9bf59b05c25b61d7ca1d31d1393ff3a009a76715729e62bb9646c3fea61de243b54f6ba585c968303b42ceab72a591a6cbdecb082e130841dfbf04049d73e31282b7ed18cd4638b1e358bb809c51bfe53fd43855871d9a1366b224199ebe4fa45b948be2f3c1fa986cdf0cdcc10fadd6eff4d1c325797578f9351c4b0c2bf36f97fee6a8c5b5162cdf9bdbd170728d34e2de9a9721e101a8ea57f1ba8b2994d8d66a784f9c843f85cc38c94ed98d864d22718f76b7105a3cd6a5822d2b688121ccc6f81128ddb5f2d895ec637b8c46aa4e3ff76f896894decc489a00bf4df2c133517e483f66eeb5aea11cece563b1959771a96a5a80498cde063c4bf82e8644cc9851132ee09a86053c0b31c0d025a20c760a4db2e46087b4f0ec82aecdf896f4835979bffe63fde207235dd7501bf9f7ed25fe6ebfb733200af0b081a2d25c800f263e4c831b7df2bdb76a8ddd32e92ed8a4117803ea8adf05cf03887a5bf0245ab609cc044a56eae9c690d692505c4696bb216cd14f59fde7ff68b6ae367796de6b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e2bca71e020b3958cbd9f5bac9aecc60531cc5c734617f88b8ec772c46f6de6c7e6fb4e325c589eb3e9128769b9b5eb1fa796db7c279f90f0338494384a3a7910dba06d00d6ae03de7bc9affdecc7bfdb0ad3f596d349ac5a9b70b0a7fc6bbf0cacb43b149322365a0c40cfbb961893c6e6844d4a70f4e0bda3a2da6249c39551522b6de4471a6105c1da5af502e0298d1efd3732c315cdb03889b5b3ceab67d010911baa1e9dc0b0c9637d7d93df09e7153fd1380c3499e5355649c61337f98b640d629434bde4205c20c42571c33fc98e5b7fc484ef24b7dde4eca65519d913d93f8c9c271ee1c17dad044a0237ca3fbb808ec0c49a8eab69fc49f733b5ec900e8b0debef8008acb168465e43f02d1c859046afc83f57d014e78100a21e243155497c9de0973b635cb3aa2a6f3ba67aabaa352e2a15ae04c58746485e3466f0547e849ae208a3324cb860b92549b0991c06f9e11b9a217e4ba3044248e40474ea9a5c42f91815afc8c60818bbbfc373adc0f6242d1abb82fbdda55200c7005992558320f3a9db3be8fc90fcda594f10b1288088a96b261eca79e23462878bb8c5b62709d8b46d03f19cf6ec2ec68a0cd83ba7823169b2bdb43739022ca97ced2dd57bd65a6546b9f9321fc23e09e87f104d2bc420dde8333aced4c27f41c120c19102e78329923c9797042ee33db6c85bb9272f34e95643e17fa46bb877ca16020278af64540d1a66e36deaa4507d8061d00199a7f9ea47436198a610e54d821dbedaabc8b8903ce15d680384dd13842abfac1283554c82fe52f104faa590cf3ff5a8cf8bdf0e8749bd31751fa760c557853f82393756c6800f8651c17c7126575a1c6bd784b037189ea50f2d5df0d111171f0cdbf080622cd9f6fc101f80216978853bfbe076467523839b56aa0e9fdd2f2d6cc231746b529f8e3427dfeea0aaa910a421aa92ffa1e49362e2ee1ac4c9586c11482b3bf66a260baccdcb252fbbec17c081be90cbf31a2a2f8a81b86ac526ebe25a072fdf3ff6b849d0a5563b7643983df7d024ad9a7e62ebdb0c662aedff94574f26daf8657ab95698607230ff7221d3893db9125e567aafa9980aed8a8297856e9ae265b654e29099a48064d6c3063a52518724b4950e13ed1a95d54b5ec999802419e76942f7f7f1409fa825ad3d72921fbc531594f9af221f727d93ac2022795517be4f4904f87b7cfbfa6e759bbc3c0b61090a5c0d85c59f6938b90ffe5b3cd6e99934b692d3c25c0f211842e9a2fd9c2688d95083db659a202e6dbb0d5df8200ec63ff5e63629b1488ee35a60badafdc87a5f9bd45315ca78c711329a2d41ee150b195a22bc1a59686638796978b7a9d3709e9c181049fb7ec038ed296da30fb12fba5c1ff35c972a0527fd53b7d005b170652c89065113c16cdb37548b6ab634bee372cf986bf71cbba2b1bf3f0410f33d9f565fe89a56369651eb7c3eb94fdbf9412798b114a833e4bf69e1a0f345b6561e8370df0896acd7aa5ec79f7ff911d715da53f779ec9d3cb7655fcf0895f83f188a5900cd02a237a4c413b872c362afa0ca098570487f02e247ff65ad16dbd5f13d5e6d49f1ce81ac7e98595abcf949261984c7f5588e31e772e8f797762a7df18c43853f1c42ff475140634b00800ec98a541aa1d2b44a1d6b800c5f2f82e6e105dcb601da4eb69719c6cdfe5adb1af98109e8d02b226d34c7fa4c71fccdd4fc68af289008e2ec044680df28c0291a6ca86554b35c9525a9555d16b3343cf14a04d19d18c864e168f4e929ddf60ddd774708211000044e2eaea2376f006580e3628eb4179704f587b104420de4e0df8f2cd6e872eba6bfdc149b8d7bd2626d7cca367af16e699a9d59feee39590cb674e01b60f79dccd9bc7a16bf408f15723e5b75def1233a465ad27f839bd9aaca05e871db1fd5203209158e327a81d8c4ac5cb87ecdd3a470b49c2cfc5a55e2c97fa773642156096c664e9ee894091ffa4f3472a07d0cf3f318c3ef21d60e03e28301ad4a8e007b9d7245592533755a9ce83938a88242575e4cf2ac9408f67c0d9ab2b265f91a92aabdfdb6241185f91187b05b1b340d429422857e356eadef470dda05556b15987879470ed6ce108000e1c21f6f6ce0b301ac43e33f9a0fef0a51b0335ae6db12a19fd42a2ffc576e51c13dd2d014d0457824a7a8472b77015b48a276d954318e3eed3a5be4d4cbd20d724cc1bffc29276e7b576f3d28df529d892b1fb7f49c0300df572e660032ecc31078d1fe29346137e1c14facb48ae1368e8bc7b4173e94e034119ef887099fb754d119d13dcc9b58be3828f86e94b8f3e3892697492758702b69f6d234786f55d9be7e777b3566de70a91989d3fcde859c2fc604da09c6ec4697ae72f07ae312e236be0f5e3ddf0c3feff19da67bb8a4c5a86e67fd3d0d795b2641d4e5b61a6a5a70c9403a9c7469b80472d5ee7dc48ac24a283666031a4f479b1d3f9eaa1f0316c14529627d8e5f655abd1f49b4feb6c0466dc76502aa8b62b1b3090cf7d5e78337a688006744dcacba3caf52c2c56857f95d1cb1a39570f5517bdf7d928f8c6c58a3b11f56615e87c2a51f446c4c4a42c7ed2f951366beb1dae4d5e525c31e052f905e1a15d032c273ab0678ce60415681db3053bb912bbed749bc8ba89eef53baed0bf6253baeb3c6313020ce0a4ec926c1123b094162c63f716105fea65ffb9e969ef771e2f2b212cd3eb0cd77292dc0fb51bfd43cd5b3d976d2b4ab4655e9dbeb4c2ced8de0cf94f7595cf69c4ee849b543de4a2eecba77c92655500b2550485195e69710ed5f5a60e345429ca084b04f5283bf35203f9fc88b33d371fc497e07587ae4d3490df2eb74f3fbf04a17cc36b3378c87d038e169378b717f1f1ba532025929d25538f00c17d2bf10fda9af2a8e314e2d656f37d8a1197304c69ce510c33221878713a632039da2954dc07e04771cdac44c6eb7443eb386c1504b4945a19f5d6b89e3983d97c8b22f6910e40b667bd0d243fc966fe16aa9d2fdb6cde5eae005d8e5e016e57f4ce13d917cb034fd915218eb8607d4a20663bd4cb5c0bdde8b21d16f0c16b29abb4432bd9a2a465a91b9e75b84d5e17fd8421731a5b709450b73954baf4d3d48245575a6180224479ba044362f335ef75523d67e874d4ecfadebc28dd2ef2ba8cde42532ee8bc2150ca665829a97aa1f76578e67e73be6016a8a4a2ef6e7888e6e9875c8f728294d95af4e369bc239aaf1249075f7ce6e76f8e039cc160c48d71c683f930dac05653e7c817e2478c1c0528537948e446990e51099a21033fe0ed43db83727501e4d0baa3aac9ce39c83e17dce22920c8c4783fa311a57e35ec7fe73cd86a8664f19a7b50279320e2c0a03844e191caff11cec4351d3c278c56ad523d4026b0850487468975d2b42c820cea108e4b15e23ee7703f306ed360f4d5ece164359fbe81698763bccad13d58c69e5f4b5a1728602798575d62f6b19632fbc4920b06ab34a5a51a3337ed7160b24653e35b8da5f0b483b3ed2fca8630af8a1cbf04fd39877baa1834065f9f") + common.Must(err) + quicHdr, err := quic.SniffQUIC(pkt) + if err != nil || quicHdr.Domain() != "test.example.com" { + t.Error("failed") + } +} + func TestSniffFakeQUICPacketWithInvalidPacketNumberLength(t *testing.T) { pkt, err := hex.DecodeString("cb00000001081c8c6d5aeb53d54400000090709b8600000000000000000000000000000000") common.Must(err) From 5e245b082e6be8c8899c34410f488e8ab001aaba Mon Sep 17 00:00:00 2001 From: meanwhile131 <51206659+meanwhile131@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:19:54 +0400 Subject: [PATCH 55/59] Routing: Export HealthCheckSettings (#6680) --- infra/conf/observatory.go | 2 +- infra/conf/router_strategy.go | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/infra/conf/observatory.go b/infra/conf/observatory.go index 62d6aabad..d33a7b3d5 100644 --- a/infra/conf/observatory.go +++ b/infra/conf/observatory.go @@ -23,7 +23,7 @@ func (o *ObservatoryConfig) Build() (proto.Message, error) { type BurstObservatoryConfig struct { SubjectSelector []string `json:"subjectSelector"` // health check settings - HealthCheck *healthCheckSettings `json:"pingConfig,omitempty"` + HealthCheck *HealthCheckSettings `json:"pingConfig,omitempty"` } func (b BurstObservatoryConfig) Build() (proto.Message, error) { diff --git a/infra/conf/router_strategy.go b/infra/conf/router_strategy.go index 6c426c510..f82a09bdb 100644 --- a/infra/conf/router_strategy.go +++ b/infra/conf/router_strategy.go @@ -43,8 +43,8 @@ type strategyLeastLoadConfig struct { Tolerance float64 `json:"tolerance,omitempty"` } -// healthCheckSettings holds settings for health Checker -type healthCheckSettings struct { +// HealthCheckSettings holds settings for health Checker +type HealthCheckSettings struct { Destination string `json:"destination"` Connectivity string `json:"connectivity"` Interval duration.Duration `json:"interval"` @@ -53,7 +53,7 @@ type healthCheckSettings struct { HttpMethod string `json:"httpMethod"` } -func (h healthCheckSettings) Build() (proto.Message, error) { +func (h HealthCheckSettings) Build() (proto.Message, error) { var httpMethod string if h.HttpMethod == "" { httpMethod = "HEAD" From 2776ea6d7426e1d024924df02d749d7021b2747f Mon Sep 17 00:00:00 2001 From: Jorik_Smith <100627853+JorikSmith@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:59:04 +0300 Subject: [PATCH 56/59] SS2022 outbound: Close connection to server on early returns (#6698) https://github.com/XTLS/Xray-core/pull/6698#issuecomment-5468662565 --- proxy/shadowsocks_2022/outbound.go | 1 + 1 file changed, 1 insertion(+) diff --git a/proxy/shadowsocks_2022/outbound.go b/proxy/shadowsocks_2022/outbound.go index f1195950d..5d1b9c9fb 100644 --- a/proxy/shadowsocks_2022/outbound.go +++ b/proxy/shadowsocks_2022/outbound.go @@ -82,6 +82,7 @@ func (o *Outbound) Process(ctx context.Context, link *transport.Link, dialer int if err != nil { return errors.New("failed to connect to server").Base(err) } + defer connection.Close() if session.TimeoutOnlyFromContext(ctx) { ctx, _ = context.WithCancel(context.Background()) From 8ee131cbbba4e9ed26e62fe3900cb9fdfb06ab89 Mon Sep 17 00:00:00 2001 From: Younes <62533356+younesvatan78@users.noreply.github.com> Date: Tue, 1 Sep 2026 02:59:56 +0300 Subject: [PATCH 57/59] HTTP inbound: Fix a potential panic in readResponseAndHandle100Continue() (#6545) --- proxy/http/server.go | 2 +- proxy/http/server_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 proxy/http/server_test.go diff --git a/proxy/http/server.go b/proxy/http/server.go index 054e40bf4..c164542e9 100644 --- a/proxy/http/server.go +++ b/proxy/http/server.go @@ -332,7 +332,7 @@ func readResponseAndHandle100Continue(r *bufio.Reader, req *http.Request, writer return nil, errors.New("failed to read http 1xx response").Base(err) } ResponseHeader1xx = append(ResponseHeader1xx, data...) - if bytes.Equal(ResponseHeader1xx[len(ResponseHeader1xx)-4:], []byte{'\r', '\n', '\r', '\n'}) { + if len(ResponseHeader1xx) >= 4 && bytes.Equal(ResponseHeader1xx[len(ResponseHeader1xx)-4:], []byte{'\r', '\n', '\r', '\n'}) { break } if len(ResponseHeader1xx) > 1024 { diff --git a/proxy/http/server_test.go b/proxy/http/server_test.go new file mode 100644 index 000000000..2578d5315 --- /dev/null +++ b/proxy/http/server_test.go @@ -0,0 +1,49 @@ +package http + +import ( + "bufio" + "bytes" + "io" + "net/http" + "strings" + "testing" +) + +// A malformed upstream response containing a bare '\n' before the real +// status line used to crash readResponseAndHandle100Continue: the first +// ReadSlice('\n') returns fewer than 4 bytes, and slicing +// ResponseHeader1xx[len(ResponseHeader1xx)-4:] panicked with a negative +// index instead of returning an error. +func TestReadResponseAndHandle100ContinueDoesNotPanicOnEarlyNewline(t *testing.T) { + payload := "X\nHTTP/1.1 100 Continue\r\n\r\n" + strings.Repeat("A", 40) + r := bufio.NewReader(bytes.NewReader([]byte(payload))) + req, err := http.NewRequest("GET", "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + + // Must not panic; a parse error for the garbage trailing bytes is fine. + _, _ = readResponseAndHandle100Continue(r, req, io.Discard) +} + +func TestReadResponseAndHandle100ContinueForwardsAndParsesFinalResponse(t *testing.T) { + payload := "HTTP/1.1 100 Continue\r\n\r\n" + + "HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello" + r := bufio.NewReader(bytes.NewReader([]byte(payload))) + req, err := http.NewRequest("GET", "http://example.com/", nil) + if err != nil { + t.Fatal(err) + } + + var forwarded bytes.Buffer + resp, err := readResponseAndHandle100Continue(r, req, &forwarded) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if resp.StatusCode != 200 { + t.Fatalf("expected status 200, got %d", resp.StatusCode) + } + if !strings.Contains(forwarded.String(), "100 Continue") { + t.Fatalf("expected 1xx response to be forwarded, got %q", forwarded.String()) + } +} From fc7b9806365ff47b3f99eb19dca59eacabacac38 Mon Sep 17 00:00:00 2001 From: patterniha <71074308+patterniha@users.noreply.github.com> Date: Tue, 1 Sep 2026 08:17:55 +0330 Subject: [PATCH 58/59] Sockopt: Fix indentation of the CustomSockopt block for Linux (#6568) https://github.com/XTLS/Xray-core/pull/6568#issuecomment-5488145252 Fixes https://github.com/XTLS/Xray-core/issues/6564 Fixes https://github.com/XTLS/Xray-core/issues/6602 --- infra/conf/transport_sockopt.go | 4 +- transport/internet/sockopt_linux.go | 67 +++++++++++++++-------------- 2 files changed, 36 insertions(+), 35 deletions(-) diff --git a/infra/conf/transport_sockopt.go b/infra/conf/transport_sockopt.go index 7001cd4d1..454128c20 100644 --- a/infra/conf/transport_sockopt.go +++ b/infra/conf/transport_sockopt.go @@ -10,7 +10,7 @@ import ( ) type CustomSockoptConfig struct { - Syetem string `json:"system"` + System string `json:"system"` Network string `json:"network"` Level string `json:"level"` Opt string `json:"opt"` @@ -124,7 +124,7 @@ func (c *SocketConfig) Build() (*internet.SocketConfig, error) { for _, copt := range c.CustomSockopt { customSockopt := &internet.CustomSockopt{ - System: copt.Syetem, + System: copt.System, Network: copt.Network, Level: copt.Level, Opt: copt.Opt, diff --git a/transport/internet/sockopt_linux.go b/transport/internet/sockopt_linux.go index bf100350e..36c4decfd 100644 --- a/transport/internet/sockopt_linux.go +++ b/transport/internet/sockopt_linux.go @@ -169,41 +169,42 @@ func applyInboundSocketOptions(network string, fd uintptr, config *SocketConfig) return errors.New("failed to set TCP_MAXSEG", err) } } - if len(config.CustomSockopt) > 0 { - for _, custom := range config.CustomSockopt { - if custom.System != "" && custom.System != runtime.GOOS { - errors.LogDebug(context.Background(), "CustomSockopt system not match: ", "want ", custom.System, " got ", runtime.GOOS) - continue + } + + if len(config.CustomSockopt) > 0 { + for _, custom := range config.CustomSockopt { + if custom.System != "" && custom.System != runtime.GOOS { + errors.LogDebug(context.Background(), "CustomSockopt system not match: ", "want ", custom.System, " got ", runtime.GOOS) + continue + } + // Skip unwanted network type + // network might be tcp4 or tcp6 + // use HasPrefix so that "tcp" can match tcp4/6 with "tcp" if user want to control all tcp (udp is also the same) + // if it is empty, strings.HasPrefix will always return true to make it apply for all networks + if !strings.HasPrefix(network, custom.Network) { + continue + } + level := 0x6 // default TCP + var opt int + if len(custom.Opt) == 0 { + return errors.New("No opt!") + } else { + opt, _ = strconv.Atoi(custom.Opt) + } + if custom.Level != "" { + level, _ = strconv.Atoi(custom.Level) + } + if custom.Type == "int" { + value, _ := strconv.Atoi(custom.Value) + if err := syscall.SetsockoptInt(int(fd), level, opt, value); err != nil { + return errors.New("failed to set CustomSockoptInt", opt, value, err) } - // Skip unwanted network type - // network might be tcp4 or tcp6 - // use HasPrefix so that "tcp" can match tcp4/6 with "tcp" if user want to control all tcp (udp is also the same) - // if it is empty, strings.HasPrefix will always return true to make it apply for all networks - if !strings.HasPrefix(network, custom.Network) { - continue - } - level := 0x6 // default TCP - var opt int - if len(custom.Opt) == 0 { - return errors.New("No opt!") - } else { - opt, _ = strconv.Atoi(custom.Opt) - } - if custom.Level != "" { - level, _ = strconv.Atoi(custom.Level) - } - if custom.Type == "int" { - value, _ := strconv.Atoi(custom.Value) - if err := syscall.SetsockoptInt(int(fd), level, opt, value); err != nil { - return errors.New("failed to set CustomSockoptInt", opt, value, err) - } - } else if custom.Type == "str" { - if err := syscall.SetsockoptString(int(fd), level, opt, custom.Value); err != nil { - return errors.New("failed to set CustomSockoptString", opt, custom.Value, err) - } - } else { - return errors.New("unknown CustomSockopt type:", custom.Type) + } else if custom.Type == "str" { + if err := syscall.SetsockoptString(int(fd), level, opt, custom.Value); err != nil { + return errors.New("failed to set CustomSockoptString", opt, custom.Value, err) } + } else { + return errors.New("unknown CustomSockopt type:", custom.Type) } } } From cd4ce973e9f6ef3a7acf9a7030927b4143f9ea47 Mon Sep 17 00:00:00 2001 From: pexcn Date: Tue, 1 Sep 2026 12:57:10 +0800 Subject: [PATCH 59/59] WebSocket client: Avoid panic before real dialing in delayDialConn (#6544) Fixes https://github.com/teddysun/xray-plugin-android/issues/11 --- transport/internet/websocket/dialer.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/transport/internet/websocket/dialer.go b/transport/internet/websocket/dialer.go index 8e295da06..199514a5c 100644 --- a/transport/internet/websocket/dialer.go +++ b/transport/internet/websocket/dialer.go @@ -175,6 +175,25 @@ type delayDialConn struct { streamSettings *internet.MemoryStreamConfig } +// LocalAddr returns nil until the deferred WebSocket dial has completed. +// Without this method, Go promotes LocalAddr from the embedded net.Conn; the +// embedded interface is nil before the first Write, so the promoted call panics. +func (d *delayDialConn) LocalAddr() net.Addr { + if d.Conn == nil { + return nil + } + return d.Conn.LocalAddr() +} + +// RemoteAddr returns nil until the deferred WebSocket dial has completed. +// See LocalAddr for why an explicit method is required here. +func (d *delayDialConn) RemoteAddr() net.Addr { + if d.Conn == nil { + return nil + } + return d.Conn.RemoteAddr() +} + func (d *delayDialConn) Write(b []byte) (int, error) { if d.closed { return 0, io.ErrClosedPipe