route-proxy: a route carries the policy applied to a request
Implements novox/hq ADR 0108, closing issue 116. The proxy's request path was a host lookup and a forward, so it applied nothing — while the ingress it replaces relies on four things it had none of. Path scoping came first because it is a prerequisite, not a sibling. The table mapped a host to one target, so a host could not be routed two ways, and the refusal this issue turns on matches a path on a host already routed to a workload. No amount of authentication or source filtering would have made it expressible. The table is now host to an ordered list of rules, matched on path prefix. The order is total, not just by priority. Sorting on priority alone leaves rules that share one in whatever order the map produced, so the same declaration would serve differently between restarts — a fault that works, and works differently each time, which is the hardest kind to believe when reported. Within a priority the longer path wins, which is also the intuitive reading. auth names a secret and never holds one. A declaration carrying a credential is refused whole rather than served unprotected, so the option ADR 0108 rejected cannot return by accident. A secret that cannot be read makes the route refuse and say so, rather than serve the workload unprotected — a gate that cannot check is not a gate that opens, and the alternative turns a missing file into a silently public admin surface. Authentication costs one bcrypt comparison on every path including an unknown user, so an unknown user is not measurably faster than a known one with a wrong password. That difference is a way to enumerate a route's users from outside it. Redirects keep the request's own path and query, or canonicalising one name onto another would land every deep link on the front page and raise no error doing it. Eleven tests, four of them for the capabilities and two for the failure modes that rot quietly: the credential-in-a-declaration refusal, and the unreadable secret failing closed. Nothing else breaks if those stop working, so nothing else would report it. No new dependency: bcrypt comes from the x/crypto module already required.
This commit is contained in:
+318
-38
@@ -10,10 +10,31 @@
|
|||||||
// program. What lives here is that contract, written as something that runs so it can be read
|
// program. What lives here is that contract, written as something that runs so it can be read
|
||||||
// rather than described.
|
// rather than described.
|
||||||
//
|
//
|
||||||
|
// **A route also carries what a request arriving at it may do** (novox/hq ADR 0108). The grant used
|
||||||
|
// to say only where to send traffic, so this proxy applied nothing; the four things the ingress it
|
||||||
|
// replaces actually relies on are now part of the contribution. The set is closed at four, because
|
||||||
|
// an open middleware surface recreates the thing being replaced and is far harder to narrow later
|
||||||
|
// than a closed one is to widen.
|
||||||
|
//
|
||||||
// What it is given, written by the host from an ordinary declaration:
|
// What it is given, written by the host from an ordinary declaration:
|
||||||
//
|
//
|
||||||
// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is
|
// $ROUTES every consumer, the name it asked for, and where the mesh says that machine is
|
||||||
//
|
//
|
||||||
|
// Each contribution's values carry the name and port as before, and optionally:
|
||||||
|
//
|
||||||
|
// path the path prefix this rule is scoped to; absent means every path
|
||||||
|
// priority which rule wins where two match; higher first, and the order is total
|
||||||
|
// deny refuse the request outright — the shape an incident mitigation needs
|
||||||
|
// redirect answer with a permanent redirect to this name, keeping the path and query
|
||||||
|
// auth the *path of a secret* holding `user:hash` lines, never the credential itself
|
||||||
|
//
|
||||||
|
// A host may appear more than once, which is what path scoping means: one rule refusing a path
|
||||||
|
// while another serves everything else on the same name.
|
||||||
|
//
|
||||||
|
// **`auth` names a secret and never holds one.** A declaration carrying a credential is refused
|
||||||
|
// outright rather than served unprotected, and a secret that cannot be read makes the route refuse
|
||||||
|
// rather than open — a gate that cannot check is not a gate that opens.
|
||||||
|
//
|
||||||
// It re-reads on change rather than being restarted, for the same reason the provisioner does:
|
// It re-reads on change rather than being restarted, for the same reason the provisioner does:
|
||||||
// a route arriving or leaving is an ordinary event and must not drop the connections of every
|
// a route arriving or leaving is an ordinary event and must not drop the connections of every
|
||||||
// other workload.
|
// other workload.
|
||||||
@@ -23,6 +44,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
@@ -42,6 +64,7 @@ import (
|
|||||||
|
|
||||||
"golang.org/x/crypto/acme"
|
"golang.org/x/crypto/acme"
|
||||||
"golang.org/x/crypto/acme/autocert"
|
"golang.org/x/crypto/acme/autocert"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Where public certificates come from when nothing says otherwise.
|
// Where public certificates come from when nothing says otherwise.
|
||||||
@@ -74,7 +97,7 @@ func issuer() string {
|
|||||||
// to what it may serve.
|
// to what it may serve.
|
||||||
func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy {
|
func onlyWhatTheMeshSaid(held *table) autocert.HostPolicy {
|
||||||
return func(_ context.Context, host string) error {
|
return func(_ context.Context, host string) error {
|
||||||
if _, known := held.find(host); known {
|
if held.routed(host) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host)
|
return fmt.Errorf("no route for %q in this mesh, so no certificate is asked for", host)
|
||||||
@@ -95,48 +118,145 @@ type contribution struct {
|
|||||||
Values map[string]any `json:"values"`
|
Values map[string]any `json:"values"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// policy is what a rule does with a request that matched it.
|
||||||
|
//
|
||||||
|
// **Decided by the mesh, not here** (novox/hq ADR 0108). A route grant used to hand back a name and
|
||||||
|
// say nothing about what the name admitted, so this proxy admitted everything. The set is closed at
|
||||||
|
// four — authentication, refusal, path scoping, redirect — because an open middleware surface
|
||||||
|
// recreates the thing being replaced and is far harder to narrow later than a closed one is to widen.
|
||||||
|
type policy struct {
|
||||||
|
// deny refuses the request outright, whatever it is.
|
||||||
|
deny bool
|
||||||
|
// redirectTo answers with a permanent redirect instead of proxying. The request's own path and
|
||||||
|
// query are carried across, which is what canonicalising one public name onto another means.
|
||||||
|
redirectTo string
|
||||||
|
// users is what a request must present, read at load time from the secret the declaration
|
||||||
|
// *named*. A declaration never carries the credential itself.
|
||||||
|
users map[string]string
|
||||||
|
// sealed is set when authentication was declared and the secret could not be read. The rule then
|
||||||
|
// refuses everything and says why.
|
||||||
|
//
|
||||||
|
// **Fail closed.** The alternative — serve the route unauthenticated because the gate is
|
||||||
|
// missing — turns an unreadable file into a silently public admin surface, which is the exact
|
||||||
|
// outcome ADR 0108 exists to prevent. A gate that cannot check is not a gate that opens.
|
||||||
|
sealed string
|
||||||
|
}
|
||||||
|
|
||||||
|
// rule is one way a host may be routed. A host may have several, which is what path scoping means.
|
||||||
|
type rule struct {
|
||||||
|
path string // "" matches every path
|
||||||
|
priority int
|
||||||
|
policy policy
|
||||||
|
to *httputil.ReverseProxy
|
||||||
|
target string
|
||||||
|
}
|
||||||
|
|
||||||
// table is what the proxy is currently serving, replaced whole whenever the file changes.
|
// table is what the proxy is currently serving, replaced whole whenever the file changes.
|
||||||
//
|
//
|
||||||
// Replaced rather than merged: the file is the whole truth about who has a route, so merging
|
// Replaced rather than merged: the file is the whole truth about who has a route, so merging
|
||||||
// would keep serving a name whose module was unassigned — which is the stale-route fault
|
// would keep serving a name whose module was unassigned — which is the stale-route fault
|
||||||
// 08-connectivity lists as open, reintroduced one level down.
|
// 08-connectivity lists as open, reintroduced one level down.
|
||||||
|
//
|
||||||
|
// Keyed by host to an *ordered* list rather than to one target, because two of the four policies
|
||||||
|
// need a single host routed more than one way: a refusal on a path the ordinary route also matches,
|
||||||
|
// and a certificate-challenge path on a host that otherwise serves a workload.
|
||||||
type table struct {
|
type table struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
to map[string]*httputil.ReverseProxy
|
to map[string][]rule
|
||||||
targets map[string]string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) set(routes map[string]string) {
|
func (t *table) set(routes map[string][]rule) {
|
||||||
made := map[string]*httputil.ReverseProxy{}
|
made := map[string][]rule{}
|
||||||
for name, target := range routes {
|
for host, rules := range routes {
|
||||||
where, err := url.Parse(target)
|
kept := make([]rule, 0, len(rules))
|
||||||
if err != nil {
|
for _, r := range rules {
|
||||||
log.Printf("route %s points at %q, which is not a URL: %v", name, target, err)
|
// A rule that only refuses or only redirects has nowhere to send anything, and needs
|
||||||
|
// nowhere: it answers by itself.
|
||||||
|
if r.policy.deny || r.policy.redirectTo != "" {
|
||||||
|
kept = append(kept, r)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
where, err := url.Parse(r.target)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("route %s points at %q, which is not a URL: %v", host, r.target, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.to = httputil.NewSingleHostReverseProxy(where)
|
||||||
|
kept = append(kept, r)
|
||||||
|
}
|
||||||
|
if len(kept) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
made[name] = httputil.NewSingleHostReverseProxy(where)
|
inOrder(kept)
|
||||||
|
made[host] = kept
|
||||||
}
|
}
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
t.to, t.targets = made, routes
|
t.to = made
|
||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) find(host string) (*httputil.ReverseProxy, bool) {
|
// inOrder puts the rules for one host into the order they are matched in, and does so totally.
|
||||||
// The port is not part of the name. A request to app.example:8080 is for app.example.
|
//
|
||||||
|
// **Equal priorities must resolve identically every time** (ADR 0108). Sorting only by priority
|
||||||
|
// leaves rules that share one in whatever order the map produced, so the same declaration would
|
||||||
|
// serve differently between restarts — a proxy that is not reproducible. Longest path first within a
|
||||||
|
// priority is also the intuitive reading: the more specific rule wins. The last two keys exist only
|
||||||
|
// to make the order total.
|
||||||
|
func inOrder(rules []rule) {
|
||||||
|
sort.SliceStable(rules, func(i, j int) bool {
|
||||||
|
a, b := rules[i], rules[j]
|
||||||
|
if a.priority != b.priority {
|
||||||
|
return a.priority > b.priority
|
||||||
|
}
|
||||||
|
if len(a.path) != len(b.path) {
|
||||||
|
return len(a.path) > len(b.path)
|
||||||
|
}
|
||||||
|
if a.path != b.path {
|
||||||
|
return a.path < b.path
|
||||||
|
}
|
||||||
|
return a.target < b.target
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// find is the rule that answers this request, or nothing if the host is not routed here at all.
|
||||||
|
func (t *table) find(host, path string) (rule, bool) {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
for _, r := range t.to[bareHost(host)] {
|
||||||
|
if r.path == "" || strings.HasPrefix(path, r.path) {
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rule{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// routed says whether this proxy serves the name at all, whatever the path.
|
||||||
|
//
|
||||||
|
// Separate from find because certificate issuance is a question about the *name*: a host whose only
|
||||||
|
// rules are path-scoped is still a name this proxy answers to, and still needs a certificate.
|
||||||
|
func (t *table) routed(host string) bool {
|
||||||
|
t.mu.RLock()
|
||||||
|
defer t.mu.RUnlock()
|
||||||
|
return len(t.to[bareHost(host)]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareHost is the name without the port, lower-cased.
|
||||||
|
//
|
||||||
|
// The port is not part of the name: a request to app.example:8080 is for app.example. Lower-cased
|
||||||
|
// because a Host header is not case-sensitive, and a route that only answers the spelling in the
|
||||||
|
// manifest answers half the requests made to it.
|
||||||
|
func bareHost(host string) string {
|
||||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
host = h
|
host = h
|
||||||
}
|
}
|
||||||
t.mu.RLock()
|
return strings.ToLower(host)
|
||||||
defer t.mu.RUnlock()
|
|
||||||
p, ok := t.to[strings.ToLower(host)]
|
|
||||||
return p, ok
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *table) names() []string {
|
func (t *table) names() []string {
|
||||||
t.mu.RLock()
|
t.mu.RLock()
|
||||||
defer t.mu.RUnlock()
|
defer t.mu.RUnlock()
|
||||||
out := make([]string, 0, len(t.targets))
|
out := make([]string, 0, len(t.to))
|
||||||
for name := range t.targets {
|
for name := range t.to {
|
||||||
out = append(out, name)
|
out = append(out, name)
|
||||||
}
|
}
|
||||||
sort.Strings(out)
|
sort.Strings(out)
|
||||||
@@ -285,13 +405,13 @@ func forThisAuthority(cache, directory string, root []byte) string {
|
|||||||
|
|
||||||
// newTable is an empty routing table.
|
// newTable is an empty routing table.
|
||||||
func newTable() *table {
|
func newTable() *table {
|
||||||
return &table{to: map[string]*httputil.ReverseProxy{}, targets: map[string]string{}}
|
return &table{to: map[string][]rule{}}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
// handler is the proxy itself, separated so it can be driven by a test without a listener.
|
||||||
func handler(held *table) http.Handler {
|
func handler(held *table) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
proxy, known := held.find(r.Host)
|
matched, known := held.find(r.Host, r.URL.Path)
|
||||||
if !known {
|
if !known {
|
||||||
// **Named, not a bare 404.** A route that was withdrawn and a name that never existed
|
// **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
|
// are different things, and a proxy that says only "not found" makes an operator go
|
||||||
@@ -302,12 +422,89 @@ func handler(held *table) http.Handler {
|
|||||||
r.Host, strings.Join(held.names(), ", "))
|
r.Host, strings.Join(held.names(), ", "))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
|
switch {
|
||||||
|
case matched.policy.sealed != "":
|
||||||
|
// Declared a gate, cannot check it. Refused, and says why — an operator reading this
|
||||||
|
// learns the secret is missing, rather than wondering why a protected name is 503.
|
||||||
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
fmt.Fprintf(w, "this route requires authentication and its credentials cannot be read: %s\n",
|
||||||
|
matched.policy.sealed)
|
||||||
|
return
|
||||||
|
|
||||||
|
case matched.policy.deny:
|
||||||
|
http.Error(w, "this path is not served to you", http.StatusForbidden)
|
||||||
|
return
|
||||||
|
|
||||||
|
case matched.policy.redirectTo != "":
|
||||||
|
http.Redirect(w, r, canonical(matched.policy.redirectTo, r.URL), http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
|
||||||
|
case len(matched.policy.users) > 0 && !allowed(matched.policy.users, r):
|
||||||
|
// The realm is the name asked for, so a browser's prompt says which route it is for.
|
||||||
|
w.Header().Set("WWW-Authenticate", fmt.Sprintf("Basic realm=%q, charset=\"UTF-8\"", bareHost(r.Host)))
|
||||||
|
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
matched.to.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// routesFrom reads what the mesh wrote and turns it into name → target.
|
// canonical is where a redirect sends this request.
|
||||||
func routesFrom(path string) (map[string]string, error) {
|
//
|
||||||
|
// The declaration names the destination *name*; the request keeps its own path and query. That is
|
||||||
|
// what canonicalising one public name onto another means — a link to a page under the old name has
|
||||||
|
// to arrive at the same page under the new one, or the redirect silently loses every deep link.
|
||||||
|
func canonical(to string, from *url.URL) string {
|
||||||
|
where, err := url.Parse(to)
|
||||||
|
if err != nil {
|
||||||
|
return to
|
||||||
|
}
|
||||||
|
if where.Path == "" || where.Path == "/" {
|
||||||
|
where.Path = from.Path
|
||||||
|
}
|
||||||
|
if where.RawQuery == "" {
|
||||||
|
where.RawQuery = from.RawQuery
|
||||||
|
}
|
||||||
|
return where.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// allowed says whether the request presented credentials this route accepts.
|
||||||
|
//
|
||||||
|
// **Every path costs one bcrypt comparison**, including an unknown user, which is why the miss
|
||||||
|
// compares against a fixed hash rather than returning early. Returning early would make an unknown
|
||||||
|
// user measurably faster than a known one with a wrong password, and that difference is a way to
|
||||||
|
// enumerate the users of a route from outside it.
|
||||||
|
func allowed(users map[string]string, r *http.Request) bool {
|
||||||
|
// A hash of nothing anybody knows. Its only job is to cost what a real comparison costs.
|
||||||
|
const absent = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"
|
||||||
|
user, password, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, known := users[user]
|
||||||
|
if !known {
|
||||||
|
want = absent
|
||||||
|
}
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(want), []byte(password)); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// `known` is checked after the comparison, not instead of it, so the timing is the same either
|
||||||
|
// way. subtle.ConstantTimeByteEq keeps the branch from being the thing that differs.
|
||||||
|
return subtle.ConstantTimeByteEq(boolByte(known), 1) == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolByte(b bool) byte {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// routesFrom reads what the mesh wrote and turns it into host → the rules for that host.
|
||||||
|
func routesFrom(path string) (map[string][]rule, error) {
|
||||||
raw, err := os.ReadFile(path)
|
raw, err := os.ReadFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -317,31 +514,114 @@ func routesFrom(path string) (map[string]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
out := map[string]string{}
|
out := map[string][]rule{}
|
||||||
for _, c := range said.Given {
|
for _, c := range said.Given {
|
||||||
name, _ := c.Values["name"].(string)
|
name, _ := c.Values["name"].(string)
|
||||||
if name == "" {
|
if name == "" {
|
||||||
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
log.Printf("%s on %s asked for a route and named nothing; skipped", c.From, c.Node)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
port, ok := asPort(c.Values["port"])
|
host := strings.ToLower(name)
|
||||||
if !ok {
|
|
||||||
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
made := rule{path: asPath(c.Values["path"])}
|
||||||
c.From, c.Node, name)
|
if p, ok := asPort(c.Values["priority"]); ok {
|
||||||
continue
|
made.priority = p
|
||||||
}
|
}
|
||||||
// Where the mesh says that machine is. Empty means it is this one — a workload beside the
|
made.policy.deny, _ = c.Values["deny"].(bool)
|
||||||
// proxy is ordinary, and reaching it over loopback is both correct and the only thing
|
made.policy.redirectTo, _ = c.Values["redirect"].(string)
|
||||||
// that works when there is no private network.
|
|
||||||
at := c.At
|
if named, carried := c.Values["auth"].(string); carried && strings.TrimSpace(named) != "" {
|
||||||
if at == "" {
|
// **A declaration names a secret; it never holds one** (ADR 0108). Refused rather than
|
||||||
at = "127.0.0.1"
|
// tolerated, and the whole rule is dropped rather than served unprotected — the
|
||||||
|
// rejected option cannot come back by accident, which is the failure this check exists
|
||||||
|
// to make impossible.
|
||||||
|
if looksLikeACredential(named) {
|
||||||
|
log.Printf("%s on %s declared route %q with a credential in the declaration rather "+
|
||||||
|
"than the name of a secret; the whole route is refused (novox/hq ADR 0108)",
|
||||||
|
c.From, c.Node, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users, err := usersFrom(named)
|
||||||
|
if err != nil {
|
||||||
|
// Fail closed: the rule is kept so the name stays routed and answers, and it
|
||||||
|
// answers by refusing. Dropping it instead would make the name 404 and read as a
|
||||||
|
// withdrawn route rather than an unreadable secret.
|
||||||
|
made.policy.sealed = err.Error()
|
||||||
|
}
|
||||||
|
made.policy.users = users
|
||||||
}
|
}
|
||||||
out[strings.ToLower(name)] = fmt.Sprintf("http://%s:%d", at, port)
|
|
||||||
|
// Only a rule that actually proxies needs somewhere to send the request.
|
||||||
|
if !made.policy.deny && made.policy.redirectTo == "" {
|
||||||
|
port, ok := asPort(c.Values["port"])
|
||||||
|
if !ok {
|
||||||
|
log.Printf("%s on %s asked for route %q and gave no usable port; skipped",
|
||||||
|
c.From, c.Node, name)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Where the mesh says that machine is. Empty means it is this one — a workload beside
|
||||||
|
// the proxy is ordinary, and reaching it over loopback is both correct and the only
|
||||||
|
// thing that works when there is no private network.
|
||||||
|
at := c.At
|
||||||
|
if at == "" {
|
||||||
|
at = "127.0.0.1"
|
||||||
|
}
|
||||||
|
made.target = fmt.Sprintf("http://%s:%d", at, port)
|
||||||
|
}
|
||||||
|
|
||||||
|
out[host] = append(out[host], made)
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// asPath is the path prefix a rule is scoped to, or "" for every path.
|
||||||
|
func asPath(v any) string {
|
||||||
|
p, _ := v.(string)
|
||||||
|
p = strings.TrimSpace(p)
|
||||||
|
if p == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(p, "/") {
|
||||||
|
p = "/" + p
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
// looksLikeACredential is the check that keeps a secret out of a declaration.
|
||||||
|
//
|
||||||
|
// It errs towards refusing: a value holding a `:` (the htpasswd separator) or opening with a bcrypt
|
||||||
|
// identifier is a credential, not a path, and no filesystem path the mesh writes needs either. A
|
||||||
|
// false refusal is a loud log and a route that does not serve; a false accept is a credential
|
||||||
|
// committed to a declaration, which is the thing being prevented.
|
||||||
|
func looksLikeACredential(v string) bool {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
return strings.Contains(v, ":") || strings.HasPrefix(v, "$2")
|
||||||
|
}
|
||||||
|
|
||||||
|
// usersFrom reads the credentials the mesh mounted, in the one format every htpasswd already is.
|
||||||
|
func usersFrom(path string) (map[string]string, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read the secret named for this route: %w", err)
|
||||||
|
}
|
||||||
|
users := map[string]string{}
|
||||||
|
for _, line := range strings.Split(string(raw), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user, hash, ok := strings.Cut(line, ":")
|
||||||
|
if !ok || user == "" || hash == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
users[user] = hash
|
||||||
|
}
|
||||||
|
if len(users) == 0 {
|
||||||
|
return nil, fmt.Errorf("the secret named for this route holds no usable credentials")
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
|
// asPort accepts what JSON makes of a number, which is a float even when it was written 8080.
|
||||||
func asPort(v any) (int, bool) {
|
func asPort(v any) (int, bool) {
|
||||||
switch n := v.(type) {
|
switch n := v.(type) {
|
||||||
|
|||||||
@@ -0,0 +1,233 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a route carries about the requests arriving at it — novox/hq ADR 0108.
|
||||||
|
//
|
||||||
|
// Each test here is one of the four capabilities that record closed the set at, plus the negative
|
||||||
|
// case it promised would be refused. The negative case is the one that rots quietly: nothing fails
|
||||||
|
// if it stops working, so nothing tells you it has.
|
||||||
|
|
||||||
|
// served starts a workload and gives back the host and port the mesh would have recorded for it.
|
||||||
|
func served(t *testing.T, body string) (string, int) {
|
||||||
|
t.Helper()
|
||||||
|
workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(workload.Close)
|
||||||
|
host, port, _ := strings.Cut(strings.TrimPrefix(workload.URL, "http://"), ":")
|
||||||
|
n, err := strconv.Atoi(port)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return host, n
|
||||||
|
}
|
||||||
|
|
||||||
|
// ask makes one request through the proxy for a given name and path, without following redirects.
|
||||||
|
func ask(t *testing.T, proxy, name, path string, auth [2]string) *http.Response {
|
||||||
|
t.Helper()
|
||||||
|
req, err := http.NewRequest(http.MethodGet, proxy+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
req.Host = name
|
||||||
|
if auth[0] != "" {
|
||||||
|
req.SetBasicAuth(auth[0], auth[1])
|
||||||
|
}
|
||||||
|
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
}}
|
||||||
|
answer, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = answer.Body.Close() })
|
||||||
|
return answer
|
||||||
|
}
|
||||||
|
|
||||||
|
func proxyFor(t *testing.T, routesJSON string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(t.TempDir(), "routes.json")
|
||||||
|
if err := os.WriteFile(path, []byte(routesJSON), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
routes, err := routesFrom(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
held := newTable()
|
||||||
|
held.set(routes)
|
||||||
|
server := httptest.NewServer(handler(held))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server.URL
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refusal on a path shadows the ordinary route for that path and leaves every other path alone.
|
||||||
|
//
|
||||||
|
// **This is why path scoping is a prerequisite and not a sibling capability.** The rule being
|
||||||
|
// reproduced matches a path on a host that is already routed to a workload, so a table mapping a
|
||||||
|
// host to one target cannot express it at all — no amount of authentication or source filtering
|
||||||
|
// would have helped.
|
||||||
|
func TestARefusedPathShadowsTheRouteAndLeavesTheRestServed(t *testing.T) {
|
||||||
|
at, port := served(t, "the workload")
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"forge","node":"anchor","at":"`+at+`","values":{"name":"forge.example","port":`+strconv.Itoa(port)+`}},
|
||||||
|
{"from":"forge","node":"anchor","values":{"name":"forge.example","path":"/api/internal","priority":100,"deny":true}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if got := ask(t, proxy, "forge.example", "/api/internal/hook", [2]string{}).StatusCode; got != http.StatusForbidden {
|
||||||
|
t.Fatalf("the refused path answered %d, so the block that was put in front of it during an "+
|
||||||
|
"incident is not in front of it any more", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "forge.example", "/", [2]string{}).StatusCode; got != http.StatusOK {
|
||||||
|
t.Fatalf("refusing one path took the whole route with it: %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redirect answers with the redirect, and the request keeps its own path and query.
|
||||||
|
//
|
||||||
|
// Losing the path would turn canonicalising one name onto another into "every deep link now lands
|
||||||
|
// on the front page", which is the kind of breakage that produces no error anywhere.
|
||||||
|
func TestARedirectKeepsThePathAndQuery(t *testing.T) {
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"site","node":"anchor","values":{"name":"www.example","redirect":"https://example/"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
answer := ask(t, proxy, "www.example", "/deep/page?ref=1", [2]string{})
|
||||||
|
if answer.StatusCode != http.StatusMovedPermanently {
|
||||||
|
t.Fatalf("a declared redirect answered %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
where := answer.Header.Get("Location")
|
||||||
|
if !strings.Contains(where, "/deep/page") || !strings.Contains(where, "ref=1") {
|
||||||
|
t.Fatalf("the redirect dropped the path or the query: %q", where)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication refuses a request with no credentials, admits one with the right ones, and refuses
|
||||||
|
// the wrong ones — with the credentials read from the secret the declaration *named*.
|
||||||
|
func TestAuthenticationAdmitsOnlyWhatTheSecretSays(t *testing.T) {
|
||||||
|
at, port := served(t, "the console")
|
||||||
|
hash, err := bcrypt.GenerateFromPassword([]byte("correct horse"), bcrypt.MinCost)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
secret := filepath.Join(t.TempDir(), "console-auth")
|
||||||
|
if err := os.WriteFile(secret, []byte("# a comment\nadmin:"+string(hash)+"\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+secret+`"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{}).StatusCode; got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("an admin surface with no login of its own answered %d without credentials", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "wrong"}).StatusCode; got != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("the wrong password answered %d", got)
|
||||||
|
}
|
||||||
|
if got := ask(t, proxy, "console.example", "/", [2]string{"admin", "correct horse"}).StatusCode; got != http.StatusOK {
|
||||||
|
t.Fatalf("the right password answered %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The negative case ADR 0108 promised would be refused: a credential in the declaration.
|
||||||
|
//
|
||||||
|
// **Refused whole, not tolerated and not served unprotected.** A hash carried in a declaration was
|
||||||
|
// the rejected option; nothing in the running system should quietly accept it later, because the
|
||||||
|
// precedent is far easier to set than to withdraw. If this test is deleted the option returns and
|
||||||
|
// nothing else notices.
|
||||||
|
func TestACredentialInTheDeclarationIsRefusedRatherThanServed(t *testing.T) {
|
||||||
|
inline := []string{
|
||||||
|
`{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"admin:$2a$10$abcdefghijklmnopqrstuv"}}]}`,
|
||||||
|
`{"given":[{"from":"c","node":"n","at":"127.0.0.1","values":{"name":"c.example","port":8080,"auth":"$2a$10$abcdefghijklmnopqrstuv"}}]}`,
|
||||||
|
}
|
||||||
|
for _, body := range inline {
|
||||||
|
path := filepath.Join(t.TempDir(), "routes.json")
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
routes, err := routesFrom(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(routes) != 0 {
|
||||||
|
t.Fatalf("a declaration carrying a credential was served anyway: %v", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authentication declared, secret unreadable: the route refuses. It does not serve unprotected.
|
||||||
|
//
|
||||||
|
// **Fail closed.** The alternative turns a missing file into a silently public admin surface, which
|
||||||
|
// is the outcome the whole record exists to prevent. It answers rather than 404s, so an operator
|
||||||
|
// sees "cannot read the credentials" instead of concluding the route was withdrawn.
|
||||||
|
func TestAnUnreadableSecretFailsClosed(t *testing.T) {
|
||||||
|
at, port := served(t, "the console")
|
||||||
|
missing := filepath.Join(t.TempDir(), "not-mounted")
|
||||||
|
|
||||||
|
proxy := proxyFor(t, `{"given":[
|
||||||
|
{"from":"console","node":"anchor","at":"`+at+`","values":{"name":"console.example","port":`+strconv.Itoa(port)+`,"auth":"`+missing+`"}}
|
||||||
|
]}`)
|
||||||
|
|
||||||
|
answer := ask(t, proxy, "console.example", "/", [2]string{})
|
||||||
|
if answer.StatusCode == http.StatusOK {
|
||||||
|
t.Fatal("a route whose credentials could not be read served the workload unprotected")
|
||||||
|
}
|
||||||
|
if answer.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Fatalf("expected the route to say it cannot check, got %d", answer.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equal priorities resolve the same way every time, so the same declaration serves the same way
|
||||||
|
// after a restart.
|
||||||
|
//
|
||||||
|
// Sorting only by priority leaves rules that share one in whatever order the map produced. The
|
||||||
|
// proxy would still work, and would work differently between restarts — which is the hardest kind
|
||||||
|
// of fault to believe when it is reported.
|
||||||
|
func TestRulesThatShareAPriorityAreStillTotallyOrdered(t *testing.T) {
|
||||||
|
first := []rule{
|
||||||
|
{path: "/a", priority: 10, target: "http://x:1"},
|
||||||
|
{path: "/bb", priority: 10, target: "http://y:2"},
|
||||||
|
{path: "", priority: 10, target: "http://z:3"},
|
||||||
|
}
|
||||||
|
second := []rule{
|
||||||
|
{path: "", priority: 10, target: "http://z:3"},
|
||||||
|
{path: "/bb", priority: 10, target: "http://y:2"},
|
||||||
|
{path: "/a", priority: 10, target: "http://x:1"},
|
||||||
|
}
|
||||||
|
inOrder(first)
|
||||||
|
inOrder(second)
|
||||||
|
for i := range first {
|
||||||
|
if first[i].path != second[i].path || first[i].target != second[i].target {
|
||||||
|
t.Fatalf("two orderings of the same rules disagree at %d: %q vs %q",
|
||||||
|
i, first[i].path, second[i].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And the more specific rule is matched first, which is the intuitive reading.
|
||||||
|
if first[0].path != "/bb" {
|
||||||
|
t.Fatalf("the longest path is not matched first: %q", first[0].path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority decides before path length does, so a rule can be made to win regardless of specificity.
|
||||||
|
func TestPriorityOutranksPathLength(t *testing.T) {
|
||||||
|
rules := []rule{
|
||||||
|
{path: "/very/long/path", priority: 1, target: "http://x:1"},
|
||||||
|
{path: "", priority: 100, target: "http://y:2"},
|
||||||
|
}
|
||||||
|
inOrder(rules)
|
||||||
|
if rules[0].priority != 100 {
|
||||||
|
t.Fatalf("a higher priority did not win: %+v", rules[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,23 @@ func write(t *testing.T, body string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// plain is the table an ordinary set of routes makes: one host, one target, no policy.
|
||||||
|
func plain(routes map[string]string) map[string][]rule {
|
||||||
|
out := map[string][]rule{}
|
||||||
|
for host, target := range routes {
|
||||||
|
out[host] = []rule{{target: target}}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetOf is where a host's first matching rule sends a request.
|
||||||
|
func targetOf(routes map[string][]rule, host string) string {
|
||||||
|
if rules := routes[host]; len(rules) > 0 {
|
||||||
|
return rules[0].target
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
|
// A route is a grant: the consumer supplies a target, and where that machine is comes from the
|
||||||
// mesh rather than from a naming convention the proxy has to know.
|
// mesh rather than from a naming convention the proxy has to know.
|
||||||
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
||||||
@@ -30,7 +47,7 @@ func TestARouteGoesToWhereTheMeshSaysTheConsumerIs(t *testing.T) {
|
|||||||
}
|
}
|
||||||
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
|
// Lower-cased, because a Host header is not case-sensitive and a route that only answers the
|
||||||
// spelling in the manifest answers half the requests made to it.
|
// spelling in the manifest answers half the requests made to it.
|
||||||
if routes["app.example"] != "http://laptop.internal:8080" {
|
if targetOf(routes, "app.example") != "http://laptop.internal:8080" {
|
||||||
t.Fatalf("the route does not point at the consumer: %v", routes)
|
t.Fatalf("the route does not point at the consumer: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -44,7 +61,7 @@ func TestAConsumerOnTheProxysOwnMachineIsReachedOverLoopback(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if routes["app.example"] != "http://127.0.0.1:9000" {
|
if targetOf(routes, "app.example") != "http://127.0.0.1:9000" {
|
||||||
t.Fatalf("a workload on this machine was not reachable: %v", routes)
|
t.Fatalf("a workload on this machine was not reachable: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -59,7 +76,7 @@ func TestAContributionMissingWhatARouteNeedsIsSkipped(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(routes) != 1 || routes["fine.example"] == "" {
|
if len(routes) != 1 || targetOf(routes, "fine.example") == "" {
|
||||||
t.Fatalf("an unusable contribution was served: %v", routes)
|
t.Fatalf("an unusable contribution was served: %v", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -75,7 +92,7 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
|||||||
host, port, _ := strings.Cut(target, ":")
|
host, port, _ := strings.Cut(target, ":")
|
||||||
|
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"app.example": "http://" + host + ":" + port})
|
held.set(plain(map[string]string{"app.example": "http://" + host + ":" + port}))
|
||||||
|
|
||||||
proxy := httptest.NewServer(handler(held))
|
proxy := httptest.NewServer(handler(held))
|
||||||
defer proxy.Close()
|
defer proxy.Close()
|
||||||
@@ -120,16 +137,16 @@ func TestTheProxyReachesTheWorkloadAndNamesWhatItServes(t *testing.T) {
|
|||||||
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
|
// nothing fails more visibly than a stale grant, which is exactly why it must not survive.
|
||||||
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{
|
held.set(plain(map[string]string{
|
||||||
"going.example": "http://a.internal:80",
|
"going.example": "http://a.internal:80",
|
||||||
"staying.example": "http://b.internal:80",
|
"staying.example": "http://b.internal:80",
|
||||||
})
|
}))
|
||||||
held.set(map[string]string{"staying.example": "http://b.internal:80"})
|
held.set(plain(map[string]string{"staying.example": "http://b.internal:80"}))
|
||||||
|
|
||||||
if _, still := held.find("going.example"); still {
|
if _, still := held.find("going.example", "/"); still {
|
||||||
t.Fatal("a route whose module was unassigned is still served")
|
t.Fatal("a route whose module was unassigned is still served")
|
||||||
}
|
}
|
||||||
if _, kept := held.find("staying.example"); !kept {
|
if _, kept := held.find("staying.example", "/"); !kept {
|
||||||
t.Fatal("withdrawing one route took another with it")
|
t.Fatal("withdrawing one route took another with it")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,8 +154,8 @@ func TestWithdrawingARouteStopsServingIt(t *testing.T) {
|
|||||||
// A Host header carries a port and the name does not.
|
// A Host header carries a port and the name does not.
|
||||||
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
|
func TestARequestNamingAPortStillFindsItsRoute(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"app.example": "http://a.internal:8080"})
|
held.set(plain(map[string]string{"app.example": "http://a.internal:8080"}))
|
||||||
if _, found := held.find("app.example:8080"); !found {
|
if _, found := held.find("app.example:8080", "/"); !found {
|
||||||
t.Fatal("a request to app.example:8080 did not find the route for app.example")
|
t.Fatal("a request to app.example:8080 did not find the route for app.example")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,7 +185,7 @@ func TestTheIssuerIsStagingUnlessNamed(t *testing.T) {
|
|||||||
// rate limit — and the proxy would look healthy throughout.
|
// rate limit — and the proxy would look healthy throughout.
|
||||||
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"})
|
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))
|
||||||
policy := onlyWhatTheMeshSaid(held)
|
policy := onlyWhatTheMeshSaid(held)
|
||||||
|
|
||||||
if err := policy(context.Background(), "photos.example"); err != nil {
|
if err := policy(context.Background(), "photos.example"); err != nil {
|
||||||
@@ -184,7 +201,7 @@ func TestNoCertificateIsAskedForOnAnUnroutedName(t *testing.T) {
|
|||||||
// A route withdrawn stops being certifiable, without the proxy restarting.
|
// A route withdrawn stops being certifiable, without the proxy restarting.
|
||||||
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
func TestWithdrawingARouteWithdrawsItsCertificate(t *testing.T) {
|
||||||
held := newTable()
|
held := newTable()
|
||||||
held.set(map[string]string{"photos.example": "http://127.0.0.1:8080"})
|
held.set(plain(map[string]string{"photos.example": "http://127.0.0.1:8080"}))
|
||||||
policy := onlyWhatTheMeshSaid(held)
|
policy := onlyWhatTheMeshSaid(held)
|
||||||
if err := policy(context.Background(), "photos.example"); err != nil {
|
if err := policy(context.Background(), "photos.example"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
Reference in New Issue
Block a user