How to Implement Upstream SOCKS5 Chaining on the MasterDnsVPN Server Side
To implement upstream SOCKS5 chaining in MasterDnsVPN, extend the server configuration to accept a slice of proxy hops, store the ordered list in the Server struct, and modify the dial logic to nest SOCKS5 CONNECT requests recursively from the last hop back to the first.
MasterDnsVPN currently supports routing outbound traffic through a single external SOCKS5 proxy via the dialExternalSOCKS5TargetContext function in internal/udpserver/socks5_upstream.go. Enabling chaining—routing requests through a series of proxies before reaching the final destination—requires changes to the configuration schema, server state management, and connection establishment logic.
Extend the Configuration Model
The existing server configuration in internal/config/server_config.go defines isolated fields for a single proxy (UseExternalSOCKS5, ForwardIP, ForwardPort). Replace these with a slice of Socks5Upstream structs to support arbitrary hop counts:
type Socks5Upstream struct {
Host string `toml:"host"` // IP or hostname of the proxy
Port uint16 `toml:"port"` // Proxy port
Auth bool `toml:"auth"` // Whether USER/PASS auth is required
Username string `toml:"username"` // Optional username
Password string `toml:"password"` // Optional password
}
type ServerConfig struct {
// … existing fields …
Socks5Chain []Socks5Upstream `toml:"socks5_chain"` // Ordered list of upstream proxies
}
This structure allows you to define multiple hops in your TOML configuration:
[[socks5_chain]]
host = "10.0.0.1"
port = 1080
auth = false
[[socks5_chain]]
host = "10.0.0.2"
port = 1080
auth = true
username = "proxyuser"
password = "proxypass"
Store the Chain in Server State
The Server type in internal/udpserver/server.go manages runtime state. Add a field to hold the ordered proxy list and expose a helper method to populate it during initialization:
type Server struct {
// … existing fields …
socks5Chain []Socks5Upstream // Ordered list of proxies
}
func (s *Server) SetSocks5Chain(chain []internalconfig.Socks5Upstream) {
s.socks5Chain = chain
}
In cmd/server/main.go, pass the configuration slice when constructing the server instance:
srv := UDPServer.New(cfg, log, codec)
srv.SetSocks5Chain(cfg.Socks5Chain)
Implement the Chaining Dialer
Replace the single-hop dialExternalSOCKS5TargetContext with a chain-aware implementation in internal/udpserver/socks5_upstream.go. The logic builds nested payloads from last hop to first, then performs sequential handshakes:
// dialChainedSOCKS5TargetContext connects through the configured SOCKS5 chain.
func (s *Server) dialChainedSOCKS5TargetContext(ctx context.Context, targetPayload []byte) (net.Conn, error) {
// Build the hop list: if a chain is defined, use it; otherwise fall back to the legacy single proxy.
hops := s.socks5Chain
if len(hops) == 0 && s.useExternalSOCKS5 {
hops = []Socks5Upstream{{Host: s.externalSOCKS5Address, Port: s.externalSOCKS5Port, Auth: s.externalSOCKS5Auth, Username: s.externalSOCKS5User, Password: s.externalSOCKS5Pass}}
}
if len(hops) == 0 {
// No upstream configured → direct TCP connection.
return s.dialTCPTargetContext(ctx, net.JoinHostPort(hostFromPayload(targetPayload), strconv.Itoa(int(portFromPayload(targetPayload)))))
}
// Start from the *last* hop and work backwards, nesting the payload.
payload := targetPayload
for i := len(hops) - 1; i >= 0; i-- {
hop := hops[i]
// Build a CONNECT request that points to the *previous* hop (or final destination for the innermost hop).
payload = buildSocks5ConnectPayload(hop.Host, hop.Port, payload)
}
// Open TCP connection to the *first* hop.
first := hops[0]
conn, err := s.dialTCPTargetContext(ctx, net.JoinHostPort(first.Host, strconv.Itoa(int(first.Port))))
if err != nil {
return nil, err
}
// Perform the handshake for each hop in order.
for _, hop := range hops {
if err = s.performSocks5Handshake(ctx, conn, hop); err != nil {
conn.Close()
return nil, err
}
}
return conn, nil
}
Support this with helper functions to construct nested payloads and handle per-hop authentication:
// buildSocks5ConnectPayload creates a SOCKS5 CONNECT request wrapping the inner payload.
func buildSocks5ConnectPayload(host string, port uint16, inner []byte) []byte {
var addr []byte
ip := net.ParseIP(host)
if ip4 := ip.To4(); ip4 != nil {
addr = append([]byte{0x01}, ip4...)
} else if ip != nil {
addr = append([]byte{0x04}, ip...)
} else {
addr = append([]byte{0x03, byte(len(host))}, []byte(host)...)
}
portBytes := []byte{byte(port >> 8), byte(port & 0xff)}
header := []byte{0x05, 0x01, 0x00}
return append(append(append(header, addr...), portBytes...), inner...)
}
// performSocks5Handshake runs greeting, auth, and CONNECT for a single hop.
func (s *Server) performSocks5Handshake(ctx context.Context, conn net.Conn, hop Socks5Upstream) error {
// Greeting
greeting := []byte{0x05, 0x01, 0x00}
if hop.Auth {
greeting[2] = 0x02
}
if err := writeAll(conn, greeting); err != nil {
return err
}
// Receive method selection
var methodResp [2]byte
if _, err := io.ReadFull(conn, methodResp[:]); err != nil {
return err
}
if methodResp[0] != 0x05 {
return fmt.Errorf("unexpected SOCKS5 version %d", methodResp[0])
}
// USER/PASS auth if required
if hop.Auth {
if err := s.handleExternalSOCKS5Auth(conn, methodResp[1]); err != nil {
return err
}
}
// Read CONNECT response
var respHeader [4]byte
if _, err := io.ReadFull(conn, respHeader[:]); err != nil {
return err
}
if respHeader[1] != 0x00 {
return fmt.Errorf("SOCKS5 hop %s:%d failed, code %d", hop.Host, hop.Port, respHeader[1])
}
return discardSOCKS5BoundAddress(conn, respHeader[3])
}
Integrate the Chain into Request Handling
Update dialSOCKSStreamTargetContext to detect when a chain is configured and delegate to the new chaining logic:
func (s *Server) dialSOCKSStreamTargetContext(ctx context.Context, host string, port uint16, targetPayload []byte) (net.Conn, error) {
if err := validateSOCKSTargetHost(host); err != nil {
return nil, err
}
// If a SOCKS5 chain is defined, use it.
if len(s.socks5Chain) > 0 {
finalPayload := buildSocks5ConnectPayload(host, port, nil)
return s.dialChainedSOCKS5TargetContext(ctx, finalPayload)
}
// Existing fallback: direct TCP or single upstream.
if !s.useExternalSOCKS5 || len(targetPayload) == 0 {
return s.dialTCPTargetContext(ctx, net.JoinHostPort(host, strconv.Itoa(int(port))))
}
return s.dialExternalSOCKS5TargetContext(ctx, targetPayload)
}
Testing and Validation
Validate your implementation using two approaches:
- Unit tests: Create
internal/udpserver/socks5_chain_test.goto mock multiple SOCKS5 servers using thenetpackage. Verify that handshakes propagate through each hop and that the final payload reaches the intended destination. - Integration tests: Update
scripts/bench/README.mdwith a chain mode benchmark. Configure a local TOML file with two or more test proxies and confirm that traffic flows sequentially through each hop before exiting to the target.
Summary
- Configuration: Replace single-proxy fields with a
Socks5Chainslice ininternal/config/server_config.goto define multiple hops with independent authentication. - State management: Store the ordered proxy list in the
Serverstruct viaSetSocks5Chainininternal/udpserver/server.go. - Dial logic: Implement
dialChainedSOCKS5TargetContextininternal/udpserver/socks5_upstream.goto build nested SOCKS5 payloads from last hop to first, then perform sequential handshakes. - Request routing: Modify
dialSOCKSStreamTargetContextto prioritize the chain whensocks5Chainhas entries, falling back to single-proxy or direct TCP mode otherwise. - Error handling: Any failure during the chain aborts the connection and returns a descriptive error, ensuring failed hops do not leak connections.
Frequently Asked Questions
What is upstream SOCKS5 chaining and why is it useful?
Upstream SOCKS5 chaining routes client traffic through multiple intermediary SOCKS5 proxies before reaching the final destination. This enables complex routing scenarios such as multi-hop anonymity layers, geographic routing through specific jurisdictions, or load balancing across different upstream providers. According to the MasterDnsVPN source code, this extends the existing single-proxy capability in socks5_upstream.go to support arbitrary hop depths.
How does authentication work when chaining multiple SOCKS5 proxies?
Each hop in the chain maintains its own authentication credentials via the Socks5Upstream struct fields (Auth, Username, Password). During performSocks5Handshake, the server checks the current hop's authentication flag and invokes handleExternalSOCKS5Auth only when method 0x02 (USER/PASS) is required, allowing you to mix authenticated and non-authenticated proxies within the same chain.
What happens if one proxy in the chain is unreachable?
If any hop in the sequence fails—whether during the initial TCP connection in dialTCPTargetContext or during the SOCKS5 handshake in performSocks5Handshake—the function immediately closes the connection and returns an error. This "fail fast" behavior prevents partial tunnels and ensures traffic does not leak through an incomplete chain.
Can I combine SOCKS5 chaining with the existing single-proxy configuration?
Yes. The implementation in dialChainedSOCKS5TargetContext checks len(s.socks5Chain) first. If the slice is empty but s.useExternalSOCKS5 is true, it falls back to the legacy single-hop behavior using s.externalSOCKS5Address. If both are unset, it establishes a direct TCP connection, maintaining backward compatibility with existing configurations.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →