diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index c666ff24..31a3c472 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -1,6 +1,7 @@ package mapper import ( + "encoding/json" "errors" "net/netip" "sort" @@ -8,6 +9,7 @@ import ( "github.com/juanfont/headscale/hscontrol/policy" "github.com/juanfont/headscale/hscontrol/types" + "github.com/rs/zerolog/log" "tailscale.com/tailcfg" "tailscale.com/types/views" "tailscale.com/util/multierr" @@ -86,11 +88,58 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder { return b } + // Add app connector capabilities if this node is advertising as an app connector + b.addAppConnectorCapabilities(nv, tailnode) + b.resp.Node = tailnode return b } +// addAppConnectorCapabilities adds app connector capabilities to a node's CapMap +// if the node is advertising as an app connector and has matching policy configuration. +func (b *MapResponseBuilder) addAppConnectorCapabilities(nv types.NodeView, tailnode *tailcfg.Node) { + configs := b.mapper.state.AppConnectorConfigForNode(nv) + if len(configs) == 0 { + return + } + + // Initialize CapMap if nil + if tailnode.CapMap == nil { + tailnode.CapMap = make(tailcfg.NodeCapMap) + } + + // Build the app connector attributes for the capability + attrs := make([]tailcfg.RawMessage, 0, len(configs)) + + for _, cfg := range configs { + // Convert the config to JSON for the capability + attrJSON, err := json.Marshal(cfg) + if err != nil { + log.Warn(). + Err(err). + Uint64("node.id", uint64(nv.ID())). + Str("app_connector.name", cfg.Name). + Msg("Failed to marshal app connector config") + + continue + } + + attrs = append(attrs, tailcfg.RawMessage(attrJSON)) + } + + if len(attrs) > 0 { + // The capability key is "tailscale.com/app-connectors" as per Tailscale protocol + tailnode.CapMap[tailcfg.NodeCapability("tailscale.com/app-connectors")] = attrs + + log.Debug(). + Uint64("node.id", uint64(nv.ID())). + Str("node.name", nv.Hostname()). + Int("app_connectors.count", len(attrs)). + Msg("Added app connector capabilities to node") + } +} + func (b *MapResponseBuilder) WithDebugType(t debugType) *MapResponseBuilder { if debugDumpMapResponsePath != "" { b.debugType = t diff --git a/hscontrol/policy/pm.go b/hscontrol/policy/pm.go index f4db88a4..e0e301f7 100644 --- a/hscontrol/policy/pm.go +++ b/hscontrol/policy/pm.go @@ -32,10 +32,18 @@ type PolicyManager interface { // NodeCanApproveRoute reports whether the given node can approve the given route. NodeCanApproveRoute(types.NodeView, netip.Prefix) bool + // AppConnectorConfigForNode returns the app connector configuration for a node + // that is advertising itself as an app connector. + AppConnectorConfigForNode(node types.NodeView) []AppConnectorAttr + Version() int DebugString() string } +// AppConnectorAttr describes a set of domains serviced by app connectors. +// Re-exported from v2 package for convenience. +type AppConnectorAttr = policyv2.AppConnectorAttr + // NewPolicyManager returns a new policy manager. func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) { var polMan PolicyManager diff --git a/hscontrol/policy/v2/appconnector_test.go b/hscontrol/policy/v2/appconnector_test.go new file mode 100644 index 00000000..7bd30f30 --- /dev/null +++ b/hscontrol/policy/v2/appconnector_test.go @@ -0,0 +1,355 @@ +package v2 + +import ( + "net/netip" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + "tailscale.com/tailcfg" + "tailscale.com/types/opt" +) + +func TestAppConnectorPolicyParsing(t *testing.T) { + tests := []struct { + name string + policyJSON string + wantConnector []AppConnector + wantErr bool + }{ + { + name: "basic app connector", + policyJSON: `{ + "tagOwners": { + "tag:connector": ["user@example.com"] + }, + "appConnectors": [ + { + "name": "Internal Apps", + "connectors": ["tag:connector"], + "domains": ["internal.example.com", "*.corp.example.com"] + } + ] + }`, + wantConnector: []AppConnector{ + { + Name: "Internal Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"internal.example.com", "*.corp.example.com"}, + }, + }, + wantErr: false, + }, + { + name: "app connector with routes", + policyJSON: `{ + "tagOwners": { + "tag:connector": ["user@example.com"] + }, + "appConnectors": [ + { + "name": "VPN Connector", + "connectors": ["tag:connector"], + "domains": ["vpn.example.com"], + "routes": ["10.0.0.0/8", "192.168.0.0/16"] + } + ] + }`, + wantConnector: []AppConnector{ + { + Name: "VPN Connector", + Connectors: []string{"tag:connector"}, + Domains: []string{"vpn.example.com"}, + Routes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8"), netip.MustParsePrefix("192.168.0.0/16")}, + }, + }, + wantErr: false, + }, + { + name: "wildcard connector", + policyJSON: `{ + "appConnectors": [ + { + "name": "Any Connector", + "connectors": ["*"], + "domains": ["app.example.com"] + } + ] + }`, + wantConnector: []AppConnector{ + { + Name: "Any Connector", + Connectors: []string{"*"}, + Domains: []string{"app.example.com"}, + }, + }, + wantErr: false, + }, + { + name: "app connector with undefined tag", + policyJSON: `{ + "appConnectors": [ + { + "name": "Bad Connector", + "connectors": ["tag:undefined"], + "domains": ["app.example.com"] + } + ] + }`, + wantErr: true, + }, + { + name: "app connector without domains", + policyJSON: `{ + "tagOwners": { + "tag:connector": ["user@example.com"] + }, + "appConnectors": [ + { + "name": "No Domains", + "connectors": ["tag:connector"], + "domains": [] + } + ] + }`, + wantErr: true, + }, + { + name: "app connector without connectors", + policyJSON: `{ + "appConnectors": [ + { + "name": "No Connectors", + "connectors": [], + "domains": ["app.example.com"] + } + ] + }`, + wantErr: true, + }, + { + name: "app connector with invalid domain", + policyJSON: `{ + "tagOwners": { + "tag:connector": ["user@example.com"] + }, + "appConnectors": [ + { + "name": "Invalid Domain", + "connectors": ["tag:connector"], + "domains": [""] + } + ] + }`, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + policy, err := unmarshalPolicy([]byte(tt.policyJSON)) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.NotNil(t, policy) + assert.Equal(t, tt.wantConnector, policy.AppConnectors) + }) + } +} + +func TestAppConnectorConfigForNode(t *testing.T) { + policyJSON := `{ + "tagOwners": { + "tag:connector": ["user@example.com"], + "tag:other": ["user@example.com"] + }, + "appConnectors": [ + { + "name": "Internal Apps", + "connectors": ["tag:connector"], + "domains": ["internal.example.com", "*.corp.example.com"] + }, + { + "name": "VPN Apps", + "connectors": ["tag:connector"], + "domains": ["vpn.example.com"], + "routes": ["10.0.0.0/8"] + }, + { + "name": "Other Apps", + "connectors": ["tag:other"], + "domains": ["other.example.com"] + } + ] + }` + + users := []types.User{ + {Model: gorm.Model{ID: 1}, Email: "user@example.com"}, + } + + uid := uint(1) + ipv4 := netip.MustParseAddr("100.64.0.1") + + // Node with tag:connector that IS advertising as app connector + connectorNode := &types.Node{ + ID: 1, + UserID: &uid, + IPv4: &ipv4, + Tags: []string{"tag:connector"}, + Hostinfo: &tailcfg.Hostinfo{ + AppConnector: opt.NewBool(true), + }, + } + + // Node with tag:connector that is NOT advertising as app connector + notAdvertisingNode := &types.Node{ + ID: 2, + UserID: &uid, + IPv4: &ipv4, + Tags: []string{"tag:connector"}, + Hostinfo: &tailcfg.Hostinfo{ + AppConnector: opt.NewBool(false), + }, + } + + // Node with different tag that IS advertising + otherTagNode := &types.Node{ + ID: 3, + UserID: &uid, + IPv4: &ipv4, + Tags: []string{"tag:other"}, + Hostinfo: &tailcfg.Hostinfo{ + AppConnector: opt.NewBool(true), + }, + } + + // Node without any matching tag + noTagNode := &types.Node{ + ID: 4, + UserID: &uid, + IPv4: &ipv4, + Tags: []string{"tag:unrelated"}, + Hostinfo: &tailcfg.Hostinfo{ + AppConnector: opt.NewBool(true), + }, + } + + nodes := types.Nodes{connectorNode, notAdvertisingNode, otherTagNode, noTagNode} + + pm, err := NewPolicyManager([]byte(policyJSON), users, nodes.ViewSlice()) + require.NoError(t, err) + + tests := []struct { + name string + node *types.Node + wantLen int + wantName string + }{ + { + name: "connector node gets matching configs", + node: connectorNode, + wantLen: 2, // Internal Apps and VPN Apps + wantName: "Internal Apps", + }, + { + name: "non-advertising node gets no config", + node: notAdvertisingNode, + wantLen: 0, + }, + { + name: "other tag node gets other config", + node: otherTagNode, + wantLen: 1, // Other Apps + wantName: "Other Apps", + }, + { + name: "unrelated tag gets no config", + node: noTagNode, + wantLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + configs := pm.AppConnectorConfigForNode(tt.node.View()) + assert.Len(t, configs, tt.wantLen) + + if tt.wantLen > 0 && tt.wantName != "" { + assert.Equal(t, tt.wantName, configs[0].Name) + } + }) + } +} + +func TestAppConnectorWildcardConnector(t *testing.T) { + policyJSON := `{ + "appConnectors": [ + { + "name": "All Connectors", + "connectors": ["*"], + "domains": ["*.example.com"] + } + ] + }` + + users := []types.User{ + {Model: gorm.Model{ID: 1}, Email: "user@example.com"}, + } + + uid := uint(1) + ipv4 := netip.MustParseAddr("100.64.0.1") + + // Any node advertising as connector should match wildcard + node := &types.Node{ + ID: 1, + UserID: &uid, + IPv4: &ipv4, + Tags: []string{"tag:anyvalue"}, + Hostinfo: &tailcfg.Hostinfo{ + AppConnector: opt.NewBool(true), + }, + } + + nodes := types.Nodes{node} + + pm, err := NewPolicyManager([]byte(policyJSON), users, nodes.ViewSlice()) + require.NoError(t, err) + + configs := pm.AppConnectorConfigForNode(node.View()) + require.Len(t, configs, 1) + assert.Equal(t, "All Connectors", configs[0].Name) + assert.Equal(t, []string{"*.example.com"}, configs[0].Domains) +} + +func TestValidateAppConnectorDomain(t *testing.T) { + tests := []struct { + domain string + wantErr bool + }{ + {"example.com", false}, + {"sub.example.com", false}, + {"*.example.com", false}, + {"a.b.c.example.com", false}, + {"", true}, + {".example.com", true}, + {"example.com.", true}, + {"example..com", true}, + {"*.", true}, + } + + for _, tt := range tests { + t.Run(tt.domain, func(t *testing.T) { + err := validateAppConnectorDomain(tt.domain) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/hscontrol/policy/v2/policy.go b/hscontrol/policy/v2/policy.go index c5d87722..f7cedda4 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -1068,3 +1068,77 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node return ret, nil } + +// AppConnectorConfigForNode returns the app connector configuration for a node +// that is advertising itself as an app connector. +// Returns nil if the node is not configured as an app connector or doesn't advertise as one. +func (pm *PolicyManager) AppConnectorConfigForNode(node types.NodeView) []AppConnectorAttr { + if pm == nil || pm.pol == nil { + return nil + } + + // Check if node advertises as an app connector + if !node.Hostinfo().Valid() { + return nil + } + + appConnector, ok := node.Hostinfo().AppConnector().Get() + if !ok || !appConnector { + return nil + } + + pm.mu.Lock() + defer pm.mu.Unlock() + + return pm.appConnectorConfigForNodeLocked(node) +} + +// appConnectorConfigForNodeLocked returns the app connector config for a node. +// pm.mu must be held. +func (pm *PolicyManager) appConnectorConfigForNodeLocked(node types.NodeView) []AppConnectorAttr { + if pm.pol == nil || len(pm.pol.AppConnectors) == 0 { + return nil + } + + var configs []AppConnectorAttr + + for _, ac := range pm.pol.AppConnectors { + if pm.nodeMatchesConnector(node, ac.Connectors) { + configs = append(configs, AppConnectorAttr(ac)) + } + } + + return configs +} + +// nodeMatchesConnector checks if a node matches any of the connector specifications. +func (pm *PolicyManager) nodeMatchesConnector(node types.NodeView, connectors []string) bool { + for _, connector := range connectors { + // Wildcard matches any advertising connector + if connector == "*" { + return true + } + + // Check if it's a tag reference + if strings.HasPrefix(connector, "tag:") { + if node.HasTag(connector) { + return true + } + } + } + + return false +} + +// AppConnectorAttr describes a set of domains serviced by specified app connectors. +// This is similar to the Tailscale appctype.AppConnectorAttr structure. +type AppConnectorAttr struct { + // Name is the name of this collection of domains. + Name string `json:"name,omitempty"` + // Connectors enumerates the app connectors which service these domains. + Connectors []string `json:"connectors,omitempty"` + // Domains enumerates the domains serviced by the specified app connectors. + Domains []string `json:"domains,omitempty"` + // Routes enumerates the predetermined routes to be advertised. + Routes []netip.Prefix `json:"routes,omitempty"` +} diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 75b16bc1..ae1dbbe3 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -37,6 +37,15 @@ var ErrCircularReference = errors.New("circular reference detected") var ErrUndefinedTagReference = errors.New("references undefined tag") +// App Connector validation errors. +var ( + ErrAppConnectorMissingConnectors = errors.New("appConnector must have at least one connector") + ErrAppConnectorMissingDomains = errors.New("appConnector must have at least one domain") + ErrAppConnectorUndefinedTag = errors.New("appConnector references undefined tag") + ErrAppConnectorDomainEmpty = errors.New("domain cannot be empty") + ErrAppConnectorDomainInvalid = errors.New("invalid domain format") +) + type Asterix int func (a Asterix) Validate() error { @@ -1510,6 +1519,29 @@ type Policy struct { ACLs []ACL `json:"acls,omitempty"` AutoApprovers AutoApproverPolicy `json:"autoApprovers"` SSHs []SSH `json:"ssh,omitempty"` + AppConnectors []AppConnector `json:"appConnectors,omitempty"` +} + +// AppConnector defines an app connector configuration that allows routing +// traffic to specific domains through designated connector nodes. +// See https://tailscale.com/kb/1281/app-connectors for more information. +type AppConnector struct { + // Name is a human-readable name for this app connector configuration. + Name string `json:"name,omitempty"` + + // Connectors is a list of tags or "*" that identifies which nodes + // can serve as connectors for these domains. + // Examples: ["tag:connector"], ["*"] + Connectors []string `json:"connectors"` + + // Domains is a list of domain names that should be routed through + // the connector. Supports wildcards like "*.example.com". + Domains []string `json:"domains"` + + // Routes is an optional list of IP prefixes that should be + // pre-configured as routes for the connector (in addition to + // dynamically discovered routes from DNS resolution). + Routes []netip.Prefix `json:"routes,omitempty"` } // MarshalJSON is deliberately not implemented for Policy. @@ -1813,6 +1845,39 @@ func (p *Policy) validate() error { } } + // Validate app connectors + for _, ac := range p.AppConnectors { + if len(ac.Connectors) == 0 { + errs = append(errs, fmt.Errorf("%w: %q", ErrAppConnectorMissingConnectors, ac.Name)) + } + + if len(ac.Domains) == 0 { + errs = append(errs, fmt.Errorf("%w: %q", ErrAppConnectorMissingDomains, ac.Name)) + } + // Validate connector references + for _, connector := range ac.Connectors { + if connector == "*" { + continue + } + + if isTag(connector) { + tag := Tag(connector) + + err := p.TagOwners.Contains(&tag) + if err != nil { + errs = append(errs, fmt.Errorf("%w: appConnector %q references %q", ErrAppConnectorUndefinedTag, ac.Name, connector)) + } + } + } + // Validate domain format + for _, domain := range ac.Domains { + err := validateAppConnectorDomain(domain) + if err != nil { + errs = append(errs, fmt.Errorf("%w: appConnector %q has invalid domain %q", err, ac.Name, domain)) + } + } + } + if len(errs) > 0 { return multierr.New(errs...) } @@ -2098,3 +2163,36 @@ func (p *Policy) usesAutogroupSelf() bool { return false } + +// validateAppConnectorDomain validates an app connector domain. +// Valid domains can be: +// - A fully qualified domain name (e.g., "example.com") +// - A wildcard subdomain (e.g., "*.example.com"). +func validateAppConnectorDomain(domain string) error { + if domain == "" { + return ErrAppConnectorDomainEmpty + } + + // Check for wildcard domains + if strings.HasPrefix(domain, "*.") { + // Remove the "*." prefix and validate the rest + rest := domain[2:] + if rest == "" { + return fmt.Errorf("%w: wildcard domain must have a base domain", ErrAppConnectorDomainInvalid) + } + + domain = rest + } + + // Basic validation - domain should not start or end with dots + if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") { + return fmt.Errorf("%w: domain cannot start or end with a dot", ErrAppConnectorDomainInvalid) + } + + // Check for empty labels + if strings.Contains(domain, "..") { + return fmt.Errorf("%w: domain cannot have empty labels", ErrAppConnectorDomainInvalid) + } + + return nil +} diff --git a/hscontrol/state/state.go b/hscontrol/state/state.go index b365269c..d018dc4c 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -874,6 +874,11 @@ func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool { return s.polMan.NodeCanHaveTag(node, tag) } +// AppConnectorConfigForNode returns the app connector configuration for a node. +func (s *State) AppConnectorConfigForNode(node types.NodeView) []policy.AppConnectorAttr { + return s.polMan.AppConnectorConfigForNode(node) +} + // SetPolicy updates the policy configuration. func (s *State) SetPolicy(pol []byte) (bool, error) { return s.polMan.SetPolicy(pol)