mirror of
				https://github.com/juanfont/headscale.git
				synced 2025-10-28 10:51:44 +01:00 
			
		
		
		
	Add SSH capability advertisement
Advertises the SSH capability, and parses the SSH ACLs to pass to the tailscale client. Doesn’t support ‘autogroup’ ACL functionality. Co-authored-by: Daniel Brooks <db48x@headline.com>
This commit is contained in:
		
							parent
							
								
									91559d0558
								
							
						
					
					
						commit
						cd6df097ad
					
				
							
								
								
									
										116
									
								
								acls.go
									
									
									
									
									
								
							
							
						
						
									
										116
									
								
								acls.go
									
									
									
									
									
								
							@ -10,6 +10,7 @@ import (
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"github.com/rs/zerolog/log"
 | 
			
		||||
	"github.com/tailscale/hujson"
 | 
			
		||||
@ -120,6 +121,16 @@ func (h *Headscale) UpdateACLRules() error {
 | 
			
		||||
	log.Trace().Interface("ACL", rules).Msg("ACL rules generated")
 | 
			
		||||
	h.aclRules = rules
 | 
			
		||||
 | 
			
		||||
	sshRules, err := h.generateSSHRules()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	log.Trace().Interface("SSH", sshRules).Msg("SSH rules generated")
 | 
			
		||||
	if h.sshPolicy == nil {
 | 
			
		||||
		h.sshPolicy = &tailcfg.SSHPolicy{}
 | 
			
		||||
	}
 | 
			
		||||
	h.sshPolicy.Rules = sshRules
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -187,6 +198,111 @@ func (h *Headscale) generateACLRules() ([]tailcfg.FilterRule, error) {
 | 
			
		||||
	return rules, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Headscale) generateSSHRules() ([]*tailcfg.SSHRule, error) {
 | 
			
		||||
	rules := []*tailcfg.SSHRule{}
 | 
			
		||||
 | 
			
		||||
	if h.aclPolicy == nil {
 | 
			
		||||
		return nil, errEmptyPolicy
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	machines, err := h.ListMachines()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	acceptAction := tailcfg.SSHAction{
 | 
			
		||||
		Message:                  "",
 | 
			
		||||
		Reject:                   false,
 | 
			
		||||
		Accept:                   true,
 | 
			
		||||
		SessionDuration:          0,
 | 
			
		||||
		AllowAgentForwarding:     false,
 | 
			
		||||
		HoldAndDelegate:          "",
 | 
			
		||||
		AllowLocalPortForwarding: true,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rejectAction := tailcfg.SSHAction{
 | 
			
		||||
		Message:                  "",
 | 
			
		||||
		Reject:                   true,
 | 
			
		||||
		Accept:                   false,
 | 
			
		||||
		SessionDuration:          0,
 | 
			
		||||
		AllowAgentForwarding:     false,
 | 
			
		||||
		HoldAndDelegate:          "",
 | 
			
		||||
		AllowLocalPortForwarding: false,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for index, sshACL := range h.aclPolicy.SSHs {
 | 
			
		||||
		action := rejectAction
 | 
			
		||||
		switch sshACL.Action {
 | 
			
		||||
		case "accept":
 | 
			
		||||
			action = acceptAction
 | 
			
		||||
		case "check":
 | 
			
		||||
			checkAction, err := sshCheckAction(sshACL.CheckPeriod)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Error().
 | 
			
		||||
					Msgf("Error parsing SSH %d, check action with unparsable duration '%s'", index, sshACL.CheckPeriod)
 | 
			
		||||
			} else {
 | 
			
		||||
				action = *checkAction
 | 
			
		||||
			}
 | 
			
		||||
		default:
 | 
			
		||||
			log.Error().
 | 
			
		||||
				Msgf("Error parsing SSH %d, unknown action '%s'", index, sshACL.Action)
 | 
			
		||||
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		principals := make([]*tailcfg.SSHPrincipal, 0, len(sshACL.Sources))
 | 
			
		||||
		for innerIndex, rawSrc := range sshACL.Sources {
 | 
			
		||||
			expandedSrcs, err := expandAlias(
 | 
			
		||||
				machines,
 | 
			
		||||
				*h.aclPolicy,
 | 
			
		||||
				rawSrc,
 | 
			
		||||
				h.cfg.OIDC.StripEmaildomain,
 | 
			
		||||
			)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Error().
 | 
			
		||||
					Msgf("Error parsing SSH %d, Source %d", index, innerIndex)
 | 
			
		||||
 | 
			
		||||
				return nil, err
 | 
			
		||||
			}
 | 
			
		||||
			for _, expandedSrc := range expandedSrcs {
 | 
			
		||||
				principals = append(principals, &tailcfg.SSHPrincipal{
 | 
			
		||||
					NodeIP: expandedSrc,
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		userMap := make(map[string]string, len(sshACL.Users))
 | 
			
		||||
		for _, user := range sshACL.Users {
 | 
			
		||||
			userMap[user] = "="
 | 
			
		||||
		}
 | 
			
		||||
		rules = append(rules, &tailcfg.SSHRule{
 | 
			
		||||
			RuleExpires: nil,
 | 
			
		||||
			Principals:  principals,
 | 
			
		||||
			SSHUsers:    userMap,
 | 
			
		||||
			Action:      &action,
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return rules, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func sshCheckAction(duration string) (*tailcfg.SSHAction, error) {
 | 
			
		||||
	sessionLength, err := time.ParseDuration(duration)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &tailcfg.SSHAction{
 | 
			
		||||
		Message:                  "",
 | 
			
		||||
		Reject:                   false,
 | 
			
		||||
		Accept:                   true,
 | 
			
		||||
		SessionDuration:          sessionLength,
 | 
			
		||||
		AllowAgentForwarding:     false,
 | 
			
		||||
		HoldAndDelegate:          "",
 | 
			
		||||
		AllowLocalPortForwarding: true,
 | 
			
		||||
	}, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (h *Headscale) generateACLPolicySrcIP(
 | 
			
		||||
	machines []Machine,
 | 
			
		||||
	aclPolicy ACLPolicy,
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										73
									
								
								acls_test.go
									
									
									
									
									
								
							
							
						
						
									
										73
									
								
								acls_test.go
									
									
									
									
									
								
							@ -73,6 +73,79 @@ func (s *Suite) TestInvalidAction(c *check.C) {
 | 
			
		||||
	c.Assert(errors.Is(err, errInvalidAction), check.Equals, true)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *Suite) TestSshRules(c *check.C) {
 | 
			
		||||
	namespace, err := app.CreateNamespace("user1")
 | 
			
		||||
	c.Assert(err, check.IsNil)
 | 
			
		||||
 | 
			
		||||
	pak, err := app.CreatePreAuthKey(namespace.Name, false, false, nil, nil)
 | 
			
		||||
	c.Assert(err, check.IsNil)
 | 
			
		||||
 | 
			
		||||
	_, err = app.GetMachine("user1", "testmachine")
 | 
			
		||||
	c.Assert(err, check.NotNil)
 | 
			
		||||
	hostInfo := tailcfg.Hostinfo{
 | 
			
		||||
		OS:          "centos",
 | 
			
		||||
		Hostname:    "testmachine",
 | 
			
		||||
		RequestTags: []string{"tag:test"},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	machine := Machine{
 | 
			
		||||
		ID:             0,
 | 
			
		||||
		MachineKey:     "foo",
 | 
			
		||||
		NodeKey:        "bar",
 | 
			
		||||
		DiscoKey:       "faa",
 | 
			
		||||
		Hostname:       "testmachine",
 | 
			
		||||
		IPAddresses:    MachineAddresses{netip.MustParseAddr("100.64.0.1")},
 | 
			
		||||
		NamespaceID:    namespace.ID,
 | 
			
		||||
		RegisterMethod: RegisterMethodAuthKey,
 | 
			
		||||
		AuthKeyID:      uint(pak.ID),
 | 
			
		||||
		HostInfo:       HostInfo(hostInfo),
 | 
			
		||||
	}
 | 
			
		||||
	app.db.Save(&machine)
 | 
			
		||||
 | 
			
		||||
	app.aclPolicy = &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"},
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = app.UpdateACLRules()
 | 
			
		||||
 | 
			
		||||
	c.Assert(err, check.IsNil)
 | 
			
		||||
	c.Assert(app.sshPolicy, check.NotNil)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules, check.HasLen, 2)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[0].SSHUsers, check.HasLen, 1)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[0].Principals, check.HasLen, 1)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[0].Principals[0].NodeIP, check.Matches, "100.64.0.1")
 | 
			
		||||
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[1].SSHUsers, check.HasLen, 1)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[1].Principals, check.HasLen, 1)
 | 
			
		||||
	c.Assert(app.sshPolicy.Rules[1].Principals[0].NodeIP, check.Matches, "*")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s *Suite) TestInvalidGroupInGroup(c *check.C) {
 | 
			
		||||
	// this ACL is wrong because the group in Sources sections doesn't exist
 | 
			
		||||
	app.aclPolicy = &ACLPolicy{
 | 
			
		||||
 | 
			
		||||
@ -17,6 +17,7 @@ type ACLPolicy struct {
 | 
			
		||||
	ACLs          []ACL         `json:"acls"          yaml:"acls"`
 | 
			
		||||
	Tests         []ACLTest     `json:"tests"         yaml:"tests"`
 | 
			
		||||
	AutoApprovers AutoApprovers `json:"autoApprovers" yaml:"autoApprovers"`
 | 
			
		||||
	SSHs          []SSH         `json:"ssh"           yaml:"ssh"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ACL is a basic rule for the ACL Policy.
 | 
			
		||||
@ -50,6 +51,15 @@ type AutoApprovers struct {
 | 
			
		||||
	ExitNode []string            `json:"exitNode" yaml:"exitNode"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SSH controls who can ssh into which machines.
 | 
			
		||||
type SSH struct {
 | 
			
		||||
	Action       string   `json:"action"                yaml:"action"`
 | 
			
		||||
	Sources      []string `json:"src"                   yaml:"src"`
 | 
			
		||||
	Destinations []string `json:"dst"                   yaml:"dst"`
 | 
			
		||||
	Users        []string `json:"users"                 yaml:"users"`
 | 
			
		||||
	CheckPeriod  string   `json:"checkPeriod,omitempty" yaml:"checkPeriod,omitempty"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UnmarshalJSON allows to parse the Hosts directly into netip objects.
 | 
			
		||||
func (hosts *Hosts) UnmarshalJSON(data []byte) error {
 | 
			
		||||
	newHosts := Hosts{}
 | 
			
		||||
 | 
			
		||||
@ -62,6 +62,7 @@ func (h *Headscale) generateMapResponse(
 | 
			
		||||
		DNSConfig:    dnsConfig,
 | 
			
		||||
		Domain:       h.cfg.BaseDomain,
 | 
			
		||||
		PacketFilter: h.aclRules,
 | 
			
		||||
		SSHPolicy:    h.sshPolicy,
 | 
			
		||||
		DERPMap:      h.DERPMap,
 | 
			
		||||
		UserProfiles: profiles,
 | 
			
		||||
		Debug: &tailcfg.Debug{
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1
									
								
								app.go
									
									
									
									
									
								
							
							
						
						
									
										1
									
								
								app.go
									
									
									
									
									
								
							@ -88,6 +88,7 @@ type Headscale struct {
 | 
			
		||||
 | 
			
		||||
	aclPolicy *ACLPolicy
 | 
			
		||||
	aclRules  []tailcfg.FilterRule
 | 
			
		||||
	sshPolicy *tailcfg.SSHPolicy
 | 
			
		||||
 | 
			
		||||
	lastStateChange *xsync.MapOf[string, time.Time]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -744,7 +744,11 @@ func (machine Machine) toNode(
 | 
			
		||||
 | 
			
		||||
		KeepAlive:         true,
 | 
			
		||||
		MachineAuthorized: !machine.isExpired(),
 | 
			
		||||
		Capabilities:      []string{tailcfg.CapabilityFileSharing},
 | 
			
		||||
		Capabilities: []string{
 | 
			
		||||
			tailcfg.CapabilityFileSharing,
 | 
			
		||||
			tailcfg.CapabilityAdmin,
 | 
			
		||||
			tailcfg.CapabilitySSH,
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return &node, nil
 | 
			
		||||
 | 
			
		||||
		Loading…
	
		Reference in New Issue
	
	Block a user