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.
234 lines
9.0 KiB
Go
234 lines
9.0 KiB
Go
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])
|
|
}
|
|
}
|