From 3b97d7be53a13d5d0805ccdae592112b4a58eda0 Mon Sep 17 00:00:00 2001 From: matanbaruch Date: Thu, 1 Jan 2026 14:16:45 +0200 Subject: [PATCH 1/3] policy: add App Connector support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement App Connector functionality for Headscale, allowing nodes to advertise as app connectors and receive domain-based routing configuration from the control plane. This addresses issue #1651. Changes: - Add `appConnectors` field to Policy struct for defining app connector configurations in ACLs - Parse app connector configuration including name, connectors (tags or "*"), domains (with wildcard support), and optional routes - Add validation for app connector configuration (domains, tags, etc.) - Add `AppConnectorConfigForNode` method to PolicyManager to get matching configurations for nodes advertising as app connectors - Update mapper to add `tailscale.com/app-connectors` capability to CapMap in MapResponse for nodes advertising as app connectors - Add comprehensive unit tests for app connector functionality Example ACL configuration: ```json { "tagOwners": { "tag:connector": ["user@example.com"] }, "appConnectors": [ { "name": "Internal Apps", "connectors": ["tag:connector"], "domains": ["internal.example.com", "*.corp.example.com"], "routes": ["10.0.0.0/8"] } ] } ``` Closes #1651 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- hscontrol/mapper/builder.go | 49 ++++ hscontrol/policy/pm.go | 8 + hscontrol/policy/v2/appconnector_test.go | 355 +++++++++++++++++++++++ hscontrol/policy/v2/policy.go | 74 +++++ hscontrol/policy/v2/types.go | 98 +++++++ hscontrol/state/state.go | 5 + 6 files changed, 589 insertions(+) create mode 100644 hscontrol/policy/v2/appconnector_test.go diff --git a/hscontrol/mapper/builder.go b/hscontrol/mapper/builder.go index 58848883..5700a00a 100644 --- a/hscontrol/mapper/builder.go +++ b/hscontrol/mapper/builder.go @@ -1,12 +1,14 @@ package mapper import ( + "encoding/json" "net/netip" "sort" "time" "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 6dfacd91..733dfbac 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(node types.NodeView, route 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 ( 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 74b7ba6a..370ac0cd 100644 --- a/hscontrol/policy/v2/policy.go +++ b/hscontrol/policy/v2/policy.go @@ -1137,3 +1137,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 8785bed0..3cd9fccb 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -99,6 +99,15 @@ var ( ErrProtocolNoSpecificPorts = errors.New("protocol does not support specific ports") ) +// 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 { @@ -1638,6 +1647,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. @@ -2088,6 +2120,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...) } @@ -2376,3 +2441,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 65ebf905..9de0cdd9 100644 --- a/hscontrol/state/state.go +++ b/hscontrol/state/state.go @@ -875,6 +875,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) From 542f1fabb8c8b8b1f63672d0f938628d9e262883 Mon Sep 17 00:00:00 2001 From: matanbaruch Date: Thu, 1 Jan 2026 14:42:51 +0200 Subject: [PATCH 2/3] docs: add App Connector documentation and integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add App Connectors section to docs/ref/acls.md with configuration examples - Add App Connectors to feature list in docs/about/features.md - Add CHANGELOG.md entry for App Connector support - Add integration tests for app connector functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CHANGELOG.md | 1 + docs/about/features.md | 1 + docs/ref/acls.md | 62 ++++++ integration/appconnector_test.go | 355 +++++++++++++++++++++++++++++++ 4 files changed, 419 insertions(+) create mode 100644 integration/appconnector_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2178ad87..61e32f83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -173,6 +173,7 @@ sequentially through each stable release, selecting the latest patch version ava ### Changes +- Add App Connector support for domain-based routing through designated connector nodes [#2987](https://github.com/juanfont/headscale/pull/2987) - Smarter change notifications send partial map updates and node removals instead of full maps [#2961](https://github.com/juanfont/headscale/pull/2961) - Send lightweight endpoint and DERP region updates instead of full maps [#2856](https://github.com/juanfont/headscale/pull/2856) - Add NixOS module in repository for faster iteration [#2857](https://github.com/juanfont/headscale/pull/2857) diff --git a/docs/about/features.md b/docs/about/features.md index 1d87019f..636b2cfa 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -33,6 +33,7 @@ provides on overview of Headscale's feature and compatibility with the Tailscale - [x] Basic registration - [x] Update user profile from identity provider - [ ] OIDC groups cannot be used in ACLs +- [x] [App Connectors](https://tailscale.com/kb/1281/app-connectors) - Route traffic to specific domains through designated connector nodes - [ ] [Funnel](https://tailscale.com/kb/1223/funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040)) - [ ] [Serve](https://tailscale.com/kb/1312/serve) ([#1234](https://github.com/juanfont/headscale/issues/1921)) - [ ] [Network flow logs](https://tailscale.com/kb/1219/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687)) diff --git a/docs/ref/acls.md b/docs/ref/acls.md index 503aa3ac..667e231d 100644 --- a/docs/ref/acls.md +++ b/docs/ref/acls.md @@ -285,3 +285,65 @@ Used in Tailscale SSH rules to allow access to any user except root. Can only be "users": ["autogroup:nonroot"] } ``` + +## App Connectors + +Headscale supports [App Connectors](https://tailscale.com/kb/1281/app-connectors), which allow you to route traffic to specific domains through designated connector nodes. This is useful for accessing internal applications or services that are only reachable from certain nodes in your tailnet. + +App connectors are configured in the `appConnectors` field of your ACL policy: + +```json +{ + "tagOwners": { + "tag:connector": ["admin@"] + }, + "appConnectors": [ + { + "name": "Internal Apps", + "connectors": ["tag:connector"], + "domains": ["internal.example.com", "*.corp.example.com"], + "routes": ["10.0.0.0/8"] + } + ] +} +``` + +### Configuration Fields + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | No | A human-readable name for this app connector configuration | +| `connectors` | Yes | A list of tags (e.g., `tag:connector`) or `*` (all nodes) that identifies which nodes can serve as connectors | +| `domains` | Yes | A list of domain names to route through the connector. Supports wildcards like `*.example.com` | +| `routes` | No | Optional list of IP prefixes to pre-configure as routes (in addition to dynamically discovered routes from DNS) | + +### How It Works + +1. Configure tagged nodes as app connectors in your ACL policy +2. Nodes with the specified tags that advertise themselves as app connectors will receive the domain configuration +3. When clients query DNS for the configured domains, traffic is automatically routed through the connector nodes +4. The connector nodes resolve the DNS and forward traffic to the destination + +### Example: Multiple Connectors + +```json +{ + "tagOwners": { + "tag:web-connector": ["admin@"], + "tag:db-connector": ["admin@"] + }, + "appConnectors": [ + { + "name": "Web Applications", + "connectors": ["tag:web-connector"], + "domains": ["*.internal.example.com", "dashboard.corp.example.com"] + }, + { + "name": "Database Access", + "connectors": ["tag:db-connector"], + "domains": ["db.internal.example.com"], + "routes": ["10.20.30.0/24"] + } + ] +} +``` diff --git a/integration/appconnector_test.go b/integration/appconnector_test.go new file mode 100644 index 00000000..f1e12873 --- /dev/null +++ b/integration/appconnector_test.go @@ -0,0 +1,355 @@ +package integration + +import ( + "encoding/json" + "testing" + "time" + + policyv2 "github.com/juanfont/headscale/hscontrol/policy/v2" + "github.com/juanfont/headscale/integration/hsic" + "github.com/juanfont/headscale/integration/integrationutil" + "github.com/juanfont/headscale/integration/tsic" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" +) + +// TestAppConnectorBasic tests that app connector configuration is properly +// propagated to nodes that advertise as app connectors and match the policy. +func TestAppConnectorBasic(t *testing.T) { + IntegrationSkip(t) + + // Policy with app connector configuration + policy := &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + "tag:connector": policyv2.Owners{usernameOwner("user1@")}, + }, + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + AppConnectors: []policyv2.AppConnector{ + { + Name: "Internal Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"internal.example.com", "*.corp.example.com"}, + }, + { + Name: "VPN Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"vpn.example.com"}, + Routes: []string{"10.0.0.0/8"}, + }, + }, + } + + spec := ScenarioSpec{ + NodesPerUser: 0, // We'll create nodes manually with specific tags + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("appconnector"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + // Create a tagged node with tag:connector using PreAuthKey (tags-as-identity) + taggedKey, err := scenario.CreatePreAuthKeyWithTags( + userMap["user1"].GetId(), false, false, []string{"tag:connector"}, + ) + require.NoError(t, err) + + connectorNode, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithNetfilter("off"), + ) + require.NoError(t, err) + + err = connectorNode.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + require.NoError(t, err) + + err = connectorNode.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + // Verify the node has the tag:connector tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := connectorNode.Status() + assert.NoError(c, err) + assert.NotNil(c, status.Self.Tags, "Node should have tags") + if status.Self.Tags != nil { + assert.Contains(c, status.Self.Tags.AsSlice(), "tag:connector", "Node should have tag:connector") + } + }, 30*time.Second, 500*time.Millisecond, "Waiting for node to have correct tags") + + // Advertise as an app connector using tailscale set --advertise-connector + t.Log("Advertising node as app connector") + _, _, err = connectorNode.Execute([]string{ + "tailscale", "set", "--advertise-connector", + }) + require.NoError(t, err) + + // Wait for the app connector capability to be propagated + t.Log("Waiting for app connector capability to be propagated") + assert.EventuallyWithT(t, func(c *assert.CollectT) { + netmap, err := connectorNode.Netmap() + assert.NoError(c, err) + + if netmap == nil || netmap.SelfNode == nil { + assert.Fail(c, "Netmap or SelfNode is nil") + return + } + + // Check for the app-connectors capability in CapMap + capMap := netmap.SelfNode.CapMap + if capMap == nil { + assert.Fail(c, "CapMap is nil") + return + } + + appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") + attrs, hasCapability := capMap[appConnectorCap] + assert.True(c, hasCapability, "Node should have app-connectors capability") + + if hasCapability { + // Verify we have the expected number of app connector configs + assert.Len(c, attrs, 2, "Should have 2 app connector configs") + + // Verify the content of the configs + var configs []policyv2.AppConnectorAttr + for _, attr := range attrs { + var cfg policyv2.AppConnectorAttr + err := json.Unmarshal([]byte(attr), &cfg) + assert.NoError(c, err) + configs = append(configs, cfg) + } + + // Check that we have the expected domains + var allDomains []string + for _, cfg := range configs { + allDomains = append(allDomains, cfg.Domains...) + } + assert.Contains(c, allDomains, "internal.example.com") + assert.Contains(c, allDomains, "*.corp.example.com") + assert.Contains(c, allDomains, "vpn.example.com") + } + }, 60*time.Second, 1*time.Second, "App connector capability should be propagated") + + t.Log("TestAppConnectorBasic PASSED: App connector configuration propagated correctly") +} + +// TestAppConnectorNonMatchingTag tests that nodes without matching tags +// do not receive app connector configuration. +func TestAppConnectorNonMatchingTag(t *testing.T) { + IntegrationSkip(t) + + // Policy with app connector configuration for tag:connector only + policy := &policyv2.Policy{ + TagOwners: policyv2.TagOwners{ + "tag:connector": policyv2.Owners{usernameOwner("user1@")}, + "tag:other": policyv2.Owners{usernameOwner("user1@")}, + }, + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + AppConnectors: []policyv2.AppConnector{ + { + Name: "Internal Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"internal.example.com"}, + }, + }, + } + + spec := ScenarioSpec{ + NodesPerUser: 0, + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{}, + hsic.WithACLPolicy(policy), + hsic.WithTestName("appconnector-nonmatch"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + ) + require.NoError(t, err) + + headscale, err := scenario.Headscale() + require.NoError(t, err) + + userMap, err := headscale.MapUsers() + require.NoError(t, err) + + // Create a node with tag:other (not tag:connector) + taggedKey, err := scenario.CreatePreAuthKeyWithTags( + userMap["user1"].GetId(), false, false, []string{"tag:other"}, + ) + require.NoError(t, err) + + otherNode, err := scenario.CreateTailscaleNode( + "head", + tsic.WithNetwork(scenario.networks[scenario.testDefaultNetwork]), + tsic.WithNetfilter("off"), + ) + require.NoError(t, err) + + err = otherNode.Login(headscale.GetEndpoint(), taggedKey.GetKey()) + require.NoError(t, err) + + err = otherNode.WaitForRunning(integrationutil.PeerSyncTimeout()) + require.NoError(t, err) + + // Verify the node has the tag:other tag + assert.EventuallyWithT(t, func(c *assert.CollectT) { + status, err := otherNode.Status() + assert.NoError(c, err) + assert.NotNil(c, status.Self.Tags, "Node should have tags") + if status.Self.Tags != nil { + assert.Contains(c, status.Self.Tags.AsSlice(), "tag:other", "Node should have tag:other") + } + }, 30*time.Second, 500*time.Millisecond, "Waiting for node to have correct tags") + + // Advertise as an app connector + t.Log("Advertising node as app connector (should NOT receive config)") + _, _, err = otherNode.Execute([]string{ + "tailscale", "set", "--advertise-connector", + }) + require.NoError(t, err) + + // Wait a bit and verify the node does NOT have app connector capability + // Use a shorter timeout since we're checking for absence + time.Sleep(5 * time.Second) + + netmap, err := otherNode.Netmap() + require.NoError(t, err) + + if netmap != nil && netmap.SelfNode != nil && netmap.SelfNode.CapMap != nil { + appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") + _, hasCapability := netmap.SelfNode.CapMap[appConnectorCap] + assert.False(t, hasCapability, "Node with non-matching tag should NOT have app-connectors capability") + } + + t.Log("TestAppConnectorNonMatchingTag PASSED: Non-matching tag correctly excluded") +} + +// TestAppConnectorWildcardConnector tests that a wildcard (*) connector +// matches all nodes that advertise as app connectors. +func TestAppConnectorWildcardConnector(t *testing.T) { + IntegrationSkip(t) + + // Policy with wildcard connector + policy := &policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + AppConnectors: []policyv2.AppConnector{ + { + Name: "All Connectors", + Connectors: []string{"*"}, + Domains: []string{"*.internal.example.com"}, + }, + }, + } + + spec := ScenarioSpec{ + NodesPerUser: 1, // Create a regular user node + Users: []string{"user1"}, + } + + scenario, err := NewScenario(spec) + require.NoError(t, err) + defer scenario.ShutdownAssertNoPanics(t) + + err = scenario.CreateHeadscaleEnv( + []tsic.Option{ + tsic.WithNetfilter("off"), + }, + hsic.WithACLPolicy(policy), + hsic.WithTestName("appconnector-wildcard"), + hsic.WithEmbeddedDERPServerOnly(), + hsic.WithTLS(), + ) + require.NoError(t, err) + + user1Clients, err := scenario.ListTailscaleClients("user1") + require.NoError(t, err) + require.Len(t, user1Clients, 1) + + regularNode := user1Clients[0] + + // Advertise as an app connector - with wildcard, any node should work + t.Log("Advertising regular node as app connector with wildcard policy") + _, _, err = regularNode.Execute([]string{ + "tailscale", "set", "--advertise-connector", + }) + require.NoError(t, err) + + // Wait for the app connector capability to be propagated + assert.EventuallyWithT(t, func(c *assert.CollectT) { + netmap, err := regularNode.Netmap() + assert.NoError(c, err) + + if netmap == nil || netmap.SelfNode == nil { + assert.Fail(c, "Netmap or SelfNode is nil") + return + } + + capMap := netmap.SelfNode.CapMap + if capMap == nil { + assert.Fail(c, "CapMap is nil") + return + } + + appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") + attrs, hasCapability := capMap[appConnectorCap] + assert.True(c, hasCapability, "Node should have app-connectors capability with wildcard connector") + + if hasCapability { + assert.Len(c, attrs, 1, "Should have 1 app connector config") + + // Verify the domain + var cfg policyv2.AppConnectorAttr + err := json.Unmarshal([]byte(attrs[0]), &cfg) + assert.NoError(c, err) + assert.Contains(c, cfg.Domains, "*.internal.example.com") + } + }, 60*time.Second, 1*time.Second, "App connector capability should be propagated with wildcard") + + t.Log("TestAppConnectorWildcardConnector PASSED: Wildcard connector works correctly") +} From fd840fabb1f7bb3aa3d90f45180779851a369718 Mon Sep 17 00:00:00 2001 From: matanbaruch Date: Wed, 11 Feb 2026 06:48:37 +0200 Subject: [PATCH 3/3] fix: ensure Docker API version compatibility and improve app connector tests - Set DOCKER_API_VERSION to 1.44 if not already defined to maintain compatibility with newer Docker daemons. - Update app connector tests to use netip.Prefix for route definitions and improve validation checks for netmap and capability maps. - Refactor assertions in tests for better clarity and consistency. This enhances the reliability of integration tests and ensures proper interaction with Docker APIs. --- cmd/hi/docker.go | 12 +++++++ integration/appconnector_test.go | 61 +++++++++++++------------------- 2 files changed, 37 insertions(+), 36 deletions(-) diff --git a/cmd/hi/docker.go b/cmd/hi/docker.go index 060057a9..56cf589a 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -256,6 +256,18 @@ func createGoTestContainer(ctx context.Context, cli *client.Client, config *RunC // Set GOCACHE to a known location (used by both bind mount and volume cases) env = append(env, "GOCACHE=/cache/go-build") + // Ensure Docker API version compatibility with newer Docker daemons (29.x+). + // dockertest's go-dockerclient uses old API versions (e.g., 1.25) in URLs which + // newer Docker daemons reject (minimum 1.44). Setting DOCKER_API_VERSION makes + // the client use a compatible version, and DOCKER_MACHINE_NAME triggers the + // NewClientFromEnv() code path in dockertest which respects DOCKER_API_VERSION. + if os.Getenv("DOCKER_API_VERSION") == "" { + env = append(env, "DOCKER_API_VERSION=1.44") + } else { + env = append(env, "DOCKER_API_VERSION="+os.Getenv("DOCKER_API_VERSION")) + } + env = append(env, "DOCKER_MACHINE_NAME=integration") + containerConfig := &container.Config{ Image: "golang:" + config.GoVersion, Cmd: goTestCmd, diff --git a/integration/appconnector_test.go b/integration/appconnector_test.go index f1e12873..d629cf72 100644 --- a/integration/appconnector_test.go +++ b/integration/appconnector_test.go @@ -2,6 +2,7 @@ package integration import ( "encoding/json" + "net/netip" "testing" "time" @@ -43,7 +44,7 @@ func TestAppConnectorBasic(t *testing.T) { Name: "VPN Apps", Connectors: []string{"tag:connector"}, Domains: []string{"vpn.example.com"}, - Routes: []string{"10.0.0.0/8"}, + Routes: []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, }, }, } @@ -111,50 +112,42 @@ func TestAppConnectorBasic(t *testing.T) { // Wait for the app connector capability to be propagated t.Log("Waiting for app connector capability to be propagated") assert.EventuallyWithT(t, func(c *assert.CollectT) { - netmap, err := connectorNode.Netmap() + nm, err := connectorNode.Netmap() assert.NoError(c, err) - if netmap == nil || netmap.SelfNode == nil { - assert.Fail(c, "Netmap or SelfNode is nil") + if nm == nil || !nm.SelfNode.Valid() { + assert.Fail(c, "Netmap or SelfNode is invalid") return } - // Check for the app-connectors capability in CapMap - capMap := netmap.SelfNode.CapMap - if capMap == nil { + capMap := nm.SelfNode.CapMap() + if capMap.IsNil() { assert.Fail(c, "CapMap is nil") return } appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") - attrs, hasCapability := capMap[appConnectorCap] + attrs, hasCapability := capMap.GetOk(appConnectorCap) assert.True(c, hasCapability, "Node should have app-connectors capability") if hasCapability { // Verify we have the expected number of app connector configs - assert.Len(c, attrs, 2, "Should have 2 app connector configs") + assert.Equal(c, 2, attrs.Len(), "Should have 2 app connector configs") // Verify the content of the configs - var configs []policyv2.AppConnectorAttr - for _, attr := range attrs { - var cfg policyv2.AppConnectorAttr - err := json.Unmarshal([]byte(attr), &cfg) - assert.NoError(c, err) - configs = append(configs, cfg) - } - - // Check that we have the expected domains var allDomains []string - for _, cfg := range configs { + for i := range attrs.Len() { + var cfg policyv2.AppConnectorAttr + err := json.Unmarshal([]byte(attrs.At(i)), &cfg) + assert.NoError(c, err) allDomains = append(allDomains, cfg.Domains...) } + assert.Contains(c, allDomains, "internal.example.com") assert.Contains(c, allDomains, "*.corp.example.com") assert.Contains(c, allDomains, "vpn.example.com") } }, 60*time.Second, 1*time.Second, "App connector capability should be propagated") - - t.Log("TestAppConnectorBasic PASSED: App connector configuration propagated correctly") } // TestAppConnectorNonMatchingTag tests that nodes without matching tags @@ -250,16 +243,14 @@ func TestAppConnectorNonMatchingTag(t *testing.T) { // Use a shorter timeout since we're checking for absence time.Sleep(5 * time.Second) - netmap, err := otherNode.Netmap() + nm, err := otherNode.Netmap() require.NoError(t, err) - if netmap != nil && netmap.SelfNode != nil && netmap.SelfNode.CapMap != nil { + if nm != nil && nm.SelfNode.Valid() && !nm.SelfNode.CapMap().IsNil() { appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") - _, hasCapability := netmap.SelfNode.CapMap[appConnectorCap] + _, hasCapability := nm.SelfNode.CapMap().GetOk(appConnectorCap) assert.False(t, hasCapability, "Node with non-matching tag should NOT have app-connectors capability") } - - t.Log("TestAppConnectorNonMatchingTag PASSED: Non-matching tag correctly excluded") } // TestAppConnectorWildcardConnector tests that a wildcard (*) connector @@ -322,34 +313,32 @@ func TestAppConnectorWildcardConnector(t *testing.T) { // Wait for the app connector capability to be propagated assert.EventuallyWithT(t, func(c *assert.CollectT) { - netmap, err := regularNode.Netmap() + nm, err := regularNode.Netmap() assert.NoError(c, err) - if netmap == nil || netmap.SelfNode == nil { - assert.Fail(c, "Netmap or SelfNode is nil") + if nm == nil || !nm.SelfNode.Valid() { + assert.Fail(c, "Netmap or SelfNode is invalid") return } - capMap := netmap.SelfNode.CapMap - if capMap == nil { + capMap := nm.SelfNode.CapMap() + if capMap.IsNil() { assert.Fail(c, "CapMap is nil") return } appConnectorCap := tailcfg.NodeCapability("tailscale.com/app-connectors") - attrs, hasCapability := capMap[appConnectorCap] + attrs, hasCapability := capMap.GetOk(appConnectorCap) assert.True(c, hasCapability, "Node should have app-connectors capability with wildcard connector") if hasCapability { - assert.Len(c, attrs, 1, "Should have 1 app connector config") + assert.Equal(c, 1, attrs.Len(), "Should have 1 app connector config") // Verify the domain var cfg policyv2.AppConnectorAttr - err := json.Unmarshal([]byte(attrs[0]), &cfg) + err := json.Unmarshal([]byte(attrs.At(0)), &cfg) assert.NoError(c, err) assert.Contains(c, cfg.Domains, "*.internal.example.com") } }, 60*time.Second, 1*time.Second, "App connector capability should be propagated with wildcard") - - t.Log("TestAppConnectorWildcardConnector PASSED: Wildcard connector works correctly") }