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:
2026-10-02 01:10:13 +02:00
parent 9acb5f1292
commit 0fcea460da
2 changed files with 354 additions and 16 deletions
+171 -16
View File
@@ -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
}