diff --git a/internal/domain/acl_filter_rules.go b/internal/domain/acl_filter_rules.go index 547786c..89590ba 100644 --- a/internal/domain/acl_filter_rules.go +++ b/internal/domain/acl_filter_rules.go @@ -1,6 +1,7 @@ package domain import ( + "fmt" "github.com/jsiebens/ionscale/pkg/client/ionscale" "net/netip" "strings" @@ -48,6 +49,24 @@ func (a ACLPolicy) IsValidPeer(src *Machine, dest *Machine) bool { } } + for _, ssh := range a.SSH { + selfIps, otherIps := a.translateDestinationAliasesToMachineIPs(ssh.Recorder, dest) + if len(selfIps) != 0 { + for _, alias := range ssh.Destination { + if len(a.translateSourceAliasToMachineIPs(alias, src, &dest.User)) != 0 { + return true + } + } + } + if len(otherIps) != 0 { + for _, alias := range ssh.Destination { + if len(a.translateSourceAliasToMachineIPs(alias, src, nil)) != 0 { + return true + } + } + } + } + return false } @@ -100,9 +119,36 @@ func (a ACLPolicy) BuildFilterRules(peers []Machine, dst *Machine) []tailcfg.Fil rules = matchSourceAndAppendRule(rules, acl.Source, other, nil) } + for _, acl := range a.SSH { + ssh := a.prepareFilterRulesFromSSH(dst, acl) + rules = matchSourceAndAppendRule(rules, acl.Destination, ssh, nil) + } + return rules } +func normalizeSSHRecordersToDestinationAliases(recorders []string) []string { + recorderAliases := make([]string, 0) + for _, alias := range recorders { + if strings.HasPrefix(alias, "tag:") { + recorderAliases = append(recorderAliases, fmt.Sprintf("%s:80", alias)) + } + } + return recorderAliases +} + +func (a ACLPolicy) prepareFilterRulesFromSSH(candidate *Machine, entry ionscale.ACLSSH) []tailcfg.FilterRule { + _, otherDstPorts := a.translateDestinationAliasesToMachineNetPortRanges(normalizeSSHRecordersToDestinationAliases(entry.Recorder), candidate) + + var otherFilterRules []tailcfg.FilterRule + + if len(otherDstPorts) != 0 { + otherFilterRules = append(otherFilterRules, tailcfg.FilterRule{IPProto: []int{protocolTCP}, DstPorts: otherDstPorts}) + } + + return otherFilterRules +} + func (a ACLPolicy) prepareFilterRulesFromACL(candidate *Machine, acl ionscale.ACLEntry) ([]tailcfg.FilterRule, []tailcfg.FilterRule) { proto := parseProtocol(acl.Protocol) diff --git a/internal/domain/acl_ssh_policy.go b/internal/domain/acl_ssh_policy.go index 71b1c78..1e4dbcc 100644 --- a/internal/domain/acl_ssh_policy.go +++ b/internal/domain/acl_ssh_policy.go @@ -2,6 +2,7 @@ package domain import ( "github.com/jsiebens/ionscale/pkg/client/ionscale" + "net/netip" "strings" "tailscale.com/tailcfg" ) @@ -29,6 +30,20 @@ func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPoli return result } + expandRecorderAliases := func(aliases []string) []netip.AddrPort { + result := make([]netip.AddrPort, 0) + + for _, alias := range aliases { + for _, src := range append(srcs, *dst) { + if src.HasTag(alias) { + result = append(result, netip.AddrPortFrom(*src.IPv4.Addr, 80)) + } + } + } + + return result + } + for _, rule := range a.SSH { if rule.Action != "accept" && rule.Action != "check" { continue @@ -46,6 +61,17 @@ func (a ACLPolicy) BuildSSHPolicy(srcs []Machine, dst *Machine) *tailcfg.SSHPoli } } + if len(rule.Recorder) != 0 { + action.Recorders = expandRecorderAliases(rule.Recorder) + action.Message = "# This session is being recorded.\n" + if rule.EnforceRecorder { + action.OnRecordingFailure = &tailcfg.SSHRecorderFailureAction{ + RejectSessionWithMessage: "# Session rejected: failed to start session recording.", + TerminateSessionWithMessage: "# Session terminated: failed to record session.", + } + } + } + selfUsers, otherUsers := a.expandSSHDstToSSHUsers(dst, rule) if len(selfUsers) != 0 { diff --git a/internal/domain/acl_ssh_policy_test.go b/internal/domain/acl_ssh_policy_test.go index b93176b..0c29458 100644 --- a/internal/domain/acl_ssh_policy_test.go +++ b/internal/domain/acl_ssh_policy_test.go @@ -1,8 +1,6 @@ package domain import ( - "encoding/json" - "fmt" "github.com/jsiebens/ionscale/pkg/client/ionscale" "github.com/stretchr/testify/assert" "tailscale.com/tailcfg" @@ -385,14 +383,6 @@ func TestACLPolicy_BuildSSHPolicy_WithTagsAndActionCheck(t *testing.T) { assert.Nil(t, actualRules.Rules) } -func printRules(rules []*tailcfg.SSHRule) { - indent, err := json.MarshalIndent(rules, "", " ") - if err != nil { - panic(err) - } - fmt.Println(string(indent)) -} - func sshPrincipalsFromMachines(machines ...Machine) []*tailcfg.SSHPrincipal { x := StringSet{} for _, m := range machines { diff --git a/pkg/client/ionscale/types.go b/pkg/client/ionscale/types.go index 128362c..0cc5ad6 100644 --- a/pkg/client/ionscale/types.go +++ b/pkg/client/ionscale/types.go @@ -46,11 +46,13 @@ type ACLEntry struct { } type ACLSSH struct { - Action string `json:"action,omitempty" hujson:"Action,omitempty"` - Users []string `json:"users,omitempty" hujson:"Users,omitempty"` - Source []string `json:"src,omitempty" hujson:"Src,omitempty"` - Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` - CheckPeriod string `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"` + Action string `json:"action,omitempty" hujson:"Action,omitempty"` + Source []string `json:"src,omitempty" hujson:"Src,omitempty"` + Destination []string `json:"dst,omitempty" hujson:"Dst,omitempty"` + Users []string `json:"users,omitempty" hujson:"Users,omitempty"` + CheckPeriod string `json:"checkPeriod,omitempty" hujson:"CheckPeriod,omitempty"` + Recorder []string `json:"recorder,omitempty" hujson:"Recorder,omitempty"` + EnforceRecorder bool `json:"enforceRecorder,omitempty" hujson:"EnforceRecorder,omitempty"` } type ACLNodeAttrGrant struct { diff --git a/tests/acl_test.go b/tests/acl_test.go index 4b37d89..24ec335 100644 --- a/tests/acl_test.go +++ b/tests/acl_test.go @@ -38,3 +38,41 @@ func TestACL_PeersShouldBeRemovedWhenNoMatchingACLRuleIsAvailable(t *testing.T) require.NoError(t, server.WaitFor(tsn.PeerCount(0))) }) } + +func TestACL_PeersShouldSeeSSHRecorder(t *testing.T) { + sc.Run(t, func(s *sc.Scenario) { + tailnet := s.CreateTailnet() + clientKey := s.CreateAuthKey(tailnet.Id, true, "tag:client") + recorderKey := s.CreateAuthKey(tailnet.Id, true, "tag:recorder") + + policy := defaults.DefaultACLPolicy() + policy.ACLs = []ionscale.ACLEntry{ + { + Action: "accept", + Source: []string{"tag:client"}, + Destination: []string{"tag:client:*"}, + }, + } + policy.SSH = []ionscale.ACLSSH{ + { + Action: "check", + Source: []string{"tag:client"}, + Destination: []string{"tag:client"}, + Users: []string{"autogroup:nonroot", "root"}, + Recorder: []string{"tag:recorder"}, + }, + } + + s.SetACLPolicy(tailnet.Id, policy) + + client1 := s.NewTailscaleNode() + client2 := s.NewTailscaleNode() + recorder1 := s.NewTailscaleNode() + + require.NoError(t, client1.Up(clientKey)) + require.NoError(t, client2.Up(clientKey)) + require.NoError(t, recorder1.Up(recorderKey)) + + require.NoError(t, recorder1.WaitFor(tsn.PeerCount(2))) + }) +}