From eb1af6b947abeafbceea932aef7dc0d2b8eb49cb Mon Sep 17 00:00:00 2001 From: matanbaruch Date: Thu, 1 Jan 2026 14:42:51 +0200 Subject: [PATCH] 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 0ef22ff2..98adc07e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,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 `oidc.email_verified_required` config option to control email verification requirement [#2860](https://github.com/juanfont/headscale/pull/2860) diff --git a/docs/about/features.md b/docs/about/features.md index 81862b70..0ca38865 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -32,6 +32,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 3368ab61..ef593a0d 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") +}