feat: ssh recording

This commit is contained in:
Johan Siebens
2024-05-31 08:24:55 +02:00
parent 78825d4e05
commit 4bce1c33b8
5 changed files with 117 additions and 15 deletions
+46
View File
@@ -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)
+26
View File
@@ -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 {
-10
View File
@@ -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 {
+3 -1
View File
@@ -47,10 +47,12 @@ 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"`
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 {
+38
View File
@@ -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)))
})
}