bastion 0.56.0
This commit is contained in:
+79
-46
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user