This commit is contained in:
Matan Baruch 2026-07-03 11:14:57 -03:00 committed by GitHub
commit 75532893d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 754 additions and 2 deletions

View file

@ -243,6 +243,9 @@ jobs:
- TestAPIAuthenticationBypassCurl
- TestRemoteCLIAuthenticationBypass
- TestCLIWithConfigAuthenticationBypass
- TestAppConnectorBasic
- TestAppConnectorNonMatchingTag
- TestAppConnectorWildcardConnector
- TestAuthKeyLogoutAndReloginSameUser
- TestAuthKeyLogoutAndReloginNewUser
- TestAuthKeyLogoutAndReloginSameUserExpiredKey

View file

@ -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)

View file

@ -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,

View file

@ -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))

View file

@ -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.

View file

@ -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")
}

View file

@ -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)
}
}
}

View file

@ -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"`
}

View file

@ -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")
}