bastion 0.53.2
This commit is contained in:
@@ -0,0 +1,635 @@
|
||||
//go:build !windows
|
||||
|
||||
// Uses setupBox / writePairingState / signTerms / grantTerms / driveOp from
|
||||
// anchor_test.go, the session helpers from session_test.go and the service
|
||||
// runtime stub from service_test.go, all unix-only.
|
||||
|
||||
package main
|
||||
|
||||
// The README's security model, driven through the code that enforces it:
|
||||
// the tunnel forwards' accept path, the session lane's handler, the
|
||||
// machine-authority gates and the management lane's allowlisted op. The
|
||||
// tunnel here is an in-memory network stack and every upstream a loopback
|
||||
// listener the test owns; nothing reaches the live machine's network, its
|
||||
// services or its processes.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
)
|
||||
|
||||
// ── inside the tunnel ───────────────────────────────────────────────────────
|
||||
|
||||
// upstreamCounter is a listener standing in for the service a forward
|
||||
// splices to. A connection arriving at it is the proof that the forward let
|
||||
// a tunnel connection through.
|
||||
type upstreamCounter struct {
|
||||
ln net.Listener
|
||||
got chan struct{}
|
||||
}
|
||||
|
||||
func newUpstream(t *testing.T, network, addr string) *upstreamCounter {
|
||||
t.Helper()
|
||||
ln, err := net.Listen(network, addr)
|
||||
if err != nil {
|
||||
t.Skipf("cannot listen on %s %s here: %v", network, addr, err)
|
||||
}
|
||||
u := &upstreamCounter{ln: ln, got: make(chan struct{}, 64)}
|
||||
go func() {
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
u.got <- struct{}{}
|
||||
_ = c.Close()
|
||||
}
|
||||
}()
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
return u
|
||||
}
|
||||
|
||||
// reached reports whether a connection arrived within d.
|
||||
func (u *upstreamCounter) reached(d time.Duration) bool {
|
||||
select {
|
||||
case <-u.got:
|
||||
return true
|
||||
case <-time.After(d):
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// tunnelDial connects to port on the in-memory stack, retrying while the
|
||||
// forward under test is still coming up. The connection is closed at once:
|
||||
// the accept is what the forward's wall acts on.
|
||||
func tunnelDial(t *testing.T, tnet *netstack.Net, gw netip.Addr, port int) {
|
||||
t.Helper()
|
||||
addr := net.JoinHostPort(gw.String(), fmt.Sprint(port))
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
c, err := tnet.DialContext(ctx, "tcp", addr)
|
||||
cancel()
|
||||
if err == nil {
|
||||
// Hold it open briefly so an allowed splice has a live peer.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("nothing listens on %s in the tunnel: %v", addr, err)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func grantMemberCaps(t *testing.T, priv ed25519.PrivateKey, caps ...string) {
|
||||
t.Helper()
|
||||
future := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
|
||||
terms, sig := signTerms(t, priv, grantTerms(tMember, caps, future))
|
||||
if _, errOut, code := driveOp(t, tAnchor, "pairing_grant_sync", []string{terms, sig}); code != 0 {
|
||||
t.Fatalf("grant %v: %d %s", caps, code, errOut)
|
||||
}
|
||||
}
|
||||
|
||||
// Inside the tunnel, only gated listeners: each inference forward, driven
|
||||
// through its real accept loop on a tunnel stack, passes a connection to its
|
||||
// upstream only while the pairing holds the `inference` family, re-read on
|
||||
// every accept.
|
||||
func TestEachInferenceForwardAdmitsOnlyThePairingThatHoldsInference(t *testing.T) {
|
||||
priv := setupBox(t)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
grantMemberCaps(t, priv, "observe")
|
||||
|
||||
ollama := newUpstream(t, "tcp", "127.0.0.1:0")
|
||||
llm := newUpstream(t, "tcp", "127.0.0.1:0")
|
||||
pii := newUpstream(t, "tcp", "127.0.0.1:0")
|
||||
t.Setenv("OLLAMA_ADDR", ollama.ln.Addr().String())
|
||||
t.Setenv("PRIVATAE_LLM_ADDR", llm.ln.Addr().String())
|
||||
t.Setenv("PRIVATAE_PII_ADDR", pii.ln.Addr().String())
|
||||
|
||||
gw := netip.MustParseAddr("2001:db8::a:1")
|
||||
_, tnet, err := netstack.CreateNetTUN([]netip.Addr{gw}, nil, 1420)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go serveOllamaForward(ctx, tMember, tnet, gw)
|
||||
go serveLLMForward(ctx, tMember, tnet, gw)
|
||||
go servePIIForward(ctx, tMember, tnet, gw)
|
||||
|
||||
lanes := []struct {
|
||||
name string
|
||||
port int
|
||||
up *upstreamCounter
|
||||
}{{"ollama", 11434, ollama}, {"llm", llmForwardPort, llm}, {"pii", piiForwardPort, pii}}
|
||||
|
||||
for _, l := range lanes {
|
||||
tunnelDial(t, tnet, gw, l.port)
|
||||
if l.up.reached(300 * time.Millisecond) {
|
||||
t.Errorf("%s forward: a pairing without the inference family reached the upstream", l.name)
|
||||
}
|
||||
}
|
||||
grantMemberCaps(t, priv, "observe", "inference")
|
||||
for _, l := range lanes {
|
||||
tunnelDial(t, tnet, gw, l.port)
|
||||
if !l.up.reached(3 * time.Second) {
|
||||
t.Errorf("%s forward: a pairing granted inference must reach the upstream, with no restart", l.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A service slot port admits a connection only when BOTH walls pass: the
|
||||
// pairing holds the `services` family, and a reach grant for that server
|
||||
// names that host. Driven through serveServiceForwards' accept loop; the
|
||||
// server's socket counts what gets through.
|
||||
func TestTheServiceSlotAdmitsOnlyWithTheFamilyAndAReachGrant(t *testing.T) {
|
||||
if runtime.GOOS != "linux" {
|
||||
t.Skip("the slot forward pins the server's socket through /proc and is linux-only")
|
||||
}
|
||||
priv := setupBox(t)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
grantMemberCaps(t, priv, "observe")
|
||||
|
||||
f := stubServiceRuntime(t, "docker")
|
||||
if b, code := runService(forgeSpec("forge")); code != 0 {
|
||||
t.Fatalf("up: %s", b)
|
||||
}
|
||||
f.running["privatae-svc-forge"] = true
|
||||
rec, err := loadServiceRecord("forge")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// The data dir's path is longer than a unix socket address may be, so the
|
||||
// server's socket is bound at a short path and moved into place: the
|
||||
// forward reaches it by inode through /proc (service_sock_linux.go), which
|
||||
// is exactly the path it takes on a real machine.
|
||||
data, _ := serviceDataDir("forge")
|
||||
short, err := os.MkdirTemp("", "svc")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(short) })
|
||||
server := newUpstream(t, "unix", filepath.Join(short, "s"))
|
||||
if err := os.Rename(filepath.Join(short, "s"), filepath.Join(data, "gitea.sock")); err != nil {
|
||||
t.Skipf("cannot move the socket into the data dir here: %v", err)
|
||||
}
|
||||
|
||||
// The reach grant, signed and on disk the way service_grant_sync leaves it.
|
||||
dir := t.TempDir()
|
||||
_, rpriv, _ := ed25519.GenerateKey(nil)
|
||||
tc, sig := signedTerms(t, rpriv, reachTermsFor(tMember, "forge", 30))
|
||||
withReach := &dcnGrantFile{VerifyPubkey: base64.StdEncoding.EncodeToString(rpriv.Public().(ed25519.PublicKey)),
|
||||
Stores: map[string]dcnGrantRec{}, Reach: map[string]dcnGrantRec{"1": {TermsCanonical: tc, Signature: sig}}}
|
||||
noReach := &dcnGrantFile{VerifyPubkey: withReach.VerifyPubkey, Stores: map[string]dcnGrantRec{}, Reach: map[string]dcnGrantRec{}}
|
||||
if err := dcnSaveFile(dir, withReach); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
gw := netip.MustParseAddr("2001:db8::a:1")
|
||||
_, tnet, err := netstack.CreateNetTUN([]netip.Addr{gw}, nil, 1420)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
go serveServiceForwards(ctx, tMember, dir, tnet, gw)
|
||||
|
||||
// A reach grant, but no services family: refused.
|
||||
tunnelDial(t, tnet, gw, rec.TunnelPort)
|
||||
if server.reached(300 * time.Millisecond) {
|
||||
t.Fatal("a pairing without the services family reached the server, reach grant or not")
|
||||
}
|
||||
// The family, but no reach grant for this server: refused.
|
||||
grantMemberCaps(t, priv, "observe", "services")
|
||||
if err := dcnSaveFile(dir, noReach); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnelDial(t, tnet, gw, rec.TunnelPort)
|
||||
if server.reached(300 * time.Millisecond) {
|
||||
t.Fatal("a pairing with no reach grant for this server reached it")
|
||||
}
|
||||
// Both: the connection goes through to the server's socket.
|
||||
if err := dcnSaveFile(dir, withReach); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tunnelDial(t, tnet, gw, rec.TunnelPort)
|
||||
if !server.reached(3 * time.Second) {
|
||||
t.Fatal("with the family and a reach grant the connection must reach the server")
|
||||
}
|
||||
}
|
||||
|
||||
// ── the session lane, through handleSessionConn ────────────────────────────
|
||||
|
||||
// openSession opens one session through the handler and returns what came
|
||||
// back: the fake shell echoes "Iping" as O "ping" when the session opened. It
|
||||
// returns only after the handler has finished, so nothing the session writes
|
||||
// (its transcript, its audit line) lands after the test's dirs are removed.
|
||||
func openSession(t *testing.T, env sessionEnv, self string, spec sessionSpec) map[byte][]byte {
|
||||
t.Helper()
|
||||
done := make(chan struct{})
|
||||
up := websocket.Upgrader{}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(done)
|
||||
c, err := up.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer c.Close()
|
||||
_, first, err := c.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
handleSessionConn(c, first, self, env)
|
||||
}))
|
||||
defer srv.Close()
|
||||
ws, _, err := websocket.DefaultDialer.Dial("ws"+strings.TrimPrefix(srv.URL, "http"), nil)
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
sendSpec(t, ws, spec)
|
||||
_ = ws.WriteMessage(websocket.BinaryMessage, []byte("Iping"))
|
||||
_ = ws.SetReadDeadline(time.Now().Add(3 * time.Second))
|
||||
got := map[byte][]byte{}
|
||||
for {
|
||||
_, data, err := ws.ReadMessage()
|
||||
if err != nil || len(data) == 0 {
|
||||
break
|
||||
}
|
||||
got[data[0]] = append(got[data[0]], data[1:]...)
|
||||
if data[0] == 'X' || (data[0] == 'O' && strings.Contains(string(got['O']), "ping")) {
|
||||
break
|
||||
}
|
||||
}
|
||||
// The host hanging up ends an open session; wait for the handler to
|
||||
// write its last line and return.
|
||||
_ = ws.Close()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("the session handler did not finish after the host hung up")
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
func opened(got map[byte][]byte) bool { return strings.Contains(string(got['O']), "ping") }
|
||||
|
||||
func refusedWith(got map[byte][]byte, words string) bool {
|
||||
return string(got['X']) == "126" && len(got['O']) == 0 && strings.Contains(string(got['E']), words)
|
||||
}
|
||||
|
||||
// withSessionRoot makes the daemon read as root, or not, for the session
|
||||
// lane's root wall only.
|
||||
func withSessionRoot(t *testing.T, root bool) {
|
||||
t.Helper()
|
||||
old := sessionDaemonIsRoot
|
||||
sessionDaemonIsRoot = func() bool { return root }
|
||||
t.Cleanup(func() { sessionDaemonIsRoot = old })
|
||||
}
|
||||
|
||||
// consoleApproval issues a one-use approval on this machine for purpose,
|
||||
// with the factor enrolled here (the code a person types at the console).
|
||||
func consoleApproval(t *testing.T, dcn, purpose string) string {
|
||||
t.Helper()
|
||||
raw, _ := b32decode(tSecret)
|
||||
code := hotp(raw, time.Now().Unix()/stepupPeriod)
|
||||
id, err := stepupIssueApproval(dcn, code, purpose)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// A terminal: on an anchored machine a host other than the anchor needs the
|
||||
// `session` family in its pairing grant. The handler refuses it before any
|
||||
// approval is looked at, and the approval it carried is still good after.
|
||||
func TestAMemberWithoutTheSessionFamilyGetsNoTerminal(t *testing.T) {
|
||||
priv := setupBox(t)
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
grantMemberCaps(t, priv, "observe")
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
||||
env.HostName = tMember
|
||||
withFake(t)
|
||||
pub, _ := deviceInit()
|
||||
_ = pinDeviceKey(pub)
|
||||
approval, err := deviceSign(mgmtOpPurpose(sessionOpOpen, env.Server, []string{"sess-member-1"}), time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := openSession(t, env, tMember, sessionSpec{Op: sessionOpOpen, SessionID: "sess-member-1", Approval: approval})
|
||||
if !refusedWith(got, "family 'session', not in this pairing's caps") {
|
||||
t.Fatalf("a member without the session family must be refused by the family wall: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(env.CtxRoot, "sessions", "sess-member-1.log")); err == nil {
|
||||
t.Fatal("a refused terminal must leave no transcript")
|
||||
}
|
||||
grantMemberCaps(t, priv, "observe", "session")
|
||||
if got := openSession(t, env, tMember, sessionSpec{Op: sessionOpOpen, SessionID: "sess-member-1", Approval: approval}); !opened(got) {
|
||||
t.Fatalf("with the family granted the same approval must open the terminal: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
}
|
||||
|
||||
// On an anchored machine each terminal needs an approval bound to that one
|
||||
// session and used once.
|
||||
func TestAnAnchoredTerminalApprovalOpensOneSessionOnce(t *testing.T) {
|
||||
setupBox(t)
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
||||
env.HostName = tAnchor
|
||||
withFake(t)
|
||||
pub, _ := deviceInit()
|
||||
_ = pinDeviceKey(pub)
|
||||
approval, err := deviceSign(mgmtOpPurpose(sessionOpOpen, env.Server, []string{"sess-bound-A"}), time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-B", Approval: approval}); !refusedWith(got, "purpose mismatch") {
|
||||
t.Fatalf("an approval for one session must not open another: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-A", Approval: approval}); !opened(got) {
|
||||
t.Fatalf("the approval must open the session it names: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
if got := openSession(t, env, tAnchor, sessionSpec{Op: sessionOpOpen, SessionID: "sess-bound-A", Approval: approval}); !refusedWith(got, "already used") {
|
||||
t.Fatalf("a spent approval must not open the session a second time: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
}
|
||||
|
||||
// A root terminal needs the bastion to run as root. Everything else in
|
||||
// order — the factor enrolled here and a code entered for this session — a
|
||||
// daemon that is not root refuses, and spends nothing: the same approval
|
||||
// opens the session once the daemon is root.
|
||||
func TestARootTerminalNeedsTheBastionToRunAsRoot(t *testing.T) {
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
|
||||
enrollFactor(t, env.DcnDir)
|
||||
withFake(t)
|
||||
p, _ := purposeForSessionRoot(env.Server, "sess-root-euid")
|
||||
approval := consoleApproval(t, env.DcnDir, p)
|
||||
|
||||
withSessionRoot(t, false)
|
||||
got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-euid", Approval: approval})
|
||||
if !refusedWith(got, "needs the bastion to run as root") {
|
||||
t.Fatalf("a root terminal on a daemon that is not root must be refused for that reason: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
withSessionRoot(t, true)
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-euid", Approval: approval}); !opened(got) {
|
||||
t.Fatalf("the same approval must open the root terminal on a root daemon: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
}
|
||||
|
||||
// An approval device cannot approve a root terminal, with or without a
|
||||
// factor enrolled here: root is approved with a code entered on this machine.
|
||||
func TestADeviceCannotApproveARootTerminal(t *testing.T) {
|
||||
withSessionRoot(t, true)
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
|
||||
withFake(t)
|
||||
uiTestConfigDir(t)
|
||||
pub, _ := deviceInit()
|
||||
_ = pinDeviceKey(pub)
|
||||
p, _ := purposeForSessionRoot(env.Server, "sess-root-dev1")
|
||||
dev, err := deviceSign(p, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-dev1", Approval: dev}); !refusedWith(got, "second factor enrolled on this machine") {
|
||||
t.Fatalf("with only a device pinned, root must be refused: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
enrollFactor(t, env.DcnDir)
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-dev1", Approval: dev}); !refusedWith(got, "not from a device") {
|
||||
t.Fatalf("with a factor enrolled, a device approval must still not open root: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
}
|
||||
|
||||
// A root approval opens the one session it was entered for, once.
|
||||
func TestARootApprovalOpensOneSessionOnce(t *testing.T) {
|
||||
withSessionRoot(t, true)
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen, sessionOpRoot}, nil)
|
||||
enrollFactor(t, env.DcnDir)
|
||||
withFake(t)
|
||||
p, _ := purposeForSessionRoot(env.Server, "sess-root-one")
|
||||
approval := consoleApproval(t, env.DcnDir, p)
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-two", Approval: approval}); !refusedWith(got, "purpose mismatch") {
|
||||
t.Fatalf("a root approval must not open another session: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-one", Approval: approval}); !opened(got) {
|
||||
t.Fatalf("the root approval must open its own session: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpRoot, SessionID: "sess-root-one", Approval: approval}); !refusedWith(got, "already used") {
|
||||
t.Fatalf("a spent root approval must not open anything again: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
}
|
||||
|
||||
// Grants are verified here: the key a session frame offers is pinned by the
|
||||
// handler the first time, and a later frame offering another key is refused
|
||||
// with the pin kept.
|
||||
func TestTheSessionHandlerPinsTheOfferedKey(t *testing.T) {
|
||||
env, _, _ := sessionTestEnv(t, []string{sessionOpOpen}, nil)
|
||||
env.DcnDir = t.TempDir() // no key pinned yet
|
||||
withFake(t)
|
||||
pub1, _, _ := ed25519.GenerateKey(nil)
|
||||
k1 := base64.StdEncoding.EncodeToString(pub1)
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpOpen, SessionID: "sess-pin-one", VerifyPubkey: k1}); !opened(got) {
|
||||
t.Fatalf("the first key offered must open and pin: X=%q E=%q", got['X'], got['E'])
|
||||
}
|
||||
if f, err := dcnLoadFile(env.DcnDir); err != nil || f.VerifyPubkey != k1 {
|
||||
t.Fatalf("the handler must pin the offered key: %v %+v", err, f)
|
||||
}
|
||||
pub2, _, _ := ed25519.GenerateKey(nil)
|
||||
k2 := base64.StdEncoding.EncodeToString(pub2)
|
||||
if got := openSession(t, env, "", sessionSpec{Op: sessionOpOpen, SessionID: "sess-pin-two", VerifyPubkey: k2}); !refusedWith(got, "mismatch") {
|
||||
t.Fatalf("a different key must be refused: X=%q E=%q O=%q", got['X'], got['E'], got['O'])
|
||||
}
|
||||
if f, _ := dcnLoadFile(env.DcnDir); f.VerifyPubkey != k1 {
|
||||
t.Fatalf("a refused key must not replace the pin: %q", f.VerifyPubkey)
|
||||
}
|
||||
}
|
||||
|
||||
// ── root only where there is no one else to ask ────────────────────────────
|
||||
|
||||
func withMachineAuthority(t *testing.T, held bool) {
|
||||
t.Helper()
|
||||
old := machineAuthorityHeld
|
||||
machineAuthorityHeld = func() bool { return held }
|
||||
t.Cleanup(func() { machineAuthorityHeld = old })
|
||||
}
|
||||
|
||||
// Claiming ownership or the anchor, and turning a box networked, are
|
||||
// refused without machine authority and write nothing; with it, each gets
|
||||
// past that wall. Both halves run whatever euid the suite runs under.
|
||||
func TestMachineWideFactsNeedMachineAuthority(t *testing.T) {
|
||||
freshBox := func(t *testing.T) {
|
||||
t.Helper()
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
||||
}
|
||||
t.Run("claim-owner", func(t *testing.T) {
|
||||
freshBox(t)
|
||||
withMachineAuthority(t, false)
|
||||
if err := cmdClaimOwner("Acme IT"); err == nil || !strings.Contains(err.Error(), "machine authority") {
|
||||
t.Fatalf("claim-owner without machine authority must be refused: %v", err)
|
||||
}
|
||||
if currentOwnerClaim() != nil {
|
||||
t.Fatal("a refused claim-owner wrote a claim")
|
||||
}
|
||||
withMachineAuthority(t, true)
|
||||
if err := cmdClaimOwner("Acme IT"); err != nil {
|
||||
t.Fatalf("claim-owner with machine authority: %v", err)
|
||||
}
|
||||
if c := currentOwnerClaim(); c == nil || c.Owner != "Acme IT" {
|
||||
t.Fatalf("the claim was not written: %+v", c)
|
||||
}
|
||||
})
|
||||
t.Run("set-mode networked", func(t *testing.T) {
|
||||
freshBox(t)
|
||||
withMachineAuthority(t, false)
|
||||
if err := setBoxMode(modeNetworked); err == nil || !strings.Contains(err.Error(), "machine authority") {
|
||||
t.Fatalf("set-mode networked without machine authority must be refused: %v", err)
|
||||
}
|
||||
if boxMode() != modeLocal {
|
||||
t.Fatal("a refused set-mode changed the mode")
|
||||
}
|
||||
withMachineAuthority(t, true)
|
||||
if err := setBoxMode(modeNetworked); err != nil || boxMode() != modeNetworked {
|
||||
t.Fatalf("set-mode networked with machine authority: %v, mode %s", err, boxMode())
|
||||
}
|
||||
})
|
||||
t.Run("claim-anchor", func(t *testing.T) {
|
||||
freshBox(t)
|
||||
withMachineAuthority(t, true)
|
||||
if err := setBoxMode(modeNetworked); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePairingState(t, tMember, pairingPending)
|
||||
withMachineAuthority(t, false)
|
||||
if err := cmdClaimAnchor("", false, "Acme IT", ""); err == nil || !strings.Contains(err.Error(), "machine authority") {
|
||||
t.Fatalf("claim-anchor without machine authority must be refused: %v", err)
|
||||
}
|
||||
if currentAnchor() != nil || currentOwnerClaim() != nil {
|
||||
t.Fatal("a refused claim-anchor wrote an anchor or an owner claim")
|
||||
}
|
||||
if err := cmdRepinAnchor(); err == nil || !strings.Contains(err.Error(), "machine authority") {
|
||||
t.Fatalf("claim-anchor --repin without machine authority must be refused: %v", err)
|
||||
}
|
||||
withMachineAuthority(t, true)
|
||||
if err := cmdClaimAnchor("", false, "Acme IT", ""); err != nil {
|
||||
t.Fatalf("claim-anchor with machine authority: %v", err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||
t.Fatalf("the anchor was not written: %+v", a)
|
||||
}
|
||||
// --repin with machine authority gets past that wall and completes: on
|
||||
// this box it re-pins the anchor to the pairing that already holds it.
|
||||
if err := cmdRepinAnchor(); err != nil {
|
||||
t.Fatalf("claim-anchor --repin with machine authority: %v", err)
|
||||
}
|
||||
if a := currentAnchor(); a == nil || a.AnchorHost != tMember {
|
||||
t.Fatalf("--repin moved the anchor: %+v", a)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ── fixed argv ──────────────────────────────────────────────────────────────
|
||||
|
||||
// The allowlisted op ignores what the caller sends: the program it runs is
|
||||
// given no argument, whatever the job carries. The program is a stand-in on
|
||||
// a PATH the test owns, which writes down the argv it was given.
|
||||
func TestTheAllowlistedOpIgnoresTheCallersArguments(t *testing.T) {
|
||||
t.Setenv("XDG_CONFIG_HOME", t.TempDir())
|
||||
bin := t.TempDir()
|
||||
record := filepath.Join(t.TempDir(), "argv")
|
||||
script := "#!/bin/sh\nprintf '%s\\n' \"$#\" \"$@\" > '" + record + "'\necho up 1 day\n"
|
||||
if err := os.WriteFile(filepath.Join(bin, "uptime"), []byte(script), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("PATH", bin)
|
||||
hostile := []string{"-p", "; touch /tmp/owned", "$(id)", "--help"}
|
||||
out, errOut, code := driveOp(t, tMember, "uptime", hostile)
|
||||
if code != 0 || !strings.Contains(out, "up 1 day") {
|
||||
t.Fatalf("uptime did not run the stand-in: %d %q %q", code, out, errOut)
|
||||
}
|
||||
got, err := os.ReadFile(record)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != "0" {
|
||||
t.Fatalf("the allowlisted op passed arguments to its program: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// ── scoped tunnel, at pairing and at start ─────────────────────────────────
|
||||
|
||||
// A pairing response whose host_tunnel_ip is not one host's address is
|
||||
// refused, and no pairing is saved; the same response with a bare address
|
||||
// pairs. The redeem is served on loopback in place of the platform's ingress.
|
||||
func TestAPairingResponseWithAWideTunnelAddressSavesNothing(t *testing.T) {
|
||||
cfgHome := uiTestConfigDir(t)
|
||||
withMarkerAt(t, filepath.Join(t.TempDir(), "machine.json"))
|
||||
hostIP := ""
|
||||
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": "203.0.113.7:51820",
|
||||
"endpoint_pubkey": testWGKey(t), "bastion_tunnel_ip": "2001:db8::a:1",
|
||||
"host_tunnel_ip": hostIP, "host_name": "acme.host.privatae.ai",
|
||||
})
|
||||
}))
|
||||
defer redeem.Close()
|
||||
old := redeemURLBase
|
||||
redeemURLBase = redeem.URL
|
||||
t.Cleanup(func() { redeemURLBase = old })
|
||||
token := "skb_" + base64.RawURLEncoding.EncodeToString([]byte("acme.secret"))
|
||||
|
||||
for _, bad := range []string{"2001:db8::e:3/64", "::/0", "2001:db8::e:3/128", "::"} {
|
||||
hostIP = bad
|
||||
if _, _, err := pairWithToken(token, false, false); err == nil || !strings.Contains(err.Error(), "not a single host address") {
|
||||
t.Fatalf("%q: the pairing must be refused for its tunnel address, got %v", bad, err)
|
||||
}
|
||||
// Nothing at all: not a pairing, not a grant file, not a key. The
|
||||
// walk covers what loadPairings would skip.
|
||||
_ = filepath.Walk(cfgHome, func(path string, fi os.FileInfo, err error) error {
|
||||
if err == nil && !fi.IsDir() {
|
||||
t.Errorf("%q: a refused pairing left a file behind: %s", bad, path)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
hostIP = "2001:db8::e:3"
|
||||
cfg, _, err := pairWithToken(token, false, false)
|
||||
if err != nil {
|
||||
t.Fatalf("a bare address must pair: %v", err)
|
||||
}
|
||||
if cfg.HostTunnelIP != "2001:db8::e:3" {
|
||||
t.Fatalf("saved %q", cfg.HostTunnelIP)
|
||||
}
|
||||
}
|
||||
|
||||
// A pairing file that already holds a wide address (written by an older
|
||||
// build, or by hand) brings no tunnel up: runPairing refuses before the
|
||||
// device is raised.
|
||||
func TestRunPairingRefusesAWideTunnelAddress(t *testing.T) {
|
||||
uiTestConfigDir(t)
|
||||
for _, bad := range []string{"2001:db8::e:3/64", "::/0"} {
|
||||
cfg := &Config{HostName: "acme.host.privatae.ai", WGPrivateKey: testWGKey(t), EndpointPubKey: testWGKey(t),
|
||||
Endpoint: "203.0.113.7:51820", BastionTunnelIP: "2001:db8::a:1", HostTunnelIP: bad}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
err := runPairing(ctx, cfg)
|
||||
cancel()
|
||||
if err == nil || !strings.Contains(err.Error(), "not a single host address") {
|
||||
t.Fatalf("%q: runPairing must refuse the tunnel address, got %v", bad, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user