tstest/integration: add integration test for Tailnet Lock

This patch adds an integration test for Tailnet Lock, checking that a node can't
talk to peers in the tailnet until it becomes signed.

This patch also introduces a new package `tstest/tkatest`, which has some helpers
for constructing a mock control server that responds to TKA requests. This allows
us to reduce boilerplate in the IPN tests.

Updates tailscale/corp#33599

Signed-off-by: Alex Chan <alexc@tailscale.com>
This commit is contained in:
Alex Chan
2025-11-19 09:41:43 +00:00
committed by Alex Chan
parent 824027305a
commit b7658a4ad2
7 changed files with 574 additions and 287 deletions
+149 -1
View File
@@ -33,6 +33,8 @@ import (
"tailscale.com/net/tsaddr"
"tailscale.com/syncs"
"tailscale.com/tailcfg"
"tailscale.com/tka"
"tailscale.com/tstest/tkatest"
"tailscale.com/types/key"
"tailscale.com/types/logger"
"tailscale.com/types/opt"
@@ -123,6 +125,10 @@ type Server struct {
nodeKeyAuthed set.Set[key.NodePublic]
msgToSend map[key.NodePublic]any // value is *tailcfg.PingRequest or entire *tailcfg.MapResponse
allExpired bool // All nodes will be told their node key is expired.
// tkaStorage records the Tailnet Lock state, if any.
// If nil, Tailnet Lock is not enabled in the Tailnet.
tkaStorage tka.CompactableChonk
}
// BaseURL returns the server's base URL, without trailing slash.
@@ -329,6 +335,7 @@ func (s *Server) initMux() {
w.WriteHeader(http.StatusNoContent)
})
s.mux.HandleFunc("/key", s.serveKey)
s.mux.HandleFunc("/machine/tka/", s.serveTKA)
s.mux.HandleFunc("/machine/", s.serveMachine)
s.mux.HandleFunc("/ts2021", s.serveNoiseUpgrade)
s.mux.HandleFunc("/c2n/", s.serveC2N)
@@ -439,7 +446,7 @@ func (s *Server) serveKey(w http.ResponseWriter, r *http.Request) {
func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
http.Error(w, "POST required", 400)
http.Error(w, "POST required for serveMachine", 400)
return
}
ctx := r.Context()
@@ -861,6 +868,132 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key.
w.Write(res)
}
func (s *Server) serveTKA(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
http.Error(w, "GET required for serveTKA", 400)
return
}
switch r.URL.Path {
case "/machine/tka/init/begin":
s.serveTKAInitBegin(w, r)
case "/machine/tka/init/finish":
s.serveTKAInitFinish(w, r)
case "/machine/tka/bootstrap":
s.serveTKABootstrap(w, r)
case "/machine/tka/sync/offer":
s.serveTKASyncOffer(w, r)
case "/machine/tka/sign":
s.serveTKASign(w, r)
default:
s.serveUnhandled(w, r)
}
}
func (s *Server) serveTKAInitBegin(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
nodes := maps.Values(s.nodes)
genesisAUM, err := tkatest.HandleTKAInitBegin(w, r, nodes)
if err != nil {
go panic(fmt.Sprintf("HandleTKAInitBegin: %v", err))
}
s.tkaStorage = tka.ChonkMem()
s.tkaStorage.CommitVerifiedAUMs([]tka.AUM{*genesisAUM})
}
func (s *Server) serveTKAInitFinish(w http.ResponseWriter, r *http.Request) {
signatures, err := tkatest.HandleTKAInitFinish(w, r)
if err != nil {
go panic(fmt.Sprintf("HandleTKAInitFinish: %v", err))
}
s.mu.Lock()
defer s.mu.Unlock()
// Apply the signatures to each of the nodes. Because s.nodes is keyed
// by public key instead of node ID, we have to do this inefficiently.
//
// We only have small tailnets in the integration tests, so this isn't
// much of an issue.
for nodeID, sig := range signatures {
for _, n := range s.nodes {
if n.ID == nodeID {
n.KeySignature = sig
}
}
}
}
func (s *Server) serveTKABootstrap(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
if s.tkaStorage == nil {
http.Error(w, "no TKA state when calling serveTKABootstrap", 400)
return
}
// Find the genesis AUM, which we need to include in the response.
var genesis *tka.AUM
allAUMs, err := s.tkaStorage.AllAUMs()
if err != nil {
http.Error(w, "unable to retrieve all AUMs from TKA state", 500)
return
}
for _, h := range allAUMs {
aum := must.Get(s.tkaStorage.AUM(h))
if _, hasParent := aum.Parent(); !hasParent {
genesis = &aum
break
}
}
if genesis == nil {
http.Error(w, "unable to find genesis AUM in TKA state", 500)
return
}
resp := tailcfg.TKABootstrapResponse{
GenesisAUM: genesis.Serialize(),
}
_, err = tkatest.HandleTKABootstrap(w, r, resp)
if err != nil {
go panic(fmt.Sprintf("HandleTKABootstrap: %v", err))
}
}
func (s *Server) serveTKASyncOffer(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
authority, err := tka.Open(s.tkaStorage)
if err != nil {
go panic(fmt.Sprintf("serveTKASyncOffer: tka.Open: %v", err))
}
err = tkatest.HandleTKASyncOffer(w, r, authority, s.tkaStorage)
if err != nil {
go panic(fmt.Sprintf("HandleTKASyncOffer: %v", err))
}
}
func (s *Server) serveTKASign(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
authority, err := tka.Open(s.tkaStorage)
if err != nil {
go panic(fmt.Sprintf("serveTKASign: tka.Open: %v", err))
}
sig, keyBeingSigned, err := tkatest.HandleTKASign(w, r, authority)
if err != nil {
go panic(fmt.Sprintf("HandleTKASign: %v", err))
}
s.nodes[*keyBeingSigned].KeySignature = *sig
s.updateLocked("TKASign", s.nodeIDsLocked(0))
}
// updateType indicates why a long-polling map request is being woken
// up for an update.
type updateType int
@@ -1197,6 +1330,21 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
v6Prefix,
}
// If the server is tracking TKA state, and there's a single TKA head,
// add it to the MapResponse.
if s.tkaStorage != nil {
heads, err := s.tkaStorage.Heads()
if err != nil {
log.Printf("unable to get TKA heads: %v", err)
} else if len(heads) != 1 {
log.Printf("unable to get single TKA head, got %v", heads)
} else {
res.TKAInfo = &tailcfg.TKAInfo{
Head: heads[0].Hash().String(),
}
}
}
s.mu.Lock()
defer s.mu.Unlock()
res.Node.PrimaryRoutes = s.nodeSubnetRoutes[nk]