mirror of
https://github.com/juanfont/headscale.git
synced 2025-08-16 00:07:59 +00:00
Compare commits
12 Commits
v0.26.0-be
...
v0.26.1
Author | SHA1 | Date | |
---|---|---|---|
![]() |
474ea236d0 | ||
![]() |
2dc2f3b3f0 | ||
![]() |
d7a503a34e | ||
![]() |
62b489dc68 | ||
![]() |
8c7e650616 | ||
![]() |
43943aeee9 | ||
![]() |
d81b0053e5 | ||
![]() |
dd0cbdf40c | ||
![]() |
37dc0dad35 | ||
![]() |
377b854dd8 | ||
![]() |
56db4ed0f1 | ||
![]() |
833e0f66f1 |
31
CHANGELOG.md
31
CHANGELOG.md
@@ -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
|
||||
|
@@ -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()),
|
||||
|
@@ -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")
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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": [
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
},
|
||||
|
@@ -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,
|
||||
|
@@ -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 ®Req, resp
|
||||
} else {
|
||||
}
|
||||
|
||||
return ®Req, 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
|
||||
}
|
||||
|
@@ -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{
|
||||
{
|
||||
|
809
hscontrol/policy/route_approval_test.go
Normal file
809
hscontrol/policy/route_approval_test.go
Normal 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")
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
@@ -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
|
||||
|
@@ -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)
|
||||
|
@@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@@ -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)
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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
|
||||
|
@@ -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:
|
||||
|
Reference in New Issue
Block a user