mirror of
https://github.com/juanfont/headscale.git
synced 2026-01-23 10:25:47 +00:00
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:
parent
84c092a9f9
commit
4ca8aae4ec
6 changed files with 589 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
355
hscontrol/policy/v2/appconnector_test.go
Normal file
355
hscontrol/policy/v2/appconnector_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue