Download
384 lines 11.1 KiBGoModified 2026-10-31 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384
// Package proxy is the allowlisting HTTP proxy that is sandboxed commands'
// only way onto the network. It runs outside the sandbox and supports
// exactly two things: CONNECT tunnels (for TLS, which it does not
// intercept) and plain HTTP requests with absolute URIs. A request goes
// through only if its port is allowed and its host is approved.
package proxy

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"net/netip"
	"slices"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Marker starts every refusal the proxy sends, so the model and the user
// can tell a refusal from the server's own errors.
const Marker = "blinker proxy:"

// Server is one proxy.
type Server struct {
	// Check approves a host, or explains its refusal to the client, after
	// Marker. It may block, for example while asking the user; the
	// client's connection is held open meanwhile. It must return false on
	// any doubt.
	Check func(ctx context.Context, host string) (ok bool, why string)
	// Ports are the ports the proxy will connect to.
	Ports []int
	// AllowPrivate lets the proxy connect to loopback, private and
	// link-local addresses. Off, it refuses them, since the proxy runs
	// outside the sandbox and could otherwise reach local services and
	// cloud metadata endpoints.
	AllowPrivate bool
	// Log, if non-nil, receives a line per request.
	Log func(string)

	mu    sync.Mutex
	conns map[net.Conn]bool
	srv   *http.Server
}

func (s *Server) logf(format string, args ...any) {
	if s.Log != nil {
		s.Log(fmt.Sprintf(format, args...))
	}
}

// serverLog passes net/http's own errors to Log.
type serverLog struct{ s *Server }

func (l serverLog) Write(p []byte) (int, error) {
	l.s.logf("ERROR server: %s", strings.TrimRight(string(p), "\n"))
	return len(p), nil
}

// Serve answers connections on l until Close.
func (s *Server) Serve(l net.Listener) error {
	s.mu.Lock()
	s.conns = map[net.Conn]bool{}
	s.srv = &http.Server{
		Handler:           s,
		ReadHeaderTimeout: 30 * time.Second,
		// Left nil, net/http would log to stderr, over the TUI or the box.
		ErrorLog: log.New(serverLog{s}, "", 0),
	}
	srv := s.srv
	s.mu.Unlock()
	err := srv.Serve(l)
	if errors.Is(err, http.ErrServerClosed) {
		return nil
	}
	return err
}

// Close stops the proxy and drops its tunnels.
func (s *Server) Close() {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.srv != nil {
		if err := s.srv.Close(); err != nil {
			s.logf("closing: %v", err)
		}
	}
	// The tunnels are being dropped, so an error closing one changes
	// nothing.
	for c := range s.conns {
		c.Close()
	}
	s.conns = nil
}

func (s *Server) track(c net.Conn, add bool) {
	s.mu.Lock()
	defer s.mu.Unlock()
	if s.conns == nil {
		// Closed (or not yet serving): no tunnel outlives the proxy.
		if add {
			c.Close()
		}
		return
	}
	if add {
		s.conns[c] = true
	} else {
		delete(s.conns, c)
	}
}

// normalHost is a host as rules and the user see it: an IP address, or a
// name of letters, digits, '-' and '_' in dot-separated labels, in lower
// case without a final dot. Anything else is refused, so what Check
// judges, the user is asked about and a rule saves is one plain name.
func normalHost(host string) (string, bool) {
	if ip, err := netip.ParseAddr(host); err == nil {
		return ip.String(), true
	}
	// A-Z only: Unicode's rules would make some other letters ASCII (the
	// Kelvin sign k), and the name approved would not be the one asked for.
	h := strings.TrimSuffix(strings.Map(func(r rune) rune {
		if r >= 'A' && r <= 'Z' {
			return r + 'a' - 'A'
		}
		return r
	}, host), ".")
	if h == "" || len(h) > 253 {
		return "", false
	}
	for _, label := range strings.Split(h, ".") {
		if label == "" || len(label) > 63 {
			return "", false
		}
		for _, c := range label {
			if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '_') {
				return "", false
			}
		}
	}
	return h, true
}

// vet decides a target: the address to dial, or the status and reason for
// refusing it. host must be normalHost's.
func (s *Server) vet(ctx context.Context, host string, port int) (addrs []string, status int, why string) {
	if !slices.Contains(s.Ports, port) {
		var ports []string
		for _, p := range s.Ports {
			ports = append(ports, strconv.Itoa(p))
		}
		return nil, http.StatusForbidden, fmt.Sprintf("port %d on %q is not permitted (allowed ports: %s).",
			port, host, strings.Join(ports, ", "))
	}
	if ok, why := s.Check(ctx, host); !ok {
		if why == "" {
			why = fmt.Sprintf("domain %q is not approved.", host)
		}
		return nil, http.StatusForbidden, why
	}
	// Dial the addresses checked, so a DNS answer that changes in between
	// cannot slip past the private-address check.
	ips, err := resolve(ctx, host)
	if err != nil {
		return nil, http.StatusBadGateway, fmt.Sprintf("cannot resolve %q: %v.", host, err)
	}
	for _, ip := range ips {
		if !s.AllowPrivate && IsPrivate(ip) {
			return nil, http.StatusForbidden, fmt.Sprintf("%q resolves to a private or loopback address (%s), which the proxy refuses.", host, ip)
		}
		addrs = append(addrs, net.JoinHostPort(ip.String(), strconv.Itoa(port)))
	}
	return addrs, 0, ""
}

func resolve(ctx context.Context, host string) ([]netip.Addr, error) {
	if ip, err := netip.ParseAddr(host); err == nil {
		return []netip.Addr{ip.Unmap()}, nil
	}
	ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
	if err != nil {
		return nil, err
	}
	if len(ips) == 0 {
		return nil, errors.New("no addresses")
	}
	for i := range ips {
		ips[i] = ips[i].Unmap()
	}
	return ips, nil
}

// dial connects to the first of addrs that answers.
func dial(ctx context.Context, addrs []string) (net.Conn, error) {
	var err error
	for _, a := range addrs {
		var c net.Conn
		c, err = (&net.Dialer{Timeout: 30 * time.Second}).DialContext(ctx, "tcp", a)
		if err == nil {
			return c, nil
		}
	}
	return nil, err
}

// IsPrivate reports whether ip is loopback, private, link-local (cloud
// metadata included), carrier-grade NAT, unspecified or otherwise not a
// public unicast address.
func IsPrivate(ip netip.Addr) bool {
	ip = ip.Unmap()
	if !ip.IsValid() || ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() ||
		ip.IsLinkLocalMulticast() || ip.IsUnspecified() || ip.IsMulticast() || ip.IsInterfaceLocalMulticast() {
		return true
	}
	if ip.Is4() {
		b := ip.As4()
		return b[0] == 0 || (b[0] == 100 && b[1] >= 64 && b[1] <= 127) || // 0/8, CGNAT 100.64/10
			b[0] == 192 && b[1] == 0 && b[2] == 0 || // IETF protocol assignments 192.0.0/24
			b[0] == 198 && (b[1] == 18 || b[1] == 19) || // benchmarking 198.18/15
			b[0] >= 240 // reserved 240/4, broadcast included
	}
	// Addresses that carry an IPv4 one, which a gateway on the way may
	// reach for them: NAT64 (64:ff9b::/96, 64:ff9b:1::/48), 6to4
	// (2002::/16), and the old IPv4-compatible ::a.b.c.d.
	b := ip.As16()
	switch {
	case b[0] == 0x00 && b[1] == 0x64 && b[2] == 0xff && b[3] == 0x9b:
		return IsPrivate(netip.AddrFrom4([4]byte(b[12:16])))
	case b[0] == 0x20 && b[1] == 0x02:
		return IsPrivate(netip.AddrFrom4([4]byte(b[2:6])))
	case [12]byte(b[:12]) == [12]byte{}:
		return IsPrivate(netip.AddrFrom4([4]byte(b[12:16])))
	case b[0] == 0xfe && b[1]&0xc0 == 0xc0: // site-local fec0::/10, deprecated
		return true
	}
	return false
}

func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	if r.Method == http.MethodConnect {
		s.connect(w, r)
		return
	}
	refuse := func(status int, why string) {
		http.Error(w, Marker+" "+why, status)
		s.logf("DENY http %s %s: %s", r.Method, r.URL, why)
	}
	if !r.URL.IsAbs() || r.URL.Host == "" {
		refuse(http.StatusBadRequest, "only absolute-URI HTTP requests and CONNECT are supported.")
		return
	}
	if r.URL.Scheme != "http" {
		refuse(http.StatusBadRequest, "https must use CONNECT.")
		return
	}
	host, ok := normalHost(r.URL.Hostname())
	if !ok {
		refuse(http.StatusBadRequest, "malformed host.")
		return
	}
	port := 80
	if p := r.URL.Port(); p != "" {
		var err error
		if port, err = strconv.Atoi(p); err != nil {
			refuse(http.StatusBadRequest, "malformed port.")
			return
		}
	}
	addrs, status, why := s.vet(r.Context(), host, port)
	if why != "" {
		refuse(status, why)
		return
	}
	out := r.Clone(r.Context())
	out.RequestURI = ""
	out.Header.Del("Proxy-Connection")
	out.Header.Del("Proxy-Authorization")
	transport := &http.Transport{
		Proxy: nil,
		DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
			return dial(ctx, addrs)
		},
		DisableKeepAlives: true,
	}
	resp, err := transport.RoundTrip(out)
	if err != nil {
		refuse(http.StatusBadGateway, fmt.Sprintf("upstream error: %v", err))
		return
	}
	defer resp.Body.Close()
	for k, vs := range resp.Header {
		for _, v := range vs {
			w.Header().Add(k, v)
		}
	}
	w.WriteHeader(resp.StatusCode)
	// An error midway can only cut the response short, as the client will
	// see; there is nothing else to tell it.
	if _, err := io.Copy(w, resp.Body); err != nil {
		s.logf("ERROR http %s %s: %v", r.Method, r.URL, err)
	}
	s.logf("ALLOW http %s %s status=%d", r.Method, r.URL, resp.StatusCode)
}

func (s *Server) connect(w http.ResponseWriter, r *http.Request) {
	target := r.Host
	host, portStr, err := net.SplitHostPort(target)
	port, perr := strconv.Atoi(portStr)
	host, ok := normalHost(host)
	if err != nil || perr != nil || !ok {
		http.Error(w, Marker+" malformed CONNECT target.", http.StatusBadRequest)
		s.logf("DENY connect %s: malformed", target)
		return
	}
	addrs, status, why := s.vet(r.Context(), host, port)
	if why != "" {
		http.Error(w, Marker+" "+why, status)
		s.logf("DENY connect %s: %s", target, why)
		return
	}
	upstream, err := dial(r.Context(), addrs)
	if err != nil {
		http.Error(w, fmt.Sprintf("%s cannot reach %s: %v", Marker, target, err), http.StatusBadGateway)
		s.logf("ERROR connect %s: %v", target, err)
		return
	}
	hj, ok := w.(http.Hijacker)
	if !ok {
		upstream.Close()
		http.Error(w, Marker+" cannot tunnel.", http.StatusInternalServerError)
		return
	}
	client, buf, err := hj.Hijack()
	if err != nil {
		upstream.Close()
		return
	}
	s.track(client, true)
	s.track(upstream, true)
	defer func() {
		s.track(client, false)
		s.track(upstream, false)
		client.Close()
		upstream.Close()
	}()
	if _, err := client.Write([]byte("HTTP/1.1 200 Connection Established\r\n\r\n")); err != nil {
		return
	}
	// buf holds anything the client sent after its request.
	up, down, upErr, downErr := Splice(client, buf.Reader, upstream)
	// Either side failing just ends the tunnel, as closing it would.
	s.logf("ALLOW connect %s up=%d (%v) down=%d (%v)", target, up, upErr, down, downErr)
}

// Splice copies what a sends (read from aIn, which may hold some of it
// already) to b, and what b sends to a, until both are done, telling each
// side when the other has no more to send.
func Splice(a net.Conn, aIn io.Reader, b net.Conn) (up, down int64, upErr, downErr error) {
	done := make(chan struct{})
	go func() {
		up, upErr = io.Copy(b, aIn)
		closeWrite(b)
		close(done)
	}()
	down, downErr = io.Copy(a, b)
	closeWrite(a)
	<-done
	return up, down, upErr, downErr
}

// closeWrite tells the other end that no more data is coming. If that
// fails the connection is broken anyway, and the deferred Close ends it.
func closeWrite(c net.Conn) {
	if cw, ok := c.(interface{ CloseWrite() error }); ok {
		cw.CloseWrite()
	} else {
		c.Close()
	}
}