mirror of
https://github.com/juanfont/headscale.git
synced 2026-07-29 02:30:07 +00:00
Merge 6b01a81440 into 048308511c
This commit is contained in:
commit
75532893d1
9 changed files with 754 additions and 2 deletions
3
.github/workflows/test-integration.yaml
vendored
3
.github/workflows/test-integration.yaml
vendored
|
|
@ -243,6 +243,9 @@ jobs:
|
|||
- TestAPIAuthenticationBypassCurl
|
||||
- TestRemoteCLIAuthenticationBypass
|
||||
- TestCLIWithConfigAuthenticationBypass
|
||||
- TestAppConnectorBasic
|
||||
- TestAppConnectorNonMatchingTag
|
||||
- TestAppConnectorWildcardConnector
|
||||
- TestAuthKeyLogoutAndReloginSameUser
|
||||
- TestAuthKeyLogoutAndReloginNewUser
|
||||
- TestAuthKeyLogoutAndReloginSameUserExpiredKey
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
192
hscontrol/policy/v2/appconnector_test.go
Normal file
192
hscontrol/policy/v2/appconnector_test.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
397
integration/appconnector_test.go
Normal file
397
integration/appconnector_test.go
Normal 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")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue