tstest/natlab: add ACME cert vmtest

This adds a fake vnet ACME service, TXT-backed SetDNS support, and a
VM test that fetches a certificate with tailscale cert, serves it with
tailscale serve, and verifies HTTPS from a second node.

This adds coverage motivated by #19915.

Updates #13038

Change-Id: Ie1e53409509337d81c8fbceb63f59f3dfbd48207
Signed-off-by: Brad Fitzpatrick <bradfitz@tailscale.com>
This commit is contained in:
Brad Fitzpatrick
2026-06-05 08:57:17 -07:00
committed by Brad Fitzpatrick
parent 84ffcd2759
commit 26864f1302
7 changed files with 818 additions and 6 deletions
@@ -60,6 +60,7 @@ type Server struct {
DNSConfig *tailcfg.DNSConfig // nil means no DNS config
MagicDNSDomain string
C2NResponses syncs.Map[string, func(*http.Response)] // token => onResponse func
OnSetDNS func(*tailcfg.SetDNSRequest) error
// PeerRelayGrants, if true, inserts relay capabilities into the wildcard
// grants rules.
@@ -508,6 +509,8 @@ func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) {
s.serveMap(w, r, mkey)
case "/machine/register":
s.serveRegister(w, r, mkey)
case "/machine/set-dns":
s.serveSetDNS(w, r, mkey)
case "/machine/update-health":
io.Copy(io.Discard, r.Body)
w.WriteHeader(http.StatusNoContent)
@@ -516,6 +519,66 @@ func (s *Server) serveMachine(w http.ResponseWriter, r *http.Request) {
}
}
func (s *Server) serveSetDNS(w http.ResponseWriter, r *http.Request, mkey key.MachinePublic) {
var req tailcfg.SetDNSRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Type != "TXT" {
http.Error(w, "only TXT records are supported", http.StatusBadRequest)
return
}
if req.Name == "" || req.Value == "" {
http.Error(w, "missing name or value", http.StatusBadRequest)
return
}
if req.NodeKey.IsZero() {
http.Error(w, "missing node key", http.StatusBadRequest)
return
}
s.mu.Lock()
node := s.nodes[req.NodeKey]
certDomains := s.certDomainsLocked(node)
s.mu.Unlock()
if node == nil {
http.Error(w, "unknown node key", http.StatusForbidden)
return
}
if node.Machine != mkey {
http.Error(w, "node key does not belong to machine", http.StatusForbidden)
return
}
baseName, ok := strings.CutPrefix(req.Name, "_acme-challenge.")
if !ok || !slices.Contains(certDomains, baseName) {
http.Error(w, "name is not an ACME challenge for a cert domain", http.StatusForbidden)
return
}
if s.OnSetDNS != nil {
if err := s.OnSetDNS(&req); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tailcfg.SetDNSResponse{})
}
func (s *Server) certDomainsLocked(node *tailcfg.Node) []string {
if node == nil {
return nil
}
var ret []string
if s.DNSConfig != nil {
ret = append(ret, s.DNSConfig.CertDomains...)
}
if s.MagicDNSDomain != "" {
ret = append(ret, node.Hostinfo.Hostname()+"."+s.MagicDNSDomain)
}
return ret
}
// SetSubnetRoutes sets the list of subnet routes which a node is routing.
func (s *Server) SetSubnetRoutes(nodeKey key.NodePublic, routes []netip.Prefix) {
s.mu.Lock()