bastion 0.56.0
ci / personal-data (push) Successful in 10s
ci / test (push) Successful in 5m30s
ci / fuzz (push) Successful in 3m16s

This commit is contained in:
Privatae
2026-10-28 02:24:07 +00:00
parent 8822d5df85
commit 4012da0b75
53 changed files with 6430 additions and 270 deletions
+79 -46
View File
@@ -6,6 +6,7 @@ import (
"errors"
"io"
"net"
"net/http"
"strings"
"time"
)
@@ -24,94 +25,110 @@ type egressDialer func(ctx context.Context, network, addr string) (net.Conn, err
// egress is refused (403, before any dial) unless its pairing grant carries
// the "egress" cap: this lane makes requests leave THIS machine's connection
// under its address, and nothing in the mgmt op table gates it.
func handleEgressConn(client net.Conn, dialOut egressDialer, self string) {
//
// It reports whether it took the job: true once the request passed every check
// and was dialled out, whatever the destination then answered; false when the
// worker closed before a request, or the request was malformed or refused. The
// worker pool (runEgressWorkers) parks again at once after a job it took, and
// waits after one it did not.
func handleEgressConn(client net.Conn, dialOut egressDialer, self string) bool {
defer client.Close()
br := bufio.NewReader(client)
// The request line and headers are read under a bound, the one Go's own HTTP
// server uses (http.DefaultMaxHeaderBytes, 1 MiB): the host sends them, and a
// head that never ends must not grow in this machine's memory.
headLim := &io.LimitedReader{R: client, N: egressMaxHeadBytes}
br := bufio.NewReader(headLim)
line, err := br.ReadString('\n')
if err != nil {
return
return false
}
parts := strings.Fields(strings.TrimSpace(line))
if len(parts) < 2 {
return
return false
}
method, target := parts[0], parts[1]
// Buffer the head so a plain-HTTP request can be replayed verbatim; capture Host.
// Buffer the head: a plain-HTTP request is parsed from it and rebuilt
// (parseEgressRequest), never replayed verbatim.
var head strings.Builder
head.WriteString(line)
hostHdr := ""
for {
h, err := br.ReadString('\n')
if err != nil {
return
return false
}
head.WriteString(h)
if h == "\r\n" || h == "\n" {
break
}
if len(h) >= 5 && strings.EqualFold(h[:5], "host:") {
hostHdr = strings.TrimSpace(h[5:])
}
}
// The head is in; what follows is the stream itself, unbounded. What the
// reader already buffered past the head stays first in line.
headLim.N = 1<<63 - 1
if why := capsAllow(self, "egress"); why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
writeEgressRefusal(client, "403 Forbidden", why, why)
return false
}
ctx, cancel := context.WithTimeout(context.Background(), egressDialTimeout)
defer cancel()
if strings.EqualFold(method, "CONNECT") {
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(target, "443"))
// The target is read the way the Host's egress gate reads it
// (parseEgressAuthority): userinfo, an unbracketed IPv6 literal or a
// malformed port is refused, never guessed at.
host, port, ok := parseEgressAuthority(target, 443)
if !ok {
writeEgressRefusal(client, "400 Bad Request", egressNoHost, egressNoHost)
return false
}
dialAddrs, why := egressVettedAddrs(ctx, egressHostPort(host, port))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
writeEgressRefusal(client, "403 Forbidden", why, why)
return false
}
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
writeEgressRefusal(client, "502 Bad Gateway", egressUnreachable, "")
return true
}
if _, err := client.Write([]byte("HTTP/1.1 200 Connection established\r\n\r\n")); err != nil {
_ = upstream.Close()
return
return true
}
splice(br, client, upstream)
return
return true
}
// Plain HTTP, absolute-form: "GET http://host/path HTTP/1.1".
host := hostHdr
if i := strings.Index(target, "://"); i >= 0 {
rest := target[i+3:]
if s := strings.IndexByte(rest, '/'); s >= 0 {
host = rest[:s]
} else {
host = rest
}
// Plain HTTP: ONE request per connection, parsed and rebuilt so the
// destination reads the host, port and body the lane vetted, sent with
// `Connection: close` (egress_http.go). Anything the Host sends after that
// request's body closes the connection unforwarded: a keep-alive connection
// can no longer carry later requests past the vetting.
req, refused := parseEgressRequest(head.String())
if refused != nil {
writeEgressRefusal(client, refused.status, refused.body, refused.body)
return false
}
if host == "" {
return
}
dialAddrs, why := egressVettedAddrs(ctx, ensurePort(host, "80"))
dialAddrs, why := egressVettedAddrs(ctx, egressHostPort(req.host, req.port))
if why != "" {
_, _ = client.Write([]byte("HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nX-Privatae-Refused: " + why + "\r\n\r\n"))
return
writeEgressRefusal(client, "403 Forbidden", why, why)
return false
}
upstream, err := dialEach(ctx, dialOut, dialAddrs)
if err != nil {
_, _ = client.Write([]byte("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\n\r\n"))
return
writeEgressRefusal(client, "502 Bad Gateway", egressUnreachable, "")
return true
}
if _, err := upstream.Write([]byte(head.String())); err != nil {
_ = upstream.Close()
return
}
splice(br, client, upstream)
oneEgressExchange(br, client, upstream, req)
return true
}
// egressMaxHeadBytes bounds an egress request's line and headers together.
const egressMaxHeadBytes = http.DefaultMaxHeaderBytes
// ensurePort returns host:port, adding defPort when target has no explicit port.
func ensurePort(target, defPort string) string {
target = strings.TrimSpace(target)
@@ -157,11 +174,14 @@ func splice(clientReader io.Reader, client, upstream net.Conn) {
// included), and any address the machine takes as its own (ownaddr.go), such
// as a public one on its NIC or a range a `local` route delivers here.
//
// What differs is the network around it. On an ANCHORED box the lane reaches
// the public internet and nothing else: private, CGNAT and reserved addresses
// (the LAN, the mesh) are refused too. On a LOCAL box the LAN stays reachable:
// the host there is the one the person paired, and their own network is
// theirs to reach.
// The network around it is refused too, on every box: the lane reaches the
// public internet and nothing else, so private, CGNAT and reserved addresses
// (the LAN, the mesh) are refused, by address and by any address a name
// resolves to. A LOCAL box used to keep its LAN reachable here; it no longer
// does (2026-10-26): the Host's egress gate holds every dial it makes to the
// same rule, and a person's own machines
// are reached through their approved lanes, never through egress. Only the
// wording differs between the two kinds of box.
//
// Resolution failures refuse too: a name this box cannot resolve is not one
// it can vouch for.
@@ -245,6 +265,19 @@ func egressVettedAddrs(ctx context.Context, hostport string) ([]string, string)
}
return nil, "refused: egress to " + host + " — that is this machine; this lane never reaches this machine"
}
// The network around this machine, on a local box as on an anchored one
// (above): any one address of the name that is not the public internet
// refuses it. Checked after this machine's own addresses, so those keep
// saying "that is this machine".
if !anchored {
for _, ip := range addrs {
if internalIP(ip) {
return nil, "refused: egress to " + host + " (" + ip.String() + ") — this lane reaches the " +
"public internet only, never this machine's network or the mesh; reach your own " +
"machines through their approved lanes"
}
}
}
out := make([]string, 0, len(addrs))
for _, ip := range addrs {
out = append(out, net.JoinHostPort(ip.String(), port))