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:
Kristoffer Dalby 2025-05-04 15:05:41 +03:00 committed by GitHub
parent f317a85ab4
commit b9868f6516
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 599 additions and 220 deletions

View file

@ -4,6 +4,7 @@ import (
"fmt"
"net/netip"
"testing"
"time"
"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)
}
})
}
}
}

View file

@ -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) {
tests := []struct {
dest string

View file

@ -90,7 +90,7 @@ func (hosts *Hosts) UnmarshalJSON(data []byte) error {
// IsZero is perhaps a bit naive here.
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
}

View file

@ -130,7 +130,7 @@ func (pol *Policy) compileSSHPolicy(
case "accept":
action = sshAction(true, 0)
case "check":
action = sshAction(true, rule.CheckPeriod)
action = sshAction(true, time.Duration(rule.CheckPeriod))
default:
return nil, fmt.Errorf("parsing SSH policy, unknown action %q, index: %d: %w", rule.Action, index, err)
}

View file

@ -6,12 +6,12 @@ import (
"fmt"
"net/netip"
"strings"
"time"
"slices"
"github.com/juanfont/headscale/hscontrol/types"
"github.com/juanfont/headscale/hscontrol/util"
"github.com/prometheus/common/model"
"github.com/tailscale/hujson"
"go4.org/netipx"
"tailscale.com/net/tsaddr"
@ -383,6 +383,12 @@ type AutoGroup string
const (
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}
@ -915,6 +921,99 @@ type Policy struct {
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
// the unmarshaling process.
// It runs through all rules and checks if there are any inconsistencies
@ -938,20 +1037,70 @@ func (p *Policy) validate() error {
}
case *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 {
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 {
switch src.(type) {
case *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) {
case *AutoGroup:
ag := dst.(*AutoGroup)
if ag.Is(AutoGroupInternet) {
errs = append(errs, fmt.Errorf(`"autogroup:internet" used in SSH destination, it can only be used in ACL destinations`))
if err := validateAutogroupSupported(ag); err != nil {
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.
type SSH struct {
Action string `json:"action"` // TODO(kradalby): add strict type
Sources SSHSrcAliases `json:"src"`
Destinations SSHDstAliases `json:"dst"`
Users []SSHUser `json:"users"`
CheckPeriod time.Duration `json:"checkPeriod,omitempty"`
Action string `json:"action"`
Sources SSHSrcAliases `json:"src"`
Destinations SSHDstAliases `json:"dst"`
Users []SSHUser `json:"users"`
CheckPeriod model.Duration `json:"checkPeriod,omitempty"`
}
// 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))
for i, alias := range aliases {
switch alias.Alias.(type) {
case *Username, *Group, *Tag, *AutoGroup:
case *Group, *Tag, *AutoGroup:
(*a)[i] = alias.Alias
default:
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
// to dynamically give all access
// https://tailscale.com/kb/1193/tailscale-ssh#dst
Asterix,
*Group:
// TODO(kradalby): remove this when we support autogroup:tagged and autogroup:member
Asterix:
(*a)[i] = alias.Alias
default:
return fmt.Errorf("type %T not supported", alias.Alias)