231 lines
8.3 KiB
Go
231 lines
8.3 KiB
Go
//go:build !windows
|
|
|
|
// Uses fakeToken from pairing_redeem_test.go and setupBox/driveOp from
|
|
// anchor_test.go, both unix-only.
|
|
|
|
package main
|
|
|
|
// A pairing whose file holds an endpoint this build refuses loads as invalid
|
|
// and is never served (endpoint_test.go). These are the paths that could
|
|
// still act on it: anchoring the box to it, an approval from the anchor host,
|
|
// what the hosts are told about it, and the two ways a person clears it.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// unservedEndpoint is one the endpoint check refuses (badTunnelEndpoints).
|
|
const unservedEndpoint = "203.0.113.7:51820\nallowed_ip=::/0"
|
|
|
|
// writeUnservedPairing writes a pairing file in the given state whose
|
|
// endpoint this build refuses, and returns its path.
|
|
func writeUnservedPairing(t *testing.T, host, state string) string {
|
|
t.Helper()
|
|
pd, err := pairingsDir()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, _ := json.Marshal(Config{HostName: host, WGPrivateKey: "k", BastionTunnelIP: "fd12:9::a:1",
|
|
State: state, Endpoint: unservedEndpoint})
|
|
p := filepath.Join(pd, host+".json")
|
|
if err := os.WriteFile(p, b, 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// fileState is the state field as written in host's pairing file.
|
|
func fileState(t *testing.T, host string) string {
|
|
t.Helper()
|
|
pd, _ := pairingsDir()
|
|
raw, err := os.ReadFile(filepath.Join(pd, host+".json"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var c Config
|
|
if err := json.Unmarshal(raw, &c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return c.State
|
|
}
|
|
|
|
// claim-anchor names the reason and writes nothing: the pairing keeps its
|
|
// state, and the box is neither anchored nor claimed. As the sole pairing and
|
|
// named with --host among others, in the legacy active state and pending.
|
|
func TestClaimAnchorRefusesAPairingThatIsNotServed(t *testing.T) {
|
|
if os.Geteuid() != 0 {
|
|
t.Skip("claim-anchor needs machine authority, which needs root")
|
|
}
|
|
for _, c := range []struct {
|
|
name string
|
|
others []string
|
|
host string
|
|
}{{"sole pairing", nil, ""}, {"named with --host", []string{tAnchor}, tMember}} {
|
|
for _, st := range []string{"", pairingPending} {
|
|
t.Run(c.name+"/"+st, func(t *testing.T) {
|
|
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
|
if err := setBoxMode(modeNetworked); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, o := range c.others {
|
|
writePairing(t, o)
|
|
}
|
|
p := writeUnservedPairing(t, tMember, st)
|
|
err := cmdClaimAnchor("", false, "", c.host)
|
|
if err == nil || !contains(err.Error(), "not a host:port") || !contains(err.Error(), p) {
|
|
t.Fatalf("claim-anchor must refuse the pairing, naming its file and why: %v", err)
|
|
}
|
|
if a := currentAnchor(); a != nil {
|
|
t.Fatalf("a refused claim anchored the box: %+v", a)
|
|
}
|
|
if oc := currentOwnerClaim(); oc != nil {
|
|
t.Fatalf("a refused claim wrote the owner claim: %+v", oc)
|
|
}
|
|
if got := fileState(t, tMember); got != st {
|
|
t.Fatalf("a refused claim changed the pairing's state from %q to %q", st, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// The anchor host's approval of such a pairing is refused: it is not made
|
|
// active, no grant is kept, and the refusal names no file of this machine.
|
|
// The copy updatePairingOnDisk hands back carries the same mark, so a caller
|
|
// that starts a pairing from it starts nothing.
|
|
func TestAnApprovalOfAPairingThatIsNotServedIsRefused(t *testing.T) {
|
|
priv := setupBox(t)
|
|
writeUnservedPairing(t, tMember, pairingPending)
|
|
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
|
terms, sig := signTerms(t, priv, grantTerms(tMember, []string{"observe"}, future))
|
|
_, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig})
|
|
if code != 126 || !contains(errOut, "not served") {
|
|
t.Fatalf("the approval must be refused (126, not served): %d %s", code, errOut)
|
|
}
|
|
pd, _ := pairingsDir()
|
|
if contains(errOut, pd) {
|
|
t.Fatalf("the refusal sent to the host names a file of this machine: %s", errOut)
|
|
}
|
|
if got := fileState(t, tMember); got != pairingPending {
|
|
t.Fatalf("a refused approval changed the state to %q", got)
|
|
}
|
|
if _, err := loadPairingGrant(tMember); err == nil {
|
|
t.Fatal("a refused approval kept the grant")
|
|
}
|
|
|
|
c, err := updatePairingOnDisk(tMember, func(d *Config) { d.State = pairingActive })
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.invalid == "" || c.active() {
|
|
t.Fatalf("the copy written back must still be invalid and never active: invalid=%q active=%v", c.invalid, c.active())
|
|
}
|
|
if startPairing(c) {
|
|
t.Fatal("a pairing that is not served was started")
|
|
}
|
|
}
|
|
|
|
// What the hosts hear: neither the peer list every host gets, nor the
|
|
// pairing list the anchor host gets (in the heartbeat and from pairing_list),
|
|
// names such a pairing, and every state they do carry is one a host knows.
|
|
// The machine's own status still shows it invalid (endpoint_test.go).
|
|
func TestNoHostIsToldOfAPairingThatIsNotServed(t *testing.T) {
|
|
setupBox(t)
|
|
const beta, pend = "beta.host.privatae.ai", "pend.host.privatae.ai"
|
|
writePairing(t, beta)
|
|
writeUnservedPairing(t, tMember, "")
|
|
writeUnservedPairing(t, pend, pairingPending)
|
|
known := map[string]bool{pairingActive: true, pairingPending: true, pairingRevoked: true}
|
|
|
|
peers := peersFor(tAnchor)
|
|
if len(peers) != 1 || peers[0].HostName != beta {
|
|
t.Fatalf("the peers must be only %s, got %+v", beta, peers)
|
|
}
|
|
for _, p := range peers {
|
|
if !known[p.State] {
|
|
t.Fatalf("a peer was sent with a state a host does not know: %+v", p)
|
|
}
|
|
}
|
|
list := pairingsReport()
|
|
var names []string
|
|
for _, e := range list {
|
|
names = append(names, e.HostName)
|
|
if !known[e.State] {
|
|
t.Fatalf("a pairing was sent with a state a host does not know: %+v", e)
|
|
}
|
|
}
|
|
if got := strings.Join(names, ","); got != tAnchor+","+beta {
|
|
t.Fatalf("the anchor host's list must be %s,%s, got %s", tAnchor, beta, got)
|
|
}
|
|
out, errOut, code := driveOp(t, tAnchor, "pairing_list", nil)
|
|
if code != 0 || contains(out, tMember) || contains(out, pend) || contains(out, pairingInvalid) {
|
|
t.Fatalf("pairing_list must leave the pairing out: %d %s %s", code, out, errOut)
|
|
}
|
|
if u := unapprovedAttempts(time.Now().Add(24 * time.Hour)); len(u) != 0 {
|
|
t.Fatalf("a pairing that is not served is no unapproved attempt: %+v", u)
|
|
}
|
|
cfgs, err := loadPairings()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c := pairingFor(cfgs, tMember); c == nil || c.stateName() != pairingInvalid {
|
|
t.Fatalf("this machine still lists it invalid: %+v", c)
|
|
}
|
|
}
|
|
|
|
// pair --replace over such a pairing pairs it again, served; unpair removes
|
|
// one. Both through the commands a person runs.
|
|
func TestPairReplaceAndUnpairClearAPairingThatIsNotServed(t *testing.T) {
|
|
uiTestConfigDir(t)
|
|
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
|
prev := unpairNeedsRoot
|
|
unpairNeedsRoot = func() bool { return false }
|
|
t.Cleanup(func() { unpairNeedsRoot = prev })
|
|
const host, good = "acme.host.privatae.ai", "203.0.113.7:51820"
|
|
redeem := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"operator_crt": "CERT", "ca_crt": "CA", "bearer": "b",
|
|
"endpoint": good, "endpoints": []string{good},
|
|
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
|
|
"host_tunnel_ip": "2001:db8::e:3", "host_name": host,
|
|
})
|
|
}))
|
|
defer redeem.Close()
|
|
old := redeemURLBase
|
|
redeemURLBase = redeem.URL
|
|
t.Cleanup(func() { redeemURLBase = old })
|
|
|
|
writeUnservedPairing(t, host, "")
|
|
if err := cmdPair(fakeToken("acme"), "", "", true); err != nil {
|
|
t.Fatalf("pair --replace over a pairing that is not served: %v", err)
|
|
}
|
|
cfgs, err := loadPairings()
|
|
if err != nil || len(cfgs) != 1 {
|
|
t.Fatalf("after pair --replace one pairing loads: %v %v", cfgs, err)
|
|
}
|
|
if c := cfgs[0]; c.HostName != host || c.invalid != "" || !c.active() || c.Endpoint != good {
|
|
t.Fatalf("pair --replace must leave a served pairing: %q invalid=%q active=%v endpoint=%q",
|
|
c.HostName, c.invalid, c.active(), c.Endpoint)
|
|
}
|
|
|
|
writeUnservedPairing(t, host, "")
|
|
if err := cmdUnpair(host); err != nil {
|
|
t.Fatalf("unpair of a pairing that is not served: %v", err)
|
|
}
|
|
if cfgs, err := loadPairings(); err != nil || len(cfgs) != 0 {
|
|
t.Fatalf("after unpair nothing is left: %v %v", cfgs, err)
|
|
}
|
|
pd, _ := pairingsDir()
|
|
if _, err := os.Stat(filepath.Join(pd, host+".json")); !os.IsNotExist(err) {
|
|
t.Fatalf("the pairing file is still there: %v", err)
|
|
}
|
|
}
|