/* SPDX-License-Identifier: MIT
*
* Phobos
*/
package phobos
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"io"
"math/rand"
"net"
"net/netip"
"strconv"
"testing"
"time"
)
type fakeSocks5Server struct {
listener net.Listener
key []byte
masking Masking
media MediaParams
login string
password string
}
func startFakeSocks5Server(t *testing.T, key []byte, masking Masking, media MediaParams, login, password string) *fakeSocks5Server {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("unable to listen: %v", err)
}
server := &fakeSocks5Server{listener: listener, key: key, masking: masking, media: media, login: login, password: password}
t.Cleanup(func() { listener.Close() })
go server.run()
return server
}
func (s *fakeSocks5Server) addr() netip.AddrPort {
return s.listener.Addr().(*net.TCPAddr).AddrPort()
}
func (s *fakeSocks5Server) run() {
for {
conn, err := s.listener.Accept()
if err != nil {
return
}
go s.handle(newObfConn(conn, s.key, s.masking, s.media))
}
}
func (s *fakeSocks5Server) handle(conn *obfConn) {
defer conn.Close()
reader := bufio.NewReader(conn)
var greeting [2]byte
if _, err := io.ReadFull(reader, greeting[:]); err != nil {
return
}
methods := make([]byte, greeting[1])
if _, err := io.ReadFull(reader, methods); err != nil {
return
}
chosen := byte(methodNoAuth)
if len(s.login) > 0 {
chosen = methodUserPass
}
if _, err := conn.Write([]byte{socks5Version, chosen}); err != nil {
return
}
if chosen == methodUserPass && !s.checkCredentials(conn, reader) {
return
}
header := make([]byte, 262)
if _, err := io.ReadFull(reader, header[:4]); err != nil {
return
}
command := header[1]
target, err := readTarget(reader, header[3:])
if err != nil {
return
}
switch command {
case cmdConnect:
s.serveConnect(conn, reader, target)
case cmdUDPAssociate:
s.serveUDP(conn, reader)
default:
conn.Write(buildReply(replyCommandNotSupported, netip.AddrPort{}))
}
}
func (s *fakeSocks5Server) checkCredentials(conn *obfConn, reader *bufio.Reader) bool {
var head [2]byte
if _, err := io.ReadFull(reader, head[:]); err != nil {
return false
}
login := make([]byte, head[1])
io.ReadFull(reader, login)
var passwordLength [1]byte
io.ReadFull(reader, passwordLength[:])
password := make([]byte, passwordLength[0])
io.ReadFull(reader, password)
if string(login) != s.login || string(password) != s.password {
conn.Write([]byte{userPassVersion, 0x01})
return false
}
conn.Write([]byte{userPassVersion, 0x00})
return true
}
func (s *fakeSocks5Server) serveConnect(conn *obfConn, reader *bufio.Reader, target socks5Target) {
host := target.domain
if target.atyp != atypDomain {
addr, _ := target.addrPort()
host = addr.Addr().String()
}
upstream, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(int(target.port))))
if err != nil {
conn.Write(buildReply(replyGeneralFailure, netip.AddrPort{}))
return
}
defer upstream.Close()
if _, err := conn.Write(buildReply(replySucceeded, netip.AddrPort{})); err != nil {
return
}
go io.Copy(upstream, reader)
io.Copy(conn, upstream)
}
func (s *fakeSocks5Server) serveUDP(conn *obfConn, reader *bufio.Reader) {
if _, err := conn.Write(buildReply(replySucceeded, netip.AddrPort{})); err != nil {
return
}
out := make([]byte, s5AccMax)
for {
var header [2]byte
if _, err := io.ReadFull(reader, header[:]); err != nil {
return
}
frame := make([]byte, binary.BigEndian.Uint16(header[:]))
if _, err := io.ReadFull(reader, frame); err != nil {
return
}
target, offset, err := parseUDPHeader(frame)
if err != nil {
return
}
source, _ := target.addrPort()
echoed, err := buildUDPFrame(targetFromAddrPort(source), frame[offset:], out)
if err != nil {
return
}
if _, err := conn.Write(echoed); err != nil {
return
}
}
}
func startEchoServer(t *testing.T) net.Addr {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("unable to listen: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
return listener.Addr()
}
func startEchoServer6(t *testing.T) net.Addr {
t.Helper()
listener, err := net.Listen("tcp6", "[::1]:0")
if err != nil {
t.Skipf("IPv6 loopback unavailable: %v", err)
}
t.Cleanup(func() { listener.Close() })
go func() {
for {
conn, err := listener.Accept()
if err != nil {
return
}
go func() {
defer conn.Close()
io.Copy(conn, conn)
}()
}
}()
return listener.Addr()
}
var socks5TestKey = []byte("Ic0OGtSf1BdMmMDzs7GmYRuPS/HGmNXsSU9EOWEeuQI=")
var socks5TestCases = []struct {
name string
masking Masking
media MediaParams
}{
{"none", MaskingNone, MediaParams{}},
{"stun", MaskingSTUN, MediaParams{}},
{"media", MaskingMEDIA, MediaParams{PayloadType: 102, SSRC: 0xC0FFEE, TimestampStep: 3000}},
{"tls", MaskingTLS, MediaParams{}},
}
func TestSocks5ClientConnect(t *testing.T) {
echo := startEchoServer(t)
echoPort := uint16(echo.(*net.TCPAddr).Port)
for _, tc := range socks5TestCases {
t.Run(tc.name, func(t *testing.T) {
server := startFakeSocks5Server(t, socks5TestKey, tc.masking, tc.media, "user", "pass")
client := NewSocks5Client(Socks5Config{
Target: server.addr(),
Key: socks5TestKey,
Masking: tc.masking,
Media: tc.media,
Login: "user",
Password: "pass",
Logf: t.Logf,
})
conn, err := client.DialTCP(context.Background(), "127.0.0.1", echoPort)
if err != nil {
t.Fatalf("dial failed: %v", err)
}
defer conn.Close()
payload := make([]byte, 200*1024)
rand.New(rand.NewSource(31)).Read(payload)
go func() {
conn.Write(payload)
}()
received := make([]byte, len(payload))
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
if _, err := io.ReadFull(conn, received); err != nil {
t.Fatalf("read failed: %v", err)
}
if !bytes.Equal(received, payload) {
t.Fatal("echoed payload does not match")
}
})
}
}
func TestSocks5ClientUDP(t *testing.T) {
for _, tc := range socks5TestCases {
t.Run(tc.name, func(t *testing.T) {
server := startFakeSocks5Server(t, socks5TestKey, tc.masking, tc.media, "", "")
client := NewSocks5Client(Socks5Config{
Target: server.addr(),
Key: socks5TestKey,
Masking: tc.masking,
Media: tc.media,
Logf: t.Logf,
})
session, err := client.DialUDP(context.Background())
if err != nil {
t.Fatalf("udp associate failed: %v", err)
}
defer session.Close()
destination := netip.MustParseAddrPort("8.8.8.8:53")
buf := make([]byte, s5AccMax)
for i := range 8 {
payload := bytes.Repeat([]byte{byte(i)}, 100+i*137)
if err := session.WriteTo(payload, destination); err != nil {
t.Fatalf("write failed: %v", err)
}
n, source, err := session.ReadFrom(buf)
if err != nil {
t.Fatalf("read failed: %v", err)
}
if source != destination {
t.Fatalf("source = %v, want %v", source, destination)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatalf("datagram %d does not match", i)
}
}
})
}
}
func TestSocks5ClientConnectIPv6Target(t *testing.T) {
echo := startEchoServer6(t)
echoPort := uint16(echo.(*net.TCPAddr).Port)
server := startFakeSocks5Server(t, socks5TestKey, MaskingNone, MediaParams{}, "", "")
client := NewSocks5Client(Socks5Config{Target: server.addr(), Key: socks5TestKey, Logf: t.Logf})
conn, err := client.DialTCP(context.Background(), "::1", echoPort)
if err != nil {
t.Fatalf("dial failed: %v", err)
}
defer conn.Close()
payload := []byte("phobos-ipv6-target")
if _, err := conn.Write(payload); err != nil {
t.Fatalf("write failed: %v", err)
}
received := make([]byte, len(payload))
conn.SetReadDeadline(time.Now().Add(15 * time.Second))
if _, err := io.ReadFull(conn, received); err != nil {
t.Fatalf("read failed: %v", err)
}
if !bytes.Equal(received, payload) {
t.Fatal("echoed payload does not match")
}
}
func TestSocks5ClientUDPIPv6Target(t *testing.T) {
server := startFakeSocks5Server(t, socks5TestKey, MaskingNone, MediaParams{}, "", "")
client := NewSocks5Client(Socks5Config{Target: server.addr(), Key: socks5TestKey, Logf: t.Logf})
session, err := client.DialUDP(context.Background())
if err != nil {
t.Fatalf("udp associate failed: %v", err)
}
defer session.Close()
destination := netip.MustParseAddrPort("[2001:4860:4860::8888]:53")
payload := []byte("phobos-ipv6-datagram")
if err := session.WriteTo(payload, destination); err != nil {
t.Fatalf("write failed: %v", err)
}
buf := make([]byte, s5AccMax)
n, source, err := session.ReadFrom(buf)
if err != nil {
t.Fatalf("read failed: %v", err)
}
if source != destination {
t.Fatalf("source = %v, want %v", source, destination)
}
if !bytes.Equal(buf[:n], payload) {
t.Fatal("echoed datagram does not match")
}
}
func TestSocks5ClientRejectsWrongCredentials(t *testing.T) {
server := startFakeSocks5Server(t, socks5TestKey, MaskingSTUN, MediaParams{}, "user", "pass")
client := NewSocks5Client(Socks5Config{
Target: server.addr(),
Key: socks5TestKey,
Masking: MaskingSTUN,
Login: "user",
Password: "wrong",
Logf: t.Logf,
})
if _, err := client.DialTCP(context.Background(), "127.0.0.1", 80); err == nil {
t.Fatal("expected an authentication failure")
}
}
func TestSocks5LocalListener(t *testing.T) {
echo := startEchoServer(t)
echoPort := uint16(echo.(*net.TCPAddr).Port)
server := startFakeSocks5Server(t, socks5TestKey, MaskingMEDIA, socks5MediaParamsForTest(), "user", "pass")
client := NewSocks5Client(Socks5Config{
Target: server.addr(),
Key: socks5TestKey,
Masking: MaskingMEDIA,
Media: socks5MediaParamsForTest(),
Login: "user",
Password: "pass",
ListenPort: 0,
Logf: t.Logf,
})
client.config.ListenPort = freePort(t)
if err := client.Start(); err != nil {
t.Fatalf("unable to start the listener: %v", err)
}
defer client.Stop()
conn, err := net.Dial("tcp4", net.JoinHostPort("127.0.0.1", strconv.Itoa(int(client.ListenPort()))))
if err != nil {
t.Fatalf("unable to reach the local listener: %v", err)
}
defer conn.Close()
conn.Write(buildGreeting(methodNoAuth, methodUserPass))
var method [2]byte
if _, err := io.ReadFull(conn, method[:]); err != nil {
t.Fatalf("no method reply: %v", err)
}
if method[1] != methodUserPass {
t.Fatalf("method = %d, want %d", method[1], methodUserPass)
}
credentials, _ := buildUserPass("user", "pass")
conn.Write(credentials)
if _, err := io.ReadFull(conn, method[:]); err != nil || method[1] != 0 {
t.Fatalf("authentication rejected: %v", err)
}
conn.Write(buildRequest(cmdConnect, targetFromHostPort("127.0.0.1", echoPort)))
buf := make([]byte, 262)
reply, _, err := readReply(conn, buf)
if err != nil || reply != replySucceeded {
t.Fatalf("connect refused: reply=%d err=%v", reply, err)
}
payload := []byte("phobos socks5 through the local listener")
conn.Write(payload)
received := make([]byte, len(payload))
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
if _, err := io.ReadFull(conn, received); err != nil {
t.Fatalf("read failed: %v", err)
}
if !bytes.Equal(received, payload) {
t.Fatal("echoed payload does not match")
}
}
func socks5MediaParamsForTest() MediaParams {
return MediaParams{PayloadType: 102, SSRC: 0xC0FFEE, TimestampStep: 3000}
}
func freePort(t *testing.T) uint16 {
t.Helper()
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
t.Fatalf("unable to reserve a port: %v", err)
}
port := uint16(listener.Addr().(*net.TCPAddr).Port)
listener.Close()
return port
}