route-proxy: serve an internal name to the private network only
The proxy answers public and internal names on the same listeners, so serving an internal-only route made it reachable from the internet by anyone sending its name. Requests and handshakes for an internal name from outside the mesh range, loopback or a container bridge are now answered as an unrouted name, and the 404 no longer lists them (novox/hq issue 191, ADR 0138 insight of 2026-10-02).
This commit is contained in:
+171
-16
@@ -54,6 +54,7 @@ import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -196,6 +197,96 @@ type table struct {
|
||||
// pass ACME's own validation (it has no public DNS to prove it against), so asking for it is
|
||||
// not merely pointless but the failing order onlyWhatTheMeshSaid exists to prevent.
|
||||
public map[string]bool
|
||||
// inside is the private network's range, where a request must come from to be served a name
|
||||
// that is only internal. Set once at start, never replaced with the routes: it is what the
|
||||
// private network is, not what is routed on it.
|
||||
inside sources
|
||||
// bridges is the machine's own container networks, read from its interfaces and refreshed with
|
||||
// the routes, since a compose network can appear at any time.
|
||||
bridges sources
|
||||
}
|
||||
|
||||
// sources is the private network, as address ranges. The machine itself is always inside it —
|
||||
// anything on a machine may call anything on it (novox/hq ADR 0144) — so loopback needs no range.
|
||||
type sources []netip.Prefix
|
||||
|
||||
// sourcesFrom reads the ranges the mesh wrote, separated by commas or spaces. A range that does not
|
||||
// parse is an error, not a range skipped: the proxy would otherwise serve internal names to fewer
|
||||
// machines than the mesh said, or start believing a typo.
|
||||
func sourcesFrom(text string) (sources, error) {
|
||||
var out sources
|
||||
for _, field := range strings.FieldsFunc(text, func(r rune) bool { return r == ',' || r == ' ' || r == '\n' || r == '\t' }) {
|
||||
prefix, err := netip.ParsePrefix(field)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%q is not an address range: %w", field, err)
|
||||
}
|
||||
out = append(out, prefix.Masked())
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// bridgesFrom is the address ranges of the machine's container bridges — the same interfaces the
|
||||
// mesh's guard names as the machine itself (docker0, and the br-* a compose network gets), so the
|
||||
// proxy and the guard agree on what "this machine" is (novox/hq ADR 0144).
|
||||
func bridgesFrom(interfaces map[string][]net.Addr) sources {
|
||||
var out sources
|
||||
for name, addrs := range interfaces {
|
||||
if name != "docker0" && !strings.HasPrefix(name, "br-") {
|
||||
continue
|
||||
}
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok {
|
||||
if prefix, err := netip.ParsePrefix(ipnet.String()); err == nil {
|
||||
out = append(out, prefix.Masked())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// theseBridges reads this machine's interfaces for bridgesFrom. An interface that cannot be read
|
||||
// contributes nothing: fewer callers inside, never more.
|
||||
func theseBridges() sources {
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
named := map[string][]net.Addr{}
|
||||
for _, i := range interfaces {
|
||||
if addrs, err := i.Addrs(); err == nil {
|
||||
named[i.Name] = addrs
|
||||
}
|
||||
}
|
||||
return bridgesFrom(named)
|
||||
}
|
||||
|
||||
// holds says whether a request from this remote address came from inside these ranges, or from
|
||||
// the machine itself.
|
||||
//
|
||||
// **By source, which the guard deliberately is not** — it names interfaces because a source
|
||||
// address can be claimed by whoever sends the packet. The proxy cannot see the interface a request
|
||||
// arrived on, and here the claim does not carry: a connection needs its replies, and replies to a
|
||||
// mesh or container address leave by the tunnel or a local bridge, never back to the claimant.
|
||||
func (s sources) holds(remote string) bool {
|
||||
host := remote
|
||||
if h, _, err := net.SplitHostPort(remote); err == nil {
|
||||
host = h
|
||||
}
|
||||
addr, err := netip.ParseAddr(host)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
addr = addr.Unmap()
|
||||
if addr.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
for _, prefix := range s {
|
||||
if prefix.Contains(addr) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (t *table) set(routes map[string][]rule, public map[string]bool) {
|
||||
@@ -314,6 +405,41 @@ func bareHost(host string) string {
|
||||
return strings.ToLower(host)
|
||||
}
|
||||
|
||||
// hiddenFrom says whether this host must look unrouted to a request from this address: it is
|
||||
// only an internal name, and the request did not come from the private network.
|
||||
//
|
||||
// **The proxy is the only way in to a routed endpoint, so it is what makes `internal` true**
|
||||
// (novox/hq ADR 0138, issue 191). It answers public names on the same listeners, so a request from
|
||||
// anywhere can carry any Host header; a name being internal keeps nobody out unless this check does.
|
||||
// Answered exactly as a name that was never routed, so an outsider learns nothing from asking.
|
||||
func (t *table) hiddenFrom(host, remote string) bool {
|
||||
if !t.eligibleForInternalACME(host) {
|
||||
return false
|
||||
}
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
return !t.inside.holds(remote) && !t.bridges.holds(remote)
|
||||
}
|
||||
|
||||
// setBridges replaces the machine's container networks.
|
||||
func (t *table) setBridges(bridges sources) {
|
||||
t.mu.Lock()
|
||||
t.bridges = bridges
|
||||
t.mu.Unlock()
|
||||
}
|
||||
|
||||
// namesSeenFrom is what this proxy says it serves to a request from this address — every routed
|
||||
// name, less the internal-only ones when the request came from outside.
|
||||
func (t *table) namesSeenFrom(remote string) []string {
|
||||
out := []string{}
|
||||
for _, name := range t.names() {
|
||||
if !t.hiddenFrom(name, remote) {
|
||||
out = append(out, name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (t *table) names() []string {
|
||||
t.mu.RLock()
|
||||
defer t.mu.RUnlock()
|
||||
@@ -343,6 +469,18 @@ func run() error {
|
||||
}
|
||||
|
||||
held := newTable()
|
||||
// Unset means only this machine and its containers are inside, which serves an internal-only
|
||||
// name to nobody else — refused rather than served to everyone, which is what the proxy did
|
||||
// before it knew.
|
||||
inside, err := sourcesFrom(os.Getenv("INTERNAL_SOURCES"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("INTERNAL_SOURCES: %w", err)
|
||||
}
|
||||
if len(inside) == 0 {
|
||||
log.Printf("INTERNAL_SOURCES is not set: a name that is only internal is served to this machine " +
|
||||
"and its containers alone")
|
||||
}
|
||||
held.inside = inside
|
||||
read := func() {
|
||||
routes, public, err := routesFrom(path)
|
||||
if err != nil {
|
||||
@@ -353,6 +491,7 @@ func run() error {
|
||||
return
|
||||
}
|
||||
held.set(routes, public)
|
||||
held.setBridges(theseBridges())
|
||||
log.Printf("serving %d route(s): %s", len(routes), strings.Join(held.names(), ", "))
|
||||
}
|
||||
read()
|
||||
@@ -422,19 +561,7 @@ func run() error {
|
||||
}()
|
||||
|
||||
tlsConfig := publicManager.TLSConfig()
|
||||
if internalManager != nil {
|
||||
// Dispatched by which authority may certify this name at all — the same question
|
||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than
|
||||
// only when an order is placed, since a cached certificate is served here on every request
|
||||
// and never goes through HostPolicy again.
|
||||
fromPublic, fromInternal := tlsConfig.GetCertificate, internalManager.TLSConfig().GetCertificate
|
||||
tlsConfig.GetCertificate = func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if held.eligibleForInternalACME(hello.ServerName) {
|
||||
return fromInternal(hello)
|
||||
}
|
||||
return fromPublic(hello)
|
||||
}
|
||||
}
|
||||
tlsConfig.GetCertificate = certificateFor(held, tlsConfig.GetCertificate, internalManager)
|
||||
|
||||
server := &http.Server{
|
||||
Addr: secure,
|
||||
@@ -592,6 +719,33 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
||||
return filepath.Join(cache, hex.EncodeToString(sum[:])[:16])
|
||||
}
|
||||
|
||||
// certificateFor picks the certificate a handshake is answered with.
|
||||
//
|
||||
// Dispatched by which authority may certify this name at all — the same question
|
||||
// eligibleForInternalACME already answers, asked once more at handshake time rather than only when
|
||||
// an order is placed, since a cached certificate is served here on every request and never goes
|
||||
// through HostPolicy again. And refused, exactly as an unrouted name is, to a client outside the
|
||||
// private network asking for a name that is only internal: the certificate would name it.
|
||||
func certificateFor(held *table, fromPublic func(*tls.ClientHelloInfo) (*tls.Certificate, error),
|
||||
internalManager *autocert.Manager) func(*tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
var fromInternal func(*tls.ClientHelloInfo) (*tls.Certificate, error)
|
||||
if internalManager != nil {
|
||||
fromInternal = internalManager.TLSConfig().GetCertificate
|
||||
}
|
||||
return func(hello *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if held.eligibleForInternalACME(hello.ServerName) {
|
||||
if hello.Conn != nil && held.hiddenFrom(hello.ServerName, hello.Conn.RemoteAddr().String()) {
|
||||
return nil, fmt.Errorf("no public route for %q in this mesh, so no certificate is asked for",
|
||||
hello.ServerName)
|
||||
}
|
||||
if fromInternal != nil {
|
||||
return fromInternal(hello)
|
||||
}
|
||||
}
|
||||
return fromPublic(hello)
|
||||
}
|
||||
}
|
||||
|
||||
// newTable is an empty routing table.
|
||||
func newTable() *table {
|
||||
return &table{to: map[string][]rule{}}
|
||||
@@ -600,8 +754,9 @@ func newTable() *table {
|
||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||
func handler(held *table) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hidden := held.hiddenFrom(r.Host, r.RemoteAddr)
|
||||
matched, known := held.find(r.Host, r.URL.Path)
|
||||
if !known {
|
||||
if hidden || !known {
|
||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
||||
// are different things, and a proxy that says only "not found" makes an operator go
|
||||
// and read the mesh to tell them apart. What it is serving is the answer to both.
|
||||
@@ -611,13 +766,13 @@ func handler(held *table) http.Handler {
|
||||
// contradiction an operator would have to disbelieve the proxy to get past.
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
if held.routed(r.Host) {
|
||||
if !hidden && held.routed(r.Host) {
|
||||
fmt.Fprintf(w, "%s is served here, but no route covers %q.\n",
|
||||
bareHost(r.Host), r.URL.Path)
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, "no route for %q in this mesh.\nserving: %s\n",
|
||||
r.Host, strings.Join(held.names(), ", "))
|
||||
r.Host, strings.Join(held.namesSeenFrom(r.RemoteAddr), ", "))
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user