repos
/ orchard main

orchard

mirror

Every site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.

blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics

3.8 KB · 106 lines · Go Raw History
  1package tools
  2
  3// The fence between the open web and this machine.
  4//
  5// Every public tool here takes a url or a query from a model, and a model will
  6// happily be talked into fetching whatever it is handed. This site now sits on
  7// orchard-edge, so an unguarded fetch of http://orchard-auth:8000 or
  8// http://127.0.0.1 reaches the estate from inside, past Caddy and past the
  9// tunnel, which is the one place nothing is expecting an untrusted caller.
 10//
 11// The check is on the address actually dialled rather than on the string, so a
 12// redirect to an internal host and a name that resolves to one are both caught.
 13// The orchard_ tools deliberately do not go through this: they are the sanctioned
 14// way in, they name their host, and they carry the caller's own session.
 15
 16import (
 17	"fmt"
 18	"net"
 19	"net/http"
 20	"net/url"
 21	"strings"
 22	"syscall"
 23	"time"
 24)
 25
 26// publicClient refuses to connect to anything that is not a public address.
 27//
 28// Control runs after the name is resolved and before the socket is connected,
 29// which is the only point where the decision can be made on the address that
 30// will really be used. Checking the hostname instead leaves DNS rebinding open.
 31func publicClient(timeout time.Duration) *http.Client {
 32	d := &net.Dialer{
 33		Timeout:   10 * time.Second,
 34		KeepAlive: 30 * time.Second,
 35		Control: func(network, address string, _ syscall.RawConn) error {
 36			host, _, err := net.SplitHostPort(address)
 37			if err != nil {
 38				return fmt.Errorf("refusing an address that cannot be parsed")
 39			}
 40			ip := net.ParseIP(host)
 41			if ip == nil {
 42				return fmt.Errorf("refusing an address that is not an ip")
 43			}
 44			if !isPublicIP(ip) {
 45				return fmt.Errorf("refusing to fetch %s, which is on this machine or its network", ip)
 46			}
 47			return nil
 48		},
 49	}
 50	return &http.Client{
 51		Timeout:   timeout,
 52		Transport: &http.Transport{DialContext: d.DialContext, ForceAttemptHTTP2: true},
 53	}
 54}
 55
 56func isPublicIP(ip net.IP) bool {
 57	if ip.IsLoopback() || ip.IsPrivate() || ip.IsUnspecified() ||
 58		ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast() ||
 59		ip.IsInterfaceLocalMulticast() || ip.IsMulticast() {
 60		return false
 61	}
 62	// 100.64.0.0/10, which is not covered by IsPrivate and is where a carrier
 63	// grade NAT and most of Tailscale live.
 64	if v4 := ip.To4(); v4 != nil && v4[0] == 100 && v4[1] >= 64 && v4[1] <= 127 {
 65		return false
 66	}
 67	// The cloud metadata address, which is public by every other measure and is
 68	// the first thing anything like this gets pointed at.
 69	if ip.Equal(net.IPv4(169, 254, 169, 254)) {
 70		return false
 71	}
 72	return true
 73}
 74
 75// publicURL is the check on the string, done before the dial so an obviously
 76// wrong scheme is refused with something a model can act on rather than with a
 77// connection error.
 78func publicURL(raw string) (string, error) {
 79	raw = strings.TrimSpace(raw)
 80	u, err := url.Parse(raw)
 81	if err != nil {
 82		return "", fmt.Errorf("that is not a url")
 83	}
 84	switch strings.ToLower(u.Scheme) {
 85	case "http", "https":
 86	case "":
 87		return "", fmt.Errorf("the url needs to start with https://")
 88	case "file":
 89		// The one a model reaches for when it is thinking about an attachment,
 90		// so the message says where the file actually is.
 91		return "", fmt.Errorf("there is no filesystem to read here, and an attached file is already in this conversation, so read it there rather than fetching it")
 92	default:
 93		return "", fmt.Errorf("only http and https can be fetched, not %s", u.Scheme)
 94	}
 95	if u.Host == "" {
 96		return "", fmt.Errorf("the url has no host")
 97	}
 98	// A bare name with no dot is a container on the bridge, and the estate is
 99	// reached with the orchard tools rather than by fetching it.
100	host := u.Hostname()
101	if !strings.Contains(host, ".") && net.ParseIP(host) == nil {
102		return "", fmt.Errorf("%q is not a public address, and this machine's own services are read with the orchard tools", host)
103	}
104	return u.String(), nil
105}