policy: add App Connector support

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 <noreply@anthropic.com>
This commit is contained in:
matanbaruch 2026-01-01 14:16:45 +02:00
parent 84c092a9f9
commit 4ca8aae4ec
6 changed files with 589 additions and 0 deletions

View file

@ -1,6 +1,7 @@
package mapper
import (
"encoding/json"
"errors"
"net/netip"
"sort"
@ -8,6 +9,7 @@ import (
"github.com/juanfont/headscale/hscontrol/policy"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog/log"
"tailscale.com/tailcfg"
"tailscale.com/types/views"
"tailscale.com/util/multierr"
@ -86,11 +88,58 @@ func (b *MapResponseBuilder) WithSelfNode() *MapResponseBuilder {
return b
}
// Add app connector capabilities if this node is advertising as an app connector
b.addAppConnectorCapabilities(nv, tailnode)
b.resp.Node = tailnode
return b
}
// addAppConnectorCapabilities adds app connector capabilities to a node's CapMap
// if the node is advertising as an app connector and has matching policy configuration.
func (b *MapResponseBuilder) addAppConnectorCapabilities(nv types.NodeView, tailnode *tailcfg.Node) {
configs := b.mapper.state.AppConnectorConfigForNode(nv)
if len(configs) == 0 {
return
}
// Initialize CapMap if nil
if tailnode.CapMap == nil {
tailnode.CapMap = make(tailcfg.NodeCapMap)
}
// Build the app connector attributes for the capability
attrs := make([]tailcfg.RawMessage, 0, len(configs))
for _, cfg := range configs {
// Convert the config to JSON for the capability
attrJSON, err := json.Marshal(cfg)
if err != nil {
log.Warn().
Err(err).
Uint64("node.id", uint64(nv.ID())).
Str("app_connector.name", cfg.Name).
Msg("Failed to marshal app connector config")
continue
}
attrs = append(attrs, tailcfg.RawMessage(attrJSON))
}
if len(attrs) > 0 {
// The capability key is "tailscale.com/app-connectors" as per Tailscale protocol
tailnode.CapMap[tailcfg.NodeCapability("tailscale.com/app-connectors")] = attrs
log.Debug().
Uint64("node.id", uint64(nv.ID())).
Str("node.name", nv.Hostname()).
Int("app_connectors.count", len(attrs)).
Msg("Added app connector capabilities to node")
}
}
func (b *MapResponseBuilder) WithDebugType(t debugType) *MapResponseBuilder {
if debugDumpMapResponsePath != "" {
b.debugType = t

View file

@ -32,10 +32,18 @@ type PolicyManager interface {
// NodeCanApproveRoute reports whether the given node can approve the given route.
NodeCanApproveRoute(types.NodeView, netip.Prefix) bool
// AppConnectorConfigForNode returns the app connector configuration for a node
// that is advertising itself as an app connector.
AppConnectorConfigForNode(node types.NodeView) []AppConnectorAttr
Version() int
DebugString() string
}
// AppConnectorAttr describes a set of domains serviced by app connectors.
// Re-exported from v2 package for convenience.
type AppConnectorAttr = policyv2.AppConnectorAttr
// NewPolicyManager returns a new policy manager.
func NewPolicyManager(pol []byte, users []types.User, nodes views.Slice[types.NodeView]) (PolicyManager, error) {
var polMan PolicyManager

View file

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

View file

@ -1068,3 +1068,77 @@ func resolveTagOwners(p *Policy, users types.Users, nodes views.Slice[types.Node
return ret, nil
}
// AppConnectorConfigForNode returns the app connector configuration for a node
// that is advertising itself as an app connector.
// Returns nil if the node is not configured as an app connector or doesn't advertise as one.
func (pm *PolicyManager) AppConnectorConfigForNode(node types.NodeView) []AppConnectorAttr {
if pm == nil || pm.pol == nil {
return nil
}
// Check if node advertises as an app connector
if !node.Hostinfo().Valid() {
return nil
}
appConnector, ok := node.Hostinfo().AppConnector().Get()
if !ok || !appConnector {
return nil
}
pm.mu.Lock()
defer pm.mu.Unlock()
return pm.appConnectorConfigForNodeLocked(node)
}
// appConnectorConfigForNodeLocked returns the app connector config for a node.
// pm.mu must be held.
func (pm *PolicyManager) appConnectorConfigForNodeLocked(node types.NodeView) []AppConnectorAttr {
if pm.pol == nil || len(pm.pol.AppConnectors) == 0 {
return nil
}
var configs []AppConnectorAttr
for _, ac := range pm.pol.AppConnectors {
if pm.nodeMatchesConnector(node, ac.Connectors) {
configs = append(configs, AppConnectorAttr(ac))
}
}
return configs
}
// nodeMatchesConnector checks if a node matches any of the connector specifications.
func (pm *PolicyManager) nodeMatchesConnector(node types.NodeView, connectors []string) bool {
for _, connector := range connectors {
// Wildcard matches any advertising connector
if connector == "*" {
return true
}
// Check if it's a tag reference
if strings.HasPrefix(connector, "tag:") {
if node.HasTag(connector) {
return true
}
}
}
return false
}
// AppConnectorAttr describes a set of domains serviced by specified app connectors.
// This is similar to the Tailscale appctype.AppConnectorAttr structure.
type AppConnectorAttr struct {
// Name is the name of this collection of domains.
Name string `json:"name,omitempty"`
// Connectors enumerates the app connectors which service these domains.
Connectors []string `json:"connectors,omitempty"`
// Domains enumerates the domains serviced by the specified app connectors.
Domains []string `json:"domains,omitempty"`
// Routes enumerates the predetermined routes to be advertised.
Routes []netip.Prefix `json:"routes,omitempty"`
}

View file

@ -37,6 +37,15 @@ var ErrCircularReference = errors.New("circular reference detected")
var ErrUndefinedTagReference = errors.New("references undefined tag")
// App Connector validation errors.
var (
ErrAppConnectorMissingConnectors = errors.New("appConnector must have at least one connector")
ErrAppConnectorMissingDomains = errors.New("appConnector must have at least one domain")
ErrAppConnectorUndefinedTag = errors.New("appConnector references undefined tag")
ErrAppConnectorDomainEmpty = errors.New("domain cannot be empty")
ErrAppConnectorDomainInvalid = errors.New("invalid domain format")
)
type Asterix int
func (a Asterix) Validate() error {
@ -1510,6 +1519,29 @@ type Policy struct {
ACLs []ACL `json:"acls,omitempty"`
AutoApprovers AutoApproverPolicy `json:"autoApprovers"`
SSHs []SSH `json:"ssh,omitempty"`
AppConnectors []AppConnector `json:"appConnectors,omitempty"`
}
// AppConnector defines an app connector configuration that allows routing
// traffic to specific domains through designated connector nodes.
// See https://tailscale.com/kb/1281/app-connectors for more information.
type AppConnector struct {
// Name is a human-readable name for this app connector configuration.
Name string `json:"name,omitempty"`
// Connectors is a list of tags or "*" that identifies which nodes
// can serve as connectors for these domains.
// Examples: ["tag:connector"], ["*"]
Connectors []string `json:"connectors"`
// Domains is a list of domain names that should be routed through
// the connector. Supports wildcards like "*.example.com".
Domains []string `json:"domains"`
// Routes is an optional list of IP prefixes that should be
// pre-configured as routes for the connector (in addition to
// dynamically discovered routes from DNS resolution).
Routes []netip.Prefix `json:"routes,omitempty"`
}
// MarshalJSON is deliberately not implemented for Policy.
@ -1813,6 +1845,39 @@ func (p *Policy) validate() error {
}
}
// Validate app connectors
for _, ac := range p.AppConnectors {
if len(ac.Connectors) == 0 {
errs = append(errs, fmt.Errorf("%w: %q", ErrAppConnectorMissingConnectors, ac.Name))
}
if len(ac.Domains) == 0 {
errs = append(errs, fmt.Errorf("%w: %q", ErrAppConnectorMissingDomains, ac.Name))
}
// Validate connector references
for _, connector := range ac.Connectors {
if connector == "*" {
continue
}
if isTag(connector) {
tag := Tag(connector)
err := p.TagOwners.Contains(&tag)
if err != nil {
errs = append(errs, fmt.Errorf("%w: appConnector %q references %q", ErrAppConnectorUndefinedTag, ac.Name, connector))
}
}
}
// Validate domain format
for _, domain := range ac.Domains {
err := validateAppConnectorDomain(domain)
if err != nil {
errs = append(errs, fmt.Errorf("%w: appConnector %q has invalid domain %q", err, ac.Name, domain))
}
}
}
if len(errs) > 0 {
return multierr.New(errs...)
}
@ -2098,3 +2163,36 @@ func (p *Policy) usesAutogroupSelf() bool {
return false
}
// validateAppConnectorDomain validates an app connector domain.
// Valid domains can be:
// - A fully qualified domain name (e.g., "example.com")
// - A wildcard subdomain (e.g., "*.example.com").
func validateAppConnectorDomain(domain string) error {
if domain == "" {
return ErrAppConnectorDomainEmpty
}
// Check for wildcard domains
if strings.HasPrefix(domain, "*.") {
// Remove the "*." prefix and validate the rest
rest := domain[2:]
if rest == "" {
return fmt.Errorf("%w: wildcard domain must have a base domain", ErrAppConnectorDomainInvalid)
}
domain = rest
}
// Basic validation - domain should not start or end with dots
if strings.HasPrefix(domain, ".") || strings.HasSuffix(domain, ".") {
return fmt.Errorf("%w: domain cannot start or end with a dot", ErrAppConnectorDomainInvalid)
}
// Check for empty labels
if strings.Contains(domain, "..") {
return fmt.Errorf("%w: domain cannot have empty labels", ErrAppConnectorDomainInvalid)
}
return nil
}

View file

@ -874,6 +874,11 @@ func (s *State) NodeCanHaveTag(node types.NodeView, tag string) bool {
return s.polMan.NodeCanHaveTag(node, tag)
}
// AppConnectorConfigForNode returns the app connector configuration for a node.
func (s *State) AppConnectorConfigForNode(node types.NodeView) []policy.AppConnectorAttr {
return s.polMan.AppConnectorConfigForNode(node)
}
// SetPolicy updates the policy configuration.
func (s *State) SetPolicy(pol []byte) (bool, error) {
return s.polMan.SetPolicy(pol)