Compare commits

..

12 Commits

Author SHA1 Message Date
Mustafa Enes Batur
474ea236d0 Fix /machine/map endpoint vulnerability (#2642)
* Improve map auth logic

* Bugfix

* Add comment, improve error message

* noise: make func, get by node

this commit splits the additional validation into a
separate function so it can be reused if we add more
endpoints in the future.

It swaps the check, so we still look up by NodeKey, but before
accepting the connection, we validate the known machinekey from
the db against the noise connection.

The reason for this is that when a node logs in or out, the node key
is replaced and it will no longer be possible to look it up, breaking
reauthentication.

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
Co-authored-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-06-06 12:16:37 +02:00
Kristoffer Dalby
2dc2f3b3f0 users: harden, test, and add cleaner of identifier (#2593)
* users: harden, test, and add cleaner of identifier

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* db: migrate badly joined provider identifiers

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-14 16:45:14 +02:00
Kristoffer Dalby
d7a503a34e changelog: entry for 0.26 (#2594)
* changelog: entry for 0.26

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* docs: bump version

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-14 16:32:56 +02:00
jasonrepos
62b489dc68 fix: change FormatUint base from 64 to 10 in preauthkeys list command (#2588) 2025-05-13 18:40:17 +00:00
nblock
8c7e650616 Remove map_legacy_users from example configuration (#2590) 2025-05-13 21:38:52 +03:00
Kristoffer Dalby
43943aeee9 bring back last_seen in database (#2579)
* db: add back last_seen to the database

Fixes #2574

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* integration: ensure last_seen is set

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-10 09:49:08 +02:00
nblock
d81b0053e5 Simplify policy migration (#2582)
These steps are easier to accomplish and require only Headscale 0.26.
They also work when a user has already upgraded the database.

See: #2567
2025-05-10 08:04:42 +02:00
nblock
dd0cbdf40c Add migration steps when policy is stored in the database (#2581)
Fixes: #2567
2025-05-09 23:30:39 +02:00
Kristoffer Dalby
37dc0dad35 policy/v2: separate exit node and 0.0.0.0/0 routes (#2578)
* policy: add tests for route auto approval

Reproduce #2568

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* policy/v2: separate exit node and 0.0.0.0/0 routes

Fixes #2568

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-09 23:20:04 +02:00
Kristoffer Dalby
377b854dd8 cli: policy check, dont require config or log (#2580)
Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-09 23:19:47 +02:00
Kristoffer Dalby
56db4ed0f1 policy/v2: validate that no undefined group or tag is used (#2576)
* policy/v2: allow Username as ssh source

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* policy/v2: validate that no undefined group or tag is used

Fixes #2570

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

* policy: fixup tests which violated tag constraing

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>

---------

Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
2025-05-09 11:51:30 +02:00
nblock
833e0f66f1 Remove subnet router visibility workaround from docs (#2569)
Previous Headscale versions required a dedicated rule to make a subnet
router visible to clients. This workaround is no longer required.
2025-05-05 15:24:59 +02:00
22 changed files with 1693 additions and 125 deletions

View File

@@ -1,6 +1,14 @@
# CHANGELOG
## Next
## 0.26.1 (2025-06-06)
### Changes
- Ensure nodes are matching both node key and machine key
when connecting.
[#2642](https://github.com/juanfont/headscale/pull/2642)
## 0.26.0 (2025-05-14)
### BREAKING
@@ -64,6 +72,27 @@ new policy code passes all of our tests.
`@` should be appended at the end. For example, if your user is `john`, it
must be written as `john@` in the policy.
<details>
<summary>Migration notes when the policy is stored in the database.</summary>
This section **only** applies if the policy is stored in the database and
Headscale 0.26 doesn't start due to a policy error
(`failed to load ACL policy`).
- Start Headscale 0.26 with the environment variable `HEADSCALE_POLICY_V1=1`
set. You can check that Headscale picked up the environment variable by
observing this message during startup: `Using policy manager version: 1`
- Dump the policy to a file: `headscale policy get > policy.json`
- Edit `policy.json` and migrate to policy V2. Use the command
`headscale policy check --file policy.json` to check for policy errors.
- Load the modified policy: `headscale policy set --file policy.json`
- Restart Headscale **without** the environment variable `HEADSCALE_POLICY_V1`.
Headscale should now print the message `Using policy manager version: 2` and
startup successfully.
</details>
**SSH**
The SSH policy has been reworked to be more consistent with the rest of the

View File

@@ -112,7 +112,7 @@ var listPreAuthKeys = &cobra.Command{
aclTags = strings.TrimLeft(aclTags, ",")
tableData = append(tableData, []string{
strconv.FormatUint(key.GetId(), 64),
strconv.FormatUint(key.GetId(), 10),
key.GetKey(),
strconv.FormatBool(key.GetReusable()),
strconv.FormatBool(key.GetEphemeral()),

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"os"
"runtime"
"slices"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog"
@@ -25,6 +26,11 @@ func init() {
return
}
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
zerolog.SetGlobalLevel(zerolog.Disabled)
return
}
cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags().
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")
@@ -60,7 +66,7 @@ func initConfig() {
logFormat := viper.GetString("log.format")
if logFormat == types.JSONLogFormat {
log.Logger = log.Output(os.Stdout)
log.Logger = log.Output(os.Stdout)
}
disableUpdateCheck := viper.GetBool("disable_check_updates")

View File

@@ -27,14 +27,14 @@ func newHeadscaleServerWithConfig() (*hscontrol.Headscale, error) {
cfg, err := types.LoadServerConfig()
if err != nil {
return nil, fmt.Errorf(
"failed to load configuration while creating headscale instance: %w",
"loading configuration: %w",
err,
)
}
app, err := hscontrol.NewHeadscale(cfg)
if err != nil {
return nil, err
return nil, fmt.Errorf("creating new headscale: %w", err)
}
return app, nil

View File

@@ -375,19 +375,6 @@ unix_socket_permission: "0770"
# # - plain: Use plain code verifier
# # - S256: Use SHA256 hashed code verifier (default, recommended)
# method: S256
#
# # Map legacy users from pre-0.24.0 versions of headscale to the new OIDC users
# # by taking the username from the legacy user and matching it with the username
# # provided by the OIDC. This is useful when migrating from legacy users to OIDC
# # to force them using the unique identifier from the OIDC and to give them a
# # proper display name and picture if available.
# # Note that this will only work if the username from the legacy user is the same
# # and there is a possibility for account takeover should a username have changed
# # with the provider.
# # When this feature is disabled, it will cause all new logins to be created as new users.
# # Note this option will be removed in the future and should be set to false
# # on all new installations, or when all users have logged in with OIDC once.
# map_legacy_users: false
# Logtail configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel

View File

@@ -149,13 +149,11 @@ Here are the ACL's to implement the same permissions as above:
},
// developers have access to the internal network through the router.
// the internal network is composed of HTTPS endpoints and Postgresql
// database servers. There's an additional rule to allow traffic to be
// forwarded to the internal subnet, 10.20.0.0/16. See this issue
// https://github.com/juanfont/headscale/issues/502
// database servers.
{
"action": "accept",
"src": ["group:dev"],
"dst": ["10.20.0.0/16:443,5432", "router.internal:0"]
"dst": ["10.20.0.0/16:443,5432"]
},
// servers should be able to talk to database in tcp/5432. Database should not be able to initiate connections to

View File

@@ -76,27 +76,19 @@ The routes announced by subnet routers are available to the nodes in a tailnet.
nodes can accept and use such routes. Configure an ACL to explicitly manage who can use routes.
The ACL snippet below defines three hosts, a subnet router `router`, a regular node `node` and `service.example.net` as
internal service that can be reached via a route on the subnet router `router`. The first ACL rule allows anyone to see
the subnet router `router` without allowing access to any service of the subnet router itself. The second ACL rule
allows the node `node` to access `service.example.net` on port 80 and 443 which is reachable via the subnet router.
internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
`service.example.net` on port 80 and 443 which is reachable via the subnet router. Access to the subnet router itself is
denied.
```json title="Access the routes of a subnet router without the subnet router itself"
{
"hosts": {
// the router is not referenced but announces 192.168.0.0/24"
"router": "100.64.0.1/32",
"node": "100.64.0.2/32",
"service.example.net": "192.168.0.1/32"
},
"acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"router:0"
]
},
{
"action": "accept",
"src": [

View File

@@ -145,7 +145,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
registrationCache,
)
if err != nil {
return nil, err
return nil, fmt.Errorf("new database: %w", err)
}
app.ipAlloc, err = db.NewIPAllocator(app.db, cfg.PrefixV4, cfg.PrefixV6, cfg.IPAllocation)
@@ -160,7 +160,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
})
if err = app.loadPolicyManager(); err != nil {
return nil, fmt.Errorf("failed to load ACL policy: %w", err)
return nil, fmt.Errorf("loading ACL policy: %w", err)
}
var authProvider AuthProvider

View File

@@ -672,7 +672,47 @@ AND auth_key_id NOT IN (
{
ID: "202502171819",
Migrate: func(tx *gorm.DB) error {
_ = tx.Migrator().DropColumn(&types.Node{}, "last_seen")
// This migration originally removed the last_seen column
// from the node table, but it was added back in
// 202505091439.
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
// Add back last_seen column to node table.
{
ID: "202505091439",
Migrate: func(tx *gorm.DB) error {
// Add back last_seen column to node table if it does not exist.
// This is a workaround for the fact that the last_seen column
// was removed in the 202502171819 migration, but only for some
// beta testers.
if !tx.Migrator().HasColumn(&types.Node{}, "last_seen") {
_ = tx.Migrator().AddColumn(&types.Node{}, "last_seen")
}
return nil
},
Rollback: func(db *gorm.DB) error { return nil },
},
// Fix the provider identifier for users that have a double slash in the
// provider identifier.
{
ID: "202505141324",
Migrate: func(tx *gorm.DB) error {
users, err := ListUsers(tx)
if err != nil {
return fmt.Errorf("listing users: %w", err)
}
for _, user := range users {
user.ProviderIdentifier.String = types.CleanIdentifier(user.ProviderIdentifier.String)
err := tx.Save(user).Error
if err != nil {
return fmt.Errorf("saving user: %w", err)
}
}
return nil
},

View File

@@ -251,6 +251,20 @@ func SetApprovedRoutes(
return nil
}
// SetLastSeen sets a node's last seen field indicating that we
// have recently communicating with this node.
func (hsdb *HSDatabase) SetLastSeen(nodeID types.NodeID, lastSeen time.Time) error {
return hsdb.Write(func(tx *gorm.DB) error {
return SetLastSeen(tx, nodeID, lastSeen)
})
}
// SetLastSeen sets a node's last seen field indicating that we
// have recently communicating with this node.
func SetLastSeen(tx *gorm.DB, nodeID types.NodeID, lastSeen time.Time) error {
return tx.Model(&types.Node{}).Where("id = ?", nodeID).Update("last_seen", lastSeen).Error
}
// RenameNode takes a Node struct and a new GivenName for the nodes
// and renames it. If the name is not unique, it will return an error.
func RenameNode(tx *gorm.DB,

View File

@@ -100,6 +100,10 @@ func (h *Headscale) NoiseUpgradeHandler(
router.HandleFunc("/machine/register", noiseServer.NoiseRegistrationHandler).
Methods(http.MethodPost)
// Endpoints outside of the register endpoint must use getAndValidateNode to
// get the node to ensure that the MachineKey matches the Node setting up the
// connection.
router.HandleFunc("/machine/map", noiseServer.NoisePollNetMapHandler)
noiseServer.httpBaseConfig = &http.Server{
@@ -209,18 +213,14 @@ func (ns *noiseServer) NoisePollNetMapHandler(
return
}
ns.nodeKey = mapRequest.NodeKey
node, err := ns.headscale.db.GetNodeByNodeKey(mapRequest.NodeKey)
node, err := ns.getAndValidateNode(mapRequest)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
httpError(writer, NewHTTPError(http.StatusNotFound, "node not found", nil))
return
}
httpError(writer, err)
return
}
ns.nodeKey = node.NodeKey
sess := ns.headscale.newMapSession(req.Context(), mapRequest, writer, node)
sess.tracef("a node sending a MapRequest with Noise protocol")
if !sess.isStreaming() {
@@ -266,8 +266,8 @@ func (ns *noiseServer) NoiseRegistrationHandler(
Error: httpErr.Msg,
}
return &regReq, resp
} else {
}
return &regReq, regErr(err)
}
@@ -289,3 +289,22 @@ func (ns *noiseServer) NoiseRegistrationHandler(
writer.WriteHeader(http.StatusOK)
writer.Write(respBody)
}
// getAndValidateNode retrieves the node from the database using the NodeKey
// and validates that it matches the MachineKey from the Noise session.
func (ns *noiseServer) getAndValidateNode(mapRequest tailcfg.MapRequest) (*types.Node, error) {
node, err := ns.headscale.db.GetNodeByNodeKey(mapRequest.NodeKey)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, NewHTTPError(http.StatusNotFound, "node not found", nil)
}
return nil, err
}
// Validate that the MachineKey in the Noise session matches the one associated with the NodeKey.
if ns.machineKey != node.MachineKey {
return nil, NewHTTPError(http.StatusNotFound, "node key in request does not match the one associated with this machine key", nil)
}
return node, nil
}

View File

@@ -709,6 +709,9 @@ func TestReduceFilterRules(t *testing.T) {
name: "1817-reduce-breaks-32-mask",
pol: `
{
"tagOwners": {
"tag:access-servers": ["user100@"],
},
"groups": {
"group:access": [
"user1@"
@@ -1688,6 +1691,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: taggedServer,
peers: types.Nodes{&nodeUser1, &nodeUser2},
policy: `{
"tagOwners": {
"tag:server": ["user3@"],
},
"groups": {
"group:users": ["user1@", "user2@"]
},
@@ -1726,6 +1732,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser1,
peers: types.Nodes{&taggedClient},
policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [
{
"action": "accept",
@@ -1756,6 +1765,10 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: taggedServer,
peers: types.Nodes{&taggedClient},
policy: `{
"tagOwners": {
"tag:client": ["user2@"],
"tag:server": ["user3@"],
},
"ssh": [
{
"action": "accept",
@@ -1818,29 +1831,14 @@ func TestSSHPolicyRules(t *testing.T) {
// we skip this test for v1 and not let it hold up v2 replacing it.
skipV1: true,
},
{
name: "invalid-source-user-not-allowed",
targetNode: nodeUser1,
peers: types.Nodes{&nodeUser2},
policy: `{
"ssh": [
{
"action": "accept",
"src": ["user2@"],
"dst": ["user1@"],
"users": ["autogroup:nonroot"]
}
]
}`,
expectErr: true,
errorMessage: "not supported",
skipV1: true,
},
{
name: "check-period-specified",
targetNode: nodeUser1,
peers: types.Nodes{&taggedClient},
policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [
{
"action": "check",
@@ -1873,6 +1871,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser2,
peers: types.Nodes{&nodeUser1},
policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [
{
"action": "accept",
@@ -1926,14 +1927,17 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser1,
peers: types.Nodes{&taggedClient},
policy: `{
"ssh": [
{
"action": "accept",
"src": ["tag:client"],
"dst": ["user1@"],
"users": ["alice", "bob"]
}
]
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [
{
"action": "accept",
"src": ["tag:client"],
"dst": ["user1@"],
"users": ["alice", "bob"]
}
]
}`,
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
{

View File

@@ -0,0 +1,809 @@
package policy
import (
"fmt"
"net/netip"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
func TestNodeCanApproveRoute(t *testing.T) {
users := []types.User{
{Name: "user1", Model: gorm.Model{ID: 1}},
{Name: "user2", Model: gorm.Model{ID: 2}},
{Name: "user3", Model: gorm.Model{ID: 3}},
}
// Create standard node setups used across tests
normalNode := types.Node{
ID: 1,
Hostname: "user1-device",
IPv4: ap("100.64.0.1"),
UserID: 1,
User: users[0],
}
exitNode := types.Node{
ID: 2,
Hostname: "user2-device",
IPv4: ap("100.64.0.2"),
UserID: 2,
User: users[1],
}
taggedNode := types.Node{
ID: 3,
Hostname: "tagged-server",
IPv4: ap("100.64.0.3"),
UserID: 3,
User: users[2],
ForcedTags: []string{"tag:router"},
}
multiTagNode := types.Node{
ID: 4,
Hostname: "multi-tag-node",
IPv4: ap("100.64.0.4"),
UserID: 2,
User: users[1],
ForcedTags: []string{"tag:router", "tag:server"},
}
tests := []struct {
name string
node types.Node
route netip.Prefix
policy string
canApprove bool
skipV1 bool
}{
{
name: "allow-all-routes-for-admin-user",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "deny-route-that-doesnt-match-autoApprovers",
node: normalNode,
route: p("10.0.0.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "user-not-in-group",
node: exitNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "tagged-node-can-approve",
node: taggedNode,
route: p("10.0.0.0/8"),
policy: `{
"tagOwners": {
"tag:router": ["user3@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.0.0.0/8": ["tag:router"]
}
}
}`,
canApprove: true,
},
{
name: "multiple-routes-in-policy",
node: normalNode,
route: p("172.16.10.0/24"),
policy: `{
"tagOwners": {
"tag:router": ["user3@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:admin"],
"172.16.0.0/12": ["group:admin"],
"10.0.0.0/8": ["tag:router"]
}
}
}`,
canApprove: true,
},
{
name: "match-specific-route-within-range",
node: normalNode,
route: p("192.168.5.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "ip-address-within-range",
node: normalNode,
route: p("192.168.1.5/32"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.1.0/24": ["group:admin"],
"192.168.1.128/25": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "all-IPv4-routes-(0.0.0.0/0)-approval",
node: normalNode,
route: p("0.0.0.0/0"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "all-IPv4-routes-exitnode-approval",
node: normalNode,
route: p("0.0.0.0/0"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"]
}
}`,
canApprove: true,
},
{
name: "all-IPv6-routes-exitnode-approval",
node: normalNode,
route: p("::/0"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"]
}
}`,
canApprove: true,
},
{
name: "specific-IPv4-route-with-exitnode-only-approval",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"]
}
}`,
canApprove: false,
},
{
name: "specific-IPv6-route-with-exitnode-only-approval",
node: normalNode,
route: p("fd00::/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"]
}
}`,
canApprove: false,
},
{
name: "specific-IPv4-route-with-all-routes-policy",
node: normalNode,
route: p("10.0.0.0/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "all-IPv6-routes-(::0/0)-approval",
node: normalNode,
route: p("::/0"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"::/0": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "specific-IPv6-route-with-all-routes-policy",
node: normalNode,
route: p("fd00::/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"::/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "IPv6-route-with-IPv4-all-routes-policy",
node: normalNode,
route: p("fd00::/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "IPv4-route-with-IPv6-all-routes-policy",
node: normalNode,
route: p("10.0.0.0/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"::/0": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "both-IPv4-and-IPv6-all-routes-policy",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["group:admin"],
"::/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "ip-address-with-all-routes-policy",
node: normalNode,
route: p("192.168.101.5/32"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"0.0.0.0/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "specific-IPv6-host-route-with-all-routes-policy",
node: normalNode,
route: p("2001:db8::1/128"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"::/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "multiple-groups-allowed-to-approve-same-route",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"],
"group:netadmin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.1.0/24": ["group:admin", "group:netadmin"]
}
}
}`,
canApprove: true,
},
{
name: "overlapping-routes-with-different-groups",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"],
"group:restricted": ["user2@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"192.168.0.0/16": ["group:restricted"],
"192.168.1.0/24": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "unique-local-IPv6-address-with-all-routes-policy",
node: normalNode,
route: p("fc00::/7"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"::/0": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "exact-prefix-match-in-policy",
node: normalNode,
route: p("203.0.113.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"203.0.113.0/24": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "narrower-range-than-policy",
node: normalNode,
route: p("203.0.113.0/26"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"203.0.113.0/24": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "wider-range-than-policy-should-fail",
node: normalNode,
route: p("203.0.113.0/23"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"203.0.113.0/24": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "adjacent-route-to-policy-route-should-fail",
node: normalNode,
route: p("203.0.114.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"203.0.113.0/24": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "combined-routes-and-exitnode-approvers-specific-route",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"],
"routes": {
"192.168.1.0/24": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "partly-overlapping-route-with-policy-should-fail",
node: normalNode,
route: p("203.0.113.128/23"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"203.0.113.0/24": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "multiple-routes-with-aggregatable-ranges",
node: normalNode,
route: p("10.0.0.0/8"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.0.0.0/9": ["group:admin"],
"10.128.0.0/9": ["group:admin"]
}
}
}`,
canApprove: false,
},
{
name: "non-standard-IPv6-notation",
node: normalNode,
route: p("2001:db8::1/128"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"2001:db8::/32": ["group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "node-with-multiple-tags-all-required",
node: multiTagNode,
route: p("10.10.0.0/16"),
policy: `{
"tagOwners": {
"tag:router": ["user2@"],
"tag:server": ["user2@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.10.0.0/16": ["tag:router", "tag:server"]
}
}
}`,
canApprove: true,
},
{
name: "node-with-multiple-tags-one-matching-is-sufficient",
node: multiTagNode,
route: p("10.10.0.0/16"),
policy: `{
"tagOwners": {
"tag:router": ["user2@"],
"tag:server": ["user2@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.10.0.0/16": ["tag:router", "group:admin"]
}
}
}`,
canApprove: true,
},
{
name: "node-with-multiple-tags-missing-required-tag",
node: multiTagNode,
route: p("10.10.0.0/16"),
policy: `{
"tagOwners": {
"tag:othertag": ["user1@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.10.0.0/16": ["tag:othertag"]
}
}
}`,
canApprove: false,
},
{
name: "node-with-tag-and-group-membership",
node: normalNode,
route: p("10.20.0.0/16"),
policy: `{
"tagOwners": {
"tag:router": ["user3@"]
},
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"routes": {
"10.20.0.0/16": ["group:admin", "tag:router"]
}
}
}`,
canApprove: true,
},
{
name: "small-subnet-with-exitnode-only-approval",
node: normalNode,
route: p("192.168.1.1/32"),
policy: `{
"groups": {
"group:admin": ["user1@"]
},
"acls": [
{"action": "accept", "src": ["group:admin"], "dst": ["*:*"]}
],
"autoApprovers": {
"exitNode": ["group:admin"]
}
}`,
canApprove: false,
},
{
name: "empty-policy",
node: normalNode,
route: p("192.168.1.0/24"),
policy: `{"acls":[{"action":"accept","src":["*"],"dst":["*:*"]}]}`,
canApprove: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Initialize all policy manager implementations
policyManagers, err := PolicyManagersForTest([]byte(tt.policy), users, types.Nodes{&tt.node})
if tt.name == "empty policy" {
// We expect this one to have a valid but empty policy
require.NoError(t, err)
if err != nil {
return
}
} else {
require.NoError(t, err)
}
for i, pm := range policyManagers {
versionNum := i + 1
if versionNum == 1 && tt.skipV1 {
// Skip V1 policy manager for specific tests
continue
}
t.Run(fmt.Sprintf("PolicyV%d", versionNum), func(t *testing.T) {
result := pm.NodeCanApproveRoute(&tt.node, tt.route)
if diff := cmp.Diff(tt.canApprove, result); diff != "" {
t.Errorf("NodeCanApproveRoute() mismatch (-want +got):\n%s", diff)
}
assert.Equal(t, tt.canApprove, result, "Unexpected route approval result")
})
}
})
}
}

View File

@@ -31,6 +31,8 @@ type PolicyManager struct {
tagOwnerMapHash deephash.Sum
tagOwnerMap map[Tag]*netipx.IPSet
exitSetHash deephash.Sum
exitSet *netipx.IPSet
autoApproveMapHash deephash.Sum
autoApproveMap map[netip.Prefix]*netipx.IPSet
@@ -97,7 +99,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
pm.tagOwnerMap = tagMap
pm.tagOwnerMapHash = tagOwnerMapHash
autoMap, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
autoMap, exitSet, err := resolveAutoApprovers(pm.pol, pm.users, pm.nodes)
if err != nil {
return false, fmt.Errorf("resolving auto approvers map: %w", err)
}
@@ -107,8 +109,13 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
pm.autoApproveMap = autoMap
pm.autoApproveMapHash = autoApproveMapHash
exitSetHash := deephash.Hash(&autoMap)
exitSetChanged := exitSetHash != pm.exitSetHash
pm.exitSet = exitSet
pm.exitSetHash = exitSetHash
// If neither of the calculated values changed, no need to update nodes
if !filterChanged && !tagOwnerChanged && !autoApproveChanged {
if !filterChanged && !tagOwnerChanged && !autoApproveChanged && !exitSetChanged {
return false, nil
}
@@ -207,6 +214,23 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
return false
}
// If the route to-be-approved is an exit route, then we need to check
// if the node is in allowed to approve it. This is treated differently
// than the auto-approvers, as the auto-approvers are not allowed to
// approve the whole /0 range.
// However, an auto approver might be /0, meaning that they can approve
// all routes available, just not exit nodes.
if tsaddr.IsExitRoute(route) {
if pm.exitSet == nil {
return false
}
if slices.ContainsFunc(node.IPs(), pm.exitSet.Contains) {
return true
}
return false
}
pm.mu.Lock()
defer pm.mu.Unlock()
@@ -224,14 +248,6 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
// cannot just lookup in the prefix map and have to check
// if there is a "parent" prefix available.
for prefix, approveAddrs := range pm.autoApproveMap {
// We do not want the exit node entry to approve all
// sorts of routes. The logic here is that it would be
// unexpected behaviour to have specific routes approved
// just because the node is allowed to designate itself as
// an exit.
if tsaddr.IsExitRoute(prefix) {
continue
}
// Check if prefix is larger (so containing) and then overlaps
// the route to see if the node can approve a subset of an autoapprover

View File

@@ -720,6 +720,20 @@ type Usernames []Username
// Groups are a map of Group to a list of Username.
type Groups map[Group]Usernames
func (g Groups) Contains(group *Group) error {
if group == nil {
return nil
}
for defined := range map[Group]Usernames(g) {
if defined == *group {
return nil
}
}
return fmt.Errorf(`Group %q is not defined in the Policy, please define or remove the reference to it`, group)
}
// UnmarshalJSON overrides the default JSON unmarshalling for Groups to ensure
// that each group name is validated using the isGroup function. This ensures
// that all group names conform to the expected format, which is always prefixed
@@ -791,6 +805,20 @@ func (h Hosts) exist(name Host) bool {
// TagOwners are a map of Tag to a list of the UserEntities that own the tag.
type TagOwners map[Tag]Owners
func (to TagOwners) Contains(tagOwner *Tag) error {
if tagOwner == nil {
return nil
}
for defined := range map[Tag]Owners(to) {
if defined == *tagOwner {
return nil
}
}
return fmt.Errorf(`Tag %q is not defined in the Policy, please define or remove the reference to it`, tagOwner)
}
// resolveTagOwners resolves the TagOwners to a map of Tag to netipx.IPSet.
// The resulting map can be used to quickly look up the IPSet for a given Tag.
// It is intended for internal use in a PolicyManager.
@@ -834,10 +862,11 @@ type AutoApproverPolicy struct {
// resolveAutoApprovers resolves the AutoApprovers to a map of netip.Prefix to netipx.IPSet.
// The resulting map can be used to quickly look up if a node can self-approve a route.
// It is intended for internal use in a PolicyManager.
func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, error) {
func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[netip.Prefix]*netipx.IPSet, *netipx.IPSet, error) {
if p == nil {
return nil, nil
return nil, nil, nil
}
var err error
routes := make(map[netip.Prefix]*netipx.IPSetBuilder)
@@ -849,7 +878,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
aa, ok := autoApprover.(Alias)
if !ok {
// Should never happen
return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
}
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
ips, _ := aa.Resolve(p, users, nodes)
@@ -863,7 +892,7 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
aa, ok := autoApprover.(Alias)
if !ok {
// Should never happen
return nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
return nil, nil, fmt.Errorf("autoApprover %v is not an Alias", autoApprover)
}
// If it does not resolve, that means the autoApprover is not associated with any IP addresses.
ips, _ := aa.Resolve(p, users, nodes)
@@ -875,22 +904,20 @@ func resolveAutoApprovers(p *Policy, users types.Users, nodes types.Nodes) (map[
for prefix, builder := range routes {
ipSet, err := builder.IPSet()
if err != nil {
return nil, err
return nil, nil, err
}
ret[prefix] = ipSet
}
var exitNodeSet *netipx.IPSet
if len(p.AutoApprovers.ExitNode) > 0 {
exitNodeSet, err := exitNodeSetBuilder.IPSet()
exitNodeSet, err = exitNodeSetBuilder.IPSet()
if err != nil {
return nil, err
return nil, nil, err
}
ret[tsaddr.AllIPv4()] = exitNodeSet
ret[tsaddr.AllIPv6()] = exitNodeSet
}
return ret, nil
return ret, exitNodeSet, nil
}
type ACL struct {
@@ -1047,6 +1074,16 @@ func (p *Policy) validate() error {
errs = append(errs, err)
continue
}
case *Group:
g := src.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := src.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
@@ -1069,6 +1106,16 @@ func (p *Policy) validate() error {
errs = append(errs, err)
continue
}
case *Group:
g := dst.Alias.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := dst.Alias.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
}
@@ -1102,6 +1149,16 @@ func (p *Policy) validate() error {
errs = append(errs, err)
continue
}
case *Group:
g := src.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := src.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
for _, dst := range ssh.Destinations {
@@ -1117,6 +1174,55 @@ func (p *Policy) validate() error {
errs = append(errs, err)
continue
}
case *Tag:
tagOwner := dst.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
}
for _, tagOwners := range p.TagOwners {
for _, tagOwner := range tagOwners {
switch tagOwner.(type) {
case *Group:
g := tagOwner.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
}
}
}
for _, approvers := range p.AutoApprovers.Routes {
for _, approver := range approvers {
switch approver.(type) {
case *Group:
g := approver.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := approver.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
}
for _, approver := range p.AutoApprovers.ExitNode {
switch approver.(type) {
case *Group:
g := approver.(*Group)
if err := p.Groups.Contains(g); err != nil {
errs = append(errs, err)
}
case *Tag:
tagOwner := approver.(*Tag)
if err := p.TagOwners.Contains(tagOwner); err != nil {
errs = append(errs, err)
}
}
}
@@ -1152,7 +1258,7 @@ func (a *SSHSrcAliases) UnmarshalJSON(b []byte) error {
*a = make([]Alias, len(aliases))
for i, alias := range aliases {
switch alias.Alias.(type) {
case *Group, *Tag, *AutoGroup:
case *Username, *Group, *Tag, *AutoGroup:
(*a)[i] = alias.Alias
default:
return fmt.Errorf("type %T not supported", alias.Alias)

View File

@@ -511,6 +511,201 @@ func TestUnmarshalPolicy(t *testing.T) {
`,
wantErr: `"autogroup:internet" used in SSH destination, it can only be used in ACL destinations`,
},
{
name: "group-must-be-defined-acl-src",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"group:notdefined"
],
"dst": [
"autogroup:internet:*"
]
}
]
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "group-must-be-defined-acl-dst",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"group:notdefined:*"
]
}
]
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "group-must-be-defined-acl-ssh-src",
input: `
{
"ssh": [
{
"action": "accept",
"src": [
"group:notdefined"
],
"dst": [
"user@"
]
}
]
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "group-must-be-defined-acl-tagOwner",
input: `
{
"tagOwners": {
"tag:test": ["group:notdefined"],
},
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "group-must-be-defined-acl-autoapprover-route",
input: `
{
"autoApprovers": {
"routes": {
"10.0.0.0/16": ["group:notdefined"]
}
},
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "group-must-be-defined-acl-autoapprover-exitnode",
input: `
{
"autoApprovers": {
"exitNode": ["group:notdefined"]
},
}
`,
wantErr: `Group "group:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-src",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"tag:notdefined"
],
"dst": [
"autogroup:internet:*"
]
}
]
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-dst",
input: `
{
"acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"tag:notdefined:*"
]
}
]
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-ssh-src",
input: `
{
"ssh": [
{
"action": "accept",
"src": [
"tag:notdefined"
],
"dst": [
"user@"
]
}
]
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-ssh-dst",
input: `
{
"groups": {
"group:defined": ["user@"],
},
"ssh": [
{
"action": "accept",
"src": [
"group:defined"
],
"dst": [
"tag:notdefined",
],
}
]
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-autoapprover-route",
input: `
{
"autoApprovers": {
"routes": {
"10.0.0.0/16": ["tag:notdefined"]
}
},
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
{
name: "tag-must-be-defined-acl-autoapprover-exitnode",
input: `
{
"autoApprovers": {
"exitNode": ["tag:notdefined"]
},
}
`,
wantErr: `Tag "tag:notdefined" is not defined in the Policy, please define or remove the reference to it`,
},
}
cmps := append(util.Comparers, cmp.Comparer(func(x, y Prefix) bool {
@@ -829,10 +1024,11 @@ func TestResolveAutoApprovers(t *testing.T) {
}
tests := []struct {
name string
policy *Policy
want map[netip.Prefix]*netipx.IPSet
wantErr bool
name string
policy *Policy
want map[netip.Prefix]*netipx.IPSet
wantAllIPRoutes *netipx.IPSet
wantErr bool
}{
{
name: "single-route",
@@ -846,7 +1042,8 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
},
wantErr: false,
wantAllIPRoutes: nil,
wantErr: false,
},
{
name: "multiple-routes",
@@ -862,7 +1059,8 @@ func TestResolveAutoApprovers(t *testing.T) {
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
mp("10.0.1.0/24"): mustIPSet("100.64.0.2/32"),
},
wantErr: false,
wantAllIPRoutes: nil,
wantErr: false,
},
{
name: "exit-node",
@@ -871,11 +1069,9 @@ func TestResolveAutoApprovers(t *testing.T) {
ExitNode: AutoApprovers{ptr.To(Username("user1@"))},
},
},
want: map[netip.Prefix]*netipx.IPSet{
tsaddr.AllIPv4(): mustIPSet("100.64.0.1/32"),
tsaddr.AllIPv6(): mustIPSet("100.64.0.1/32"),
},
wantErr: false,
want: map[netip.Prefix]*netipx.IPSet{},
wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
wantErr: false,
},
{
name: "group-route",
@@ -892,7 +1088,8 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
},
wantErr: false,
wantAllIPRoutes: nil,
wantErr: false,
},
{
name: "tag-route-and-exit",
@@ -918,10 +1115,9 @@ func TestResolveAutoApprovers(t *testing.T) {
},
want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.1.0/24"): mustIPSet("100.64.0.4/32"),
tsaddr.AllIPv4(): mustIPSet("100.64.0.5/32"),
tsaddr.AllIPv6(): mustIPSet("100.64.0.5/32"),
},
wantErr: false,
wantAllIPRoutes: mustIPSet("100.64.0.5/32"),
wantErr: false,
},
{
name: "mixed-routes-and-exit-nodes",
@@ -940,10 +1136,9 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
mp("10.0.1.0/24"): mustIPSet("100.64.0.3/32"),
tsaddr.AllIPv4(): mustIPSet("100.64.0.1/32"),
tsaddr.AllIPv6(): mustIPSet("100.64.0.1/32"),
},
wantErr: false,
wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
wantErr: false,
},
}
@@ -951,7 +1146,7 @@ func TestResolveAutoApprovers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveAutoApprovers(tt.policy, users, nodes)
got, gotAllIPRoutes, err := resolveAutoApprovers(tt.policy, users, nodes)
if (err != nil) != tt.wantErr {
t.Errorf("resolveAutoApprovers() error = %v, wantErr %v", err, tt.wantErr)
return
@@ -959,6 +1154,15 @@ func TestResolveAutoApprovers(t *testing.T) {
if diff := cmp.Diff(tt.want, got, cmps...); diff != "" {
t.Errorf("resolveAutoApprovers() mismatch (-want +got):\n%s", diff)
}
if tt.wantAllIPRoutes != nil {
if gotAllIPRoutes == nil {
t.Error("resolveAutoApprovers() expected non-nil allIPRoutes, got nil")
} else if diff := cmp.Diff(tt.wantAllIPRoutes, gotAllIPRoutes, cmps...); diff != "" {
t.Errorf("resolveAutoApprovers() allIPRoutes mismatch (-want +got):\n%s", diff)
}
} else if gotAllIPRoutes != nil {
t.Error("resolveAutoApprovers() expected nil allIPRoutes, got non-nil")
}
})
}
}

View File

@@ -409,6 +409,10 @@ func (h *Headscale) updateNodeOnlineStatus(online bool, node *types.Node) {
change.LastSeen = &now
}
if node.LastSeen != nil {
h.db.SetLastSeen(node.ID, *node.LastSeen)
}
ctx := types.NotifyCtx(context.Background(), "poll-nodeupdate-onlinestatus", node.Hostname)
h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerPatch(change), node.ID)
}

View File

@@ -98,11 +98,7 @@ type Node struct {
// LastSeen is when the node was last in contact with
// headscale. It is best effort and not persisted.
LastSeen *time.Time `gorm:"-"`
// DEPRECATED: Use the ApprovedRoutes field instead.
// TODO(kradalby): remove when ApprovedRoutes is used all over the code.
// Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
LastSeen *time.Time `gorm:"column:last_seen"`
// ApprovedRoutes is a list of routes that the node is allowed to announce
// as a subnet router. They are not necessarily the routes that the node

View File

@@ -194,13 +194,110 @@ type OIDCClaims struct {
Username string `json:"preferred_username,omitempty"`
}
// Identifier returns a unique identifier string combining the Iss and Sub claims.
// The format depends on whether Iss is a URL or not:
// - For URLs: Joins the URL and sub path (e.g., "https://example.com/sub")
// - For non-URLs: Joins with a slash (e.g., "oidc/sub")
// - For empty Iss: Returns just "sub"
// - For empty Sub: Returns just the Issuer
// - For both empty: Returns empty string
//
// The result is cleaned using CleanIdentifier() to ensure consistent formatting.
func (c *OIDCClaims) Identifier() string {
if strings.HasPrefix(c.Iss, "http") {
if i, err := url.JoinPath(c.Iss, c.Sub); err == nil {
return i
// Handle empty components special cases
if c.Iss == "" && c.Sub == "" {
return ""
}
if c.Iss == "" {
return CleanIdentifier(c.Sub)
}
if c.Sub == "" {
return CleanIdentifier(c.Iss)
}
// We'll use the raw values and let CleanIdentifier handle all the whitespace
issuer := c.Iss
subject := c.Sub
var result string
// Try to parse as URL to handle URL joining correctly
if u, err := url.Parse(issuer); err == nil && u.Scheme != "" {
// For URLs, use proper URL path joining
if joined, err := url.JoinPath(issuer, subject); err == nil {
result = joined
}
}
return c.Iss + "/" + c.Sub
// If URL joining failed or issuer wasn't a URL, do simple string join
if result == "" {
// Default case: simple string joining with slash
issuer = strings.TrimSuffix(issuer, "/")
subject = strings.TrimPrefix(subject, "/")
result = issuer + "/" + subject
}
// Clean the result and return it
return CleanIdentifier(result)
}
// CleanIdentifier cleans a potentially malformed identifier by removing double slashes
// while preserving protocol specifications like http://. This function will:
// - Trim all whitespace from the beginning and end of the identifier
// - Remove whitespace within path segments
// - Preserve the scheme (http://, https://, etc.) for URLs
// - Remove any duplicate slashes in the path
// - Remove empty path segments
// - For non-URL identifiers, it joins non-empty segments with a single slash
// - Returns empty string for identifiers with only slashes
// - Normalize URL schemes to lowercase
func CleanIdentifier(identifier string) string {
if identifier == "" {
return identifier
}
// Trim leading/trailing whitespace
identifier = strings.TrimSpace(identifier)
// Handle URLs with schemes
u, err := url.Parse(identifier)
if err == nil && u.Scheme != "" {
// Clean path by removing empty segments and whitespace within segments
parts := strings.FieldsFunc(u.Path, func(c rune) bool { return c == '/' })
for i, part := range parts {
parts[i] = strings.TrimSpace(part)
}
// Remove empty parts after trimming
cleanParts := make([]string, 0, len(parts))
for _, part := range parts {
if part != "" {
cleanParts = append(cleanParts, part)
}
}
if len(cleanParts) == 0 {
u.Path = ""
} else {
u.Path = "/" + strings.Join(cleanParts, "/")
}
// Ensure scheme is lowercase
u.Scheme = strings.ToLower(u.Scheme)
return u.String()
}
// Handle non-URL identifiers
parts := strings.FieldsFunc(identifier, func(c rune) bool { return c == '/' })
// Clean whitespace from each part
cleanParts := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed != "" {
cleanParts = append(cleanParts, trimmed)
}
}
if len(cleanParts) == 0 {
return ""
}
return strings.Join(cleanParts, "/")
}
type OIDCUserInfo struct {
@@ -231,7 +328,13 @@ func (u *User) FromClaim(claims *OIDCClaims) {
}
}
u.ProviderIdentifier = sql.NullString{String: claims.Identifier(), Valid: true}
// Get provider identifier
identifier := claims.Identifier()
// Ensure provider identifier always has a leading slash for backward compatibility
if claims.Iss == "" && !strings.HasPrefix(identifier, "/") {
identifier = "/" + identifier
}
u.ProviderIdentifier = sql.NullString{String: identifier, Valid: true}
u.DisplayName = claims.Name
u.ProfilePicURL = claims.ProfilePictureURL
u.Provider = util.RegisterMethodOIDC

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
)
func TestUnmarshallOIDCClaims(t *testing.T) {
@@ -76,6 +77,218 @@ func TestUnmarshallOIDCClaims(t *testing.T) {
}
}
func TestOIDCClaimsIdentifier(t *testing.T) {
tests := []struct {
name string
iss string
sub string
expected string
}{
{
name: "standard URL with trailing slash",
iss: "https://oidc.example.com/",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL without trailing slash",
iss: "https://oidc.example.com",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL with uppercase protocol",
iss: "HTTPS://oidc.example.com/",
sub: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
expected: "https://oidc.example.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
},
{
name: "standard URL with path and trailing slash",
iss: "https://login.microsoftonline.com/v2.0/",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "standard URL with path without trailing slash",
iss: "https://login.microsoftonline.com/v2.0",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "non-URL identifier with slash",
iss: "oidc",
sub: "sub",
expected: "oidc/sub",
},
{
name: "non-URL identifier with trailing slash",
iss: "oidc/",
sub: "sub",
expected: "oidc/sub",
},
{
name: "subject with slash",
iss: "oidc/",
sub: "sub/",
expected: "oidc/sub",
},
{
name: "whitespace",
iss: " oidc/ ",
sub: " sub ",
expected: "oidc/sub",
},
{
name: "newline",
iss: "\noidc/\n",
sub: "\nsub\n",
expected: "oidc/sub",
},
{
name: "tab",
iss: "\toidc/\t",
sub: "\tsub\t",
expected: "oidc/sub",
},
{
name: "empty issuer",
iss: "",
sub: "sub",
expected: "sub",
},
{
name: "empty subject",
iss: "https://oidc.example.com",
sub: "",
expected: "https://oidc.example.com",
},
{
name: "both empty",
iss: "",
sub: "",
expected: "",
},
{
name: "URL with double slash",
iss: "https://login.microsoftonline.com//v2.0",
sub: "I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "FTP URL protocol",
iss: "ftp://example.com/directory",
sub: "resource",
expected: "ftp://example.com/directory/resource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
claims := OIDCClaims{
Iss: tt.iss,
Sub: tt.sub,
}
result := claims.Identifier()
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("Identifier() mismatch (-want +got):\n%s", diff)
}
// Now clean the identifier and verify it's still the same
cleaned := CleanIdentifier(result)
// Double-check with cmp.Diff for better error messages
if diff := cmp.Diff(tt.expected, cleaned); diff != "" {
t.Errorf("CleanIdentifier(Identifier()) mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestCleanIdentifier(t *testing.T) {
tests := []struct {
name string
identifier string
expected string
}{
{
name: "empty identifier",
identifier: "",
expected: "",
},
{
name: "simple identifier",
identifier: "oidc/sub",
expected: "oidc/sub",
},
{
name: "double slashes in the middle",
identifier: "oidc//sub",
expected: "oidc/sub",
},
{
name: "trailing slash",
identifier: "oidc/sub/",
expected: "oidc/sub",
},
{
name: "multiple double slashes",
identifier: "oidc//sub///id//",
expected: "oidc/sub/id",
},
{
name: "HTTP URL with proper scheme",
identifier: "http://example.com/path",
expected: "http://example.com/path",
},
{
name: "HTTP URL with double slashes in path",
identifier: "http://example.com//path///resource",
expected: "http://example.com/path/resource",
},
{
name: "HTTPS URL with empty segments",
identifier: "https://example.com///path//",
expected: "https://example.com/path",
},
{
name: "URL with double slashes in domain",
identifier: "https://login.microsoftonline.com//v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
expected: "https://login.microsoftonline.com/v2.0/I-70OQnj3TogrNSfkZQqB3f7dGwyBWSm1dolHNKrMzQ",
},
{
name: "FTP URL with double slashes",
identifier: "ftp://example.com//resource//",
expected: "ftp://example.com/resource",
},
{
name: "Just slashes",
identifier: "///",
expected: "",
},
{
name: "Leading slash without URL",
identifier: "/path//to///resource",
expected: "path/to/resource",
},
{
name: "Non-standard protocol",
identifier: "ldap://example.org//path//to//resource",
expected: "ldap://example.org/path/to/resource",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CleanIdentifier(tt.identifier)
assert.Equal(t, tt.expected, result)
if diff := cmp.Diff(tt.expected, result); diff != "" {
t.Errorf("CleanIdentifier() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestOIDCClaimsJSONToUser(t *testing.T) {
tests := []struct {
name string

View File

@@ -9,6 +9,7 @@ import (
"slices"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic"
"github.com/samber/lo"
@@ -44,6 +45,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err)
@@ -66,6 +70,10 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
nodeCountBeforeLogout := len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
for _, client := range allClients {
err := client.Logout()
if err != nil {
@@ -78,6 +86,13 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
t.Logf("all clients logged out")
listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes))
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
// if the server is not running with HTTPS, we have to wait a bit before
// reconnection as the newest Tailscale client has a measure that will only
// reconnect over HTTPS if they saw a noise connection previously.
@@ -105,8 +120,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes))
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.String()
@@ -137,8 +153,20 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
}
}
}
listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes))
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
})
}
}
func assertLastSeenSet(t *testing.T, node *v1.Node) {
assert.NotNil(t, node)
assert.NotNil(t, node.LastSeen)
}
// This test will first log in two sets of nodes to two sets of users, then

View File

@@ -107,7 +107,7 @@ extra:
- icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx
headscale:
version: 0.25.0
version: 0.26.0
# Extensions
markdown_extensions: