osbase: move test packages under osbase/test

The cmd, freeport and socksproxy packages are only used in tests, and
are also intended to be only used in tests. This change moves these
packages under osbase/test.

Change-Id: I8fe679945990c295bdf822abfce56c25233c4588
Reviewed-on: https://review.monogon.dev/c/monogon/+/4564
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
Tested-by: Jenkins CI
diff --git a/osbase/test/cmd/BUILD.bazel b/osbase/test/cmd/BUILD.bazel
new file mode 100644
index 0000000..6d6a3c9
--- /dev/null
+++ b/osbase/test/cmd/BUILD.bazel
@@ -0,0 +1,11 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "cmd",
+    srcs = ["run.go"],
+    importpath = "source.monogon.dev/osbase/test/cmd",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//osbase/logbuffer",
+    ],
+)
diff --git a/osbase/test/cmd/run.go b/osbase/test/cmd/run.go
new file mode 100644
index 0000000..9649c03
--- /dev/null
+++ b/osbase/test/cmd/run.go
@@ -0,0 +1,111 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package cmd contains helpers that abstract away the chore of starting new
+// processes, tracking their lifetime, inspecting their output, etc.
+package cmd
+
+import (
+	"context"
+	"fmt"
+	"io"
+	"os"
+	"os/exec"
+	"strings"
+
+	"source.monogon.dev/osbase/logbuffer"
+)
+
+// RunCommand starts a new process and waits until either its completion, or
+// until the supplied predicate function pf returns true. The function is called
+// for each line produced by the new process.
+//
+// The returned boolean value equals the last value returned by pf.
+//
+// The process will be killed both in the event the context is cancelled, and
+// when expectedOutput is found.
+func RunCommand(ctx context.Context, path string, args []string, pf func(string) bool) (bool, error) {
+	// Make a sub-context to ensure the process exits when this function is done.
+	ctx, ctxC := context.WithCancel(ctx)
+	defer ctxC()
+
+	// Copy the stdout and stderr output to a single channel of lines so that they
+	// can then be matched against expectedOutput.
+
+	// Since LineBuffer can write its buffered contents on a deferred Close,
+	// after the reader loop is broken, avoid deadlocks by making lineC a
+	// buffered channel.
+	lineC := make(chan string, 2)
+	lineCB := func(l *logbuffer.Line) {
+		// If the context is canceled, no-one is listening on lineC anymore, so we would
+		// block.
+		select {
+		case <-ctx.Done():
+			return
+		case lineC <- l.Data:
+		}
+	}
+	outBuffer := logbuffer.NewLineBuffer(1024, lineCB)
+	defer outBuffer.Close()
+	errBuffer := logbuffer.NewLineBuffer(1024, lineCB)
+	defer errBuffer.Close()
+
+	// Prepare the command context, and start the process.
+	cmd := exec.CommandContext(ctx, path, args...)
+	// Tee std{out,err} into the linebuffers above and the process' std{out,err}, to
+	// allow easier debugging.
+	cmd.Stdout = io.MultiWriter(os.Stdout, outBuffer)
+	cmd.Stderr = io.MultiWriter(os.Stderr, errBuffer)
+	if err := cmd.Start(); err != nil {
+		return false, fmt.Errorf("couldn't start the process: %w", err)
+	}
+
+	// Handle the case in which the process finishes before pf takes the chance to
+	// kill it.
+	complC := make(chan error, 1)
+	go func() {
+		complC <- cmd.Wait()
+	}()
+
+	// Try matching against expectedOutput and return the result.
+	for {
+		select {
+		case <-ctx.Done():
+			return false, ctx.Err()
+		case line := <-lineC:
+			if pf(line) {
+				cmd.Process.Kill()
+				<-complC
+				return true, nil
+			}
+		case err := <-complC:
+			return false, err
+		}
+	}
+}
+
+// TerminateIfFound creates RunCommand predicates that instantly terminate
+// program execution in the event the given string is found in any line
+// produced. RunCommand will return true, if the string searched for was found,
+// and false otherwise. If logf isn't nil, it will be called whenever a new
+// line is received.
+func TerminateIfFound(needle string, logf func(string)) func(string) bool {
+	return func(haystack string) bool {
+		if logf != nil {
+			logf(haystack)
+		}
+		return strings.Contains(haystack, needle)
+	}
+}
+
+// WaitUntilCompletion creates a RunCommand predicate that will make it wait
+// for the process to exit on its own. If logf isn't nil, it will be called
+// whenever a new line is received.
+func WaitUntilCompletion(logf func(string)) func(string) bool {
+	return func(line string) bool {
+		if logf != nil {
+			logf(line)
+		}
+		return false
+	}
+}
diff --git a/osbase/test/freeport/BUILD.bazel b/osbase/test/freeport/BUILD.bazel
new file mode 100644
index 0000000..30d229f
--- /dev/null
+++ b/osbase/test/freeport/BUILD.bazel
@@ -0,0 +1,8 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library")
+
+go_library(
+    name = "freeport",
+    srcs = ["freeport.go"],
+    importpath = "source.monogon.dev/osbase/test/freeport",
+    visibility = ["//visibility:public"],
+)
diff --git a/osbase/test/freeport/freeport.go b/osbase/test/freeport/freeport.go
new file mode 100644
index 0000000..ec6a417
--- /dev/null
+++ b/osbase/test/freeport/freeport.go
@@ -0,0 +1,41 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package freeport
+
+import (
+	"io"
+	"net"
+)
+
+// AllocateTCPPort allocates a TCP port on the looopback address, and starts a
+// temporary listener on it. That listener is returned to the caller alongside with
+// the allocated port number. The listener must be closed right before the port is
+// used by the caller. This naturally still leaves a race condition window where
+// that port number might be snatched up by some other process, but there doesn't
+// seem to be a better way to do this.
+func AllocateTCPPort() (uint16, io.Closer, error) {
+	addr, err := net.ResolveTCPAddr("tcp", "127.0.0.1:0")
+	if err != nil {
+		return 0, nil, err
+	}
+
+	l, err := net.ListenTCP("tcp", addr)
+	if err != nil {
+		return 0, nil, err
+	}
+	return uint16(l.Addr().(*net.TCPAddr).Port), l, nil
+}
+
+// MustConsume takes the result of AllocateTCPPort, closes the listener and returns
+// the allocated port. If anything goes wrong (port could not be allocated or
+// closed) it will panic.
+func MustConsume(port uint16, lis io.Closer, err error) int {
+	if err != nil {
+		panic(err)
+	}
+	if err := lis.Close(); err != nil {
+		panic(err)
+	}
+	return int(port)
+}
diff --git a/osbase/test/qemu/BUILD.bazel b/osbase/test/qemu/BUILD.bazel
index 0145ddb..dc89881 100644
--- a/osbase/test/qemu/BUILD.bazel
+++ b/osbase/test/qemu/BUILD.bazel
@@ -15,7 +15,7 @@
         "xQEMUPath": "$(rlocationpath //build/toolchain/toolchain-bundle:qemu-kvm )",
     },
     deps = [
-        "//osbase/freeport",
+        "//osbase/test/freeport",
         "@io_bazel_rules_go//go/runfiles",
         "@org_golang_x_sys//unix",
     ],
diff --git a/osbase/test/qemu/launch.go b/osbase/test/qemu/launch.go
index ba33961..a427f17 100644
--- a/osbase/test/qemu/launch.go
+++ b/osbase/test/qemu/launch.go
@@ -21,7 +21,7 @@
 	"github.com/bazelbuild/rules_go/go/runfiles"
 	"golang.org/x/sys/unix"
 
-	"source.monogon.dev/osbase/freeport"
+	"source.monogon.dev/osbase/test/freeport"
 )
 
 var (
diff --git a/osbase/test/socksproxy/BUILD.bazel b/osbase/test/socksproxy/BUILD.bazel
new file mode 100644
index 0000000..4ba8a71
--- /dev/null
+++ b/osbase/test/socksproxy/BUILD.bazel
@@ -0,0 +1,18 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "socksproxy",
+    srcs = [
+        "protocol.go",
+        "socksproxy.go",
+    ],
+    importpath = "source.monogon.dev/osbase/test/socksproxy",
+    visibility = ["//visibility:public"],
+)
+
+go_test(
+    name = "socksproxy_test",
+    srcs = ["socksproxy_test.go"],
+    embed = [":socksproxy"],
+    deps = ["@org_golang_x_net//proxy"],
+)
diff --git a/osbase/test/socksproxy/protocol.go b/osbase/test/socksproxy/protocol.go
new file mode 100644
index 0000000..698d324
--- /dev/null
+++ b/osbase/test/socksproxy/protocol.go
@@ -0,0 +1,212 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package socksproxy
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+)
+
+// readMethods implements RFC1928 3. “Procedure for TCP-based clients”,
+// paragraph 3. It receives a 'version identifier/method selection message' from
+// r and returns the methods supported by the client.
+func readMethods(r io.Reader) ([]method, error) {
+	var ver uint8
+	if err := binary.Read(r, binary.BigEndian, &ver); err != nil {
+		return nil, fmt.Errorf("when reading ver: %w", err)
+	}
+	if ver != 5 {
+		return nil, fmt.Errorf("unimplemented version %d", ver)
+	}
+	var nmethods uint8
+	if err := binary.Read(r, binary.BigEndian, &nmethods); err != nil {
+		return nil, fmt.Errorf("when reading nmethods: %w", err)
+	}
+	methodBytes := make([]byte, nmethods)
+	if _, err := io.ReadFull(r, methodBytes); err != nil {
+		return nil, fmt.Errorf("while reading methods: %w", err)
+	}
+	methods := make([]method, nmethods)
+	for i, m := range methodBytes {
+		methods[i] = method(m)
+	}
+	return methods, nil
+}
+
+// writeMethod implements RFC1928 3. “Procedure for TCP-based clients”,
+// paragraph 5. It sends a selected method to w.
+func writeMethod(w io.Writer, m method) error {
+	if err := binary.Write(w, binary.BigEndian, uint8(5)); err != nil {
+		return fmt.Errorf("while writing version: %w", err)
+	}
+	if err := binary.Write(w, binary.BigEndian, uint8(m)); err != nil {
+		return fmt.Errorf("while writing method: %w", err)
+	}
+	return nil
+}
+
+// method is an RFC1928 authentication method.
+type method uint8
+
+const (
+	methodNoAuthenticationRequired method = 0
+	methodNoAcceptableMethods      method = 0xff
+)
+
+// negotiateMethod implements the entire flow RFC1928 3. “Procedure for
+// TCP-based clients” by negotiating for the 'NO AUTHENTICATION REQUIRED'
+// authentication method, and failing otherwise.
+func negotiateMethod(rw io.ReadWriter) error {
+	methods, err := readMethods(rw)
+	if err != nil {
+		return fmt.Errorf("could not read methods: %w", err)
+	}
+
+	found := false
+	for _, m := range methods {
+		if m == methodNoAuthenticationRequired {
+			found = true
+			break
+		}
+	}
+	if !found {
+		// Discard error, as this connection is failed anyway.
+		writeMethod(rw, methodNoAcceptableMethods)
+		return fmt.Errorf("no acceptable methods found")
+	}
+	if err := writeMethod(rw, methodNoAuthenticationRequired); err != nil {
+		return fmt.Errorf("could not respond with method: %w", err)
+	}
+	return nil
+}
+
+var (
+	// errNotConnect is returned by readRequest when the request contained some
+	// other request than CONNECT.
+	errNotConnect = errors.New("not CONNECT")
+	// errUnsupportedAddressType is returned by readRequest when the request
+	// contained some unsupported address type (not IPv4 or IPv6).
+	errUnsupportedAddressType = errors.New("unsupported address type")
+)
+
+// readRequest implements RFC1928 4. “Requests” by reading a SOCKS request from
+// r and ensuring it's an IPv4/IPv6 CONNECT request. The parsed address/port
+// pair is then returned.
+func readRequest(r io.Reader) (*connectRequest, error) {
+	header := struct {
+		Ver  uint8
+		Cmd  uint8
+		Rsv  uint8
+		Atyp uint8
+	}{}
+	if err := binary.Read(r, binary.BigEndian, &header); err != nil {
+		return nil, fmt.Errorf("when reading request header: %w", err)
+	}
+
+	if header.Ver != 5 {
+		return nil, fmt.Errorf("invalid version %d", header.Ver)
+	}
+	if header.Cmd != 1 {
+		return nil, errNotConnect
+	}
+
+	var addrBytes []byte
+	var hostnameBytes []byte
+	switch header.Atyp {
+	case 1:
+		addrBytes = make([]byte, 4)
+	case 3:
+		// Variable-length string to resolve
+		addrBytes = make([]byte, 1)
+	case 4:
+		addrBytes = make([]byte, 16)
+	default:
+		return nil, errUnsupportedAddressType
+	}
+	if _, err := io.ReadFull(r, addrBytes); err != nil {
+		return nil, fmt.Errorf("when reading address: %w", err)
+	}
+
+	// Handle domain name addressing, required by for example Chrome
+	if header.Atyp == 3 {
+		hostnameBytes = make([]byte, addrBytes[0])
+		if _, err := io.ReadFull(r, hostnameBytes); err != nil {
+			return nil, fmt.Errorf("when reading address: %w", err)
+		}
+	}
+
+	var port uint16
+	if err := binary.Read(r, binary.BigEndian, &port); err != nil {
+		return nil, fmt.Errorf("when reading port: %w", err)
+	}
+
+	return &connectRequest{
+		address:  addrBytes,
+		hostname: string(hostnameBytes),
+		port:     port,
+	}, nil
+}
+
+type connectRequest struct {
+	address  net.IP
+	hostname string
+	port     uint16
+}
+
+// Reply is an RFC1928 6. “Replies” reply field value. It's returned to the
+// client by internal socksproxy code or a Handler to signal a success or error
+// condition within an RFC1928 reply.
+type Reply uint8
+
+const (
+	ReplySucceeded               Reply = 0
+	ReplyGeneralFailure          Reply = 1
+	ReplyConnectionNotAllowed    Reply = 2
+	ReplyNetworkUnreachable      Reply = 3
+	ReplyHostUnreachable         Reply = 4
+	ReplyConnectionRefused       Reply = 5
+	ReplyTTLExpired              Reply = 6
+	ReplyCommandNotSupported     Reply = 7
+	ReplyAddressTypeNotSupported Reply = 8
+)
+
+// writeReply implements RFC1928 6. “Replies” by sending a given Reply, bind
+// address and bind port to w. An error is returned if the given bind address is
+// invaild, or if a communication error occurred.
+func writeReply(w io.Writer, r Reply, bindAddr net.IP, bindPort uint16) error {
+	var atyp uint8
+	switch len(bindAddr) {
+	case 4:
+		atyp = 1
+	case 16:
+		atyp = 4
+	default:
+		return fmt.Errorf("unsupported bind address type")
+	}
+
+	header := struct {
+		Ver   uint8
+		Reply uint8
+		Rsv   uint8
+		Atyp  uint8
+	}{
+		Ver:   5,
+		Reply: uint8(r),
+		Rsv:   0,
+		Atyp:  atyp,
+	}
+	if err := binary.Write(w, binary.BigEndian, &header); err != nil {
+		return fmt.Errorf("when writing reply header: %w", err)
+	}
+	if _, err := w.Write(bindAddr); err != nil {
+		return fmt.Errorf("when writing reply bind address: %w", err)
+	}
+	if err := binary.Write(w, binary.BigEndian, bindPort); err != nil {
+		return fmt.Errorf("when writing reply bind port: %w", err)
+	}
+	return nil
+}
diff --git a/osbase/test/socksproxy/socksproxy.go b/osbase/test/socksproxy/socksproxy.go
new file mode 100644
index 0000000..6143f90
--- /dev/null
+++ b/osbase/test/socksproxy/socksproxy.go
@@ -0,0 +1,237 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+// 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"
+	"errors"
+	"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
+	// Hostname is a string that the client requested to connect to. Only set if
+	// Address is empty. Format and resolution rules are up to the implementer,
+	// a lot of clients only allow valid DNS labels.
+	Hostname string
+	// 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 ReadWriteCloser that will be bridged over to the connecting
+	// client if no Error is set.
+	Backend io.ReadWriteCloser
+	// 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)
+	var host string
+	if req.Hostname != "" {
+		host = req.Hostname
+	} else {
+		host = req.Address.String()
+	}
+	addr := net.JoinHostPort(host, 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 {
+	case errors.Is(err, errNotConnect):
+		writeReply(con, ReplyCommandNotSupported, net.IPv4(0, 0, 0, 0), 0)
+		return
+	case errors.Is(err, errUnsupportedAddressType):
+		writeReply(con, ReplyAddressTypeNotSupported, net.IPv4(0, 0, 0, 0), 0)
+		return
+	case err == 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,
+		Hostname: req.hostname,
+		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 func() {
+		io.Copy(conRes.Backend, con)
+		conRes.Backend.Close()
+	}()
+	io.Copy(con, conRes.Backend)
+	conRes.Backend.Close()
+}
diff --git a/osbase/test/socksproxy/socksproxy_test.go b/osbase/test/socksproxy/socksproxy_test.go
new file mode 100644
index 0000000..2c49ebd
--- /dev/null
+++ b/osbase/test/socksproxy/socksproxy_test.go
@@ -0,0 +1,177 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package socksproxy
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"io"
+	"net"
+	"net/http"
+	"os"
+	"sync/atomic"
+	"testing"
+
+	"golang.org/x/net/proxy"
+)
+
+// TestE2E implements a happy path test by chaining together an HTTP server, a
+// proxy server, a proxy client (from golang.org/x/net) and an HTTP client into
+// an end-to-end test. It uses HostHandler and the actual host network stack for
+// the test HTTP server and test proxy server.
+func TestE2E(t *testing.T) {
+	ctx, ctxC := context.WithCancel(context.Background())
+	defer ctxC()
+
+	// Start test HTTP server.
+	lisSrv, err := net.Listen("tcp", "127.0.0.1:0")
+	if err != nil {
+		t.Fatalf("could not bind http listener: %v", err)
+	}
+
+	mux := http.NewServeMux()
+	mux.HandleFunc("/", func(rw http.ResponseWriter, req *http.Request) {
+		fmt.Fprintf(rw, "foo")
+	})
+	go func() {
+		err := http.Serve(lisSrv, mux)
+		if err != nil {
+			t.Errorf("http.Serve: %v", err)
+			return
+		}
+	}()
+
+	// Start proxy server.
+	lisPrx, err := net.Listen("tcp", ":")
+	if err != nil {
+		t.Fatalf("could not bind proxy listener: %v", err)
+	}
+	go func() {
+		err := Serve(ctx, HostHandler, lisPrx)
+		if err != nil && !errors.Is(err, ctx.Err()) {
+			t.Errorf("proxy.Serve: %v", err)
+			return
+		}
+	}()
+
+	// Start proxy client.
+	dialer, err := proxy.SOCKS5("tcp", lisPrx.Addr().String(), nil, proxy.Direct)
+	if err != nil {
+		t.Fatalf("creating SOCKS dialer failed: %v", err)
+	}
+
+	// Create http client.
+	tr := &http.Transport{
+		Dial: dialer.Dial,
+	}
+	cl := &http.Client{
+		Transport: tr,
+	}
+
+	// Perform request and expect 'foo' in response.
+	url := fmt.Sprintf("http://%s/", lisSrv.Addr().String())
+	req, err := http.NewRequest("GET", url, nil)
+	if err != nil {
+		t.Fatalf("creating test request failed: %v", err)
+	}
+	res, err := cl.Do(req)
+	if err != nil {
+		t.Fatalf("test http request failed: %v", err)
+	}
+	defer res.Body.Close()
+	body, _ := io.ReadAll(res.Body)
+	if want, got := "foo", string(body); want != got {
+		t.Errorf("wrong response from HTTP, wanted %q, got %q", want, got)
+	}
+}
+
+// testHandler is a handler which serves /dev/zero and keeps count of the
+// current number of live connections. It's used in TestCancellation to ensure
+// contexts are canceled appropriately.
+type testHandler struct {
+	live int64
+}
+
+func (t *testHandler) Connect(ctx context.Context, req *ConnectRequest) *ConnectResponse {
+	f, _ := os.Open("/dev/zero")
+
+	atomic.AddInt64(&t.live, 1)
+	go func() {
+		<-ctx.Done()
+		atomic.AddInt64(&t.live, -1)
+		f.Close()
+	}()
+
+	return &ConnectResponse{
+		Backend:      f,
+		LocalAddress: net.ParseIP("127.0.0.1"),
+		LocalPort:    42123,
+	}
+}
+
+// TestCancellation ensures request contexts are canceled correctly - when an
+// incoming connection is closed and when the entire server is stopped.
+func TestCancellation(t *testing.T) {
+	handler := &testHandler{}
+
+	ctx, ctxC := context.WithCancel(context.Background())
+	defer ctxC()
+
+	// Start proxy server.
+	lisPrx, err := net.Listen("tcp", ":")
+	if err != nil {
+		t.Fatalf("could not bind proxy listener: %v", err)
+	}
+	go func() {
+		err := Serve(ctx, handler, lisPrx)
+		if err != nil && !errors.Is(err, ctx.Err()) {
+			t.Errorf("proxy.Serve: %v", err)
+			return
+		}
+	}()
+
+	// Start proxy client.
+	dialer, err := proxy.SOCKS5("tcp", lisPrx.Addr().String(), nil, proxy.Direct)
+	if err != nil {
+		t.Fatalf("creating SOCKS dialer failed: %v", err)
+	}
+
+	// Open two connections.
+	con1, err := dialer.Dial("tcp", "192.2.0.10:1234")
+	if err != nil {
+		t.Fatalf("Dialing first client failed: %v", err)
+	}
+	con2, err := dialer.Dial("tcp", "192.2.0.10:1234")
+	if err != nil {
+		t.Fatalf("Dialing first client failed: %v", err)
+	}
+
+	// Read some data. This makes sure we're ready to check for the liveness of
+	// currently running connections.
+	io.ReadFull(con1, make([]byte, 3))
+	io.ReadFull(con2, make([]byte, 3))
+
+	// Ensure we have two connections.
+	if want, got := int64(2), atomic.LoadInt64(&handler.live); want != got {
+		t.Errorf("wanted %d connections at first, got %d", want, got)
+	}
+
+	// Close one connection. Wait for its context to be canceled.
+	con2.Close()
+	for {
+		if atomic.LoadInt64(&handler.live) == 1 {
+			break
+		}
+	}
+
+	// Cancel the entire server context. Wait for the other connection's context to
+	// be canceled as well.
+	ctxC()
+	for {
+		if atomic.LoadInt64(&handler.live) == 0 {
+			break
+		}
+	}
+}