mirror of
https://github.com/juanfont/headscale.git
synced 2025-05-18 01:16:48 +02:00
Make more granular SSH tests for both Policies (#2555)
* policy/v1: dont consider empty if ssh has rules Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: replace time.Duration with model.Duration Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: add autogroup and ssh validation Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy/v2: replace time.Duration with model.Duration Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy: replace old ssh tests with more granular test Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy: skip v1 tests expected to fail (missing error handling) Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * policy: skip v1 group tests, old bugs wont be fixed Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * integration: user valid policy for ssh Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * Changelog, add ssh section Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> * nix update Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com> --------- Signed-off-by: Kristoffer Dalby <kristoffer@tailscale.com>
This commit is contained in:
parent
f317a85ab4
commit
b9868f6516
16
CHANGELOG.md
16
CHANGELOG.md
@ -62,6 +62,20 @@ 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.
|
||||||
|
|
||||||
|
**SSH**
|
||||||
|
|
||||||
|
The SSH policy has been reworked to be more consistent with the rest of the
|
||||||
|
policy. In addition, several inconsistencies between our implementation and
|
||||||
|
Tailscale's upstream has been closed and this might be a breaking change for
|
||||||
|
some users. Please refer to the
|
||||||
|
[upstream documentation](https://tailscale.com/kb/1337/acl-syntax#tailscale-ssh)
|
||||||
|
for more information on which types are allowed in `src`, `dst` and `users`.
|
||||||
|
|
||||||
|
There is one large inconsistency left, we allow `*` as a destination as we
|
||||||
|
currently do not support `autogroup:self`, `autogroup:member` and
|
||||||
|
`autogroup:tagged`. The support for `*` will be removed when we have support for
|
||||||
|
the autogroups.
|
||||||
|
|
||||||
**Current state**
|
**Current state**
|
||||||
|
|
||||||
The new policy is passing all tests, both integration and unit tests. This does
|
The new policy is passing all tests, both integration and unit tests. This does
|
||||||
@ -70,8 +84,6 @@ working in v1 and not tested might be broken in v2 (and vice versa).
|
|||||||
|
|
||||||
**We do need help testing this code**
|
**We do need help testing this code**
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#### Other breaking changes
|
#### Other breaking changes
|
||||||
|
|
||||||
- Disallow `server_url` and `base_domain` to be equal
|
- Disallow `server_url` and `base_domain` to be equal
|
||||||
|
@ -20,11 +20,11 @@
|
|||||||
},
|
},
|
||||||
"nixpkgs": {
|
"nixpkgs": {
|
||||||
"locked": {
|
"locked": {
|
||||||
"lastModified": 1746152631,
|
"lastModified": 1746300365,
|
||||||
"narHash": "sha256-zBuvmL6+CUsk2J8GINpyy8Hs1Zp4PP6iBWSmZ4SCQ/s=",
|
"narHash": "sha256-thYTdWqCRipwPRxWiTiH1vusLuAy0okjOyzRx4hLWh4=",
|
||||||
"owner": "NixOS",
|
"owner": "NixOS",
|
||||||
"repo": "nixpkgs",
|
"repo": "nixpkgs",
|
||||||
"rev": "032bc6539bd5f14e9d0c51bd79cfe9a055b094c3",
|
"rev": "f21e4546e3ede7ae34d12a84602a22246b31f7e0",
|
||||||
"type": "github"
|
"type": "github"
|
||||||
},
|
},
|
||||||
"original": {
|
"original": {
|
||||||
|
@ -4,6 +4,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
"github.com/juanfont/headscale/hscontrol/policy/matcher"
|
||||||
|
|
||||||
@ -1540,3 +1541,408 @@ func TestFilterNodesByACL(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSSHPolicyRules(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
|
||||||
|
nodeUser1 := types.Node{
|
||||||
|
Hostname: "user1-device",
|
||||||
|
IPv4: ap("100.64.0.1"),
|
||||||
|
UserID: 1,
|
||||||
|
User: users[0],
|
||||||
|
}
|
||||||
|
nodeUser2 := types.Node{
|
||||||
|
Hostname: "user2-device",
|
||||||
|
IPv4: ap("100.64.0.2"),
|
||||||
|
UserID: 2,
|
||||||
|
User: users[1],
|
||||||
|
}
|
||||||
|
taggedServer := types.Node{
|
||||||
|
Hostname: "tagged-server",
|
||||||
|
IPv4: ap("100.64.0.3"),
|
||||||
|
UserID: 3,
|
||||||
|
User: users[2],
|
||||||
|
ForcedTags: []string{"tag:server"},
|
||||||
|
}
|
||||||
|
taggedClient := types.Node{
|
||||||
|
Hostname: "tagged-client",
|
||||||
|
IPv4: ap("100.64.0.4"),
|
||||||
|
UserID: 2,
|
||||||
|
User: users[1],
|
||||||
|
ForcedTags: []string{"tag:client"},
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
targetNode types.Node
|
||||||
|
peers types.Nodes
|
||||||
|
policy string
|
||||||
|
wantSSH *tailcfg.SSHPolicy
|
||||||
|
expectErr bool
|
||||||
|
errorMessage string
|
||||||
|
|
||||||
|
// There are some tests that will not pass on V1 since we do not
|
||||||
|
// have the same kind of error handling as V2, so we skip them.
|
||||||
|
skipV1 bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "group-to-user",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&nodeUser2},
|
||||||
|
policy: `{
|
||||||
|
"groups": {
|
||||||
|
"group:admins": ["user2@"]
|
||||||
|
},
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["group:admins"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.2"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
|
||||||
|
// It looks like the group implementation in v1 is broken, so
|
||||||
|
// we skip this test for v1 and not let it hold up v2 replacing it.
|
||||||
|
skipV1: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group-to-tag",
|
||||||
|
targetNode: taggedServer,
|
||||||
|
peers: types.Nodes{&nodeUser1, &nodeUser2},
|
||||||
|
policy: `{
|
||||||
|
"groups": {
|
||||||
|
"group:users": ["user1@", "user2@"]
|
||||||
|
},
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["group:users"],
|
||||||
|
"dst": ["tag:server"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.1"},
|
||||||
|
{NodeIP: "100.64.0.2"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
|
||||||
|
// It looks like the group implementation in v1 is broken, so
|
||||||
|
// we skip this test for v1 and not let it hold up v2 replacing it.
|
||||||
|
skipV1: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag-to-user",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&taggedClient},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.4"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag-to-tag",
|
||||||
|
targetNode: taggedServer,
|
||||||
|
peers: types.Nodes{&taggedClient},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["tag:server"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.4"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "group-to-wildcard",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&nodeUser2, &taggedClient},
|
||||||
|
policy: `{
|
||||||
|
"groups": {
|
||||||
|
"group:admins": ["user2@"]
|
||||||
|
},
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["group:admins"],
|
||||||
|
"dst": ["*"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.2"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
|
||||||
|
// It looks like the group implementation in v1 is broken, so
|
||||||
|
// 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: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "check",
|
||||||
|
"checkPeriod": "24h",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.4"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"autogroup:nonroot": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
SessionDuration: 24 * time.Hour,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no-matching-rules",
|
||||||
|
targetNode: nodeUser2,
|
||||||
|
peers: types.Nodes{&nodeUser1},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: nil},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-action",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&nodeUser2},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "invalid",
|
||||||
|
"src": ["group:admins"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
expectErr: true,
|
||||||
|
errorMessage: `SSH action "invalid" is not valid, must be accept or check`,
|
||||||
|
skipV1: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid-check-period",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&nodeUser2},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "check",
|
||||||
|
"checkPeriod": "invalid",
|
||||||
|
"src": ["group:admins"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:nonroot"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
expectErr: true,
|
||||||
|
errorMessage: "not a valid duration string",
|
||||||
|
skipV1: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple-ssh-users-with-autogroup",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&taggedClient},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["alice", "bob"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
wantSSH: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
||||||
|
{
|
||||||
|
Principals: []*tailcfg.SSHPrincipal{
|
||||||
|
{NodeIP: "100.64.0.4"},
|
||||||
|
},
|
||||||
|
SSHUsers: map[string]string{
|
||||||
|
"alice": "=",
|
||||||
|
"bob": "=",
|
||||||
|
},
|
||||||
|
Action: &tailcfg.SSHAction{
|
||||||
|
Accept: true,
|
||||||
|
AllowAgentForwarding: true,
|
||||||
|
AllowLocalPortForwarding: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unsupported-autogroup",
|
||||||
|
targetNode: nodeUser1,
|
||||||
|
peers: types.Nodes{&taggedClient},
|
||||||
|
policy: `{
|
||||||
|
"ssh": [
|
||||||
|
{
|
||||||
|
"action": "accept",
|
||||||
|
"src": ["tag:client"],
|
||||||
|
"dst": ["user1@"],
|
||||||
|
"users": ["autogroup:invalid"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}`,
|
||||||
|
expectErr: true,
|
||||||
|
errorMessage: "autogroup \"autogroup:invalid\" is not supported",
|
||||||
|
skipV1: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
for idx, pmf := range PolicyManagerFuncsForTest([]byte(tt.policy)) {
|
||||||
|
version := idx + 1
|
||||||
|
t.Run(fmt.Sprintf("%s-v%d", tt.name, version), func(t *testing.T) {
|
||||||
|
if version == 1 && tt.skipV1 {
|
||||||
|
t.Skip()
|
||||||
|
}
|
||||||
|
|
||||||
|
var pm PolicyManager
|
||||||
|
var err error
|
||||||
|
pm, err = pmf(users, append(tt.peers, &tt.targetNode))
|
||||||
|
|
||||||
|
if tt.expectErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), tt.errorMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := pm.SSHPolicy(&tt.targetNode)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
if diff := cmp.Diff(tt.wantSSH, got); diff != "" {
|
||||||
|
t.Errorf("SSHPolicy() unexpected result (-want +got):\n%s", diff)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -2159,200 +2159,6 @@ func Test_getTags(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSSHRules(t *testing.T) {
|
|
||||||
users := []types.User{
|
|
||||||
{
|
|
||||||
Name: "user1",
|
|
||||||
},
|
|
||||||
}
|
|
||||||
tests := []struct {
|
|
||||||
name string
|
|
||||||
node types.Node
|
|
||||||
peers types.Nodes
|
|
||||||
pol ACLPolicy
|
|
||||||
want *tailcfg.SSHPolicy
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "peers-can-connect",
|
|
||||||
node: types.Node{
|
|
||||||
Hostname: "testnodes",
|
|
||||||
IPv4: iap("100.64.99.42"),
|
|
||||||
UserID: 0,
|
|
||||||
User: users[0],
|
|
||||||
},
|
|
||||||
peers: types.Nodes{
|
|
||||||
&types.Node{
|
|
||||||
Hostname: "testnodes2",
|
|
||||||
IPv4: iap("100.64.0.1"),
|
|
||||||
UserID: 0,
|
|
||||||
User: users[0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pol: ACLPolicy{
|
|
||||||
Groups: Groups{
|
|
||||||
"group:test": []string{"user1"},
|
|
||||||
},
|
|
||||||
Hosts: Hosts{
|
|
||||||
"client": netip.PrefixFrom(netip.MustParseAddr("100.64.99.42"), 32),
|
|
||||||
},
|
|
||||||
ACLs: []ACL{
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"*"},
|
|
||||||
Destinations: []string{"*:*"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SSHs: []SSH{
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"group:test"},
|
|
||||||
Destinations: []string{"client"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"*"},
|
|
||||||
Destinations: []string{"client"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"group:test"},
|
|
||||||
Destinations: []string{"100.64.99.42"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"*"},
|
|
||||||
Destinations: []string{"100.64.99.42"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
want: &tailcfg.SSHPolicy{Rules: []*tailcfg.SSHRule{
|
|
||||||
{
|
|
||||||
Principals: []*tailcfg.SSHPrincipal{
|
|
||||||
{
|
|
||||||
UserLogin: "user1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SSHUsers: map[string]string{
|
|
||||||
"autogroup:nonroot": "=",
|
|
||||||
},
|
|
||||||
Action: &tailcfg.SSHAction{
|
|
||||||
Accept: true,
|
|
||||||
AllowAgentForwarding: true,
|
|
||||||
AllowLocalPortForwarding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SSHUsers: map[string]string{
|
|
||||||
"autogroup:nonroot": "=",
|
|
||||||
},
|
|
||||||
Principals: []*tailcfg.SSHPrincipal{
|
|
||||||
{
|
|
||||||
Any: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Action: &tailcfg.SSHAction{
|
|
||||||
Accept: true,
|
|
||||||
AllowAgentForwarding: true,
|
|
||||||
AllowLocalPortForwarding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Principals: []*tailcfg.SSHPrincipal{
|
|
||||||
{
|
|
||||||
UserLogin: "user1",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SSHUsers: map[string]string{
|
|
||||||
"autogroup:nonroot": "=",
|
|
||||||
},
|
|
||||||
Action: &tailcfg.SSHAction{
|
|
||||||
Accept: true,
|
|
||||||
AllowAgentForwarding: true,
|
|
||||||
AllowLocalPortForwarding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
SSHUsers: map[string]string{
|
|
||||||
"autogroup:nonroot": "=",
|
|
||||||
},
|
|
||||||
Principals: []*tailcfg.SSHPrincipal{
|
|
||||||
{
|
|
||||||
Any: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
Action: &tailcfg.SSHAction{
|
|
||||||
Accept: true,
|
|
||||||
AllowAgentForwarding: true,
|
|
||||||
AllowLocalPortForwarding: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "peers-cannot-connect",
|
|
||||||
node: types.Node{
|
|
||||||
Hostname: "testnodes",
|
|
||||||
IPv4: iap("100.64.0.1"),
|
|
||||||
UserID: 0,
|
|
||||||
User: users[0],
|
|
||||||
},
|
|
||||||
peers: types.Nodes{
|
|
||||||
&types.Node{
|
|
||||||
Hostname: "testnodes2",
|
|
||||||
IPv4: iap("100.64.99.42"),
|
|
||||||
UserID: 0,
|
|
||||||
User: users[0],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
pol: ACLPolicy{
|
|
||||||
Groups: Groups{
|
|
||||||
"group:test": []string{"user1"},
|
|
||||||
},
|
|
||||||
Hosts: Hosts{
|
|
||||||
"client": netip.PrefixFrom(netip.MustParseAddr("100.64.99.42"), 32),
|
|
||||||
},
|
|
||||||
ACLs: []ACL{
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"*"},
|
|
||||||
Destinations: []string{"*:*"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
SSHs: []SSH{
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"group:test"},
|
|
||||||
Destinations: []string{"100.64.99.42"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Action: "accept",
|
|
||||||
Sources: []string{"*"},
|
|
||||||
Destinations: []string{"100.64.99.42"},
|
|
||||||
Users: []string{"autogroup:nonroot"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
want: &tailcfg.SSHPolicy{Rules: nil},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
|
||||||
got, err := tt.pol.CompileSSHPolicy(&tt.node, users, tt.peers)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
if diff := cmp.Diff(tt.want, got); diff != "" {
|
|
||||||
t.Errorf("TestSSHRules() unexpected result (-want +got):\n%s", diff)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestParseDestination(t *testing.T) {
|
func TestParseDestination(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
dest string
|
dest string
|
||||||
|
@ -90,7 +90,7 @@ func (hosts *Hosts) UnmarshalJSON(data []byte) error {
|
|||||||
|
|
||||||
// IsZero is perhaps a bit naive here.
|
// IsZero is perhaps a bit naive here.
|
||||||
func (pol ACLPolicy) IsZero() bool {
|
func (pol ACLPolicy) IsZero() bool {
|
||||||
if len(pol.Groups) == 0 && len(pol.Hosts) == 0 && len(pol.ACLs) == 0 {
|
if len(pol.Groups) == 0 && len(pol.Hosts) == 0 && len(pol.ACLs) == 0 && len(pol.SSHs) == 0 {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -130,7 +130,7 @@ func (pol *Policy) compileSSHPolicy(
|
|||||||
case "accept":
|
case "accept":
|
||||||
action = sshAction(true, 0)
|
action = sshAction(true, 0)
|
||||||
case "check":
|
case "check":
|
||||||
action = sshAction(true, rule.CheckPeriod)
|
action = sshAction(true, time.Duration(rule.CheckPeriod))
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
|
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
|
||||||
}
|
}
|
||||||
|
@ -6,12 +6,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
|
||||||
|
|
||||||
"slices"
|
"slices"
|
||||||
|
|
||||||
"github.com/juanfont/headscale/hscontrol/types"
|
"github.com/juanfont/headscale/hscontrol/types"
|
||||||
"github.com/juanfont/headscale/hscontrol/util"
|
"github.com/juanfont/headscale/hscontrol/util"
|
||||||
|
"github.com/prometheus/common/model"
|
||||||
"github.com/tailscale/hujson"
|
"github.com/tailscale/hujson"
|
||||||
"go4.org/netipx"
|
"go4.org/netipx"
|
||||||
"tailscale.com/net/tsaddr"
|
"tailscale.com/net/tsaddr"
|
||||||
@ -383,6 +383,12 @@ type AutoGroup string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
AutoGroupInternet AutoGroup = "autogroup:internet"
|
AutoGroupInternet AutoGroup = "autogroup:internet"
|
||||||
|
AutoGroupNonRoot AutoGroup = "autogroup:nonroot"
|
||||||
|
|
||||||
|
// These are not yet implemented.
|
||||||
|
AutoGroupSelf AutoGroup = "autogroup:self"
|
||||||
|
AutoGroupMember AutoGroup = "autogroup:member"
|
||||||
|
AutoGroupTagged AutoGroup = "autogroup:tagged"
|
||||||
)
|
)
|
||||||
|
|
||||||
var autogroups = []AutoGroup{AutoGroupInternet}
|
var autogroups = []AutoGroup{AutoGroupInternet}
|
||||||
@ -915,6 +921,99 @@ type Policy struct {
|
|||||||
SSHs []SSH `json:"ssh"`
|
SSHs []SSH `json:"ssh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
autogroupForSrc = []AutoGroup{}
|
||||||
|
autogroupForDst = []AutoGroup{AutoGroupInternet}
|
||||||
|
autogroupForSSHSrc = []AutoGroup{}
|
||||||
|
autogroupForSSHDst = []AutoGroup{}
|
||||||
|
autogroupForSSHUser = []AutoGroup{AutoGroupNonRoot}
|
||||||
|
autogroupNotSupported = []AutoGroup{AutoGroupSelf, AutoGroupMember, AutoGroupTagged}
|
||||||
|
)
|
||||||
|
|
||||||
|
func validateAutogroupSupported(ag *AutoGroup) error {
|
||||||
|
if ag == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if slices.Contains(autogroupNotSupported, *ag) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported in headscale", *ag)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutogroupForSrc(src *AutoGroup) error {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if src.Is(AutoGroupInternet) {
|
||||||
|
return fmt.Errorf(`"autogroup:internet" used in source, it can only be used in ACL destinations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForSrc, *src) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported for ACL sources, can be %v", *src, autogroupForSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutogroupForDst(dst *AutoGroup) error {
|
||||||
|
if dst == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForDst, *dst) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported for ACL destinations, can be %v", *dst, autogroupForDst)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutogroupForSSHSrc(src *AutoGroup) error {
|
||||||
|
if src == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if src.Is(AutoGroupInternet) {
|
||||||
|
return fmt.Errorf(`"autogroup:internet" used in SSH source, it can only be used in ACL destinations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForSSHSrc, *src) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported for SSH sources, can be %v", *src, autogroupForSSHSrc)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutogroupForSSHDst(dst *AutoGroup) error {
|
||||||
|
if dst == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if dst.Is(AutoGroupInternet) {
|
||||||
|
return fmt.Errorf(`"autogroup:internet" used in SSH destination, it can only be used in ACL destinations`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForSSHDst, *dst) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported for SSH sources, can be %v", *dst, autogroupForSSHDst)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAutogroupForSSHUser(user *AutoGroup) error {
|
||||||
|
if user == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !slices.Contains(autogroupForSSHUser, *user) {
|
||||||
|
return fmt.Errorf("autogroup %q is not supported for SSH user, can be %v", *user, autogroupForSSHUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// validate reports if there are any errors in a policy after
|
// validate reports if there are any errors in a policy after
|
||||||
// the unmarshaling process.
|
// the unmarshaling process.
|
||||||
// It runs through all rules and checks if there are any inconsistencies
|
// It runs through all rules and checks if there are any inconsistencies
|
||||||
@ -938,20 +1037,70 @@ func (p *Policy) validate() error {
|
|||||||
}
|
}
|
||||||
case *AutoGroup:
|
case *AutoGroup:
|
||||||
ag := src.(*AutoGroup)
|
ag := src.(*AutoGroup)
|
||||||
if ag.Is(AutoGroupInternet) {
|
|
||||||
errs = append(errs, fmt.Errorf(`"autogroup:internet" used in source, it can only be used in ACL destinations`))
|
if err := validateAutogroupSupported(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAutogroupForSrc(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dst := range acl.Destinations {
|
||||||
|
switch dst.Alias.(type) {
|
||||||
|
case *Host:
|
||||||
|
h := dst.Alias.(*Host)
|
||||||
|
if !p.Hosts.exist(*h) {
|
||||||
|
errs = append(errs, fmt.Errorf(`Host %q is not defined in the Policy, please define or remove the reference to it`, *h))
|
||||||
|
}
|
||||||
|
case *AutoGroup:
|
||||||
|
ag := dst.Alias.(*AutoGroup)
|
||||||
|
|
||||||
|
if err := validateAutogroupSupported(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAutogroupForDst(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, ssh := range p.SSHs {
|
for _, ssh := range p.SSHs {
|
||||||
|
if ssh.Action != "accept" && ssh.Action != "check" {
|
||||||
|
errs = append(errs, fmt.Errorf("SSH action %q is not valid, must be accept or check", ssh.Action))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, user := range ssh.Users {
|
||||||
|
if strings.HasPrefix(string(user), "autogroup:") {
|
||||||
|
maybeAuto := AutoGroup(user)
|
||||||
|
if err := validateAutogroupForSSHUser(&maybeAuto); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for _, src := range ssh.Sources {
|
for _, src := range ssh.Sources {
|
||||||
switch src.(type) {
|
switch src.(type) {
|
||||||
case *AutoGroup:
|
case *AutoGroup:
|
||||||
ag := src.(*AutoGroup)
|
ag := src.(*AutoGroup)
|
||||||
if ag.Is(AutoGroupInternet) {
|
|
||||||
errs = append(errs, fmt.Errorf(`"autogroup:internet" used in SSH source, it can only be used in ACL destinations`))
|
if err := validateAutogroupSupported(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAutogroupForSSHSrc(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -959,8 +1108,14 @@ func (p *Policy) validate() error {
|
|||||||
switch dst.(type) {
|
switch dst.(type) {
|
||||||
case *AutoGroup:
|
case *AutoGroup:
|
||||||
ag := dst.(*AutoGroup)
|
ag := dst.(*AutoGroup)
|
||||||
if ag.Is(AutoGroupInternet) {
|
if err := validateAutogroupSupported(ag); err != nil {
|
||||||
errs = append(errs, fmt.Errorf(`"autogroup:internet" used in SSH destination, it can only be used in ACL destinations`))
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := validateAutogroupForSSHDst(ag); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -976,11 +1131,11 @@ func (p *Policy) validate() error {
|
|||||||
|
|
||||||
// SSH controls who can ssh into which machines.
|
// SSH controls who can ssh into which machines.
|
||||||
type SSH struct {
|
type SSH struct {
|
||||||
Action string `json:"action"` // TODO(kradalby): add strict type
|
Action string `json:"action"`
|
||||||
Sources SSHSrcAliases `json:"src"`
|
Sources SSHSrcAliases `json:"src"`
|
||||||
Destinations SSHDstAliases `json:"dst"`
|
Destinations SSHDstAliases `json:"dst"`
|
||||||
Users []SSHUser `json:"users"`
|
Users []SSHUser `json:"users"`
|
||||||
CheckPeriod time.Duration `json:"checkPeriod,omitempty"`
|
CheckPeriod model.Duration `json:"checkPeriod,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSHSrcAliases is a list of aliases that can be used as sources in an SSH rule.
|
// SSHSrcAliases is a list of aliases that can be used as sources in an SSH rule.
|
||||||
@ -997,7 +1152,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 *Username, *Group, *Tag, *AutoGroup:
|
case *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)
|
||||||
@ -1042,8 +1197,8 @@ func (a *SSHDstAliases) UnmarshalJSON(b []byte) error {
|
|||||||
// so we will leave it in as there is no other option
|
// so we will leave it in as there is no other option
|
||||||
// to dynamically give all access
|
// to dynamically give all access
|
||||||
// https://tailscale.com/kb/1193/tailscale-ssh#dst
|
// https://tailscale.com/kb/1193/tailscale-ssh#dst
|
||||||
Asterix,
|
// TODO(kradalby): remove this when we support autogroup:tagged and autogroup:member
|
||||||
*Group:
|
Asterix:
|
||||||
(*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)
|
||||||
|
@ -172,7 +172,7 @@ func TestSSHMultipleUsersAllToAll(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Action: "accept",
|
Action: "accept",
|
||||||
Sources: []string{"group:integration-test"},
|
Sources: []string{"group:integration-test"},
|
||||||
Destinations: []string{"group:integration-test"},
|
Destinations: []string{"user1@", "user2@"},
|
||||||
Users: []string{"ssh-it-user"},
|
Users: []string{"ssh-it-user"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -267,7 +267,7 @@ func TestSSHIsBlockedInACL(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Action: "accept",
|
Action: "accept",
|
||||||
Sources: []string{"group:integration-test"},
|
Sources: []string{"group:integration-test"},
|
||||||
Destinations: []string{"group:integration-test"},
|
Destinations: []string{"user1@"},
|
||||||
Users: []string{"ssh-it-user"},
|
Users: []string{"ssh-it-user"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -317,13 +317,13 @@ func TestSSHUserOnlyIsolation(t *testing.T) {
|
|||||||
{
|
{
|
||||||
Action: "accept",
|
Action: "accept",
|
||||||
Sources: []string{"group:ssh1"},
|
Sources: []string{"group:ssh1"},
|
||||||
Destinations: []string{"group:ssh1"},
|
Destinations: []string{"user1@"},
|
||||||
Users: []string{"ssh-it-user"},
|
Users: []string{"ssh-it-user"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Action: "accept",
|
Action: "accept",
|
||||||
Sources: []string{"group:ssh2"},
|
Sources: []string{"group:ssh2"},
|
||||||
Destinations: []string{"group:ssh2"},
|
Destinations: []string{"user2@"},
|
||||||
Users: []string{"ssh-it-user"},
|
Users: []string{"ssh-it-user"},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Loading…
Reference in New Issue
Block a user