332 lines
14 KiB
Go
332 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"crypto/tls"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"reflect"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
|
|
stdio "git.novox.be/novox/mesh-sdk/go"
|
|
"git.novox.be/novox/mesh-sdk/go/delivery"
|
|
|
|
"git.novox.be/novox/mesh-controller/modules/route-proxy/contract"
|
|
)
|
|
|
|
// 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(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)
|
|
}
|
|
}
|
|
}
|
|
|
|
// fakePlans is the seat's plans as a watch hands them: every current key, then each change after Watch returned.
|
|
type fakePlans struct {
|
|
current map[string]string
|
|
handle func(stdio.StateChange) error
|
|
}
|
|
|
|
func (f *fakePlans) Watch(_ string, handle func(stdio.StateChange) error) error {
|
|
f.handle = handle
|
|
for k, v := range f.current {
|
|
if err := handle(stdio.StateChange{Key: k, Op: "put", Value: json.RawMessage(v), Current: true}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// plan is a plan of the seat mesh-provisioner for module on node, its entries as given.
|
|
func plan(node, module, entries string) string {
|
|
return fmt.Sprintf(`{"v":1,"node":%q,"module":%q,"plan":{"templates":{"source":"","digest":""},"files":null,`+
|
|
`"provisions":[%s]}}`, node, module, entries)
|
|
}
|
|
|
|
// contributed is a plan entry's opaque payload: the consumer's machine at at, and each contribution by local name.
|
|
func contributed(at string, each ...string) string {
|
|
var parts []string
|
|
for i := 0; i+1 < len(each); i += 2 {
|
|
parts = append(parts, fmt.Sprintf(`{"local":%q,"values":%s}`, each[i], each[i+1]))
|
|
}
|
|
return fmt.Sprintf(`"contributed":{"v":1,"at":%q,"each":[%s]}`, at, strings.Join(parts, ","))
|
|
}
|
|
|
|
// What the plans give is what is served, and nothing else (issue 774, research 072 row 34): each entry naming this
|
|
// proxy on its node as the route's provider, every contribution of it read with route-proxy's contract, reached over
|
|
// loopback on this machine and at the plan's address on another; an entry routing elsewhere, carrying nothing, naming
|
|
// no address or a route the contract refuses is not served, and is said refused. A change is served as it comes and
|
|
// said served or withdrawn, a plan that cannot be read keeps what it gave, and a deleted one withdraws it. The
|
|
// placement says only who is inside: an internal-only name is served to the machines it decides addresses for.
|
|
func TestThePlansAreServedAndThePlacementSaysWhoIsInside(t *testing.T) {
|
|
const route = `{"provision":"route","provider":"anchor","provider-module":"route-proxy",`
|
|
gitea := route + contributed("10.10.0.7",
|
|
"web", `{"name":"git.example","internal-name":"git.anchor.internal","port":3000}`,
|
|
"api", `{"internal-name":"git-api.anchor.internal","port":3001}`,
|
|
"refused", `{"name":"bad.example","port":3002,"host":"x"}`) + `}`
|
|
plans := &fakePlans{current: map[string]string{
|
|
"laptop.gitea": plan("laptop", "gitea", gitea+`,{"provision":"postgres-database","provider":"anchor",`+
|
|
`"provider-module":"postgres",`+contributed("10.10.0.7", "", `{"name":"gitea"}`)+`}`),
|
|
"anchor.blog": plan("anchor", "blog", route+contributed("10.10.0.1", "", `{"name":"blog.example","port":2368}`)+`}`),
|
|
"anchor.other": plan("anchor", "other", `{"provision":"route","provider":"edge","provider-module":"route-proxy",`+
|
|
contributed("", "", `{"name":"other.example","port":80}`)+`}`),
|
|
"anchor.older": plan("anchor", "older", route+`"local":""}`),
|
|
"laptop.lost": plan("laptop", "lost", route+contributed("", "", `{"name":"lost.example","port":80}`)+`}`),
|
|
}}
|
|
held := newTable()
|
|
var said []string
|
|
say := func(kind string, e contract.Event) {
|
|
said = append(said, kind+" "+e.Proxy+" "+e.Name+" "+e.Module+" "+e.Local+" "+e.Why)
|
|
}
|
|
if err := followPlans(plans, newPlanned("anchor", say), held, say); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
served := func() map[string]string {
|
|
out := map[string]string{}
|
|
held.mu.RLock()
|
|
defer held.mu.RUnlock()
|
|
for host, rules := range held.to {
|
|
out[host] = rules[0].target
|
|
}
|
|
return out
|
|
}
|
|
want := map[string]string{"git.example": "http://10.10.0.7:3000", "git.anchor.internal": "http://10.10.0.7:3000",
|
|
"git-api.anchor.internal": "http://10.10.0.7:3001", "blog.example": "http://127.0.0.1:2368"}
|
|
if got := served(); !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("serving %v, want %v", got, want)
|
|
}
|
|
if !held.eligibleForACME("git.example") || held.eligibleForACME("git-api.anchor.internal") {
|
|
t.Error("a public name is not certified publicly, or an internal-only one is")
|
|
}
|
|
// Each refusal said with why, and each name served said once.
|
|
sort.Strings(said)
|
|
for _, w := range []string{
|
|
"route.refused anchor gitea refused not a route this contract reads: json: unknown field \"host\"",
|
|
"route.refused anchor lost it runs on laptop and its plan says no address on the private network for it",
|
|
"route.refused anchor older its plan names this proxy for its route and carries nothing to serve",
|
|
"route.served anchor blog.example blog ",
|
|
"route.served anchor git-api.anchor.internal gitea ",
|
|
} {
|
|
if !slices.Contains(said, w) {
|
|
t.Errorf("not said %q, said:\n%s", w, strings.Join(said, "\n"))
|
|
}
|
|
}
|
|
if len(said) != 7 {
|
|
t.Errorf("said %d events, want 3 refusals and 4 names served:\n%s", len(said), strings.Join(said, "\n"))
|
|
}
|
|
for _, row := range []struct {
|
|
name, key, value string
|
|
served []string
|
|
withdrawn []string
|
|
say []string
|
|
}{
|
|
{"a route arrives", "anchor.new", plan("anchor", "new", route+contributed("", "", `{"name":"new.example","port":81}`)+`}`),
|
|
[]string{"new.example"}, nil, []string{"route.served anchor new.example new "}},
|
|
{"an unreadable plan keeps what it gave", "laptop.gitea", `{"v":1,`, []string{"git.example"}, nil, nil},
|
|
{"a deleted plan withdraws its routes", "laptop.gitea", "", nil, []string{"git.example", "git-api.anchor.internal"},
|
|
[]string{"route.withdrawn anchor git-api.anchor.internal ", "route.withdrawn anchor git.anchor.internal ",
|
|
"route.withdrawn anchor git.example "}},
|
|
} {
|
|
said = nil
|
|
change := stdio.StateChange{Key: row.key, Op: "put", Value: json.RawMessage(row.value)}
|
|
if row.value == "" {
|
|
change = stdio.StateChange{Key: row.key, Op: "delete"}
|
|
}
|
|
if err := plans.handle(change); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := served()
|
|
for _, host := range row.served {
|
|
if _, ok := got[host]; !ok {
|
|
t.Errorf("%s: %s is not served (%v)", row.name, host, got)
|
|
}
|
|
}
|
|
for _, host := range row.withdrawn {
|
|
if _, ok := got[host]; ok {
|
|
t.Errorf("%s: %s is still served", row.name, host)
|
|
}
|
|
}
|
|
if !slices.Equal(said, row.say) {
|
|
t.Errorf("%s: said %q, want %q", row.name, said, row.say)
|
|
}
|
|
}
|
|
|
|
held = behind(t)
|
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code != http.StatusNotFound {
|
|
t.Errorf("before the placement is read, a machine of the mesh was served an internal-only name: %d", code)
|
|
}
|
|
laptop, err := delivery.PlacementValues(delivery.Placement{Node: "laptop", Decided: delivery.NodeDecided{Address: "10.10.0.7"}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
placement := &fakePlans{current: map[string]string{}}
|
|
for k, v := range laptop {
|
|
placement.current[k] = string(v)
|
|
}
|
|
if err := followInside(placement, held); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
|
t.Error("a machine the placement decides an address for was refused the internal-only route")
|
|
}
|
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.8:1"); code != http.StatusNotFound {
|
|
t.Error("a machine the placement does not name was served the internal-only route")
|
|
}
|
|
if err := placement.handle(stdio.StateChange{Key: "laptop.node", Op: "put", Value: json.RawMessage(`{`)}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if code, _ := askFrom(held, "admin.anchor.internal", "10.10.0.7:1"); code == http.StatusNotFound {
|
|
t.Error("an unreadable placement key changed who is inside")
|
|
}
|
|
}
|