m/pkg/socksproxy: init

This implements a simple SOCKS5 proxy server, which will be used within
nanoswitch to expose multiple nodes to test code and metroctl.

Some existing alternatives were considered, but none were in a healthy
enough state to be usable within Metropolis. And, in the end, we only
need a small subset of an already simple standard, so implementing this
ourselves isn't a massive waste of time.

Change-Id: Ifa4d4edf837b55b93cae9981028efef336ff2a3d
Reviewed-on: https://review.monogon.dev/c/monogon/+/646
Reviewed-by: Mateusz Zalega <mateusz@monogon.tech>
diff --git a/metropolis/pkg/socksproxy/socksproxy.go b/metropolis/pkg/socksproxy/socksproxy.go
new file mode 100644
index 0000000..ce35cec
--- /dev/null
+++ b/metropolis/pkg/socksproxy/socksproxy.go
@@ -0,0 +1,218 @@
+// package socksproxy implements a limited subset of the SOCKS 5 (RFC1928)
+// protocol in the form of a pluggable Proxy object. However, this
+// implementation is _not_ RFC1928 compliant, as it does not implement GSSAPI
+// (which is mandated by the spec). It currently only implements CONNECT
+// requests to IPv4/IPv6 addresses. It also doesn't implement any
+// timeout/keepalive system for killing inactive connections.
+//
+// The intended use of the library is internally within Metropolis development
+// environments for contacting test clusters. The code is simple and robust, but
+// not really productionized (as noted above - no timeouts and no authentication
+// make it a bad idea to ever expose this proxy server publicly).
+//
+// There are multiple other, existing Go SOCKS4/5 server implementations, but
+// many of them are either not context aware, part of a larger project (and thus
+// difficult to extract) or are brand new/untested/bleeding edge code.
+package socksproxy
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"log"
+	"net"
+	"strconv"
+)
+
+// Handler should be implemented by socksproxy users to allow SOCKS connections
+// to be proxied in any other way than via the HostHandler.
+type Handler interface {
+	// Connect is called by the server any time a SOCKS client sends a CONNECT
+	// request. The function should return a ConnectResponse describing some
+	// 'backend' connection, ie. the connection that will then be exposed to the
+	// SOCKS client.
+	//
+	// Connect should return with Error set to a non-default value to abort/deny the
+	// connection request.
+	//
+	// The underlying incoming socket is managed by the proxy server and is not
+	// visible to the client. However, any sockets/connections/files opened by the
+	// Handler should be cleaned up by tying them to the given context, which will
+	// be canceled whenever the connection is closed.
+	Connect(context.Context, *ConnectRequest) *ConnectResponse
+}
+
+// ConnectRequest represents a pending CONNECT request from a client.
+type ConnectRequest struct {
+	// Address is an IPv4 or IPv6 address that the client requested to connect to.
+	// This address might be invalid/malformed/internal, and the Connect method
+	// should sanitize it before using it.
+	Address net.IP
+	// Port is the TCP port number that the client requested to connect to.
+	Port uint16
+}
+
+// ConnectResponse indicates a 'backend' connection that the proxy should expose
+// to the client, or an error if the connection cannot be made.
+type ConnectResponse struct {
+	// Error will cause an error to be returned if it is anything else than the
+	// default value (ReplySucceeded).
+	Error Reply
+
+	// Backend is the ReadWriter that will be bridged over to the connecting client
+	// if no Error is set.
+	Backend io.ReadWriter
+	// LocalAddress is the IP address that is returned to the client as the local
+	// address of the newly established backend connection.
+	LocalAddress net.IP
+	// LocalPort is the local TCP port number that is returned to the client as the
+	// local port of the newly established backend connection.
+	LocalPort uint16
+}
+
+// ConnectResponseFromConn builds a ConnectResponse from a net.Conn. This can be
+// used by custom Handlers to easily return a ConnectResponse for a newly
+// established net.Conn, eg. from a Dial call.
+//
+// An error is returned if the given net.Conn does not carry a properly formed
+// LocalAddr.
+func ConnectResponseFromConn(c net.Conn) (*ConnectResponse, error) {
+	laddr := c.LocalAddr().String()
+	host, port, err := net.SplitHostPort(laddr)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse LocalAddr %q: %w", laddr, err)
+	}
+	addr := net.ParseIP(host)
+	if addr == nil {
+		return nil, fmt.Errorf("could not parse LocalAddr host %q as IP", host)
+	}
+	portNum, err := strconv.ParseUint(port, 10, 16)
+	if err != nil {
+		return nil, fmt.Errorf("could not parse LocalAddr port %q", port)
+	}
+	return &ConnectResponse{
+		Backend:      c,
+		LocalAddress: addr,
+		LocalPort:    uint16(portNum),
+	}, nil
+}
+
+type hostHandler struct{}
+
+func (h *hostHandler) Connect(ctx context.Context, req *ConnectRequest) *ConnectResponse {
+	port := fmt.Sprintf("%d", req.Port)
+	addr := net.JoinHostPort(req.Address.String(), port)
+	s, err := net.Dial("tcp", addr)
+	if err != nil {
+		log.Printf("HostHandler could not dial %q: %v", addr, err)
+		return &ConnectResponse{Error: ReplyConnectionRefused}
+	}
+	go func() {
+		<-ctx.Done()
+		s.Close()
+	}()
+	res, err := ConnectResponseFromConn(s)
+	if err != nil {
+		log.Printf("HostHandler could not build response: %v", err)
+		return &ConnectResponse{Error: ReplyGeneralFailure}
+	}
+	return res
+}
+
+var (
+	// HostHandler is an unsafe SOCKS5 proxy Handler which passes all incoming
+	// connections into the local network stack. The incoming addresses/ports are
+	// not sanitized, and as the proxy does not perform authentication, this handler
+	// is an open proxy. This handler should never be used in cases where the proxy
+	// server is publicly available.
+	HostHandler = &hostHandler{}
+)
+
+// Serve runs a SOCKS5 proxy server for a given Handler at a given listener.
+//
+// When the given context is canceled, the server will stop and the listener
+// will be closed. All pending connections will also be canceled and their
+// sockets closed.
+func Serve(ctx context.Context, handler Handler, lis net.Listener) error {
+	go func() {
+		<-ctx.Done()
+		lis.Close()
+	}()
+
+	for {
+		con, err := lis.Accept()
+		if err != nil {
+			// Context cancellation will close listener socket with a generic 'use of closed
+			// network connection' error, translate that back to context error.
+			if ctx.Err() != nil {
+				return ctx.Err()
+			}
+			return err
+		}
+		go handle(ctx, handler, con)
+	}
+}
+
+// handle runs in a goroutine per incoming SOCKS connection. Its lifecycle
+// corresponds to the lifecycle of a running proxy connection.
+func handle(ctx context.Context, handler Handler, con net.Conn) {
+	// ctxR is a per-request context, and will be canceled whenever the handler
+	// exits or the server is stopped.
+	ctxR, ctxRC := context.WithCancel(ctx)
+	defer ctxRC()
+
+	go func() {
+		<-ctxR.Done()
+		con.Close()
+	}()
+
+	// Perform method negotiation with the client.
+	if err := negotiateMethod(con); err != nil {
+		return
+	}
+
+	// Read request from the client and translate problems into early error replies.
+	req, err := readRequest(con)
+	switch err {
+	case errNotConnect:
+		writeReply(con, ReplyCommandNotSupported, net.IPv4(0, 0, 0, 0), 0)
+		return
+	case errUnsupportedAddressType:
+		writeReply(con, ReplyAddressTypeNotSupported, net.IPv4(0, 0, 0, 0), 0)
+		return
+	case nil:
+	default:
+		writeReply(con, ReplyGeneralFailure, net.IPv4(0, 0, 0, 0), 0)
+		return
+	}
+
+	// Ask handler.Connect for a backend.
+	conRes := handler.Connect(ctxR, &ConnectRequest{
+		Address: req.address,
+		Port:    req.port,
+	})
+	// Handle programming error when returned value is nil.
+	if conRes == nil {
+		writeReply(con, ReplyGeneralFailure, net.IPv4(0, 0, 0, 0), 0)
+		return
+	}
+	// Handle returned errors.
+	if conRes.Error != ReplySucceeded {
+		writeReply(con, conRes.Error, net.IPv4(0, 0, 0, 0), 0)
+		return
+	}
+
+	// Ensure Bound.* fields are set.
+	if conRes.Backend == nil || conRes.LocalAddress == nil || conRes.LocalPort == 0 {
+		writeReply(con, ReplyGeneralFailure, net.IPv4(0, 0, 0, 0), 0)
+		return
+	}
+	// Send reply.
+	if err := writeReply(con, ReplySucceeded, conRes.LocalAddress, conRes.LocalPort); err != nil {
+		return
+	}
+
+	// Pipe returned backend into connection.
+	go io.Copy(conRes.Backend, con)
+	io.Copy(con, conRes.Backend)
+}