tsnet: add support for Services

This change allows tsnet nodes to act as Service hosts by adding a new
function, tsnet.Server.ListenService. Invoking this function will
advertise the node as a host for the Service and create a listener to
receive traffic for the Service.

Fixes #17697
Fixes tailscale/corp#27200
Signed-off-by: Harry Harpham <harry@tailscale.com>
This commit is contained in:
Harry Harpham
2026-01-13 14:36:12 -07:00
parent 1b88e93ff5
commit 3840183be9
8 changed files with 983 additions and 35 deletions
+89 -3
View File
@@ -110,6 +110,16 @@ type Server struct {
// nodeCapMaps overrides the capability map sent down to a client.
nodeCapMaps map[key.NodePublic]tailcfg.NodeCapMap
// globalAppCaps configures global app capabilities, equivalent to:
// "grants": [
// {
// "src": ["*"],
// "dst": ["*"],
// "app": <contents of the input map>
// }
// ]
globalAppCaps tailcfg.PeerCapMap
// suppressAutoMapResponses is the set of nodes that should not be sent
// automatic map responses from serveMap. (They should only get manually sent ones)
suppressAutoMapResponses set.Set[key.NodePublic]
@@ -289,6 +299,43 @@ func (s *Server) addDebugMessage(nodeKeyDst key.NodePublic, msg any) bool {
return sendUpdate(oldUpdatesCh, updateDebugInjection)
}
// ForceNetmapUpdate waits for the node to get stuck in a map poll and then
// sends the current netmap (which may result in a redundant netmap). The
// intended use case is ensuring state changes propagate before running tests.
//
// This should only be called for nodes connected as streaming clients. Calling
// this with a non-streaming node will result in non-deterministic behavior.
//
// This function cannot guarantee that the node has processed the issued update,
// so tests should confirm processing by querying the node. By example:
//
// if err := s.ForceNetmapUpdate(node.Key()); err != nil {
// // handle error
// }
// for !updatesPresent(node.NetMap()) {
// time.Sleep(10 * time.Millisecond)
// }
func (s *Server) ForceNetmapUpdate(ctx context.Context, nodeKey key.NodePublic) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
if err := s.AwaitNodeInMapRequest(ctx, nodeKey); err != nil {
return fmt.Errorf("waiting for node to poll: %w", err)
}
mr, err := s.MapResponse(&tailcfg.MapRequest{NodeKey: nodeKey})
if err != nil {
return fmt.Errorf("generating map response: %w", err)
}
if s.addDebugMessage(nodeKey, mr) {
return nil
}
// If we failed to send the map response, loop around and try again.
}
}
// Mark the Node key of every node as expired
func (s *Server) SetExpireAllNodes(expired bool) {
s.mu.Lock()
@@ -531,6 +578,31 @@ func (s *Server) SetNodeCapMap(nodeKey key.NodePublic, capMap tailcfg.NodeCapMap
s.updateLocked("SetNodeCapMap", s.nodeIDsLocked(0))
}
// SetGlobalAppCaps configures global app capabilities. This is equivalent to
//
// "grants": [
// {
// "src": ["*"],
// "dst": ["*"],
// "app": <contents of the input map>
// }
// ]
func (s *Server) SetGlobalAppCaps(appCaps tailcfg.PeerCapMap) {
s.mu.Lock()
s.globalAppCaps = appCaps
s.mu.Unlock()
}
// AddDNSRecords adds records to the server's DNS config.
func (s *Server) AddDNSRecords(records ...tailcfg.DNSRecord) {
s.mu.Lock()
defer s.mu.Unlock()
if s.DNSConfig == nil {
s.DNSConfig = new(tailcfg.DNSConfig)
}
s.DNSConfig.ExtraRecords = append(s.DNSConfig.ExtraRecords, records...)
}
// nodeIDsLocked returns the node IDs of all nodes in the server, except
// for the node with the given ID.
func (s *Server) nodeIDsLocked(except tailcfg.NodeID) []tailcfg.NodeID {
@@ -838,6 +910,9 @@ func (s *Server) serveRegister(w http.ResponseWriter, r *http.Request, mkey key.
CapMap: capMap,
Capabilities: slices.Collect(maps.Keys(capMap)),
}
if s.MagicDNSDomain != "" {
node.Name = node.Name + "." + s.MagicDNSDomain + "."
}
s.nodes[nk] = node
}
requireAuth := s.RequireAuth
@@ -1261,9 +1336,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
dns := s.DNSConfig
if dns != nil && s.MagicDNSDomain != "" {
dns = dns.Clone()
dns.CertDomains = []string{
node.Hostinfo.Hostname() + "." + s.MagicDNSDomain,
}
dns.CertDomains = append(dns.CertDomains, node.Hostinfo.Hostname()+"."+s.MagicDNSDomain)
}
res = &tailcfg.MapResponse{
@@ -1279,6 +1352,7 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
s.mu.Lock()
nodeMasqs := s.masquerades[node.Key]
jailed := maps.Clone(s.peerIsJailed[node.Key])
globalAppCaps := s.globalAppCaps
s.mu.Unlock()
for _, p := range s.AllNodes() {
if p.StableID == node.StableID {
@@ -1330,6 +1404,18 @@ func (s *Server) MapResponse(req *tailcfg.MapRequest) (res *tailcfg.MapResponse,
v6Prefix,
}
if globalAppCaps != nil {
res.PacketFilter = append(res.PacketFilter, tailcfg.FilterRule{
SrcIPs: []string{"*"},
CapGrant: []tailcfg.CapGrant{
{
Dsts: []netip.Prefix{tsaddr.AllIPv4(), tsaddr.AllIPv6()},
CapMap: globalAppCaps,
},
},
})
}
// If the server is tracking TKA state, and there's a single TKA head,
// add it to the MapResponse.
if s.tkaStorage != nil {