diff --git a/hscontrol/mapper/mapper.go b/hscontrol/mapper/mapper.go index e18276ad..03b4cedd 100644 --- a/hscontrol/mapper/mapper.go +++ b/hscontrol/mapper/mapper.go @@ -115,6 +115,7 @@ func generateUserProfiles( func generateDNSConfig( cfg *types.Config, node *types.Node, + nodeAttrs []string, ) *tailcfg.DNSConfig { if cfg.TailcfgDNSConfig == nil { return nil @@ -122,7 +123,7 @@ func generateDNSConfig( dnsConfig := cfg.TailcfgDNSConfig.Clone() - addNextDNSMetadata(dnsConfig.Resolvers, node) + addNextDNSMetadata(dnsConfig.Resolvers, node, nodeAttrs) return dnsConfig } @@ -134,9 +135,21 @@ func generateDNSConfig( // // This will produce a resolver like: // `https://dns.nextdns.io/?device_name=node-name&device_model=linux&device_ip=100.64.0.1` -func addNextDNSMetadata(resolvers []*dnstype.Resolver, node *types.Node) { +func addNextDNSMetadata(resolvers []*dnstype.Resolver, node *types.Node, nodeAttrs []string) { for _, resolver := range resolvers { if strings.HasPrefix(resolver.Addr, nextDNSDoHPrefix) { + + idx := slices.IndexFunc(nodeAttrs, func(item string) bool { return strings.HasPrefix(item, "nextdns:") && item != "nextdns:no-device-info" }) + + if idx != -1 { + nextDNSProfile := strings.Split(nodeAttrs[idx], ":")[1] + resolver.Addr = fmt.Sprintf("%s/%s", nextDNSDoHPrefix, nextDNSProfile) + } + + if slices.Contains(nodeAttrs, "nextdns:no-device-info") { + continue + } + attrs := url.Values{ "device_name": []string{node.Hostname}, "device_model": []string{node.Hostinfo.OS}, @@ -158,7 +171,13 @@ func (m *Mapper) fullMapResponse( peers types.Nodes, capVer tailcfg.CapabilityVersion, ) (*tailcfg.MapResponse, error) { - resp, err := m.baseWithConfigMapResponse(node, capVer) + + nodeAttrs, err := m.polMan.NodeAttributes(node) + if err != nil { + return nil, err + } + + resp, err := m.baseWithConfigMapResponse(node, capVer, nodeAttrs) if err != nil { return nil, err } @@ -171,6 +190,7 @@ func (m *Mapper) fullMapResponse( capVer, peers, m.cfg, + nodeAttrs, ) if err != nil { return nil, err @@ -206,7 +226,13 @@ func (m *Mapper) ReadOnlyMapResponse( node *types.Node, messages ...string, ) ([]byte, error) { - resp, err := m.baseWithConfigMapResponse(node, mapRequest.Version) + + nodeAttrs, err := m.polMan.NodeAttributes(node) + if err != nil { + return nil, err + } + + resp, err := m.baseWithConfigMapResponse(node, mapRequest.Version, nodeAttrs) if err != nil { return nil, err } @@ -268,6 +294,11 @@ func (m *Mapper) PeerChangedResponse( } } + nodeAttrs, err := m.polMan.NodeAttributes(node) + if err != nil { + return nil, err + } + err = appendPeerChanges( &resp, false, // partial change @@ -276,6 +307,7 @@ func (m *Mapper) PeerChangedResponse( mapRequest.Version, changedNodes, m.cfg, + nodeAttrs, ) if err != nil { return nil, err @@ -300,7 +332,7 @@ func (m *Mapper) PeerChangedResponse( // Add the node itself, it might have changed, and particularly // if there are no patches or changes, this is a self update. - tailnode, err := tailNode(node, mapRequest.Version, m.polMan, m.cfg) + tailnode, err := tailNode(node, mapRequest.Version, m.polMan, m.cfg, nodeAttrs) if err != nil { return nil, err } @@ -444,10 +476,11 @@ func (m *Mapper) baseMapResponse() tailcfg.MapResponse { func (m *Mapper) baseWithConfigMapResponse( node *types.Node, capVer tailcfg.CapabilityVersion, + nodeAttrs []string, ) (*tailcfg.MapResponse, error) { resp := m.baseMapResponse() - tailnode, err := tailNode(node, capVer, m.polMan, m.cfg) + tailnode, err := tailNode(node, capVer, m.polMan, m.cfg, nodeAttrs) if err != nil { return nil, err } @@ -505,6 +538,7 @@ func appendPeerChanges( capVer tailcfg.CapabilityVersion, changed types.Nodes, cfg *types.Config, + attrs []string, ) error { filter := polMan.Filter() @@ -521,7 +555,7 @@ func appendPeerChanges( profiles := generateUserProfiles(node, changed) - dnsConfig := generateDNSConfig(cfg, node) + dnsConfig := generateDNSConfig(cfg, node, attrs) tailPeers, err := tailNodes(changed, capVer, polMan, cfg) if err != nil { diff --git a/hscontrol/mapper/mapper_test.go b/hscontrol/mapper/mapper_test.go index 55ab2ccb..c9fe2e32 100644 --- a/hscontrol/mapper/mapper_test.go +++ b/hscontrol/mapper/mapper_test.go @@ -120,6 +120,7 @@ func TestDNSConfigMapResponse(t *testing.T) { TailcfgDNSConfig: &dnsConfigOrig, }, nodeInShared1, + []string{}, ) if diff := cmp.Diff(tt.want, got, cmpopts.EquateEmpty()); diff != "" { diff --git a/hscontrol/mapper/tail.go b/hscontrol/mapper/tail.go index 4082df2b..da48a439 100644 --- a/hscontrol/mapper/tail.go +++ b/hscontrol/mapper/tail.go @@ -20,11 +20,18 @@ func tailNodes( tNodes := make([]*tailcfg.Node, len(nodes)) for index, node := range nodes { + + nodeAttrs, err := polMan.NodeAttributes(node) + if err != nil { + return nil, err + } + node, err := tailNode( node, capVer, polMan, cfg, + nodeAttrs, ) if err != nil { return nil, err @@ -42,6 +49,7 @@ func tailNode( capVer tailcfg.CapabilityVersion, polMan policy.PolicyManager, cfg *types.Config, + nodeAttrs []string, ) (*tailcfg.Node, error) { addrs := node.Prefixes() @@ -124,6 +132,10 @@ func tailNode( tNode.CapMap[tailcfg.NodeAttrRandomizeClientPort] = []tailcfg.RawMessage{} } + for _, nodeAttr := range nodeAttrs { + tNode.CapMap[tailcfg.NodeCapability(nodeAttr)] = []tailcfg.RawMessage{} + } + if node.IsOnline == nil || !*node.IsOnline { // LastSeen is only set when node is // not connected to the control server. diff --git a/hscontrol/mapper/tail_test.go b/hscontrol/mapper/tail_test.go index 96c008ab..633bce26 100644 --- a/hscontrol/mapper/tail_test.go +++ b/hscontrol/mapper/tail_test.go @@ -195,6 +195,7 @@ func TestTailNode(t *testing.T) { 0, polMan, cfg, + []string{}, ) if (err != nil) != tt.wantErr { @@ -248,6 +249,7 @@ func TestNodeExpiry(t *testing.T) { 0, &policy.PolicyManagerV1{}, &types.Config{}, + []string{}, ) if err != nil { t.Fatalf("nodeExpiry() error = %v", err) diff --git a/hscontrol/policy/acls.go b/hscontrol/policy/acls.go index 3d7a6f4a..c7adff3e 100644 --- a/hscontrol/policy/acls.go +++ b/hscontrol/policy/acls.go @@ -442,6 +442,42 @@ func (pol *ACLPolicy) CompileSSHPolicy( }, nil } +func (pol *ACLPolicy) GetAttributesForNode( + node *types.Node, + users []types.User, + peers types.Nodes, +) ([]string, error) { + if pol == nil { + return nil, nil + } + + var attributes []string + + for _, nodeAttr := range pol.NodeAttributes { + var dest netipx.IPSetBuilder + for _, target := range nodeAttr.Targets { + expanded, err := pol.ExpandAlias(append(peers, node), users, target) + if err != nil { + return nil, err + } + dest.AddSet(expanded) + } + + destSet, err := dest.IPSet() + if err != nil { + return nil, err + } + + if !node.InIPSet(destSet) { + continue + } + + attributes = append(attributes, nodeAttr.Attributes...) + } + + return attributes, nil +} + // ipSetAll returns a function that iterates over all the IPs in the IPSet. func ipSetAll(ipSet *netipx.IPSet) iter.Seq[netip.Addr] { return func(yield func(netip.Addr) bool) { diff --git a/hscontrol/policy/acls_types.go b/hscontrol/policy/acls_types.go index 5b5d1838..075d2859 100644 --- a/hscontrol/policy/acls_types.go +++ b/hscontrol/policy/acls_types.go @@ -10,13 +10,14 @@ import ( // ACLPolicy represents a Tailscale ACL Policy. type ACLPolicy struct { - Groups Groups `json:"groups"` - Hosts Hosts `json:"hosts"` - TagOwners TagOwners `json:"tagOwners"` - ACLs []ACL `json:"acls"` - Tests []ACLTest `json:"tests"` - AutoApprovers AutoApprovers `json:"autoApprovers"` - SSHs []SSH `json:"ssh"` + Groups Groups `json:"groups"` + Hosts Hosts `json:"hosts"` + TagOwners TagOwners `json:"tagOwners"` + ACLs []ACL `json:"acls"` + Tests []ACLTest `json:"tests"` + AutoApprovers AutoApprovers `json:"autoApprovers"` + SSHs []SSH `json:"ssh"` + NodeAttributes []NodeAttributes `json:"nodeAttrs"` } // ACL is a basic rule for the ACL Policy. @@ -50,6 +51,12 @@ type AutoApprovers struct { ExitNode []string `json:"exitNode"` } +// NodeAttributes is for applying additional attributes to specific devices +type NodeAttributes struct { + Targets []string `json:"target"` + Attributes []string `json:"attr"` +} + // SSH controls who can ssh into which machines. type SSH struct { Action string `json:"action"` diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index 4e10003e..b62163ea 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -17,6 +17,7 @@ import ( type PolicyManager interface { Filter() []tailcfg.FilterRule SSHPolicy(*types.Node) (*tailcfg.SSHPolicy, error) + NodeAttributes(node *types.Node) ([]string, error) Tags(*types.Node) []string ApproversForRoute(netip.Prefix) []string ExpandAlias(string) (*netipx.IPSet, error) @@ -140,6 +141,13 @@ func (pm *PolicyManagerV1) SetPolicy(polB []byte) (bool, error) { return pm.updateLocked() } +func (pm *PolicyManagerV1) NodeAttributes(node *types.Node) ([]string, error) { + pm.mu.Lock() + defer pm.mu.Unlock() + + return pm.pol.GetAttributesForNode(node, pm.users, pm.nodes) +} + // SetUsers updates the users in the policy manager and updates the filter rules. func (pm *PolicyManagerV1) SetUsers(users []types.User) (bool, error) { pm.mu.Lock()