Files
privatae-bastion/client-go/hostdoor_linux_test.go
T
Privatae 48899b41a9
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 4m58s
ci / fuzz (push) Successful in 3m33s
bastion 0.63.1
2026-11-03 22:56:18 +00:00

480 lines
17 KiB
Go

//go:build linux
package main
import (
"bytes"
"context"
"crypto/tls"
"net"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// freeDoorAddr is a loopback port nothing listens on.
func freeDoorAddr(t *testing.T) string {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
a := ln.Addr().String()
_ = ln.Close()
return a
}
// syncBuf is a bytes.Buffer two goroutines can share.
type syncBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (s *syncBuf) Write(p []byte) (int, error) { s.mu.Lock(); defer s.mu.Unlock(); return s.b.Write(p) }
func (s *syncBuf) String() string { s.mu.Lock(); defer s.mu.Unlock(); return s.b.String() }
// openWindowFor runs `pair --open` on addr in the background and returns the
// code it shows, its output, and its result.
func openWindowFor(t *testing.T, addr string) (code string, out *syncBuf, result <-chan error) {
t.Helper()
prevPoll := windowPollEvery
windowPollEvery = 20 * time.Millisecond
t.Cleanup(func() { windowPollEvery = prevPoll })
out = &syncBuf{}
res := make(chan error, 1)
go func() { res <- cmdPairOpen(addr, out) }()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if w := loadOpenWindow(); w != nil && strings.Contains(out.String(), formatWindowCode(w.Code)) {
return w.Code, out, res
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("pair --open showed no code: %q", out.String())
return "", nil, nil
}
func pairingAuditText(t *testing.T) string {
t.Helper()
d, _ := configDir()
b, _ := os.ReadFile(filepath.Join(d, "pairing_audit.jsonl"))
return string(b)
}
// The code the person reads is the code both sides key the proofs with,
// however it is typed back.
func TestTheWindowCodeIsReadBackHoweverItIsTyped(t *testing.T) {
c, err := newWindowCode()
if err != nil || normalizeWindowCode(c) != c || len(c) != windowCodeLen {
t.Fatalf("a fresh code is not normalised: %q %v", c, err)
}
f := formatWindowCode(c)
if strings.Count(f, "-") != 3 || normalizeWindowCode(f) != c || normalizeWindowCode(strings.ToLower(f)) != c {
t.Fatalf("the shown code %q does not read back as %q", f, c)
}
if normalizeWindowCode("O1IL0-00000-00000-00000") != "01110000000000000000" {
t.Fatal("O, I and L read as the digits they look like")
}
for _, bad := range []string{"", "ABCDE", c + "0", strings.Replace(c, c[:1], "U", 1)} {
if normalizeWindowCode(bad) != "" {
t.Fatalf("%q is not a code", bad)
}
}
for args, want := range map[string]string{"--open": doorDefaultAddr, "--open --port 9443": ":9443", "--port=9443 --open": ":9443"} {
got, ok, err := parsePairOpenArgs(strings.Fields(args))
if !ok || err != nil || got != want {
t.Fatalf("%s: %q %v %v", args, got, ok, err)
}
}
for _, args := range []string{"--open --port 0", "--open --port x", "--open skb_x"} {
if _, ok, err := parsePairOpenArgs(strings.Fields(args)); !ok || err == nil {
t.Fatalf("%s must be refused", args)
}
}
if _, ok, _ := parsePairOpenArgs([]string{"skb_x"}); ok {
t.Fatal("a token pair is not --open")
}
}
// A Host that connects pairs through the window: the code proved both ways,
// the pairing saved with its CA pinned and no address to dial, the window
// closed after one use, and each step in the audit.
func TestAHostThatConnectsPairsThroughTheWindow(t *testing.T) {
pairingBox(t)
addr := freeDoorAddr(t)
code, out, res := openWindowFor(t, addr)
ca := newTestCA(t, "home host CA")
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
done, err := h.pair(formatWindowCode(code))
if err != nil {
t.Fatalf("%v\n%s", err, pairingAuditText(t))
}
if done.State != pairingActive || done.HostName != "home.cseries.localhost" {
t.Fatalf("done: %+v", done)
}
select {
case err := <-res:
if err != nil {
t.Fatalf("pair --open: %v\n%s", err, out.String())
}
case <-time.After(10 * time.Second):
t.Fatal("pair --open never saw the pairing end its window")
}
if !strings.Contains(out.String(), "Your Host connects to this server") || !strings.Contains(out.String(), "Paired with home.cseries.localhost") {
t.Fatalf("what the person reads:\n%s", out.String())
}
cfgs, _ := loadPairings()
c := pairingFor(cfgs, "home")
if c == nil || !c.hostDials() || c.DoorAddr != addr || c.LaneAddr != "" || !sameCAs(c.CACert, ca.pem) ||
!strings.HasPrefix(c.Bearer, "mcp_dials_home_") || operatorCertRefusal(c.OperatorCert, ca.pem, c.OperatorKey) != nil {
t.Fatalf("the pairing: %+v", c)
}
if w := readWindowFile(); w != nil {
t.Fatalf("the window outlived its pairing: %+v", w)
}
// One use: the same code again finds no window.
if _, err := h.pair(code); err == nil {
t.Fatal("a used code paired again")
}
a := pairingAuditText(t)
for _, op := range []string{`"window_opened"`, `"window_paired"`} {
if !strings.Contains(a, op) {
t.Fatalf("the audit has no %s:\n%s", op, a)
}
}
}
// SECURITY: a wrong code gets nothing, costs a wait that doubles, and the
// fifth closes the window. A proof made over another certificate (one
// relayed from another connection) is a wrong code. A Host never signs for a
// server that did not prove the code.
func TestAWrongCodeIsThrottledAndClosesTheWindow(t *testing.T) {
pairingBox(t)
prev := windowFailWait
windowFailWait = 100 * time.Millisecond
t.Cleanup(func() { windowFailWait = prev })
addr := freeDoorAddr(t)
code, _, res := openWindowFor(t, addr)
ca := newTestCA(t, "home host CA")
h := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
relayed := newStandinHost(t, ca, "home", "home.cseries.localhost", addr)
relayed.lieLeaf = true
if _, err := relayed.pair(code); err == nil {
t.Fatal("a proof over another certificate paired")
}
// The wait after it: the next try, at once, is not even offered a pairing.
if _, err := h.pair(code); err == nil || !strings.Contains(err.Error(), "handshake") {
t.Fatalf("a try inside the wait must be refused at the handshake, got %v", err)
}
wrong := "ZZZZZ-ZZZZZ-ZZZZZ-ZZZZZ"
for i := 2; i <= windowMaxFailures; i++ {
time.Sleep(time.Duration(1<<(i-2))*windowFailWait + 50*time.Millisecond)
if _, err := h.pair(wrong); err == nil {
t.Fatalf("wrong code %d paired", i)
}
}
select {
case err := <-res:
if err == nil || !strings.Contains(err.Error(), "wrong codes") {
t.Fatalf("pair --open after %d wrong codes: %v", windowMaxFailures, err)
}
case <-time.After(5 * time.Second):
t.Fatal("the window stayed open after five wrong codes")
}
if _, err := h.pair(code); err == nil {
t.Fatal("the right code paired after the window closed")
}
cfgs, _ := loadPairings()
if len(cfgs) != 0 || h.csrs != 0 || relayed.csrs != 0 {
t.Fatalf("a wrong code got something: %d pairings, %d CSRs signed", len(cfgs), h.csrs+relayed.csrs)
}
if n := strings.Count(pairingAuditText(t), `"window_wrong_code"`); n != windowMaxFailures {
t.Fatalf("%d wrong codes audited, want %d", n, windowMaxFailures)
}
// The Host's side: a server whose proof does not check gets no signature.
addr2 := freeDoorAddr(t)
code2, _, res2 := openWindowFor(t, addr2)
strict := newStandinHost(t, ca, "home", "home.cseries.localhost", addr2)
strict.badProof = true
if d, err := strict.pair(code2); err != nil || d.State != "refused" {
t.Fatalf("a Host refusing the server's proof: %+v %v", d, err)
}
if err := <-res2; err == nil {
t.Fatal("the window reported a pairing the Host refused")
}
if cfgs, _ := loadPairings(); len(cfgs) != 0 || strict.csrs != 0 {
t.Fatal("a pairing was kept that the Host never signed for")
}
}
// hostDialsPairingFor is a host-dials pairing with an operator certificate
// from ca and its door on addr.
func hostDialsPairingFor(t *testing.T, ca *testCA, host, addr string) *Config {
t.Helper()
c := tcpPairingFor(t, ca, host, "")
c.HostDials, c.DoorAddr = true, addr
c.Bearer = "mcp_dials_lane_bearer"
return c
}
// runPairingBG runs the pairing until the returned stop.
func runPairingBG(t *testing.T, cfg *Config) (stop func() error) {
ctx, cancel := context.WithCancel(context.Background())
errc := make(chan error, 1)
go func() { errc <- runPairing(ctx, cfg) }()
return func() error {
cancel()
select {
case err := <-errc:
return err
case <-time.After(10 * time.Second):
t.Fatal("the pairing did not stop")
}
return nil
}
}
func doorWaitFor(t *testing.T, d time.Duration, what string, ok func() bool) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if ok() {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
// The lanes run over the connections the Host opens, exactly as over a
// dialled lane port: the heartbeat with the bearer, every worker pool, a
// harness job on a parked worker, every handshake under host.cseries.internal
// and with the operator certificate (the stand-in requires and verifies it).
// The door is bound only while the pairing is served, and the bastion never
// dials anything.
func TestTheLanesRunOverTheConnectionsTheHostOpens(t *testing.T) {
pairingBox(t)
laneTestProcess(t)
ca := newTestCA(t, "home host CA")
addr := freeDoorAddr(t)
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
h := newStandinHost(t, ca, "home", cfg.HostName, addr)
h.lane.jobs <- []byte(`{"harness_id":"h1","mode":"echo","prompt":"hello over a Host-opened connection"}`)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go h.serveLanes(ctx)
stop := runPairingBG(t, cfg)
doorWaitFor(t, 10*time.Second, "the heartbeat and every worker", func() bool {
hellos, beats := h.lane.snapshot()
return len(beats) > 0 && len(hellos) >= harnessWorkerCount+sessionWorkerCount+mgmtWorkerCount+egressWorkerCount+serveWorkerCount
})
select {
case r := <-h.lane.replies:
if !strings.HasSuffix(r, "exit=0") {
t.Fatalf("the harness job: %s", r)
}
case <-time.After(10 * time.Second):
t.Fatal("a parked harness worker served no job")
}
hellos, beats := h.lane.snapshot()
if beats[0] != "Bearer "+cfg.Bearer {
t.Fatalf("the heartbeat carries the bearer: %q", beats)
}
count := map[string]int{}
for _, hl := range hellos {
path, msg, _ := strings.Cut(hl, " ")
if !strings.HasPrefix(msg, "BEARER "+cfg.Bearer) {
t.Fatalf("a worker opened without the bearer: %q", hl)
}
if strings.Contains(msg, "lane=session") {
path += " session"
}
count[path]++
}
if count["/bastion-harness"] < harnessWorkerCount || count["/bastion-harness session"] != sessionWorkerCount ||
count["/bastion-mgmt"] != mgmtWorkerCount || count["/bastion-egress"] != egressWorkerCount ||
count["/bastion-serve"] != serveWorkerCount {
t.Fatalf("the worker pools over Host-opened connections: %v", count)
}
h.lane.mu.Lock()
sni := append([]string(nil), h.lane.sni...)
h.lane.mu.Unlock()
for _, n := range sni {
if n != tcpServerName {
t.Fatalf("a handshake asked for %q", n)
}
}
if !strings.Contains(pairingAuditText(t), `"host_connected"`) {
t.Fatal("the Host's connection is not in the audit")
}
if err := stop(); err != nil {
t.Fatal(err)
}
// Closed with its last pairing.
doorWaitFor(t, 5*time.Second, "the door to close", func() bool {
ln, err := net.Listen("tcp", addr)
if err == nil {
_ = ln.Close()
}
return err == nil
})
}
// SECURITY: the door keeps a connection only from the Host whose pinned CA
// its certificate verifies under, for host.cseries.internal, naming the lane
// protocol. Another CA, the right CA under another name, no protocol, and a
// pairing protocol with no window open all end at the handshake: nothing is
// pooled and no lane ever runs on them.
func TestTheDoorRefusesAnythingButItsPinnedHost(t *testing.T) {
pairingBox(t)
laneTestProcess(t)
ca := newTestCA(t, "home host CA")
addr := freeDoorAddr(t)
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
stop := runPairingBG(t, cfg)
defer stop()
doorWaitFor(t, 5*time.Second, "the door", func() bool {
c, err := net.Dial("tcp", addr)
if err == nil {
_ = c.Close()
}
return err == nil
})
impostor := newTestCA(t, "impostor")
cases := map[string]struct {
cert tls.Certificate
protos []string
}{
"another CA": {impostor.serverCert(t, tcpServerName), []string{laneALPN}},
"the Host's own name": {ca.serverCert(t, "home.cseries.localhost"), []string{laneALPN}},
"no protocol": {ca.serverCert(t, tcpServerName), nil},
"pairing, no window": {ca.serverCert(t, tcpServerName), []string{pairALPN}},
}
for name, tc := range cases {
raw, err := net.Dial("tcp", addr)
if err != nil {
t.Fatal(err)
}
s := tls.Server(raw, &tls.Config{Certificates: []tls.Certificate{tc.cert}, NextProtos: tc.protos,
ClientAuth: tls.RequestClientCert})
_ = s.SetDeadline(time.Now().Add(5 * time.Second))
herr := s.Handshake()
if herr == nil {
// TLS 1.3: the server's side can finish before the client's
// verdict arrives; the verdict is the connection closing.
_, herr = s.Read(make([]byte, 1))
}
if herr == nil {
t.Fatalf("%s: the door kept the connection", name)
}
if got := len(s.ConnectionState().PeerCertificates); got != 0 {
t.Fatalf("%s: the door presented this bastion's certificate to a stranger", name)
}
_ = raw.Close()
}
// Nothing was pooled: a lane's dial finds no connection. Its wait is
// cut short by the context: doorTakeWait is a constant, read by every
// lane, an earlier test's still running ones too.
short, cancelShort := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancelShort()
if c, err := hostDoorDial(cfg)(short); err == nil {
_ = c.Close()
t.Fatal("a refused connection reached the pool")
}
if !strings.Contains(pairingAuditText(t), `"door_refused"`) {
t.Fatal("the refusals are not in the audit")
}
}
// The Host redials, the bastion never does: a Host that stops and comes back
// gets its lanes again, and so does a bastion that restarts, all on
// connections the Host opens. A connection the Host closes while idle leaves
// the pool at once.
func TestTheHostReconnectsAfterEitherSideRestarts(t *testing.T) {
pairingBox(t)
laneTestProcess(t)
ca := newTestCA(t, "home host CA")
addr := freeDoorAddr(t)
cfg := hostDialsPairingFor(t, ca, "home.cseries.localhost", addr)
h := newStandinHost(t, ca, "home", cfg.HostName, addr)
beats := func() int { _, b := h.lane.snapshot(); return len(b) }
stop := runPairingBG(t, cfg)
ctx, cancel := context.WithCancel(context.Background())
go h.serveLanes(ctx)
doorWaitFor(t, 10*time.Second, "the first heartbeat", func() bool { return beats() >= 1 })
// The Host stops: every connection closes, and the pool empties.
cancel()
doorWaitFor(t, 5*time.Second, "the pool to empty", func() bool {
doors.Lock()
d := doors.m[addr]
doors.Unlock()
d.mu.Lock()
p := d.pairings[cfg.HostName]
d.mu.Unlock()
return p.live.Load() == 0
})
doorWaitFor(t, 5*time.Second, "the Host going away in the audit", func() bool {
return strings.Contains(pairingAuditText(t), `"host_disconnected"`)
})
// It comes back: a beat at once (the reconnect brings the lanes up; the
// beat loop's next tick or a new run sends it).
n := beats()
ctx2, cancel2 := context.WithCancel(context.Background())
defer cancel2()
go h.serveLanes(ctx2)
if err := stop(); err != nil {
t.Fatal(err)
}
// The bastion restarts while the Host keeps redialling.
stop = runPairingBG(t, cfg)
defer stop()
doorWaitFor(t, 15*time.Second, "a heartbeat after both restarts", func() bool { return beats() > n })
if h.dials.Load() < 2*hostSpareConns {
t.Fatalf("the Host opened %d connections over two runs", h.dials.Load())
}
}
// The terminal closing the window and a door committing a pairing on it are
// one rename each, across processes: whichever comes first wins. A window
// closed first is never claimed; a window claimed first is reported as the
// pairing it made, never "nothing was paired".
func TestClosingAWindowAndClaimingItCannotBothWin(t *testing.T) {
pairingBox(t)
open := func(id string) {
w := &pairWindow{ID: id, Code: "0123456789ABCDEFGHJK", Door: ":8812", Opened: time.Now().UTC().Format(time.RFC3339),
Expires: time.Now().Add(time.Minute).UTC().Format(time.RFC3339)}
if err := saveWindowFile(w); err != nil {
t.Fatal(err)
}
}
open("a")
if removeWindow("a") {
t.Fatal("an unclaimed window closed as claimed")
}
if claimWindow("a") {
t.Fatal("a window closed at the terminal was claimed")
}
open("b")
if !claimWindow("b") {
t.Fatal("an open window could not be claimed")
}
if !removeWindow("b") {
t.Fatal("closing a claimed window did not say it was claimed")
}
endWindow("b", windowResult{Host: "home.cseries.localhost", State: pairingActive})
if res := awaitClaimedResult("b"); res == nil || res.Host != "home.cseries.localhost" {
t.Fatalf("the claimed window's result: %+v", res)
}
if claimedWindow("b") != nil {
t.Fatal("the claimed file outlived its result")
}
}