diff --git a/.github/workflows/test-integration.yaml b/.github/workflows/test-integration.yaml index 0e2a03377..6a2044248 100644 --- a/.github/workflows/test-integration.yaml +++ b/.github/workflows/test-integration.yaml @@ -243,6 +243,9 @@ jobs: - TestAPIAuthenticationBypassCurl - TestRemoteCLIAuthenticationBypass - TestCLIWithConfigAuthenticationBypass + - TestAppConnectorBasic + - TestAppConnectorNonMatchingTag + - TestAppConnectorWildcardConnector - TestAuthKeyLogoutAndReloginSameUser - TestAuthKeyLogoutAndReloginNewUser - TestAuthKeyLogoutAndReloginSameUserExpiredKey diff --git a/CHANGELOG.md b/CHANGELOG.md index 785d7d855..a475be91a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -516,6 +516,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/cmd/hi/docker.go b/cmd/hi/docker.go index 102ee526e..cff29fdda 100644 --- a/cmd/hi/docker.go +++ b/cmd/hi/docker.go @@ -257,6 +257,19 @@ 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/docs/about/features.md b/docs/about/features.md index e92327c8c..cc1650bf4 100644 --- a/docs/about/features.md +++ b/docs/about/features.md @@ -40,6 +40,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/docs/features/tailscale-funnel) ([#1040](https://github.com/juanfont/headscale/issues/1040)) - [ ] [Serve](https://tailscale.com/docs/features/tailscale-serve) ([#1234](https://github.com/juanfont/headscale/issues/1921)) - [ ] [Network flow logs](https://tailscale.com/docs/features/logging/network-flow-logs) ([#1687](https://github.com/juanfont/headscale/issues/1687)) diff --git a/docs/ref/policy.md b/docs/ref/policy.md index f326d7ecb..66bb0a902 100644 --- a/docs/ref/policy.md +++ b/docs/ref/policy.md @@ -226,6 +226,11 @@ configuration and attributes. At least the following node attributes are current } ``` +The `attr` field above carries key-only capabilities. A `nodeAttrs` entry may instead (or additionally) carry an `app` +field for *valued* capabilities — a capability name mapped to a list of JSON payloads, matching Tailscale's `app` field. +Headscale passes these payloads through unmodified. [App Connectors](#app-connectors) are delivered this way via the +`tailscale.com/app-connectors` capability. + ## Network-wide policy options The following options are applied for the entire tailnet. Consider [node attributes](#node-attributes) for a more @@ -242,6 +247,118 @@ fine-grained configuration instead. } ``` +## App Connectors + +Headscale supports [App Connectors](https://tailscale.com/kb/1281/app-connectors), which route traffic for specific +domains through designated connector nodes. This is useful for reaching internal applications or services that are only +reachable from certain nodes in your tailnet. + +App connectors are configured exactly as they are in Tailscale: through the [`app`](#node-attributes) field of a +`nodeAttrs` entry, using the `tailscale.com/app-connectors` capability. The `target` selects which nodes receive the +configuration; a connector node must also be started with `tailscale set --advertise-connector`. + +```json title="policy.json" +{ + "tagOwners": { + "tag:connector": ["admin@"] + }, + "nodeAttrs": [ + { + "target": ["tag:connector"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "Internal Apps", + "connectors": ["tag:connector"], + "domains": ["internal.example.com", "*.corp.example.com"], + "routes": ["10.0.0.0/8"] + } + ] + } + } + ] +} +``` + +### Configuration fields + +The objects under `tailscale.com/app-connectors` use Tailscale's [app connector +attribute](https://tailscale.com/kb/1281/app-connectors) format. Headscale passes them through unmodified; the connector +client interprets them. + +| Field | Required | Description | +|-------|----------|-------------| +| `name` | No | A human-readable name for this collection of domains. | +| `connectors` | Yes | A list of tags (e.g. `tag:connector`) or `*` that identifies which nodes serve as connectors for these domains. Evaluated by the client. | +| `domains` | Yes | Domain names to route through the connector. Supports wildcards like `*.example.com`. | +| `routes` | No | Optional IP prefixes to advertise as routes, in addition to routes discovered dynamically from DNS. | + +### Auto-approving routes + +App connectors work as dynamic subnet routers under the hood. When a connector resolves DNS for a configured domain, it +advertises the resulting IP addresses as subnet routes. For these routes to take effect automatically, configure +`autoApprovers` to approve routes from the connector nodes: + +```json title="policy.json" +{ + "autoApprovers": { + "routes": { + "0.0.0.0/0": ["tag:connector"], + "::/0": ["tag:connector"] + } + } +} +``` + +Without `autoApprovers`, each dynamically discovered route requires manual approval. + +### How it works + +1. Declare the app connector configuration in a `nodeAttrs` entry whose `target` selects the connector nodes. +2. Configure `autoApprovers` to auto-approve routes from your connector tags. +3. Start the connector node with `tailscale set --advertise-connector`. It receives the domain configuration via the + `tailscale.com/app-connectors` capability. +4. When clients query DNS for a configured domain, traffic is routed through the connector node, which resolves the DNS + and forwards traffic to the destination. + +### Example: multiple connectors + +```json title="policy.json" +{ + "tagOwners": { + "tag:web-connector": ["admin@"], + "tag:db-connector": ["admin@"] + }, + "nodeAttrs": [ + { + "target": ["tag:web-connector"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "Web Applications", + "connectors": ["tag:web-connector"], + "domains": ["*.internal.example.com", "dashboard.corp.example.com"] + } + ] + } + }, + { + "target": ["tag:db-connector"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "Database Access", + "connectors": ["tag:db-connector"], + "domains": ["db.internal.example.com"], + "routes": ["10.20.30.0/24"] + } + ] + } + } + ] +} +``` + [^1]: Headscale also allows to store the policy in the database. This is typically only required in case a [web interface](integration/web-ui.md) is used. diff --git a/hscontrol/policy/v2/appconnector_test.go b/hscontrol/policy/v2/appconnector_test.go new file mode 100644 index 000000000..4d8fba99e --- /dev/null +++ b/hscontrol/policy/v2/appconnector_test.go @@ -0,0 +1,192 @@ +package v2 + +import ( + "encoding/json" + "net/netip" + "slices" + "testing" + + "github.com/juanfont/headscale/hscontrol/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "tailscale.com/tailcfg" + "tailscale.com/types/appctype" +) + +// appConnectorsCap is the Tailscale capability key under which app connector +// configuration is delivered to a node via the nodeAttrs "app" field. App +// connectors are not a bespoke Headscale policy block: they ride the generic +// valued-capability path, exactly as a Tailscale-hosted control plane delivers +// them. +const appConnectorsCap = tailcfg.NodeCapability("tailscale.com/app-connectors") + +// decodeAppConnectorAttrs decodes the app-connectors payloads on a node's +// CapMap into the real Tailscale [appctype.AppConnectorAttr]. Decoding through +// the upstream type (rather than a Headscale duplicate) asserts that the +// values Headscale passes through are wire-compatible with what a Tailscale +// client reads. +func decodeAppConnectorAttrs(t *testing.T, capMap tailcfg.NodeCapMap) []appctype.AppConnectorAttr { + t.Helper() + + raws, ok := capMap[appConnectorsCap] + if !ok { + return nil + } + + attrs := make([]appctype.AppConnectorAttr, 0, len(raws)) + + for _, raw := range raws { + var attr appctype.AppConnectorAttr + + require.NoError(t, json.Unmarshal([]byte(raw), &attr)) + attrs = append(attrs, attr) + } + + return attrs +} + +// TestAppConnectorViaNodeAttrs verifies that app connector configuration +// declared in nodeAttrs "app" lands in the CapMap of every node the target +// selects, and only those nodes. It reuses the shared nodeAttrs fixtures +// (node 3 = tag:server, node 4 = tag:client, nodes 1-2 = untagged). +func TestAppConnectorViaNodeAttrs(t *testing.T) { + t.Parallel() + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + policy := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + "nodeAttrs": [{ + "target": ["tag:server"], + "app": { + "tailscale.com/app-connectors": [ + { + "name": "Internal Apps", + "connectors": ["tag:server"], + "domains": ["internal.example.com", "*.corp.example.com"] + }, + { + "name": "VPN Apps", + "connectors": ["tag:server"], + "domains": ["vpn.example.com"], + "routes": ["10.0.0.0/8"] + } + ] + } + }] + }` + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoError(t, err) + + // The targeted node (tag:server, ID 3) receives both configs. + attrs := decodeAppConnectorAttrs(t, pm.NodeCapMap(3)) + require.Len(t, attrs, 2) + + domains := make([]string, 0, len(attrs)) + for _, a := range attrs { + domains = append(domains, a.Domains...) + } + + assert.ElementsMatch(t, + []string{"internal.example.com", "*.corp.example.com", "vpn.example.com"}, + domains, + ) + + // The "routes" field round-trips through the upstream type. + var withRoutes *appctype.AppConnectorAttr + + for i := range attrs { + if attrs[i].Name == "VPN Apps" { + withRoutes = &attrs[i] + } + } + + require.NotNil(t, withRoutes) + assert.Equal(t, + []netip.Prefix{netip.MustParsePrefix("10.0.0.0/8")}, + withRoutes.Routes, + ) + + // Nodes the target does not select get no app-connectors capability. + assert.Nil(t, decodeAppConnectorAttrs(t, pm.NodeCapMap(1)), "untagged node") + assert.Nil(t, decodeAppConnectorAttrs(t, pm.NodeCapMap(4)), "tag:client node") +} + +// TestAppConnectorWildcardTarget verifies that a wildcard target delivers the +// app-connectors capability to every node, mirroring Tailscale's "*" target. +func TestAppConnectorWildcardTarget(t *testing.T) { + t.Parallel() + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + policy := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + "nodeAttrs": [{ + "target": ["*"], + "app": { + "tailscale.com/app-connectors": [ + {"name": "All", "connectors": ["*"], "domains": ["*.example.com"]} + ] + } + }] + }` + + pm, err := NewPolicyManager([]byte(policy), users, nodes.ViewSlice()) + require.NoError(t, err) + + for _, id := range []types.NodeID{1, 2, 3, 4, 5} { + attrs := decodeAppConnectorAttrs(t, pm.NodeCapMap(id)) + require.Lenf(t, attrs, 1, "node %d", id) + assert.Equal(t, []string{"*.example.com"}, attrs[0].Domains) + } +} + +// TestAppConnectorChangeTracking proves that app connector caps delivered via +// nodeAttrs.app participate in NodesWithChangedCapMap — the drain that drives +// per-node change.SelfUpdate on policy reload (hscontrol/state/state.go). This +// is why app connectors no longer need a PolicyChange{IncludeSelf:true} +// broadcast: a node whose app config changes is reported here and gets a +// targeted self-update through the same path as every other nodeAttrs cap. +func TestAppConnectorChangeTracking(t *testing.T) { + t.Parallel() + + users := nodeAttrsTestUsers() + nodes := nodeAttrsTestNodes(users) + + // Start with no nodeAttrs at all. + base := `{"tagOwners": {` + nodeAttrsTagOwners + `}}` + + pm, err := NewPolicyManager([]byte(base), users, nodes.ViewSlice()) + require.NoError(t, err) + + require.Empty(t, pm.NodesWithChangedCapMap(), + "no nodeAttrs means no node has a CapMap") + + // Add an app connector targeting tag:server (node 3 only). + withConnector := `{ + "tagOwners": {` + nodeAttrsTagOwners + `}, + "nodeAttrs": [{ + "target": ["tag:server"], + "app": { + "tailscale.com/app-connectors": [ + {"name": "Internal", "connectors": ["tag:server"], "domains": ["internal.example.com"]} + ] + } + }] + }` + + changed, err := pm.SetPolicy([]byte(withConnector)) + require.NoError(t, err) + require.True(t, changed) + + delta := pm.NodesWithChangedCapMap() + slices.Sort(delta) + assert.Equal(t, []types.NodeID{3}, delta, + "only the targeted node's app-connectors cap appeared") + + assert.Empty(t, pm.NodesWithChangedCapMap(), + "NodesWithChangedCapMap drains on read") +} diff --git a/hscontrol/policy/v2/compiled.go b/hscontrol/policy/v2/compiled.go index 642100c7d..a6ba3936b 100644 --- a/hscontrol/policy/v2/compiled.go +++ b/hscontrol/policy/v2/compiled.go @@ -157,13 +157,19 @@ func (pol *Policy) compileNodeAttrs( } result := make(map[types.NodeID]tailcfg.NodeCapMap) - stamp := func(id types.NodeID, attr tailcfg.NodeCapability) { + capMapFor := func(id types.NodeID) tailcfg.NodeCapMap { capMap, ok := result[id] if !ok { capMap = tailcfg.NodeCapMap{} result[id] = capMap } + return capMap + } + + stamp := func(id types.NodeID, attr tailcfg.NodeCapability) { + capMap := capMapFor(id) + // nil [tailcfg.RawMessage] matches the wire format from a // Tailscale-hosted control plane: capabilities without companion // data marshal as null rather than []. Storing nil keeps the @@ -174,6 +180,16 @@ func (pol *Policy) compileNodeAttrs( } } + // stampValues appends valued capabilities from a nodeAttrs "app" + // block (a capability name → JSON payloads, e.g. + // "tailscale.com/app-connectors"). The payloads are passed through + // opaquely; the client interprets them. Multiple nodeAttrs entries + // targeting the same node accumulate their values. + stampValues := func(id types.NodeID, capKey tailcfg.NodeCapability, vals []tailcfg.RawMessage) { + capMap := capMapFor(id) + capMap[capKey] = append(capMap[capKey], vals...) + } + // Cache each node's IPs once per call. Without the cache, the // node-attr inner loop would call [types.NodeView.IPs] once per attr // per node — O(grants × nodes) allocations of a 2-element slice @@ -195,7 +211,7 @@ func (pol *Policy) compileNodeAttrs( } for _, na := range pol.NodeAttrs { - if len(na.Attrs) == 0 { + if len(na.Attrs) == 0 && len(na.App) == 0 { continue } @@ -216,6 +232,10 @@ func (pol *Policy) compileNodeAttrs( for _, attr := range na.Attrs { stamp(ni.id, attr) } + + for capKey, vals := range na.App { + stampValues(ni.id, capKey, vals) + } } } diff --git a/hscontrol/policy/v2/types.go b/hscontrol/policy/v2/types.go index 50a89f8c1..6f822c027 100644 --- a/hscontrol/policy/v2/types.go +++ b/hscontrol/policy/v2/types.go @@ -1888,11 +1888,19 @@ type Grant struct { // resolved exactly like ACL/grant sources, so users, groups, tags, hosts, // prefixes, autogroup:member, autogroup:tagged, and "*" are all valid. // +// Attrs holds key-only capabilities (e.g. "magicdns-aaaa"), stamped into the +// node's CapMap with a nil value. App holds valued capabilities — a +// capability name mapped to a list of JSON payloads — matching Tailscale's +// nodeAttrs "app" field. App connectors are delivered this way via the +// "tailscale.com/app-connectors" capability; Headscale passes the payloads +// through opaquely and the client interprets them. +// // IPPool is parsed and validated for forward compatibility with the IP // allocator; the policy compiler does not consume it yet. type NodeAttrGrant struct { Targets Aliases `json:"target"` Attrs []tailcfg.NodeCapability `json:"attr,omitempty"` + App tailcfg.NodeCapMap `json:"app,omitempty"` IPPool []netip.Prefix `json:"ipPool,omitempty"` } diff --git a/integration/appconnector_test.go b/integration/appconnector_test.go new file mode 100644 index 000000000..66225c0d6 --- /dev/null +++ b/integration/appconnector_test.go @@ -0,0 +1,397 @@ +package integration + +import ( + "encoding/json" + "net/netip" + "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" + "tailscale.com/types/appctype" +) + +// appConnectorsCap is the Tailscale capability under which app connector +// configuration is delivered to a node. App connectors ride the generic +// nodeAttrs "app" path rather than a bespoke Headscale policy block. +const appConnectorsCap = tailcfg.NodeCapability("tailscale.com/app-connectors") + +// appConnectorAttrs builds a nodeAttrs "app" CapMap carrying the given app +// connector configs, marshalled through the upstream [appctype.AppConnectorAttr] +// type so the test exercises the same wire shape a Tailscale-hosted control +// plane would emit. +func appConnectorAttrs(t *testing.T, attrs ...appctype.AppConnectorAttr) tailcfg.NodeCapMap { + t.Helper() + + raws := make([]tailcfg.RawMessage, 0, len(attrs)) + + for _, a := range attrs { + b, err := json.Marshal(a) + require.NoError(t, err) + + raws = append(raws, tailcfg.RawMessage(b)) + } + + return tailcfg.NodeCapMap{appConnectorsCap: raws} +} + +// TestAppConnectorBasic tests that app connector configuration declared in +// nodeAttrs "app" is propagated to a node selected by the target. +func TestAppConnectorBasic(t *testing.T) { + IntegrationSkip(t) + + // Policy with app connector configuration delivered via nodeAttrs.app + // targeting tag:connector. + 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), + }, + }, + }, + NodeAttrs: []policyv2.NodeAttrGrant{ + { + Targets: policyv2.Aliases{tagp("tag:connector")}, + App: appConnectorAttrs(t, + appctype.AppConnectorAttr{ + Name: "Internal Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"internal.example.com", "*.corp.example.com"}, + }, + appctype.AppConnectorAttr{ + Name: "VPN Apps", + Connectors: []string{"tag:connector"}, + Domains: []string{"vpn.example.com"}, + Routes: []netip.Prefix{netip.MustParsePrefix("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"), + ) + 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. Delivery of the capability is driven by + // the nodeAttrs target, not by advertising, but a real connector advertises + // so we mirror that here. + 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) { + nm, err := connectorNode.Netmap() + assert.NoError(c, err) + + if nm == nil || !nm.SelfNode.Valid() { + assert.Fail(c, "Netmap or SelfNode is invalid") + return + } + + capMap := nm.SelfNode.CapMap() + if capMap.IsNil() { + assert.Fail(c, "CapMap is nil") + return + } + + attrs, hasCapability := capMap.GetOk(appConnectorsCap) + assert.True(c, hasCapability, "Node should have app-connectors capability") + + if hasCapability { + // Verify we have the expected number of app connector configs + assert.Equal(c, 2, attrs.Len(), "Should have 2 app connector configs") + + // Verify the content of the configs + var allDomains []string + + for i := range attrs.Len() { + var cfg appctype.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") +} + +// TestAppConnectorNonMatchingTag tests that nodes not selected by the nodeAttrs +// target do not receive app connector configuration. +func TestAppConnectorNonMatchingTag(t *testing.T) { + IntegrationSkip(t) + + // App connector configuration targets 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), + }, + }, + }, + NodeAttrs: []policyv2.NodeAttrGrant{ + { + Targets: policyv2.Aliases{tagp("tag:connector")}, + App: appConnectorAttrs(t, + appctype.AppConnectorAttr{ + 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"), + ) + 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) + + // Verify the node does NOT have app connector capability + assert.EventuallyWithT(t, func(c *assert.CollectT) { + nm, err := otherNode.Netmap() + assert.NoError(c, err) + + if nm == nil || !nm.SelfNode.Valid() { + return + } + + capMap := nm.SelfNode.CapMap() + if capMap.IsNil() { + return + } + + _, hasCapability := capMap.GetOk(appConnectorsCap) + assert.False(c, hasCapability, "Node with non-matching tag should NOT have app-connectors capability") + }, 10*time.Second, 1*time.Second, "Verifying node does not receive app connector capability") +} + +// TestAppConnectorWildcardConnector tests that a wildcard (*) target delivers +// app connector configuration to every node. +func TestAppConnectorWildcardConnector(t *testing.T) { + IntegrationSkip(t) + + // Policy with a wildcard target. + policy := &policyv2.Policy{ + ACLs: []policyv2.ACL{ + { + Action: "accept", + Sources: []policyv2.Alias{wildcard()}, + Destinations: []policyv2.AliasWithPorts{ + aliasWithPorts(wildcard(), tailcfg.PortRangeAny), + }, + }, + }, + NodeAttrs: []policyv2.NodeAttrGrant{ + { + Targets: policyv2.Aliases{policyv2.Wildcard}, + App: appConnectorAttrs(t, + appctype.AppConnectorAttr{ + 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"), + ) + 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 a wildcard target, any node receives + // the configuration. + 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) { + nm, err := regularNode.Netmap() + assert.NoError(c, err) + + if nm == nil || !nm.SelfNode.Valid() { + assert.Fail(c, "Netmap or SelfNode is invalid") + return + } + + capMap := nm.SelfNode.CapMap() + if capMap.IsNil() { + assert.Fail(c, "CapMap is nil") + return + } + + attrs, hasCapability := capMap.GetOk(appConnectorsCap) + assert.True(c, hasCapability, "Node should have app-connectors capability with wildcard target") + + if hasCapability { + assert.Equal(c, 1, attrs.Len(), "Should have 1 app connector config") + + // Verify the domain + var cfg appctype.AppConnectorAttr + + 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") +}