package main import ( "crypto/tls" "encoding/json" "fmt" "io" "net" "net/http" "net/http/httptest" "net/url" "strings" "testing" "github.com/novox/mesh-controller/internal/broker" ) // behind is a workload the proxy can send to, and a table routing one public name and one // internal-only name to it, with the mesh's machines as the membership would issue them. func behind(t *testing.T, mesh ...string) *table { t.Helper() workload := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "the workload") })) t.Cleanup(workload.Close) at, _ := url.Parse(workload.URL) host, port, _ := net.SplitHostPort(at.Host) routes, public, err := routesFrom(write(t, fmt.Sprintf(`{"given":[ {"from":"app","node":"anchor","at":%q, "values":{"name":"app.example","internal-name":"app.anchor.internal","port":%s}}, {"from":"admin","node":"anchor","at":%q, "values":{"internal-name":"admin.anchor.internal","port":%s}} ]}`, host, port, host, port))) if err != nil { t.Fatal(err) } held := newTable() inside, err := sourcesOf(mesh) if err != nil { t.Fatal(err) } held.setInside(inside) held.set(routes, public) return held } // askFrom is what the proxy answers a request for host coming from remote. func askFrom(held *table, host, remote string) (int, string) { r := httptest.NewRequest(http.MethodGet, "http://"+host+"/", nil) r.RemoteAddr = remote w := httptest.NewRecorder() handler(held).ServeHTTP(w, r) return w.Code, w.Body.String() } // **An internal-only name is served to the private network and to nobody else** (novox/hq ADR // 0138, issue 191). The proxy answers public names on the same listeners, so without this a name // being internal kept nobody out: a request from the internet only had to carry it. func TestAnInternalOnlyNameIsServedOnlyInsideThePrivateNetwork(t *testing.T) { held := behind(t, "10.10.0.1", "10.10.0.7") if code, body := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK || body != "the workload" { t.Errorf("a request from the private network was not served: %d %q", code, body) } if code, body := askFrom(held, "admin.anchor.internal", "127.0.0.1:51000"); code != http.StatusOK { t.Errorf("a request from the machine itself was not served: %d %q", code, body) } code, body := askFrom(held, "admin.anchor.internal", "203.0.113.9:51000") if code != http.StatusNotFound { t.Fatalf("a request from outside the private network reached an internal-only name: %d %q", code, body) } // Answered as a name never routed, and the list of what is served does not name it either — // otherwise the refusal would tell an outsider exactly what to ask for from inside. if strings.Contains(strings.SplitN(body, "\n", 2)[1], "admin.anchor.internal") { t.Errorf("the refusal names the internal-only route to an outsider: %q", body) } if !strings.Contains(body, "app.example") { t.Errorf("the refusal stopped listing the public names: %q", body) } } // The internal name of a route that also has a public one is internal too: served inside, and to // an outsider only under the public name. Nothing is lost — the outsider has the public name — and a // name stays one thing whichever route it came from. func TestAnInternalAliasOfAPublicRouteIsServedInsideOnly(t *testing.T) { held := behind(t, "10.10.0.1", "10.10.0.7") if code, body := askFrom(held, "app.anchor.internal", "10.10.0.7:51000"); code != http.StatusOK { t.Errorf("the internal alias stopped answering the private network: %d %q", code, body) } if code, _ := askFrom(held, "app.anchor.internal", "203.0.113.9:51000"); code != http.StatusNotFound { t.Errorf("the internal alias was served to an outsider: %d", code) } if code, _ := askFrom(held, "app.example", "203.0.113.9:51000"); code != http.StatusOK { t.Errorf("the public name was refused to an outsider: %d", code) } } // Before a membership has said who the mesh is, only the machine itself is inside — refused to // everyone else, never served to everyone. func TestUntilTheMeshIsIssuedAnInternalOnlyNameIsServedToTheMachineAlone(t *testing.T) { held := behind(t) if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:51000"); code != http.StatusNotFound { t.Errorf("an internal-only name was served with no private network said: %d", code) } if code, _ := askFrom(held, "admin.anchor.internal", "[::1]:51000"); code != http.StatusOK { t.Errorf("an internal-only name was refused to the machine itself: %d", code) } } type from struct { net.Conn remote net.Addr } func (c from) RemoteAddr() net.Addr { return c.remote } // The handshake refuses an internal-only name to an outsider too: the certificate would name it, // and serving it would answer the question the routing refuses to. func TestTheHandshakeRefusesAnInternalOnlyNameToAnOutsider(t *testing.T) { held := behind(t, "10.10.0.1", "10.10.0.7") served := &tls.Certificate{} pick := certificateFor(held, func(*tls.ClientHelloInfo) (*tls.Certificate, error) { return served, nil }, nil) hello := func(name, remote string) *tls.ClientHelloInfo { addr, _ := net.ResolveTCPAddr("tcp", remote) return &tls.ClientHelloInfo{ServerName: name, Conn: from{remote: addr}} } if _, err := pick(hello("admin.anchor.internal", "203.0.113.9:443")); err == nil { t.Error("an outsider was handed a certificate for an internal-only name") } if got, err := pick(hello("admin.anchor.internal", "10.10.0.7:443")); err != nil || got != served { t.Errorf("a client on the private network was refused: %v", err) } if got, err := pick(hello("app.example", "203.0.113.9:443")); err != nil || got != served { t.Errorf("a public name was refused to an outsider: %v", err) } } // The mesh is issued as machines' addresses; a range is read as well. One that does not parse is // refused rather than skipped, so a typo never quietly narrows or widens who is inside. func TestTheMeshIsReadAsAddressesAndRanges(t *testing.T) { if _, err := sourcesOf([]string{"10.10.0.1", "not-an-address"}); err == nil { t.Error("an entry that is not an address was accepted") } inside, err := sourcesOf([]string{"10.10.0.1", "fd00::1", "10.20.0.0/24"}) if err != nil { t.Fatal(err) } for remote, want := range map[string]bool{ "10.10.0.1:1": true, "[::ffff:10.10.0.1]:1": true, "[fd00::1]:1": true, "10.20.0.200:1": true, "10.10.0.2:1": false, "192.168.1.10:1": false, "not-an-address": false, } { if inside.holds(remote) != want { t.Errorf("%s inside the mesh: got %v, want %v", remote, !want, want) } } } // What the mesh issues is what is served: the routes in the membership, internal names to the // machines it names (novox/hq ADR 0167). func TestAMembershipIsServedAsIssued(t *testing.T) { held := newTable() took := applyMembership(broker.Membership{ Receives: map[string]json.RawMessage{"route": json.RawMessage(`[ {"from":"admin","node":"anchor","at":"anchor.internal", "values":{"internal-name":"admin.anchor.internal","port":8080}}]`)}, Mesh: []string{"10.10.0.7"}, }, held) if !took { t.Fatal("a membership carrying routes was not applied") } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound { t.Error("a machine the membership names was refused the internal-only route") } if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.9:1"); code != http.StatusNotFound { t.Errorf("a machine the membership does not name was served the internal-only route: %d", code) } } // A membership that says nothing about routes is one from a controller that does not issue them, // and changes nothing: the file stays the source rather than every route being withdrawn. func TestAMembershipWithoutRoutesLeavesTheFileServing(t *testing.T) { held := behind(t, "10.10.0.7") before := held.names() if applyMembership(broker.Membership{Mesh: []string{"10.10.0.7"}}, held) { t.Error("a membership without routes was taken as the source of routes") } if got := held.names(); strings.Join(got, ",") != strings.Join(before, ",") { t.Errorf("a membership without routes changed what is served: %v, was %v", got, before) } if applyMembership(broker.Membership{ Receives: map[string]json.RawMessage{"route": json.RawMessage(`[]`)}, Mesh: []string{"not-an-address"}, }, held) { t.Error("a membership whose mesh cannot be read was applied") } }