mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-28 10:51:44 +01:00 
			
		
		
		
	
		
			
				
	
	
		
			282 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			282 lines
		
	
	
		
			6.9 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
package mapper
 | 
						|
 | 
						|
import (
 | 
						|
	"encoding/json"
 | 
						|
	"fmt"
 | 
						|
	"io/fs"
 | 
						|
	"net/netip"
 | 
						|
	"net/url"
 | 
						|
	"os"
 | 
						|
	"path"
 | 
						|
	"slices"
 | 
						|
	"strings"
 | 
						|
	"time"
 | 
						|
 | 
						|
	"github.com/juanfont/headscale/hscontrol/state"
 | 
						|
	"github.com/juanfont/headscale/hscontrol/types"
 | 
						|
	"github.com/rs/zerolog/log"
 | 
						|
	"tailscale.com/envknob"
 | 
						|
	"tailscale.com/tailcfg"
 | 
						|
	"tailscale.com/types/dnstype"
 | 
						|
)
 | 
						|
 | 
						|
const (
 | 
						|
	nextDNSDoHPrefix     = "https://dns.nextdns.io"
 | 
						|
	mapperIDLength       = 8
 | 
						|
	debugMapResponsePerm = 0o755
 | 
						|
)
 | 
						|
 | 
						|
var debugDumpMapResponsePath = envknob.String("HEADSCALE_DEBUG_DUMP_MAPRESPONSE_PATH")
 | 
						|
 | 
						|
// TODO: Optimise
 | 
						|
// As this work continues, the idea is that there will be one Mapper instance
 | 
						|
// per node, attached to the open stream between the control and client.
 | 
						|
// This means that this can hold a state per node and we can use that to
 | 
						|
// improve the mapresponses sent.
 | 
						|
// We could:
 | 
						|
// - Keep information about the previous mapresponse so we can send a diff
 | 
						|
// - Store hashes
 | 
						|
// - Create a "minifier" that removes info not needed for the node
 | 
						|
// - some sort of batching, wait for 5 or 60 seconds before sending
 | 
						|
 | 
						|
type mapper struct {
 | 
						|
	// Configuration
 | 
						|
	state   *state.State
 | 
						|
	cfg     *types.Config
 | 
						|
	batcher Batcher
 | 
						|
 | 
						|
	created time.Time
 | 
						|
}
 | 
						|
 | 
						|
type patch struct {
 | 
						|
	timestamp time.Time
 | 
						|
	change    *tailcfg.PeerChange
 | 
						|
}
 | 
						|
 | 
						|
func newMapper(
 | 
						|
	cfg *types.Config,
 | 
						|
	state *state.State,
 | 
						|
) *mapper {
 | 
						|
	// uid, _ := util.GenerateRandomStringDNSSafe(mapperIDLength)
 | 
						|
 | 
						|
	return &mapper{
 | 
						|
		state: state,
 | 
						|
		cfg:   cfg,
 | 
						|
 | 
						|
		created: time.Now(),
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func generateUserProfiles(
 | 
						|
	node *types.Node,
 | 
						|
	peers types.Nodes,
 | 
						|
) []tailcfg.UserProfile {
 | 
						|
	userMap := make(map[uint]*types.User)
 | 
						|
	ids := make([]uint, 0, len(userMap))
 | 
						|
	userMap[node.User.ID] = &node.User
 | 
						|
	ids = append(ids, node.User.ID)
 | 
						|
	for _, peer := range peers {
 | 
						|
		userMap[peer.User.ID] = &peer.User
 | 
						|
		ids = append(ids, peer.User.ID)
 | 
						|
	}
 | 
						|
 | 
						|
	slices.Sort(ids)
 | 
						|
	ids = slices.Compact(ids)
 | 
						|
	var profiles []tailcfg.UserProfile
 | 
						|
	for _, id := range ids {
 | 
						|
		if userMap[id] != nil {
 | 
						|
			profiles = append(profiles, userMap[id].TailscaleUserProfile())
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return profiles
 | 
						|
}
 | 
						|
 | 
						|
func generateDNSConfig(
 | 
						|
	cfg *types.Config,
 | 
						|
	node *types.Node,
 | 
						|
) *tailcfg.DNSConfig {
 | 
						|
	if cfg.TailcfgDNSConfig == nil {
 | 
						|
		return nil
 | 
						|
	}
 | 
						|
 | 
						|
	dnsConfig := cfg.TailcfgDNSConfig.Clone()
 | 
						|
 | 
						|
	addNextDNSMetadata(dnsConfig.Resolvers, node)
 | 
						|
 | 
						|
	return dnsConfig
 | 
						|
}
 | 
						|
 | 
						|
// If any nextdns DoH resolvers are present in the list of resolvers it will
 | 
						|
// take metadata from the node metadata and instruct tailscale to add it
 | 
						|
// to the requests. This makes it possible to identify from which device the
 | 
						|
// requests come in the NextDNS dashboard.
 | 
						|
//
 | 
						|
// This will produce a resolver like:
 | 
						|
// `https://dns.nextdns.io/<nextdns-id>?device_name=node-name&device_model=linux&device_ip=100.64.0.1`
 | 
						|
func addNextDNSMetadata(resolvers []*dnstype.Resolver, node *types.Node) {
 | 
						|
	for _, resolver := range resolvers {
 | 
						|
		if strings.HasPrefix(resolver.Addr, nextDNSDoHPrefix) {
 | 
						|
			attrs := url.Values{
 | 
						|
				"device_name":  []string{node.Hostname},
 | 
						|
				"device_model": []string{node.Hostinfo.OS},
 | 
						|
			}
 | 
						|
 | 
						|
			if len(node.IPs()) > 0 {
 | 
						|
				attrs.Add("device_ip", node.IPs()[0].String())
 | 
						|
			}
 | 
						|
 | 
						|
			resolver.Addr = fmt.Sprintf("%s?%s", resolver.Addr, attrs.Encode())
 | 
						|
		}
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// fullMapResponse returns a MapResponse for the given node.
 | 
						|
func (m *mapper) fullMapResponse(
 | 
						|
	nodeID types.NodeID,
 | 
						|
	capVer tailcfg.CapabilityVersion,
 | 
						|
	messages ...string,
 | 
						|
) (*tailcfg.MapResponse, error) {
 | 
						|
	peers, err := m.listPeers(nodeID)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
 | 
						|
	return m.NewMapResponseBuilder(nodeID).
 | 
						|
		WithCapabilityVersion(capVer).
 | 
						|
		WithSelfNode().
 | 
						|
		WithDERPMap().
 | 
						|
		WithDomain().
 | 
						|
		WithCollectServicesDisabled().
 | 
						|
		WithDebugConfig().
 | 
						|
		WithSSHPolicy().
 | 
						|
		WithDNSConfig().
 | 
						|
		WithUserProfiles(peers).
 | 
						|
		WithPacketFilters().
 | 
						|
		WithPeers(peers).
 | 
						|
		Build(messages...)
 | 
						|
}
 | 
						|
 | 
						|
func (m *mapper) derpMapResponse(
 | 
						|
	nodeID types.NodeID,
 | 
						|
) (*tailcfg.MapResponse, error) {
 | 
						|
	return m.NewMapResponseBuilder(nodeID).
 | 
						|
		WithDERPMap().
 | 
						|
		Build()
 | 
						|
}
 | 
						|
 | 
						|
// PeerChangedPatchResponse creates a patch MapResponse with
 | 
						|
// incoming update from a state change.
 | 
						|
func (m *mapper) peerChangedPatchResponse(
 | 
						|
	nodeID types.NodeID,
 | 
						|
	changed []*tailcfg.PeerChange,
 | 
						|
) (*tailcfg.MapResponse, error) {
 | 
						|
	return m.NewMapResponseBuilder(nodeID).
 | 
						|
		WithPeerChangedPatch(changed).
 | 
						|
		Build()
 | 
						|
}
 | 
						|
 | 
						|
// peerChangeResponse returns a MapResponse with changed or added nodes.
 | 
						|
func (m *mapper) peerChangeResponse(
 | 
						|
	nodeID types.NodeID,
 | 
						|
	capVer tailcfg.CapabilityVersion,
 | 
						|
	changedNodeID types.NodeID,
 | 
						|
) (*tailcfg.MapResponse, error) {
 | 
						|
	peers, err := m.listPeers(nodeID, changedNodeID)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
 | 
						|
	return m.NewMapResponseBuilder(nodeID).
 | 
						|
		WithCapabilityVersion(capVer).
 | 
						|
		WithSelfNode().
 | 
						|
		WithUserProfiles(peers).
 | 
						|
		WithPeerChanges(peers).
 | 
						|
		Build()
 | 
						|
}
 | 
						|
 | 
						|
// peerRemovedResponse creates a MapResponse indicating that a peer has been removed.
 | 
						|
func (m *mapper) peerRemovedResponse(
 | 
						|
	nodeID types.NodeID,
 | 
						|
	removedNodeID types.NodeID,
 | 
						|
) (*tailcfg.MapResponse, error) {
 | 
						|
	return m.NewMapResponseBuilder(nodeID).
 | 
						|
		WithPeersRemoved(removedNodeID).
 | 
						|
		Build()
 | 
						|
}
 | 
						|
 | 
						|
func writeDebugMapResponse(
 | 
						|
	resp *tailcfg.MapResponse,
 | 
						|
	nodeID types.NodeID,
 | 
						|
	messages ...string,
 | 
						|
) {
 | 
						|
	data := map[string]any{
 | 
						|
		"Messages":    messages,
 | 
						|
		"MapResponse": resp,
 | 
						|
	}
 | 
						|
 | 
						|
	responseType := "keepalive"
 | 
						|
 | 
						|
	switch {
 | 
						|
	case len(resp.Peers) > 0:
 | 
						|
		responseType = "full"
 | 
						|
	case resp.Peers == nil && resp.PeersChanged == nil && resp.PeersChangedPatch == nil && resp.DERPMap == nil && !resp.KeepAlive:
 | 
						|
		responseType = "self"
 | 
						|
	case len(resp.PeersChanged) > 0:
 | 
						|
		responseType = "changed"
 | 
						|
	case len(resp.PeersChangedPatch) > 0:
 | 
						|
		responseType = "patch"
 | 
						|
	case len(resp.PeersRemoved) > 0:
 | 
						|
		responseType = "removed"
 | 
						|
	}
 | 
						|
 | 
						|
	body, err := json.MarshalIndent(data, "", "  ")
 | 
						|
	if err != nil {
 | 
						|
		panic(err)
 | 
						|
	}
 | 
						|
 | 
						|
	perms := fs.FileMode(debugMapResponsePerm)
 | 
						|
	mPath := path.Join(debugDumpMapResponsePath, nodeID.String())
 | 
						|
	err = os.MkdirAll(mPath, perms)
 | 
						|
	if err != nil {
 | 
						|
		panic(err)
 | 
						|
	}
 | 
						|
 | 
						|
	now := time.Now().Format("2006-01-02T15-04-05.999999999")
 | 
						|
 | 
						|
	mapResponsePath := path.Join(
 | 
						|
		mPath,
 | 
						|
		fmt.Sprintf("%s-%s.json", now, responseType),
 | 
						|
	)
 | 
						|
 | 
						|
	log.Trace().Msgf("Writing MapResponse to %s", mapResponsePath)
 | 
						|
	err = os.WriteFile(mapResponsePath, body, perms)
 | 
						|
	if err != nil {
 | 
						|
		panic(err)
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
// listPeers returns peers of node, regardless of any Policy or if the node is expired.
 | 
						|
// If no peer IDs are given, all peers are returned.
 | 
						|
// If at least one peer ID is given, only these peer nodes will be returned.
 | 
						|
func (m *mapper) listPeers(nodeID types.NodeID, peerIDs ...types.NodeID) (types.Nodes, error) {
 | 
						|
	peers, err := m.state.ListPeers(nodeID, peerIDs...)
 | 
						|
	if err != nil {
 | 
						|
		return nil, err
 | 
						|
	}
 | 
						|
 | 
						|
	// TODO(kradalby): Add back online via batcher. This was removed
 | 
						|
	// to avoid a circular dependency between the mapper and the notification.
 | 
						|
	for _, peer := range peers {
 | 
						|
		online := m.batcher.IsConnected(peer.ID)
 | 
						|
		peer.IsOnline = &online
 | 
						|
	}
 | 
						|
 | 
						|
	return peers, nil
 | 
						|
}
 | 
						|
 | 
						|
// routeFilterFunc is a function that takes a node ID and returns a list of
 | 
						|
// netip.Prefixes that are allowed for that node. It is used to filter routes
 | 
						|
// from the primary route manager to the node.
 | 
						|
type routeFilterFunc func(id types.NodeID) []netip.Prefix
 |