Compare commits

...

11 Commits

Author SHA1 Message Date
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
21 changed files with 1659 additions and 116 deletions

View File

@@ -2,6 +2,8 @@
## Next ## Next
## 0.26.0 (2025-05-14)
### BREAKING ### BREAKING
#### Routes #### Routes
@@ -64,6 +66,27 @@ new policy code passes all of our tests.
`@` should be appended at the end. For example, if your user is `john`, it `@` should be appended at the end. For example, if your user is `john`, it
must be written as `john@` in the policy. 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** **SSH**
The SSH policy has been reworked to be more consistent with the rest of the 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, ",") aclTags = strings.TrimLeft(aclTags, ",")
tableData = append(tableData, []string{ tableData = append(tableData, []string{
strconv.FormatUint(key.GetId(), 64), strconv.FormatUint(key.GetId(), 10),
key.GetKey(), key.GetKey(),
strconv.FormatBool(key.GetReusable()), strconv.FormatBool(key.GetReusable()),
strconv.FormatBool(key.GetEphemeral()), strconv.FormatBool(key.GetEphemeral()),

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"runtime" "runtime"
"slices"
"github.com/juanfont/headscale/hscontrol/types" "github.com/juanfont/headscale/hscontrol/types"
"github.com/rs/zerolog" "github.com/rs/zerolog"
@@ -25,6 +26,11 @@ func init() {
return return
} }
if slices.Contains(os.Args, "policy") && slices.Contains(os.Args, "check") {
zerolog.SetGlobalLevel(zerolog.Disabled)
return
}
cobra.OnInitialize(initConfig) cobra.OnInitialize(initConfig)
rootCmd.PersistentFlags(). rootCmd.PersistentFlags().
StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)") StringVarP(&cfgFile, "config", "c", "", "config file (default is /etc/headscale/config.yaml)")

View File

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

View File

@@ -375,19 +375,6 @@ unix_socket_permission: "0770"
# # - plain: Use plain code verifier # # - plain: Use plain code verifier
# # - S256: Use SHA256 hashed code verifier (default, recommended) # # - S256: Use SHA256 hashed code verifier (default, recommended)
# method: S256 # 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 configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel # 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. // developers have access to the internal network through the router.
// the internal network is composed of HTTPS endpoints and Postgresql // the internal network is composed of HTTPS endpoints and Postgresql
// database servers. There's an additional rule to allow traffic to be // database servers.
// forwarded to the internal subnet, 10.20.0.0/16. See this issue
// https://github.com/juanfont/headscale/issues/502
{ {
"action": "accept", "action": "accept",
"src": ["group:dev"], "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 // 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. 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 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 internal service that can be reached via a route on the subnet router `router`. It allows the node `node` to access
the subnet router `router` without allowing access to any service of the subnet router itself. The second ACL rule `service.example.net` on port 80 and 443 which is reachable via the subnet router. Access to the subnet router itself is
allows the node `node` to access `service.example.net` on port 80 and 443 which is reachable via the subnet router. denied.
```json title="Access the routes of a subnet router without the subnet router itself" ```json title="Access the routes of a subnet router without the subnet router itself"
{ {
"hosts": { "hosts": {
// the router is not referenced but announces 192.168.0.0/24"
"router": "100.64.0.1/32", "router": "100.64.0.1/32",
"node": "100.64.0.2/32", "node": "100.64.0.2/32",
"service.example.net": "192.168.0.1/32" "service.example.net": "192.168.0.1/32"
}, },
"acls": [ "acls": [
{
"action": "accept",
"src": [
"*"
],
"dst": [
"router:0"
]
},
{ {
"action": "accept", "action": "accept",
"src": [ "src": [

View File

@@ -145,7 +145,7 @@ func NewHeadscale(cfg *types.Config) (*Headscale, error) {
registrationCache, registrationCache,
) )
if err != nil { 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) 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 { 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 var authProvider AuthProvider

View File

@@ -672,7 +672,47 @@ AND auth_key_id NOT IN (
{ {
ID: "202502171819", ID: "202502171819",
Migrate: func(tx *gorm.DB) error { 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 return nil
}, },

View File

@@ -251,6 +251,20 @@ func SetApprovedRoutes(
return nil 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 // 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. // and renames it. If the name is not unique, it will return an error.
func RenameNode(tx *gorm.DB, func RenameNode(tx *gorm.DB,

View File

@@ -709,6 +709,9 @@ func TestReduceFilterRules(t *testing.T) {
name: "1817-reduce-breaks-32-mask", name: "1817-reduce-breaks-32-mask",
pol: ` pol: `
{ {
"tagOwners": {
"tag:access-servers": ["user100@"],
},
"groups": { "groups": {
"group:access": [ "group:access": [
"user1@" "user1@"
@@ -1688,6 +1691,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: taggedServer, targetNode: taggedServer,
peers: types.Nodes{&nodeUser1, &nodeUser2}, peers: types.Nodes{&nodeUser1, &nodeUser2},
policy: `{ policy: `{
"tagOwners": {
"tag:server": ["user3@"],
},
"groups": { "groups": {
"group:users": ["user1@", "user2@"] "group:users": ["user1@", "user2@"]
}, },
@@ -1726,6 +1732,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser1, targetNode: nodeUser1,
peers: types.Nodes{&taggedClient}, peers: types.Nodes{&taggedClient},
policy: `{ policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [ "ssh": [
{ {
"action": "accept", "action": "accept",
@@ -1756,6 +1765,10 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: taggedServer, targetNode: taggedServer,
peers: types.Nodes{&taggedClient}, peers: types.Nodes{&taggedClient},
policy: `{ policy: `{
"tagOwners": {
"tag:client": ["user2@"],
"tag:server": ["user3@"],
},
"ssh": [ "ssh": [
{ {
"action": "accept", "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. // we skip this test for v1 and not let it hold up v2 replacing it.
skipV1: true, 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", name: "check-period-specified",
targetNode: nodeUser1, targetNode: nodeUser1,
peers: types.Nodes{&taggedClient}, peers: types.Nodes{&taggedClient},
policy: `{ policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [ "ssh": [
{ {
"action": "check", "action": "check",
@@ -1873,6 +1871,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser2, targetNode: nodeUser2,
peers: types.Nodes{&nodeUser1}, peers: types.Nodes{&nodeUser1},
policy: `{ policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [ "ssh": [
{ {
"action": "accept", "action": "accept",
@@ -1926,6 +1927,9 @@ func TestSSHPolicyRules(t *testing.T) {
targetNode: nodeUser1, targetNode: nodeUser1,
peers: types.Nodes{&taggedClient}, peers: types.Nodes{&taggedClient},
policy: `{ policy: `{
"tagOwners": {
"tag:client": ["user1@"],
},
"ssh": [ "ssh": [
{ {
"action": "accept", "action": "accept",

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 tagOwnerMapHash deephash.Sum
tagOwnerMap map[Tag]*netipx.IPSet tagOwnerMap map[Tag]*netipx.IPSet
exitSetHash deephash.Sum
exitSet *netipx.IPSet
autoApproveMapHash deephash.Sum autoApproveMapHash deephash.Sum
autoApproveMap map[netip.Prefix]*netipx.IPSet autoApproveMap map[netip.Prefix]*netipx.IPSet
@@ -97,7 +99,7 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
pm.tagOwnerMap = tagMap pm.tagOwnerMap = tagMap
pm.tagOwnerMapHash = tagOwnerMapHash 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 { if err != nil {
return false, fmt.Errorf("resolving auto approvers map: %w", err) return false, fmt.Errorf("resolving auto approvers map: %w", err)
} }
@@ -107,8 +109,13 @@ func (pm *PolicyManager) updateLocked() (bool, error) {
pm.autoApproveMap = autoMap pm.autoApproveMap = autoMap
pm.autoApproveMapHash = autoApproveMapHash 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 neither of the calculated values changed, no need to update nodes
if !filterChanged && !tagOwnerChanged && !autoApproveChanged { if !filterChanged && !tagOwnerChanged && !autoApproveChanged && !exitSetChanged {
return false, nil return false, nil
} }
@@ -207,6 +214,23 @@ func (pm *PolicyManager) NodeCanApproveRoute(node *types.Node, route netip.Prefi
return false 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() pm.mu.Lock()
defer pm.mu.Unlock() 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 // cannot just lookup in the prefix map and have to check
// if there is a "parent" prefix available. // if there is a "parent" prefix available.
for prefix, approveAddrs := range pm.autoApproveMap { 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 // Check if prefix is larger (so containing) and then overlaps
// the route to see if the node can approve a subset of an autoapprover // 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. // Groups are a map of Group to a list of Username.
type Groups map[Group]Usernames 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 // UnmarshalJSON overrides the default JSON unmarshalling for Groups to ensure
// that each group name is validated using the isGroup function. This ensures // 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 // 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. // TagOwners are a map of Tag to a list of the UserEntities that own the tag.
type TagOwners map[Tag]Owners 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. // 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. // 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. // 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. // 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. // 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. // 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 { if p == nil {
return nil, nil return nil, nil, nil
} }
var err error
routes := make(map[netip.Prefix]*netipx.IPSetBuilder) 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) aa, ok := autoApprover.(Alias)
if !ok { if !ok {
// Should never happen // 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. // If it does not resolve, that means the autoApprover is not associated with any IP addresses.
ips, _ := aa.Resolve(p, users, nodes) 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) aa, ok := autoApprover.(Alias)
if !ok { if !ok {
// Should never happen // 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. // If it does not resolve, that means the autoApprover is not associated with any IP addresses.
ips, _ := aa.Resolve(p, users, nodes) 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 { for prefix, builder := range routes {
ipSet, err := builder.IPSet() ipSet, err := builder.IPSet()
if err != nil { if err != nil {
return nil, err return nil, nil, err
} }
ret[prefix] = ipSet ret[prefix] = ipSet
} }
var exitNodeSet *netipx.IPSet
if len(p.AutoApprovers.ExitNode) > 0 { if len(p.AutoApprovers.ExitNode) > 0 {
exitNodeSet, err := exitNodeSetBuilder.IPSet() exitNodeSet, err = exitNodeSetBuilder.IPSet()
if err != nil { if err != nil {
return nil, err return nil, nil, err
}
} }
ret[tsaddr.AllIPv4()] = exitNodeSet return ret, exitNodeSet, nil
ret[tsaddr.AllIPv6()] = exitNodeSet
}
return ret, nil
} }
type ACL struct { type ACL struct {
@@ -1047,6 +1074,16 @@ func (p *Policy) validate() error {
errs = append(errs, err) errs = append(errs, err)
continue 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) errs = append(errs, err)
continue 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) errs = append(errs, err)
continue 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 { for _, dst := range ssh.Destinations {
@@ -1117,6 +1174,55 @@ func (p *Policy) validate() error {
errs = append(errs, err) errs = append(errs, err)
continue 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)) *a = make([]Alias, len(aliases))
for i, alias := range aliases { for i, alias := range aliases {
switch alias.Alias.(type) { switch alias.Alias.(type) {
case *Group, *Tag, *AutoGroup: case *Username, *Group, *Tag, *AutoGroup:
(*a)[i] = alias.Alias (*a)[i] = alias.Alias
default: default:
return fmt.Errorf("type %T not supported", alias.Alias) 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`, 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 { cmps := append(util.Comparers, cmp.Comparer(func(x, y Prefix) bool {
@@ -832,6 +1027,7 @@ func TestResolveAutoApprovers(t *testing.T) {
name string name string
policy *Policy policy *Policy
want map[netip.Prefix]*netipx.IPSet want map[netip.Prefix]*netipx.IPSet
wantAllIPRoutes *netipx.IPSet
wantErr bool wantErr bool
}{ }{
{ {
@@ -846,6 +1042,7 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{ want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"), mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
}, },
wantAllIPRoutes: nil,
wantErr: false, wantErr: false,
}, },
{ {
@@ -862,6 +1059,7 @@ func TestResolveAutoApprovers(t *testing.T) {
mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"), mp("10.0.0.0/24"): mustIPSet("100.64.0.1/32"),
mp("10.0.1.0/24"): mustIPSet("100.64.0.2/32"), mp("10.0.1.0/24"): mustIPSet("100.64.0.2/32"),
}, },
wantAllIPRoutes: nil,
wantErr: false, wantErr: false,
}, },
{ {
@@ -871,10 +1069,8 @@ func TestResolveAutoApprovers(t *testing.T) {
ExitNode: AutoApprovers{ptr.To(Username("user1@"))}, ExitNode: AutoApprovers{ptr.To(Username("user1@"))},
}, },
}, },
want: map[netip.Prefix]*netipx.IPSet{ want: map[netip.Prefix]*netipx.IPSet{},
tsaddr.AllIPv4(): mustIPSet("100.64.0.1/32"), wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
tsaddr.AllIPv6(): mustIPSet("100.64.0.1/32"),
},
wantErr: false, wantErr: false,
}, },
{ {
@@ -892,6 +1088,7 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{ 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.0.0/24"): mustIPSet("100.64.0.1/32", "100.64.0.2/32"),
}, },
wantAllIPRoutes: nil,
wantErr: false, wantErr: false,
}, },
{ {
@@ -918,9 +1115,8 @@ func TestResolveAutoApprovers(t *testing.T) {
}, },
want: map[netip.Prefix]*netipx.IPSet{ want: map[netip.Prefix]*netipx.IPSet{
mp("10.0.1.0/24"): mustIPSet("100.64.0.4/32"), 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"),
}, },
wantAllIPRoutes: mustIPSet("100.64.0.5/32"),
wantErr: false, wantErr: false,
}, },
{ {
@@ -940,9 +1136,8 @@ func TestResolveAutoApprovers(t *testing.T) {
want: map[netip.Prefix]*netipx.IPSet{ 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.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"), 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"),
}, },
wantAllIPRoutes: mustIPSet("100.64.0.1/32"),
wantErr: false, wantErr: false,
}, },
} }
@@ -951,7 +1146,7 @@ func TestResolveAutoApprovers(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { 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 { if (err != nil) != tt.wantErr {
t.Errorf("resolveAutoApprovers() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("resolveAutoApprovers() error = %v, wantErr %v", err, tt.wantErr)
return return
@@ -959,6 +1154,15 @@ func TestResolveAutoApprovers(t *testing.T) {
if diff := cmp.Diff(tt.want, got, cmps...); diff != "" { if diff := cmp.Diff(tt.want, got, cmps...); diff != "" {
t.Errorf("resolveAutoApprovers() mismatch (-want +got):\n%s", 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 change.LastSeen = &now
} }
if node.LastSeen != nil {
h.db.SetLastSeen(node.ID, *node.LastSeen)
}
ctx := types.NotifyCtx(context.Background(), "poll-nodeupdate-onlinestatus", node.Hostname) ctx := types.NotifyCtx(context.Background(), "poll-nodeupdate-onlinestatus", node.Hostname)
h.nodeNotifier.NotifyWithIgnore(ctx, types.UpdatePeerPatch(change), node.ID) 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 // LastSeen is when the node was last in contact with
// headscale. It is best effort and not persisted. // headscale. It is best effort and not persisted.
LastSeen *time.Time `gorm:"-"` LastSeen *time.Time `gorm:"column:last_seen"`
// DEPRECATED: Use the ApprovedRoutes field instead.
// TODO(kradalby): remove when ApprovedRoutes is used all over the code.
// Routes []Route `gorm:"constraint:OnDelete:CASCADE;"`
// ApprovedRoutes is a list of routes that the node is allowed to announce // 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 // 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"` 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 { func (c *OIDCClaims) Identifier() string {
if strings.HasPrefix(c.Iss, "http") { // Handle empty components special cases
if i, err := url.JoinPath(c.Iss, c.Sub); err == nil { if c.Iss == "" && c.Sub == "" {
return i 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 { 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.DisplayName = claims.Name
u.ProfilePicURL = claims.ProfilePictureURL u.ProfilePicURL = claims.ProfilePictureURL
u.Provider = util.RegisterMethodOIDC u.Provider = util.RegisterMethodOIDC

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/juanfont/headscale/hscontrol/util" "github.com/juanfont/headscale/hscontrol/util"
"github.com/stretchr/testify/assert"
) )
func TestUnmarshallOIDCClaims(t *testing.T) { 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) { func TestOIDCClaimsJSONToUser(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -9,6 +9,7 @@ import (
"slices" "slices"
v1 "github.com/juanfont/headscale/gen/go/headscale/v1"
"github.com/juanfont/headscale/integration/hsic" "github.com/juanfont/headscale/integration/hsic"
"github.com/juanfont/headscale/integration/tsic" "github.com/juanfont/headscale/integration/tsic"
"github.com/samber/lo" "github.com/samber/lo"
@@ -44,6 +45,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
allClients, err := scenario.ListTailscaleClients() allClients, err := scenario.ListTailscaleClients()
assertNoErrListClients(t, err) assertNoErrListClients(t, err)
allIps, err := scenario.ListTailscaleClientsIPs()
assertNoErrListClientIPs(t, err)
err = scenario.WaitForTailscaleSync() err = scenario.WaitForTailscaleSync()
assertNoErrSync(t, err) assertNoErrSync(t, err)
@@ -66,6 +70,10 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
nodeCountBeforeLogout := len(listNodes) nodeCountBeforeLogout := len(listNodes)
t.Logf("node count before logout: %d", nodeCountBeforeLogout) t.Logf("node count before logout: %d", nodeCountBeforeLogout)
for _, node := range listNodes {
assertLastSeenSet(t, node)
}
for _, client := range allClients { for _, client := range allClients {
err := client.Logout() err := client.Logout()
if err != nil { if err != nil {
@@ -78,6 +86,13 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
t.Logf("all clients logged out") 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 // 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 // reconnection as the newest Tailscale client has a measure that will only
// reconnect over HTTPS if they saw a noise connection previously. // reconnect over HTTPS if they saw a noise connection previously.
@@ -105,8 +120,9 @@ func TestAuthKeyLogoutAndReloginSameUser(t *testing.T) {
listNodes, err = headscale.ListNodes() listNodes, err = headscale.ListNodes()
require.Equal(t, nodeCountBeforeLogout, len(listNodes)) require.Equal(t, nodeCountBeforeLogout, len(listNodes))
allIps, err := scenario.ListTailscaleClientsIPs() for _, node := range listNodes {
assertNoErrListClientIPs(t, err) assertLastSeenSet(t, node)
}
allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string { allAddrs := lo.Map(allIps, func(x netip.Addr, index int) string {
return x.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 // 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 - icon: fontawesome/brands/discord
link: https://discord.gg/c84AZQhmpx link: https://discord.gg/c84AZQhmpx
headscale: headscale:
version: 0.25.0 version: 0.26.0
# Extensions # Extensions
markdown_extensions: markdown_extensions: