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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,14 @@ of wireproxy by [@artem-russkikh](https://github.com/artem-russkikh).
# Feature

- TCP static routing for client and server
- SOCKS5/HTTP proxy (currently only CONNECT is supported)
- UDP static routing for client and server
- SOCKS5 proxy with TCP `CONNECT` and UDP `ASSOCIATE`
- HTTP proxy (currently only CONNECT is supported)
- Transparent TLS ([SNI](https://en.wikipedia.org/wiki/Server_Name_Indication)) proxy

# TODO

- UDP Support in SOCKS5
- UDP static routing
SOCKS5 UDP ASSOCIATE is intended for clients that support relaying UDP over
SOCKS5 (for example transparent proxy clients). Most browsers do not use UDP
ASSOCIATE, even when configured with a SOCKS5 proxy.

# Usage

Expand Down Expand Up @@ -128,6 +129,7 @@ Target = localhost:25545
Target = ssh.myserver.net:22

# Socks5 creates a socks5 proxy on your LAN, and all traffic would be routed via wireguard.
# Both TCP CONNECT and UDP ASSOCIATE are supported.
[Socks5]
BindAddress = 127.0.0.1:25344

Expand All @@ -141,6 +143,8 @@ BindAddress = 127.0.0.1:25344
# whose destination host matches one of the patterns are routed through wireguard;
# every other connection is dialed directly over your normal network. When
# TunnelDomains is unset, all traffic is routed through wireguard (default).
# The same rules apply to UDP ASSOCIATE datagrams, using each datagram's
# destination host.
# Each TunnelDomains line is a single, full Go regular expression (RE2). Repeat
# the key for multiple patterns; do NOT comma-separate (so quantifiers like {2,4}
# keep working). Matching is case-insensitive and a trailing dot is ignored.
Expand Down Expand Up @@ -229,11 +233,25 @@ Target = service-two.servicenet:5001
ListenPort = 5080
Target = service-three.servicenet:80

# UDPProxyTunnel is the UDP client tunnel: UDP traffic sent to BindAddress is
# forwarded through WireGuard to Target.
# Flow:
# <an app on your LAN> --> localhost:53 --(wireguard)--> 1.1.1.1:53
[UDPProxyTunnel]
BindAddress = 127.0.0.1:53
Target = 1.1.1.1:53
InactivityTimeout = 30 # If its set to 0, it will never timeout

# UDPServerTunnel is the UDP server tunnel: UDP traffic arriving on the
# WireGuard side on ListenPort is forwarded to a target on your local network.
# The listener binds to every address in [Interface] Address.
# Flow:
# <an app on your wireguard network> --(wireguard)--> ListenPort --> localhost:19132
[UDPServerTunnel]
ListenPort = 19132
Target = 127.0.0.1:19132
InactivityTimeout = 30 # If its set to 0, it will never timeout

[Resolve]
# Set DNS Resovle Strategy
# `ipv4`: Prioritize A records.
Expand Down
74 changes: 64 additions & 10 deletions config.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,22 @@ type DeviceConfig struct {
CheckAliveInterval int
}

// UDPProxyTunnelConfig listens on the local machine and forwards UDP traffic
// through WireGuard to a fixed target.
type UDPProxyTunnelConfig struct {
BindAddress string
Target string
InactivityTimeout int
}

// UDPServerTunnelConfig listens on the WireGuard side and forwards UDP
// traffic to a target reachable from the local network.
type UDPServerTunnelConfig struct {
ListenPort int
Target string
InactivityTimeout int
}

type TCPClientTunnelConfig struct {
BindAddress *net.TCPAddr
Target string
Expand Down Expand Up @@ -565,6 +575,22 @@ func parseResolveConfig(section *ini.Section) (*ResolveConfig, error) {
return config, nil
}

// parseInactivityTimeout reads the optional InactivityTimeout key shared by
// the UDP tunnel sections. A missing key means "never expire".
func parseInactivityTimeout(section *ini.Section) (int, error) {
if sectionKey, err := section.GetKey("InactivityTimeout"); err == nil {
timeoutVal, err := sectionKey.Int()
if err != nil {
return 0, err
}
if timeoutVal < 0 {
return 0, errors.New("InactivityTimeout should not be negative")
}
return timeoutVal, nil
}
return 0, nil
}

func parseUDPProxyTunnelConfig(section *ini.Section) (RoutineSpawner, error) {
config := &UDPProxyTunnelConfig{}

Expand All @@ -578,18 +604,41 @@ func parseUDPProxyTunnelConfig(section *ini.Section) (RoutineSpawner, error) {
if err != nil {
return nil, err
}
if target == "" {
return nil, errors.New("Target should not be empty")
}
config.Target = target

inactivityTimeout := 0
if sectionKey, err := section.GetKey("InactivityTimeout"); err == nil {
timeoutVal, err := sectionKey.Int()
if err != nil {
return nil, err
}
if timeoutVal < 0 {
return nil, errors.New("InactivityTimeout should not be negative")
}
inactivityTimeout = timeoutVal
inactivityTimeout, err := parseInactivityTimeout(section)
if err != nil {
return nil, err
}
config.InactivityTimeout = inactivityTimeout

return config, nil
}

func parseUDPServerTunnelConfig(section *ini.Section) (RoutineSpawner, error) {
config := &UDPServerTunnelConfig{}

listenPort, err := parsePort(section, "ListenPort")
if err != nil {
return nil, err
}
config.ListenPort = listenPort

target, err := parseString(section, "Target")
if err != nil {
return nil, err
}
if target == "" {
return nil, errors.New("Target should not be empty")
}
config.Target = target

inactivityTimeout, err := parseInactivityTimeout(section)
if err != nil {
return nil, err
}
config.InactivityTimeout = inactivityTimeout

Expand Down Expand Up @@ -681,6 +730,11 @@ func ParseConfig(path string) (*Configuration, error) {
return nil, err
}

err = parseRoutinesConfig(&routinesSpawners, cfg, "UDPServerTunnel", parseUDPServerTunnelConfig)
if err != nil {
return nil, err
}

err = parseRoutinesConfig(&routinesSpawners, cfg, "Socks5", parseSocks5Config)
if err != nil {
return nil, err
Expand Down
60 changes: 60 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,3 +124,63 @@ InactivityTimeout = -1`
t.Fatal("expected negative InactivityTimeout to be rejected")
}
}

func TestParseUDPServerTunnelConfig(t *testing.T) {
const config = `
[UDPServerTunnel]
ListenPort = 19132
Target = 127.0.0.1:19132
InactivityTimeout = 30`

iniData, err := loadIniConfig(config)
if err != nil {
t.Fatal(err)
}

spawner, err := parseUDPServerTunnelConfig(iniData.Section("UDPServerTunnel"))
if err != nil {
t.Fatal(err)
}

cfg, ok := spawner.(*UDPServerTunnelConfig)
if !ok {
t.Fatalf("unexpected spawner type %T", spawner)
}
if cfg.ListenPort != 19132 {
t.Fatalf("ListenPort = %d, want 19132", cfg.ListenPort)
}
if cfg.Target != "127.0.0.1:19132" {
t.Fatalf("Target = %q, want 127.0.0.1:19132", cfg.Target)
}
if cfg.InactivityTimeout != 30 {
t.Fatalf("InactivityTimeout = %d, want 30", cfg.InactivityTimeout)
}
}

func TestParseUDPServerTunnelRejectsInvalidConfig(t *testing.T) {
cases := map[string]string{
"missing listen port": `
[UDPServerTunnel]
Target = 127.0.0.1:19132`,
"missing target": `
[UDPServerTunnel]
ListenPort = 19132`,
"negative timeout": `
[UDPServerTunnel]
ListenPort = 19132
Target = 127.0.0.1:19132
InactivityTimeout = -1`,
}

for name, config := range cases {
t.Run(name, func(t *testing.T) {
iniData, err := loadIniConfig(config)
if err != nil {
t.Fatal(err)
}
if _, err := parseUDPServerTunnelConfig(iniData.Section("UDPServerTunnel")); err == nil {
t.Fatal("expected invalid config to be rejected")
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ require (
github.com/akamensky/argparse v1.4.0
github.com/go-ini/ini v1.67.0
github.com/landlock-lsm/go-landlock v0.6.0
github.com/things-go/go-socks5 v0.1.3
github.com/things-go/go-socks5 v0.1.4
golang.org/x/net v0.55.0
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb
suah.dev/protect v1.2.3
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ github.com/landlock-lsm/go-landlock v0.6.0 h1:KwHctSfiTmEw12jeCBK0lryabdlFR7YvH3
github.com/landlock-lsm/go-landlock v0.6.0/go.mod h1:mn5GSi81Jf7yMs5WSi+SUi4sUeNLUGVdbT4Id6wXNQw=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/things-go/go-socks5 v0.1.3 h1:W6lC9bUiuubcVVGU5BRRrwUP6k/7HEwrJm5O+mIUHDE=
github.com/things-go/go-socks5 v0.1.3/go.mod h1:LZ99kKdQKnpwKv9P1Ilowna9cafg3cmYB/Dr1iagwDg=
github.com/things-go/go-socks5 v0.1.4 h1:DF0OvKL4O1pxmSh2J/suagjyGV7+hduBblpldPOuVSM=
github.com/things-go/go-socks5 v0.1.4/go.mod h1:LZ99kKdQKnpwKv9P1Ilowna9cafg3cmYB/Dr1iagwDg=
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=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
Expand Down
39 changes: 39 additions & 0 deletions router_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package wireproxy

import (
"context"
"io"
"net"
"regexp"
"testing"
)
Expand Down Expand Up @@ -134,6 +137,42 @@ TunnelDomains = ^(unclosed`
}
}

func TestRoutedDialContextDialsDirect(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = listener.Close() }()

go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
_, _ = conn.Write([]byte("direct"))
}()

// A zero VirtualTun is enough: a non-matching destination must never touch
// netstack. If this regresses, the nil Tnet will panic.
vt := VirtualTun{}
router := NewDomainRouter(mustCompile(t, `^tunnel\.invalid$`), false)

conn, err := vt.RoutedDialContext(router)(context.Background(), "tcp", listener.Addr().String())
if err != nil {
t.Fatal(err)
}
defer func() { _ = conn.Close() }()

got := make([]byte, len("direct"))
if _, err := io.ReadFull(conn, got); err != nil {
t.Fatal(err)
}
if string(got) != "direct" {
t.Fatalf("got %q, want %q", got, "direct")
}
}

func TestParseConfigDefaultsNoRouting(t *testing.T) {
const config = `
[SNI]
Expand Down
35 changes: 28 additions & 7 deletions routine.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,15 +166,26 @@ func (passthroughResolver) Resolve(ctx context.Context, name string) (context.Co
return ctx, nil, nil
}

// RoutedDialContext returns a context-aware dialer that sends whitelisted
// hosts through the tunnel and dials everything else directly. It is used by
// the SOCKS5 UDP relay, where each datagram is dialed independently.
func (d VirtualTun) RoutedDialContext(router *DomainRouter) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network, address string) (net.Conn, error) {
if router.route(hostFromAddr(address)) {
return d.Tnet.DialContext(ctx, network, address)
}
var dialer net.Dialer
return dialer.DialContext(ctx, network, address)
}
}

// RoutedDial returns a dialer that sends whitelisted hosts through the tunnel
// and dials everything else directly. Used by the HTTP and SNI proxies, whose
// dial callbacks receive the destination hostname intact.
func (d VirtualTun) RoutedDial(router *DomainRouter) func(network, address string) (net.Conn, error) {
dial := d.RoutedDialContext(router)
return func(network, address string) (net.Conn, error) {
if router.route(hostFromAddr(address)) {
return d.Tnet.Dial(network, address)
}
return net.Dial(network, address)
return dial(context.Background(), network, address)
}
}

Expand All @@ -195,8 +206,10 @@ func (d VirtualTun) routedSocks5Dial(router *DomainRouter) func(context.Context,
}
}

// SpawnRoutine spawns a socks5 server.
func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) {
// newServer builds the socks5 server. WithDial is used by both CONNECT and
// UDP ASSOCIATE, while WithDialAndRequest lets CONNECT make its routing
// decision using the original request FQDN.
func (config *Socks5Config) newServer(vt *VirtualTun) *socks5.Server {
var authMethods []socks5.Authenticator
if username := config.Username; username != "" {
authMethods = append(authMethods, socks5.UserPassAuthenticator{
Expand All @@ -219,14 +232,22 @@ func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) {
)
} else {
// Split-routing path: keep the FQDN so we can decide per connection.
// WithDial covers UDP ASSOCIATE datagrams, whose destination is only
// known per datagram.
router := NewDomainRouter(config.TunnelDomains, config.LogDomains)
options = append(options,
socks5.WithDial(vt.RoutedDialContext(router)),
socks5.WithDialAndRequest(vt.routedSocks5Dial(router)),
socks5.WithResolver(passthroughResolver{}),
)
}

server := socks5.NewServer(options...)
return socks5.NewServer(options...)
}

// SpawnRoutine spawns a socks5 server.
func (config *Socks5Config) SpawnRoutine(vt *VirtualTun) {
server := config.newServer(vt)

if err := server.ListenAndServe("tcp", config.BindAddress); err != nil {
log.Fatal(err)
Expand Down
Loading